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

154 lines
5.6 KiB
PHP

<?php
namespace App\Services\Evaluation;
use App\Models\EvaluationRuleSet;
/**
* Service: MatchingService
*
* Účel:
* - Implementuje matching (párování) QSO mezi deníky v rámci vyhodnocení.
* - Pro každý QSO záznam hledá odpovídající protizáznam v logu protistanice
* a rozhoduje o stavu QSO (matched / NIL / duplicate / busted / out-of-window).
*
* Kontext v architektuře:
* - Používá se v rámci vyhodnocovací pipeline (EvaluationRun), typicky
* z jobu MatchQsoGroupJob nebo nepřímo přes EvaluationCoordinator.
* - Pracuje nad pracovním datasetem (working set) připraveným BuildWorkingSetLogJob
* a nad pravidly z EvaluationRuleSet (time tolerance, policy/volby).
*
* Co služba dělá:
* - Provede párování QSO v daném scope (group), např.:
* - round_id
* - band_id
* - category_id
* - power_category_id
* - Pro každý QSO:
* - vyhledá kandidátní protistanici podle volací značky (a případných voleb)
* - ověří časovou shodu v rámci tolerance (time_tolerance)
* - ověří shodu výměny (RST / pořadové číslo / kód / lokátor dle soutěže)
* - vyhodnotí příznaky:
* - NIL (nenalezen protizáznam)
* - duplicate (duplicitní QSO)
* - busted_call (neshoda značky)
* - busted_exchange (neshoda výměny / kódu)
* - out_of_window (mimo časové okno kola, pokud se řeší v matchingu)
* - nastaví vazbu na matched QSO (pokud existuje)
*
* Co služba NEDĚLÁ:
* - neprovádí parsing EDI (to je EdiParserService)
* - nepočítá body ani skóre (to je ScoringService)
* - neagreguje výsledky a pořadí (to je ResultsAggregationService)
* - neřeší publikaci výsledků (to je finalizace běhu)
*
* Výstupy služby:
* - Mezivýsledky matchingu vhodné pro uložení do QsoResult (se svázáním
* na evaluation_run_id), případně do pracovních/staging tabulek.
* - Strukturované diagnostiky (např. počty NIL/dup/busted) pro monitoring.
*
* Zásady návrhu:
* - Determinismus: stejné vstupy => stejné párování a stejné příznaky.
* - Idempotence: opakované spuštění pro stejný run+group musí přepsat
* předchozí mezivýsledky bez duplicit.
* - Výkon: matching musí být navržen pro dávkové zpracování (chunking),
* s využitím indexů a minimalizací N+1 dotazů.
* - Pravidla: veškeré odchylky (např. ignorovat část značky za lomítkem,
* tolerovat třetí znak reportu) musí být řízené konfigurací (RuleSet/options)
* a explicitně testované.
*/
class MatchingService
{
/**
* Služba je bezstavová (stateless).
*
* V praxi sem patří injektované závislosti, např.:
* - repository / query helpery pro čtení pracovních dat
* - helpery pro normalizaci značek/lokátorů
* - výpočet vzdálenosti (pokud je potřeba pro rozhodování)
*
* Matching algoritmus a jeho varianty mají být testované a řízené
* konfigurací soutěže (EvaluationRuleSet).
*/
public function __construct()
{
//
}
/**
* Normalizuje volaci znak podle pravidel rulesetu.
*
* - STRICT: pouze trim + uppercase.
* - IGNORE_SUFFIX: odstrani suffix za lomitkem (/P, /M, /9...).
*/
public function normalizeCallsign(string $call, EvaluationRuleSet $ruleSet): string
{
$value = mb_strtoupper(trim($call));
$value = preg_replace('/\s+/', '', $value) ?? '';
if ($ruleSet->ignoreSlashPart()) {
// Ignoruje portable suffixy (/P, /M, /9...), ale ponechá základ prefixu.
$value = $this->stripCallsignSuffix($value, $ruleSet->callsignSuffixMaxLen());
} elseif ($ruleSet->ignoreThirdPart()) {
// Zachová první dvě části (např. OK1ABC/1), zbytek odřízne.
$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;
}
/**
* Rozhodne, zda jsou dve QSO ve stejnem dupe scope.
* MVP: BAND nebo BAND_MODE.
*/
public function isSameDupeScope(array $qsoA, array $qsoB, EvaluationRuleSet $ruleSet): bool
{
// Dupe scope určuje, zda se duplicity řeší jen v pásmu nebo i v rámci módu.
$bandMatch = ($qsoA['band_id'] ?? null) === ($qsoB['band_id'] ?? null);
if (! $bandMatch) {
return false;
}
if ($ruleSet->dupe_scope === 'BAND_MODE') {
return ($qsoA['mode'] ?? null) === ($qsoB['mode'] ?? null);
}
return true;
}
/**
* Vyhodnoti, zda QSO spada mimo casove okno kola.
* MVP: pouze vraci boolean pro dalsi zpracovani.
*/
public function isOutOfWindow(?\DateTimeInterface $qsoTime, ?\DateTimeInterface $start, ?\DateTimeInterface $end): bool
{
if (! $qsoTime || ! $start || ! $end) {
return false;
}
// True => další kroky rozhodnou, zda QSO zneplatnit, penalizovat nebo DQ log.
return $qsoTime < $start || $qsoTime > $end;
}
}