1350 lines
52 KiB
PHP
1350 lines
52 KiB
PHP
<?php
|
|
|
|
namespace App\Jobs;
|
|
|
|
use App\Models\EdiBand;
|
|
use App\Models\EdiCategory;
|
|
use App\Models\EvaluationRun;
|
|
use App\Models\EvaluationRuleSet;
|
|
use App\Models\Log;
|
|
use App\Models\LogQso;
|
|
use App\Models\LogOverride;
|
|
use App\Models\QsoResult;
|
|
use App\Models\QsoOverride;
|
|
use App\Models\Round;
|
|
use App\Models\WorkingQso;
|
|
use App\Enums\QsoErrorCode;
|
|
use App\Services\Evaluation\MatchingService;
|
|
use App\Services\Evaluation\EvaluationCoordinator;
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
use Illuminate\Bus\Batchable;
|
|
use Illuminate\Foundation\Queue\Queueable;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Job: MatchQsoGroupJob
|
|
*
|
|
* Odpovědnost:
|
|
* - Provede matching (párování) QSO pro konkrétní skupinu dat v rámci jednoho
|
|
* vyhodnocovacího běhu (EvaluationRun).
|
|
* - Skupina ("group") typicky odpovídá kombinaci scope parametrů, např.:
|
|
* - round_id
|
|
* - band_id
|
|
* - category_id
|
|
* - power_category_id
|
|
* nebo jednodušší agregaci (např. pouze band).
|
|
*
|
|
* Kontext:
|
|
* - Spouští se po vytvoření working setu (BuildWorkingSetLogJob) a před scoringem.
|
|
* - Je navržen tak, aby bylo možné paralelizovat matching po skupinách
|
|
* (různé bandy/kategorie), ale nikdy nesmí běžet paralelně nad stejnou skupinou.
|
|
*
|
|
* Co job dělá (typicky):
|
|
* - Načte pracovní dataset (working set) pro daný run a group.
|
|
* - Provede párování QSO mezi logy:
|
|
* - najde protistanici (candidate) pro každý QSO záznam
|
|
* - aplikuje časovou toleranci (time_tolerance)
|
|
* - aplikuje pravidla pro kontrolu reportu / kódu / značky
|
|
* (včetně voleb typu "ignorovat část značky za lomítkem")
|
|
* - Označí výsledky matching-u pro další kroky:
|
|
* - matched / unmatched (NIL)
|
|
* - duplicate
|
|
* - busted_call / busted_exchange
|
|
* - out_of_window (pokud se řeší v matchingu)
|
|
* - Uloží mezivýsledky do staging struktur/tabulek svázaných s EvaluationRun,
|
|
* např. QsoResult s evaluation_run_id.
|
|
*
|
|
* Co job NEDĚLÁ:
|
|
* - nepočítá body ani skóre (to je úkol Score*Job)
|
|
* - neagreguje výsledky do pořadí (to je úkol Aggregate/Finalize)
|
|
* - neprovádí parsing EDI souborů
|
|
*
|
|
* Zásady návrhu:
|
|
* - Matching musí být deterministický: stejné vstupy => 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;
|
|
}
|
|
}
|
|
}
|