Files
vkv/app/Jobs/MatchQsoBucketJob.php
Zdeněk Burda 41e3ce6f25 Initial commit
2026-01-09 21:26:40 +01:00

433 lines
19 KiB
PHP

<?php
namespace App\Jobs;
use App\Enums\QsoErrorCode;
use App\Models\EvaluationRun;
use App\Models\EvaluationRuleSet;
use App\Models\LogOverride;
use App\Models\LogQso;
use App\Models\QsoOverride;
use App\Models\QsoResult;
use App\Models\Round;
use App\Models\WorkingQso;
use App\Services\Evaluation\EvaluationCoordinator;
use Illuminate\Bus\Batchable;
use Throwable;
/**
* Job: MatchQsoBucketJob
*
* Odpovědnost:
* - Provede matching QSO v bucketu definovaném kombinací band_id + call_norm.
* - Bucket obsahuje QSO se shodným call_norm (zdrojová strana),
* kandidáti se berou z QSO se shodným rcall_norm.
*
* Poznámka:
* - Logika matchingu zůstává shodná s MatchQsoGroupJob, jen pracuje nad menšími
* podmnožinami dat pro kratší dobu běhu jednoho jobu.
*/
class MatchQsoBucketJob extends MatchQsoGroupJob
{
use Batchable;
public int $tries = 3;
public array $backoff = [30, 120, 300];
protected ?int $bandId;
protected ?string $callNorm;
public function __construct(
int $evaluationRunId,
?int $bandId,
?string $callNorm,
int $pass = 1
) {
parent::__construct($evaluationRunId, null, null, $pass);
$this->bandId = $bandId;
$this->callNorm = $callNorm;
}
public function handle(): void
{
$run = EvaluationRun::find($this->evaluationRunId);
if (! $run || $run->isCanceled()) {
return;
}
$coordinator = new EvaluationCoordinator();
try {
$ruleSet = EvaluationRuleSet::find($run->rule_set_id);
if (! $ruleSet) {
$coordinator->eventError($run, 'Matching nelze spustit: chybí ruleset.', [
'step' => 'match',
'round_id' => $run->round_id,
]);
return;
}
$round = Round::with(['bands', 'categories', 'powerCategories'])->find($run->round_id);
if (! $round) {
return;
}
$coordinator->eventInfo($run, 'Matching bucket.', [
'step' => 'match',
'round_id' => $run->round_id,
'band_id' => $this->bandId,
'call_norm' => $this->callNorm,
'pass' => $this->pass,
]);
$logOverrides = LogOverride::where('evaluation_run_id', $run->id)->get()->keyBy('log_id');
$qsoOverrides = QsoOverride::where('evaluation_run_id', $run->id)->get()->keyBy('log_qso_id');
$groupLogIds = $this->groupLogsByKey($round, $ruleSet, $logOverrides);
$groupKey = 'b' . ($this->bandId ?? 0);
$logIds = $groupLogIds[$groupKey] ?? [];
if (! $logIds) {
$coordinator->progressTick($run, 1);
return;
}
$sourceQsos = WorkingQso::where('evaluation_run_id', $run->id)
->whereIn('log_id', $logIds)
->when($this->bandId !== null, fn ($q) => $q->where('band_id', $this->bandId), fn ($q) => $q->whereNull('band_id'))
->when($this->callNorm !== null, fn ($q) => $q->where('call_norm', $this->callNorm), fn ($q) => $q->whereNull('call_norm'))
->get();
if ($sourceQsos->isEmpty()) {
$coordinator->progressTick($run, 1);
return;
}
$candidateQsos = WorkingQso::where('evaluation_run_id', $run->id)
->whereIn('log_id', $logIds)
->when($this->bandId !== null, fn ($q) => $q->where('band_id', $this->bandId), fn ($q) => $q->whereNull('band_id'))
->when($this->callNorm !== null, fn ($q) => $q->where('rcall_norm', $this->callNorm), fn ($q) => $q->whereNull('rcall_norm'))
->get();
$workingQsos = $sourceQsos->concat($candidateQsos)->unique('log_qso_id');
$logQsoIds = $workingQsos->pluck('log_qso_id')->all();
$logQsoMap = LogQso::whereIn('id', $logQsoIds)->get()->keyBy('id');
$workingMap = $workingQsos->keyBy('log_qso_id');
$alreadyMatched = [];
if ($this->pass > 1) {
$alreadyMatched = QsoResult::where('evaluation_run_id', $run->id)
->whereNotNull('matched_log_qso_id')
->pluck('log_qso_id')
->all();
$alreadyMatched = array_fill_keys($alreadyMatched, true);
}
$byMatchKey = [];
$byBandRcall = [];
foreach ($candidateQsos as $wqso) {
if ($wqso->match_key) {
$byMatchKey[$wqso->match_key][] = $wqso;
}
if ($wqso->band_id && $wqso->rcall_norm) {
$byBandRcall[$wqso->band_id . '|' . $wqso->rcall_norm][] = $wqso;
}
}
$timeTolerance = $ruleSet->time_tolerance_sec !== null
? (int) $ruleSet->time_tolerance_sec
: 300;
$forcedMap = [];
foreach ($qsoOverrides as $override) {
if ($override->forced_matched_log_qso_id) {
$forcedMap[$override->log_qso_id] = (int) $override->forced_matched_log_qso_id;
}
}
$forcedBackMap = [];
$forcedConflicts = [];
foreach ($forcedMap as $a => $b) {
if (isset($forcedMap[$b]) && $forcedMap[$b] !== $a) {
$forcedConflicts[$a] = true;
$forcedConflicts[$b] = true;
}
if (! isset($forcedMap[$b])) {
$forcedBackMap[$b] = $a;
}
}
$paired = [];
foreach ($sourceQsos as $wqso) {
if ($this->pass > 1 && isset($alreadyMatched[$wqso->log_qso_id])) {
continue;
}
$reverseKey = $wqso->band_id && $wqso->call_norm && $wqso->rcall_norm
? $wqso->band_id . '|' . $wqso->rcall_norm . '|' . $wqso->call_norm
: null;
$candidates = [];
if ($reverseKey && isset($byMatchKey[$reverseKey])) {
$candidates = $byMatchKey[$reverseKey];
}
if ($wqso->band_id && $wqso->call_norm) {
$fuzzyKey = $wqso->band_id . '|' . $wqso->call_norm;
if (isset($byBandRcall[$fuzzyKey])) {
$candidates = array_merge($candidates, $byBandRcall[$fuzzyKey]);
}
}
if ($candidates) {
$unique = [];
foreach ($candidates as $candidate) {
$unique[$candidate->log_qso_id] = $candidate;
}
$candidates = array_values($unique);
}
$best = null;
$bestDecision = null;
$tiebreakOrder = $this->resolveTiebreakOrder($ruleSet);
$forcedMatchId = $forcedMap[$wqso->log_qso_id]
?? $forcedBackMap[$wqso->log_qso_id]
?? null;
$forcedMissing = false;
$forcedDecision = null;
if ($forcedMatchId) {
if (! $workingMap->has($forcedMatchId)) {
$forcedWorking = WorkingQso::where('evaluation_run_id', $run->id)
->where('log_qso_id', $forcedMatchId)
->first();
if ($forcedWorking) {
$workingMap->put($forcedMatchId, $forcedWorking);
if (! $logQsoMap->has($forcedMatchId)) {
$forcedLogQso = LogQso::find($forcedMatchId);
if ($forcedLogQso) {
$logQsoMap->put($forcedMatchId, $forcedLogQso);
}
}
}
}
$best = $workingMap->get($forcedMatchId);
if (! $best || $best->log_id === $wqso->log_id) {
$best = null;
$forcedMissing = true;
}
if ($best) {
$forcedDecision = $this->evaluateMatchDecision(
$wqso,
$best,
$logQsoMap,
$ruleSet,
$timeTolerance
);
$bestDecision = $forcedDecision;
}
} else {
foreach ($candidates as $candidate) {
if ($candidate->log_id === $wqso->log_id) {
continue;
}
if (isset($paired[$candidate->log_qso_id]) && $paired[$candidate->log_qso_id] !== $wqso->log_qso_id) {
continue;
}
$decision = $this->evaluateMatchDecision(
$wqso,
$candidate,
$logQsoMap,
$ruleSet,
$timeTolerance
);
if (! $decision) {
continue;
}
if ($this->pass === 1 && ($decision['match_type'] ?? '') !== 'MATCH_EXACT') {
continue;
}
if ($bestDecision === null || $decision['rank'] < $bestDecision['rank']) {
$best = $candidate;
$bestDecision = $decision;
continue;
}
if ($decision['rank'] === $bestDecision['rank']) {
$cmp = $this->compareCandidates(
$decision['tiebreak'],
$bestDecision['tiebreak'],
$tiebreakOrder
);
if ($cmp < 0) {
$best = $candidate;
$bestDecision = $decision;
}
}
}
}
$timeDiffSec = null;
if ($best && $wqso->ts_utc && $best->ts_utc) {
$timeDiffSec = abs($best->ts_utc->getTimestamp() - $wqso->ts_utc->getTimestamp());
}
$isNil = $best === null;
$isDuplicate = false;
$isBustedExchange = false;
$isBustedCall = false;
$isBustedRst = false;
$bustedCallTx = false;
$bustedRstTx = false;
$bustedExchangeReason = null;
$customMismatch = false;
$isBustedExchangeOnly = false;
$bustedSerialRx = false;
$bustedSerialTx = false;
$bustedWwlRx = false;
$bustedWwlTx = false;
$matchType = null;
$errorFlags = [];
$timeMismatch = false;
$isOutOfWindow = (bool) $wqso->out_of_window;
$errorCode = null;
if (! $isNil && $best && $bestDecision) {
$a = $logQsoMap->get($wqso->log_qso_id);
$b = $logQsoMap->get($best->log_qso_id);
$aWork = $workingMap->get($wqso->log_qso_id);
$bWork = $workingMap->get($best->log_qso_id);
if ($a && $b) {
$exchange = $this->evaluateExchange($a, $b, $aWork, $bWork, $ruleSet);
$bustedCallTx = $exchange['sent_call_mismatch'] && $ruleSet->discard_qso_sent_diff_call;
$isBustedCall = $exchange['recv_call_mismatch'] && $ruleSet->discard_qso_rec_diff_call;
$bustedRstTx = ($ruleSet->exchange_requires_report ?? false)
&& $exchange['report_sent_mismatch']
&& $ruleSet->discard_qso_sent_diff_rst;
$isBustedRst = ($ruleSet->exchange_requires_report ?? false)
&& $exchange['report_recv_mismatch']
&& $ruleSet->discard_qso_rec_diff_rst;
$discardSerialRx = $ruleSet->discard_qso_rec_diff_serial ?? $ruleSet->discard_qso_rec_diff_code;
$discardSerialTx = $ruleSet->discard_qso_sent_diff_serial ?? $ruleSet->discard_qso_sent_diff_code;
$discardWwlRx = $ruleSet->discard_qso_rec_diff_wwl ?? $ruleSet->discard_qso_rec_diff_code;
$discardWwlTx = $ruleSet->discard_qso_sent_diff_wwl ?? $ruleSet->discard_qso_sent_diff_code;
$customMismatch = (bool) ($exchange['custom_mismatch'] ?? false);
$bustedSerialRx = ($exchange['serial_recv_mismatch'] || $exchange['missing_serial'] || $customMismatch) && $discardSerialRx;
$bustedSerialTx = ($exchange['serial_sent_mismatch'] || $customMismatch) && $discardSerialTx;
$bustedWwlRx = ($exchange['locator_recv_mismatch'] || $exchange['missing_wwl']) && $discardWwlRx;
$bustedWwlTx = $exchange['locator_sent_mismatch'] && $discardWwlTx;
$isBustedExchangeOnly = ($customMismatch && $ruleSet->discard_qso_rec_diff_code);
$isBustedExchange = $isBustedExchangeOnly || $bustedSerialRx || $bustedWwlRx;
$bustedExchangeReason = $exchange['busted_exchange_reason'] ?? null;
$matchType = $bestDecision['match_type'] ?? null;
$errorFlags = $bestDecision['error_flags'] ?? [];
$timeMismatch = (bool) ($bestDecision['time_mismatch'] ?? false);
}
}
$isValid = ! $isNil && ! $isBustedCall && ! $isBustedRst && ! $isBustedExchange;
$errorDetail = null;
$bustedCallRx = $isBustedCall;
$bustedRstRx = $isBustedRst;
if ($forcedMissing) {
$errorDetail = 'FORCED_MATCH_MISSING';
} elseif (isset($forcedConflicts[$wqso->log_qso_id])) {
$errorDetail = 'FORCED_MATCH_CONFLICT';
} elseif ($isBustedExchange && $bustedExchangeReason) {
$errorDetail = $bustedExchangeReason;
}
$errorSide = $this->resolveErrorSide(
$bustedCallRx,
$bustedCallTx,
$bustedRstRx,
$bustedRstTx,
$bustedSerialRx,
$bustedSerialTx,
$bustedWwlRx,
$bustedWwlTx,
$timeMismatch
);
$override = $qsoOverrides->get($wqso->log_qso_id);
if ($override && $override->forced_status && $override->forced_status !== 'AUTO') {
$this->applyForcedStatus(
$override->forced_status,
$isValid,
$isDuplicate,
$isNil,
$isBustedCall,
$isBustedRst,
$isBustedExchange,
$isOutOfWindow,
$errorCode,
$errorSide
);
}
$matchConfidence = $this->resolveMatchConfidence($bestDecision['match_type'] ?? null, $timeMismatch);
if (! $errorCode) {
if ($timeMismatch) {
$errorCode = QsoErrorCode::TIME_MISMATCH;
} elseif ($isBustedCall || $bustedCallTx) {
$errorCode = QsoErrorCode::BUSTED_CALL;
} elseif ($isBustedRst || $bustedRstTx) {
$errorCode = QsoErrorCode::BUSTED_RST;
} elseif ($bustedSerialRx || $bustedSerialTx) {
$errorCode = QsoErrorCode::BUSTED_SERIAL;
} elseif ($bustedWwlRx || $bustedWwlTx) {
$errorCode = QsoErrorCode::BUSTED_LOCATOR;
} elseif ($isBustedExchangeOnly) {
$errorCode = QsoErrorCode::BUSTED_SERIAL;
} elseif (! $isNil && ! $isDuplicate && ! $isBustedExchange && ! $isOutOfWindow) {
$errorCode = QsoErrorCode::OK;
}
}
QsoResult::updateOrCreate(
[
'evaluation_run_id' => $run->id,
'log_qso_id' => $wqso->log_qso_id,
],
[
'is_valid' => $isValid,
'is_duplicate' => $isDuplicate,
'is_nil' => $isNil,
'is_busted_call' => $isBustedCall,
'is_busted_rst' => $isBustedRst,
'is_busted_exchange' => $isBustedExchange,
'is_time_out_of_window' => $isOutOfWindow,
'points' => 0,
'distance_km' => null,
'time_diff_sec' => $timeDiffSec,
'wwl' => null,
'dxcc' => null,
'matched_qso_id' => $best?->log_qso_id,
'matched_log_qso_id' => $best?->log_qso_id,
'match_type' => $matchType,
'match_confidence' => $matchConfidence,
'error_code' => $errorCode,
'error_side' => $errorSide,
'error_detail' => $errorDetail,
'error_flags' => $errorFlags ?: null,
]
);
if ($best && $best->log_qso_id) {
$paired[$wqso->log_qso_id] = $best->log_qso_id;
$paired[$best->log_qso_id] = $wqso->log_qso_id;
}
}
$coordinator->progressTick($run, 1);
} catch (Throwable $e) {
$coordinator->eventError($run, 'Matching bucket: krok selhal.', [
'step' => 'match',
'round_id' => $run->round_id,
'band_id' => $this->bandId,
'call_norm' => $this->callNorm,
'pass' => $this->pass,
'error' => $e->getMessage(),
]);
throw $e;
}
}
}