154 lines
5.6 KiB
PHP
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;
|
|
}
|
|
}
|