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; } } }