stejné výstupy. * - Job musí být idempotentní: opakované spuštění přepíše/obnoví výstupy * pro daný run+group bez duplicit. * - Veškerá matching logika patří do service layer (např. MatchingService). * - Job pouze načte kontext, deleguje práci a zapíše výsledek. * * Decision trail (v QsoResult): * - error_code: OK / NIL / NOT_IN_COUNTERPART_LOG / NO_COUNTERPART_LOG / UNIQUE / * BUSTED_CALL / BUSTED_RST / BUSTED_SERIAL / BUSTED_LOCATOR / DUP / TIME_MISMATCH / * OUT_OF_WINDOW (kód popisuje primární důvod). * - error_flags: doplňkové signály (např. TIME_MISMATCH), mohou existovat i při OK. * - error_side: RX / TX / NONE (kdo udělal chybu, rozhoduje o penalizaci). * - match_type: EXACT / LEVENSHTEIN / TIME_SHIFT / TIME_MISMATCH (typ párování). * - match_confidence: HIGH / MEDIUM / LOW / TIME_MISMATCH (syntetické skóre důvěry). * * Queue: * - Spouští se ve frontě "evaluation". * - Musí být chráněn proti souběhu nad stejnou group (lock / WithoutOverlapping). */ class MatchQsoGroupJob implements ShouldQueue { use Batchable; use Queueable; /** * Create a new job instance. */ public function __construct( protected int $evaluationRunId, protected ?string $groupKey = null, protected ?array $group = null, protected int $pass = 1 ) { // } /** * Provede matching QSO pro jednu skupinu (group). * * Metoda handle(): * - získá kontext EvaluationRun + group parametry * - provede matching a označení QSO (NIL/busted/duplicate/...) * - zapíše mezivýsledky pro scoring * - aktualizuje progress a auditní události pro UI * * Poznámky: * - Job by měl pracovat dávkově a používat indexy (výkon). * - Při chybě v jedné skupině má selhat job (retry), * protože bez kompletního matchingu nelze korektně skórovat. */ public function handle(): void { $run = EvaluationRun::find($this->evaluationRunId); if (! $run || $run->isCanceled()) { return; } $coordinator = new EvaluationCoordinator(); try { $coordinator->eventInfo($run, 'Matching: krok spuštěn.', [ 'step' => 'match', 'round_id' => $run->round_id, 'group_key' => $this->groupKey, ]); $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; } $run->update([ 'status' => 'RUNNING', 'current_step' => 'match', ]); $groups = []; $singleGroup = (bool) ($this->groupKey || $this->group); if ($this->groupKey || $this->group) { $bandId = $this->group['band_id'] ?? null; $groups[] = [ 'key' => 'b' . ($bandId ?? 0), 'band_id' => $bandId, 'category_id' => null, 'power_category_id' => null, ]; } elseif (! empty($run->scope['band_ids']) && is_array($run->scope['band_ids'])) { foreach ($run->scope['band_ids'] as $bandId) { $groups[] = [ 'key' => 'b' . ($bandId ?? 0), 'band_id' => $bandId, 'category_id' => null, 'power_category_id' => null, ]; } } else { $groups[] = [ 'key' => 'b0', 'band_id' => null, 'category_id' => null, 'power_category_id' => null, ]; } $total = count($groups); if (! $singleGroup) { $run->update([ 'progress_total' => $total, 'progress_done' => 0, ]); } $matcher = new MatchingService(); $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); $processed = 0; foreach ($groups as $group) { if (EvaluationRun::isCanceledRun($run->id)) { return; } $processed++; $groupKey = $group['key'] ?? 'all'; $logIds = $groupLogIds[$groupKey] ?? []; $coordinator->eventInfo($run, 'Matching QSO.', [ 'step' => 'match', 'round_id' => $run->round_id, 'group_key' => $group['key'] ?? null, 'group' => [ 'band_id' => $group['band_id'] ?? null, 'category_id' => null, 'power_category_id' => null, ], 'group_logs' => count($logIds), 'step_progress_done' => $processed, 'step_progress_total' => $total, ]); if (! $logIds) { if ($singleGroup) { EvaluationRun::where('id', $run->id)->increment('progress_done'); } else { $run->update(['progress_done' => $processed]); } continue; } $workingQsos = WorkingQso::where('evaluation_run_id', $run->id) ->whereIn('log_id', $logIds) ->when(! empty($group['band_id']), fn ($q) => $q->where('band_id', $group['band_id'])) ->get(); $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); } $logQsoMap = LogQso::whereIn('id', $workingQsos->pluck('log_qso_id')->all()) ->get() ->keyBy('id'); $workingMap = $workingQsos->keyBy('log_qso_id'); // Indexace pracovních QSO pro rychlé vyhledání kandidátů a detekci duplicit. $byMatchKey = []; $byLog = []; $byBandRcall = []; foreach ($workingQsos as $wqso) { if ($wqso->match_key) { $byMatchKey[$wqso->match_key][] = $wqso; } $byLog[$wqso->log_id][] = $wqso; if ($wqso->band_id && $wqso->rcall_norm) { $byBandRcall[$wqso->band_id . '|' . $wqso->rcall_norm][] = $wqso; } } // Duplicitní QSO se řeší až po matchingu v samostatném kroku. // Tolerance času určuje maximální rozdíl mezi oběma stranami QSO. $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 ($workingQsos as $wqso) { // PASS 1 (EXACT) WHY: minimalizuje riziko chybných párování; // ORDER: běží vždy první, aby přesné shody byly „uzamčené“ před fuzzy passy; // IRREVERSIBLE: jakmile je QSO spárované, další kroky už ho nepřepárují. 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; // Kandidáti jsou protistanice se shodným reverse match key. $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; // Výběr nejlepšího kandidáta je deterministický podle pravidel tiebreaku. $tiebreakOrder = $this->resolveTiebreakOrder($ruleSet); $forcedMatchId = $forcedMap[$wqso->log_qso_id] ?? $forcedBackMap[$wqso->log_qso_id] ?? null; $forcedMissing = false; $forcedDecision = null; if ($forcedMatchId) { $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; } // PASS 2 (FAULTY) WHY: umožní dohledat „pravděpodobné“ shody s chybami; // ORDER: běží až po exact passu a pracuje jen s nenapárovanými QSO; // IRREVERSIBLE: výsledek se považuje za finální vstup pro scoring. 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); } } // TIME_MISMATCH se neřeší jako invalidita v matchingu, ale až ve scorování dle policy. $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; } } if ($singleGroup) { EvaluationRun::where('id', $run->id)->increment('progress_done'); } else { $run->update(['progress_done' => $processed]); } } $coordinator->eventInfo($run, 'Matching: krok dokončen.', [ 'step' => 'match', 'round_id' => $run->round_id, 'group_key' => $this->groupKey, ]); } catch (Throwable $e) { $coordinator->eventError($run, 'Matching: krok selhal.', [ 'step' => 'match', 'round_id' => $run->round_id, 'group_key' => $this->groupKey, 'error' => $e->getMessage(), ]); throw $e; } } protected function groupLogsByKey( Round $round, EvaluationRuleSet $ruleSet, \Illuminate\Support\Collection $logOverrides ): array { $logs = Log::where('round_id', $round->id)->get(); $map = []; foreach ($logs as $log) { if (! $ruleSet->checklog_matching && $this->isCheckLog($log)) { continue; } $override = $logOverrides->get($log->id); if ($override && $override->forced_log_status === 'IGNORED') { continue; } $bandId = $override && $override->forced_band_id ? (int) $override->forced_band_id : $this->resolveBandId($log, $round); $key = 'b' . ($bandId ?? 0); $map[$key][] = $log->id; } return $map; } protected function resolveCategoryId(Log $log, Round $round): ?int { $value = $log->psect; if (! $value) { return null; } $ediCat = EdiCategory::whereRaw('LOWER(value) = ?', [mb_strtolower(trim($value))])->first(); if (! $ediCat) { $ediCat = $this->matchEdiCategoryByRegex($value); } if (! $ediCat) { return null; } $mappedCategoryId = $ediCat->categories()->value('categories.id'); if (! $mappedCategoryId) { return null; } if ($round->categories()->count() === 0) { return $mappedCategoryId; } return $round->categories()->where('categories.id', $mappedCategoryId)->exists() ? $mappedCategoryId : null; } protected function matchEdiCategoryByRegex(string $value): ?EdiCategory { $candidates = EdiCategory::whereNotNull('regex_pattern')->get(); foreach ($candidates as $candidate) { $pattern = $candidate->regex_pattern; if (! $pattern) { continue; } $delimited = preg_match('/^([#\\/]).+\\1[imsxuADSUXJ]*$/', $pattern) ? $pattern : '/' . $pattern . '/i'; set_error_handler(function () { }); $matched = @preg_match($delimited, $value) === 1; restore_error_handler(); if ($matched) { return $candidate; } } return null; } protected function resolveBandId(Log $log, Round $round): ?int { if (! $log->pband) { return null; } $pbandVal = mb_strtolower(trim($log->pband)); $ediBand = EdiBand::whereRaw('LOWER(value) = ?', [$pbandVal])->first(); if ($ediBand) { $mappedBandId = $ediBand->bands()->value('bands.id'); if (! $mappedBandId) { return null; } if ($round->bands()->count() === 0) { return $mappedBandId; } return $round->bands()->where('bands.id', $mappedBandId)->exists() ? $mappedBandId : null; } $num = is_numeric($pbandVal) ? (float) $pbandVal : null; if ($num === null && $log->pband) { if (preg_match('/([0-9]+(?:[\\.,][0-9]+)?)/', $log->pband, $m)) { $num = (float) str_replace(',', '.', $m[1]); } } if ($num === null) { return null; } $bandMatch = \App\Models\Band::where('edi_band_begin', '<=', $num) ->where('edi_band_end', '>=', $num) ->first(); if (! $bandMatch) { return null; } if ($round->bands()->count() === 0) { return $bandMatch->id; } return $round->bands()->where('bands.id', $bandMatch->id)->exists() ? $bandMatch->id : null; } protected function normalizeCallsignStrict(string $call): string { $value = mb_strtoupper(trim($call)); $value = preg_replace('/\s+/', '', $value); return $value ?? ''; } protected function normalizeCallsignForBusted(string $call, EvaluationRuleSet $ruleSet): string { $value = $this->normalizeCallsignStrict($call); if ($ruleSet->ignoreSlashPart()) { $value = $this->stripCallsignSuffix($value, $ruleSet->callsignSuffixMaxLen()); } elseif ($ruleSet->ignoreThirdPart()) { $parts = explode('/', $value); if (count($parts) > 2) { $value = $parts[0] . '/' . $parts[1]; } } elseif ($ruleSet->callsign_normalization === 'IGNORE_SUFFIX') { $value = $this->stripCallsignSuffix($value, $ruleSet->callsignSuffixMaxLen()); } return $value; } protected function stripCallsignSuffix(string $value, int $maxLen): string { $parts = explode('/', $value); if (count($parts) < 2) { return $value; } $suffix = end($parts) ?: ''; if ($suffix !== '' && mb_strlen($suffix) <= $maxLen) { array_pop($parts); return implode('/', $parts); } return $value; } protected function isCheckLog(Log $log): bool { $psect = trim((string) $log->psect); return $psect !== '' && preg_match('/\\bcheck\\b/i', $psect) === 1; } protected function resolveTiebreakOrder(EvaluationRuleSet $ruleSet): array { // Umožňuje soutěži nastavit pořadí preferencí při výběru kandidáta. $order = $ruleSet->match_tiebreak_order; if (is_array($order) && count($order) > 0) { return $order; } return ['time_diff', 'exchange_match', 'locator_match', 'report_match', 'log_qso_id']; } protected function evaluateCandidate( WorkingQso $aWork, WorkingQso $bWork, int $diffSeconds, \Illuminate\Support\Collection $logQsoMap, EvaluationRuleSet $ruleSet ): ?array { // Shromáždí metriky pro tiebreak (čas, exchange/locator/report match). $a = $logQsoMap->get($aWork->log_qso_id); $b = $logQsoMap->get($bWork->log_qso_id); if (! $a || ! $b) { return null; } $exchange = $this->evaluateExchange($a, $b, $aWork, $bWork, $ruleSet); $locatorMatch = $exchange['locator_match']; $exchangeMatch = $exchange['exchange_match']; $reportMatch = $exchange['report_match']; return [ 'candidate' => $bWork, 'time_diff' => $diffSeconds, 'exchange_match' => $exchangeMatch, 'locator_match' => $locatorMatch, 'report_match' => $reportMatch, 'log_qso_id' => $bWork->log_qso_id, ]; } protected function compareCandidates(?array $a, ?array $b, array $order): int { // Deterministické srovnání podle zadaného pořadí kritérií. if (! $a && ! $b) { return 0; } if (! $a) { return 1; } if (! $b) { return -1; } foreach ($order as $key) { if (! array_key_exists($key, $a) || ! array_key_exists($key, $b)) { continue; } $av = $a[$key]; $bv = $b[$key]; if ($key === 'time_diff' || $key === 'log_qso_id') { if ($av === $bv) { continue; } return $av <=> $bv; } if (is_bool($av) || is_bool($bv)) { if ($av === $bv) { continue; } return $av ? -1 : 1; } } return $a['log_qso_id'] <=> $b['log_qso_id']; } protected function evaluateMatchDecision( WorkingQso $aWork, WorkingQso $bWork, \Illuminate\Support\Collection $logQsoMap, EvaluationRuleSet $ruleSet, int $timeTolerance ): ?array { $a = $logQsoMap->get($aWork->log_qso_id); $b = $logQsoMap->get($bWork->log_qso_id); if (! $a || ! $b) { return null; } if (! $aWork->call_norm || ! $aWork->rcall_norm || ! $bWork->call_norm || ! $bWork->rcall_norm) { return null; } $callMatch = $aWork->call_norm === $bWork->rcall_norm && $aWork->rcall_norm === $bWork->call_norm; $callDistance = $this->maxCallDistance($aWork, $bWork); $maxLev = $ruleSet->callsignLevenshteinMax(); if (! $callMatch && ($callDistance === null || $callDistance > $maxLev)) { return null; } $timeDiff = null; if ($aWork->ts_utc && $bWork->ts_utc) { $timeDiff = abs($aWork->ts_utc->getTimestamp() - $bWork->ts_utc->getTimestamp()); } elseif ($timeTolerance === 0) { return null; } else { $timeDiff = 0; } $timeInTolerance = $timeDiff <= $timeTolerance; $maxTimeDiff = $timeTolerance + (int) ($ruleSet->time_shift_seconds ?? 0); if (! $timeInTolerance && $timeDiff > $maxTimeDiff && ! $ruleSet->allowTimeMismatchPairing()) { return null; } $timeShiftMatch = false; if ($ruleSet->allowTimeShiftOneHour() && $aWork->ts_utc && $bWork->ts_utc) { $shift = (int) ($ruleSet->time_shift_seconds ?? 3600); $shiftForward = abs(($aWork->ts_utc->getTimestamp() + $shift) - $bWork->ts_utc->getTimestamp()); $shiftBackward = abs(($aWork->ts_utc->getTimestamp() - $shift) - $bWork->ts_utc->getTimestamp()); $shiftDiff = min($shiftForward, $shiftBackward); if ($shiftDiff <= $timeTolerance) { $timeShiftMatch = true; } } $exchange = $this->evaluateExchange($a, $b, $aWork, $bWork, $ruleSet); $requireLocatorMatch = $ruleSet->matchRequireLocatorMatch(); $requireExchangeMatch = $ruleSet->matchRequireExchangeMatch(); $serialMismatch = $exchange['missing_serial'] || $exchange['serial_sent_mismatch'] || $exchange['serial_recv_mismatch']; $wwlMismatch = $exchange['missing_wwl'] || $exchange['locator_sent_mismatch'] || $exchange['locator_recv_mismatch']; $rstMismatch = ($ruleSet->exchange_requires_report ?? false) && ($exchange['report_sent_mismatch'] || $exchange['report_recv_mismatch']); $customMismatch = ($ruleSet->exchange_type === 'CUSTOM') && $exchange['custom_mismatch']; $wwlMismatch = $wwlMismatch || ($requireLocatorMatch && ! $exchange['locator_match']); $customMismatch = $customMismatch || ($requireExchangeMatch && ! $exchange['exchange_match']); $callMismatch = ! $callMatch; $mismatchCount = 0; $mismatchCount += $callMismatch ? 1 : 0; $mismatchCount += $rstMismatch ? 1 : 0; $mismatchCount += $serialMismatch ? 1 : 0; $mismatchCount += $wwlMismatch ? 1 : 0; $mismatchCount += $customMismatch ? 1 : 0; $errorFlags = []; if ($exchange['recv_call_mismatch']) { $errorFlags[] = 'BUSTED_CALL_RX'; } if ($exchange['sent_call_mismatch']) { $errorFlags[] = 'BUSTED_CALL_TX'; } if (($ruleSet->exchange_requires_report ?? false) && $exchange['report_recv_mismatch']) { $errorFlags[] = 'BUSTED_RST_RX'; } if (($ruleSet->exchange_requires_report ?? false) && $exchange['report_sent_mismatch']) { $errorFlags[] = 'BUSTED_RST_TX'; } if (($ruleSet->exchange_requires_serial ?? false) && ($exchange['serial_recv_mismatch'] || $exchange['missing_serial'])) { $errorFlags[] = 'BUSTED_SERIAL_RX'; } if (($ruleSet->exchange_requires_serial ?? false) && $exchange['serial_sent_mismatch']) { $errorFlags[] = 'BUSTED_SERIAL_TX'; } if (($ruleSet->exchange_requires_wwl ?? false || $requireLocatorMatch) && ($exchange['locator_recv_mismatch'] || $exchange['missing_wwl'])) { $errorFlags[] = 'BUSTED_LOCATOR_RX'; } if (($ruleSet->exchange_requires_wwl ?? false || $requireLocatorMatch) && $exchange['locator_sent_mismatch']) { $errorFlags[] = 'BUSTED_LOCATOR_TX'; } if ($customMismatch || ($requireExchangeMatch && ! $exchange['exchange_match'])) { $errorFlags[] = 'BUSTED_EXCHANGE'; } $decision = null; if ($timeInTolerance && $callMatch && ! $rstMismatch && ! $serialMismatch && ! $wwlMismatch && ! $customMismatch) { $decision = ['rank' => 0, 'match_type' => 'MATCH_EXACT']; } elseif ($timeInTolerance && $callMatch && $mismatchCount === 1) { if ($rstMismatch) { $decision = ['rank' => 1, 'match_type' => 'MATCH_ONE_ERROR_RST']; } elseif ($serialMismatch) { $decision = ['rank' => 2, 'match_type' => 'MATCH_ONE_ERROR_SERIAL']; } elseif ($wwlMismatch) { $decision = ['rank' => 3, 'match_type' => 'MATCH_ONE_ERROR_WWL']; } elseif ($customMismatch) { $decision = ['rank' => 4, 'match_type' => 'MATCH_ONE_ERROR_CUSTOM']; } } elseif (! $timeInTolerance && $callMatch && $mismatchCount === 0 && $ruleSet->allowTimeMismatchPairing()) { $maxMismatch = $ruleSet->timeMismatchMaxSec(); if ($maxMismatch === null || $timeDiff <= $maxMismatch) { $decision = ['rank' => 5, 'match_type' => 'TIME_MISMATCH']; $errorFlags[] = 'TIME_MISMATCH'; } } elseif ($timeInTolerance && $callMismatch && $mismatchCount === 1 && $callDistance !== null) { $maxLevenshtein = max(0, $ruleSet->callsignLevenshteinMax()); if ($maxLevenshtein >= 1 && $callDistance <= 1) { $decision = ['rank' => 6, 'match_type' => 'MATCH_FUZZY_CALL_1']; } elseif ($maxLevenshtein >= 2 && $callDistance <= 2) { $decision = ['rank' => 7, 'match_type' => 'MATCH_FUZZY_CALL_2']; } } elseif ($timeInTolerance && $mismatchCount === 2) { $decision = ['rank' => 8, 'match_type' => 'MATCH_COMBINED_ERRORS']; } elseif ($timeShiftMatch && $mismatchCount === 1 && $ruleSet->allowTimeShiftOneHour()) { $decision = ['rank' => 9, 'match_type' => 'MATCH_TIME_SHIFT_1H']; $errorFlags[] = 'TIME_SHIFT_1H'; } if (! $decision) { return null; } return [ 'rank' => $decision['rank'], 'match_type' => $decision['match_type'], 'error_flags' => array_values(array_unique($errorFlags)), 'time_mismatch' => $decision['match_type'] === 'TIME_MISMATCH', 'tiebreak' => [ 'time_diff' => $timeDiff, 'exchange_match' => $exchange['exchange_match'], 'locator_match' => $exchange['locator_match'], 'report_match' => $exchange['report_match'], 'log_qso_id' => $bWork->log_qso_id, ], ]; } protected function maxCallDistance(WorkingQso $aWork, WorkingQso $bWork): ?int { if (! $aWork->call_norm || ! $aWork->rcall_norm || ! $bWork->call_norm || ! $bWork->rcall_norm) { return null; } $distanceA = levenshtein($aWork->call_norm, $bWork->rcall_norm); $distanceB = levenshtein($aWork->rcall_norm, $bWork->call_norm); return max($distanceA, $distanceB); } protected function evaluateExchange( LogQso $a, LogQso $b, ?WorkingQso $aWork, ?WorkingQso $bWork, EvaluationRuleSet $ruleSet ): array { // Výsledek porovnání výměn slouží pro busted_exchange i tiebreak. $serialMatch = $this->serialsMatch($a, $b); $locatorMatch = $this->locatorsMatch($aWork, $bWork); $reportMatch = $this->reportsMatch($a, $b, $ruleSet); $reportSentMismatch = $this->reportSideMismatch($a->my_rst, $b->dx_rst, $ruleSet); $reportRecvMismatch = $this->reportSideMismatch($a->dx_rst, $b->my_rst, $ruleSet); $customMatch = $this->customExchangeMatch($a, $b, $ruleSet); $serialSentMatch = $this->serialSentMatch($a, $b); $serialRecvMatch = $this->serialRecvMatch($a, $b); $serialSentMismatch = $serialSentMatch === false; $serialRecvMismatch = $serialRecvMatch === false; $locatorSentMatch = $this->locatorSentMatch($aWork, $bWork); $locatorRecvMatch = $this->locatorRecvMatch($aWork, $bWork); $locatorSentMismatch = $locatorSentMatch === false; $locatorRecvMismatch = $locatorRecvMatch === false; $sentCallMismatch = $this->sentCallMismatch($a, $b, $ruleSet); $recvCallMismatch = $this->recvCallMismatch($a, $b, $ruleSet); $requiresSerial = $ruleSet->exchange_requires_serial; $requiresWwl = $ruleSet->exchange_requires_wwl; $exchangeMatch = true; if ($ruleSet->exchange_type === 'SERIAL') { $exchangeMatch = $serialMatch; } elseif ($ruleSet->exchange_type === 'WWL') { $exchangeMatch = $locatorMatch; } elseif ($ruleSet->exchange_type === 'SERIAL_WWL') { $exchangeMatch = $serialMatch && $locatorMatch; } elseif ($ruleSet->exchange_type === 'CUSTOM') { $exchangeMatch = $customMatch; } $missingSerial = $requiresSerial && ! $this->hasSerialExchange($a, $b); $missingWwl = $requiresWwl && ! $this->hasWwlExchange($aWork, $bWork); $missingReport = $ruleSet->exchange_requires_report && ! $reportMatch; $reportMismatch = $reportSentMismatch || $reportRecvMismatch; $exchangeMismatch = (! $exchangeMatch && $ruleSet->exchange_type !== 'CUSTOM') || ($ruleSet->exchange_type === 'CUSTOM' && ! $customMatch) || $missingSerial || $missingWwl; $shouldDiscardExchange = $ruleSet->discard_qso_rec_diff_code || $ruleSet->discard_qso_sent_diff_code; $bustedExchange = $shouldDiscardExchange && $exchangeMismatch; $bustedExchangeReason = null; if ($bustedExchange) { if ($missingSerial) { $bustedExchangeReason = 'EXCHANGE_SERIAL_MISSING'; } elseif ($missingWwl) { $bustedExchangeReason = 'EXCHANGE_WWL_MISSING'; } elseif ($ruleSet->exchange_type === 'CUSTOM' && ! $customMatch) { $bustedExchangeReason = 'EXCHANGE_CUSTOM_MISMATCH'; } elseif ($ruleSet->exchange_type === 'SERIAL' && ! $serialMatch) { $bustedExchangeReason = 'EXCHANGE_SERIAL_MISMATCH'; } elseif ($ruleSet->exchange_type === 'WWL' && ! $locatorMatch) { $bustedExchangeReason = 'EXCHANGE_WWL_MISMATCH'; } elseif ($ruleSet->exchange_type === 'SERIAL_WWL') { if (! $serialMatch && ! $locatorMatch) { $bustedExchangeReason = 'EXCHANGE_SERIAL_WWL_MISMATCH'; } elseif (! $serialMatch) { $bustedExchangeReason = 'EXCHANGE_SERIAL_MISMATCH'; } elseif (! $locatorMatch) { $bustedExchangeReason = 'EXCHANGE_WWL_MISMATCH'; } } $bustedExchangeReason = $bustedExchangeReason ?? 'EXCHANGE_MISMATCH'; } $bustedRstRx = $ruleSet->exchange_requires_report && $ruleSet->discard_qso_rec_diff_rst && ($reportRecvMismatch || $missingReport); $bustedRstTx = $ruleSet->exchange_requires_report && $ruleSet->discard_qso_sent_diff_rst && $reportSentMismatch; return [ 'serial_match' => $serialMatch, 'serial_sent_mismatch' => $serialSentMismatch, 'serial_recv_mismatch' => $serialRecvMismatch, 'locator_match' => $locatorMatch, 'locator_sent_mismatch' => $locatorSentMismatch, 'locator_recv_mismatch' => $locatorRecvMismatch, 'report_match' => $reportMatch, 'report_sent_mismatch' => $reportSentMismatch, 'report_recv_mismatch' => $reportRecvMismatch, 'exchange_match' => $exchangeMatch, 'busted_exchange' => $bustedExchange, 'busted_exchange_reason' => $bustedExchangeReason, 'busted_rst_rx' => $bustedRstRx, 'busted_rst_tx' => $bustedRstTx, 'custom_match' => $customMatch, 'custom_mismatch' => ! $customMatch, 'missing_serial' => $missingSerial, 'missing_wwl' => $missingWwl, 'sent_call_mismatch' => $sentCallMismatch, 'recv_call_mismatch' => $recvCallMismatch, ]; } protected function serialSentMatch(LogQso $a, LogQso $b): ?bool { if ($a->my_serial === null || $b->dx_serial === null) { return null; } return (string) $a->my_serial === (string) $b->dx_serial; } protected function serialRecvMatch(LogQso $a, LogQso $b): ?bool { if ($a->dx_serial === null || $b->my_serial === null) { return null; } return (string) $a->dx_serial === (string) $b->my_serial; } protected function locatorSentMatch(?WorkingQso $aWork, ?WorkingQso $bWork): ?bool { if (! $aWork || ! $bWork || ! $aWork->loc_norm || ! $bWork->rloc_norm) { return null; } return strtoupper($aWork->loc_norm) === strtoupper($bWork->rloc_norm); } protected function locatorRecvMatch(?WorkingQso $aWork, ?WorkingQso $bWork): ?bool { if (! $aWork || ! $bWork || ! $aWork->rloc_norm || ! $bWork->loc_norm) { return null; } return strtoupper($aWork->rloc_norm) === strtoupper($bWork->loc_norm); } protected function resolveErrorSide( bool $bustedCallRx, bool $bustedCallTx, bool $bustedRstRx, bool $bustedRstTx, bool $bustedSerialRx, bool $bustedSerialTx, bool $bustedWwlRx, bool $bustedWwlTx, bool $timeMismatch ): ?string { if ($timeMismatch) { return 'BOTH'; } $rx = $bustedCallRx || $bustedRstRx || $bustedSerialRx || $bustedWwlRx; $tx = $bustedCallTx || $bustedRstTx || $bustedSerialTx || $bustedWwlTx; if ($rx && $tx) { return 'BOTH'; } if ($rx) { return 'RX'; } if ($tx) { return 'TX'; } return 'NONE'; } protected function resolveMatchConfidence(?string $matchType, bool $timeMismatch): ?string { if (! $matchType) { return null; } if ($matchType === 'MATCH_EXACT') { return 'EXACT'; } if (str_contains($matchType, 'MATCH_FUZZY_CALL')) { return 'FUZZY_CALL'; } if ($timeMismatch || $matchType === 'TIME_MISMATCH') { return 'TIME_MISMATCH'; } return 'PARTIAL'; } protected function sentCallMismatch(LogQso $a, LogQso $b, EvaluationRuleSet $ruleSet): bool { $aMy = $this->normalizeCallsignForBusted($a->my_call ?? '', $ruleSet); $bDx = $this->normalizeCallsignForBusted($b->dx_call ?? '', $ruleSet); if ($aMy === '' || $bDx === '') { return false; } return $aMy !== $bDx; } protected function recvCallMismatch(LogQso $a, LogQso $b, EvaluationRuleSet $ruleSet): bool { $aDx = $this->normalizeCallsignForBusted($a->dx_call ?? '', $ruleSet); $bMy = $this->normalizeCallsignForBusted($b->my_call ?? '', $ruleSet); if ($aDx === '' || $bMy === '') { return false; } return $aDx !== $bMy; } protected function serialsMatch(LogQso $a, LogQso $b): bool { $aSent = $a->my_serial; $aRecv = $a->dx_serial; $bSent = $b->my_serial; $bRecv = $b->dx_serial; if ($aSent === null || $aRecv === null || $bSent === null || $bRecv === null) { return false; } return (string) $aSent === (string) $bRecv && (string) $aRecv === (string) $bSent; } protected function hasSerialExchange(LogQso $a, LogQso $b): bool { return $a->my_serial !== null && $a->dx_serial !== null && $b->my_serial !== null && $b->dx_serial !== null; } protected function locatorsMatch(?WorkingQso $aWork, ?WorkingQso $bWork): bool { if (! $aWork || ! $bWork) { return false; } if (! $aWork->loc_norm || ! $aWork->rloc_norm || ! $bWork->loc_norm || ! $bWork->rloc_norm) { return false; } return strtoupper($aWork->loc_norm) === strtoupper($bWork->rloc_norm) && strtoupper($aWork->rloc_norm) === strtoupper($bWork->loc_norm); } protected function hasWwlExchange(?WorkingQso $aWork, ?WorkingQso $bWork): bool { return $aWork && $bWork && $aWork->loc_norm && $aWork->rloc_norm && $bWork->loc_norm && $bWork->rloc_norm; } protected function reportsMatch(LogQso $a, LogQso $b, EvaluationRuleSet $ruleSet): bool { $aMy = $this->normalizeReport($a->my_rst, $ruleSet); $aDx = $this->normalizeReport($a->dx_rst, $ruleSet); $bMy = $this->normalizeReport($b->my_rst, $ruleSet); $bDx = $this->normalizeReport($b->dx_rst, $ruleSet); if ($aMy === '' || $aDx === '' || $bMy === '' || $bDx === '') { return false; } return $aMy === $bDx && $aDx === $bMy; } protected function reportSideMismatch(?string $sent, ?string $received, EvaluationRuleSet $ruleSet): bool { $sentValue = $this->normalizeReport($sent, $ruleSet); $receivedValue = $this->normalizeReport($received, $ruleSet); if ($sentValue === '' || $receivedValue === '') { return true; } return $sentValue !== $receivedValue; } protected function normalizeReport(?string $value, EvaluationRuleSet $ruleSet): string { $value = strtoupper(trim((string) $value)); $value = preg_replace('/\s+/', '', $value) ?? ''; if ($value === '') { return ''; } if (! $ruleSet->lettersInRst()) { $value = preg_replace('/[A-Z]/', '', $value) ?? ''; } if ($ruleSet->rstIgnoreThirdChar() && strlen($value) >= 3) { $value = substr($value, 0, 2); } return $value; } protected function customExchangeMatch(LogQso $a, LogQso $b, EvaluationRuleSet $ruleSet): bool { // CUSTOM exchange: regex + symetrie hodnot (obě strany musí shodně sedět). $pattern = $ruleSet->exchange_pattern; if (! $pattern) { return true; } $delimited = preg_match('/^([#\\/]).+\\1[imsxuADSUXJ]*$/', $pattern) ? $pattern : '/' . $pattern . '/i'; set_error_handler(function () { }); $aVal = trim((string) $a->rx_exchange); $bVal = trim((string) $b->rx_exchange); $aMatch = @preg_match($delimited, $aVal) === 1; $bMatch = @preg_match($delimited, $bVal) === 1; restore_error_handler(); if (! ($aMatch && $bMatch)) { return false; } return $aVal !== '' && $aVal === $bVal; } protected function applyForcedStatus( string $status, bool &$isValid, bool &$isDuplicate, bool &$isNil, bool &$isBustedCall, bool &$isBustedRst, bool &$isBustedExchange, bool &$isOutOfWindow, ?string &$errorCode, ?string &$errorSide ): void { $isValid = false; $isDuplicate = false; $isNil = false; $isBustedCall = false; $isBustedRst = false; $isBustedExchange = false; $isOutOfWindow = false; $errorCode = null; $errorSide = 'NONE'; switch ($status) { case 'VALID': $isValid = true; $errorCode = QsoErrorCode::OK; break; case 'INVALID': $errorCode = QsoErrorCode::NO_COUNTERPART_LOG; break; case 'NIL': $isNil = true; $errorCode = QsoErrorCode::NO_COUNTERPART_LOG; break; case 'DUPLICATE': $isDuplicate = true; $errorCode = QsoErrorCode::DUP; break; case 'BUSTED_CALL': $isBustedCall = true; $errorCode = QsoErrorCode::BUSTED_CALL; $errorSide = 'RX'; break; case 'BUSTED_EXCHANGE': $isBustedExchange = true; $errorCode = QsoErrorCode::BUSTED_SERIAL; $errorSide = 'RX'; break; case 'OUT_OF_WINDOW': $isOutOfWindow = true; $errorCode = null; break; } } }