Initial commit
This commit is contained in:
153
app/Services/Evaluation/MatchingService.php
Normal file
153
app/Services/Evaluation/MatchingService.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user