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; } }