logId = $logId; } public function handle(): void { $run = EvaluationRun::find($this->evaluationRunId); if (! $run || $run->isCanceled()) { return; } $coordinator = new EvaluationCoordinator(); try { $ruleSet = EvaluationRuleSet::find($run->rule_set_id); if (! $ruleSet) { $coordinator->eventError($run, 'Working set nelze připravit: chybí ruleset.', [ 'step' => 'build_working_set', 'round_id' => $run->round_id, ]); return; } $round = Round::with(['bands'])->find($run->round_id); if (! $round) { return; } $log = Log::find($this->logId); if (! $log || (int) $log->round_id !== (int) $run->round_id) { return; } $override = LogOverride::where('evaluation_run_id', $run->id) ->where('log_id', $this->logId) ->first(); if ($override && $override->forced_log_status === 'IGNORED') { return; } $logLocator = $log->pwwlo; $logCallsign = $log->pcall; $logBand = $log->pband; $total = LogQso::where('log_id', $this->logId)->count(); if ($total === 0) { return; } $matcher = new MatchingService(); $processed = 0; $lastReported = 0; LogQso::where('log_id', $this->logId) ->chunkById(200, function ($qsos) use ($run, $round, $ruleSet, $matcher, $total, &$processed, &$lastReported, $override, $logLocator, $logCallsign, $logBand, $coordinator) { foreach ($qsos as $qso) { if (EvaluationRun::isCanceledRun($run->id)) { return false; } $processed++; $errors = []; $rawMyCall = $qso->my_call ?: ($logCallsign ?? ''); $callNorm = $matcher->normalizeCallsign($rawMyCall, $ruleSet); $rcallNorm = $matcher->normalizeCallsign($qso->dx_call ?? '', $ruleSet); // Lokátor může být v QSO nebo jen v hlavičce logu (PWWLo) – ber jako fallback. $rawLocator = $qso->my_locator ?: ($logLocator ?? null); $locNorm = $this->normalizeLocator($rawLocator); if ($rawLocator && $locNorm === null) { $errors[] = 'INVALID_LOCATOR'; } $rlocNorm = $this->normalizeLocator($qso->rx_wwl); if ($qso->rx_wwl && $rlocNorm === null) { $errors[] = 'INVALID_RLOCATOR'; } $bandId = $override && $override->forced_band_id ? (int) $override->forced_band_id : $this->resolveBandId($qso, $round); if (! $bandId) { $bandId = $this->resolveBandIdFromPband($logBand ?? null, $round); } $mode = $qso->mode_code ?: $qso->mode; $modeNorm = $mode ? mb_strtoupper(trim($mode)) : null; $matchKey = $bandId && $callNorm && $rcallNorm ? $bandId . '|' . $callNorm . '|' . $rcallNorm : null; // Klíč pro detekci duplicit – závisí na dupe_scope v rulesetu. $dupeKey = null; if ($bandId && $rcallNorm) { $dupeKey = $bandId . '|' . $rcallNorm; if ($ruleSet->dupe_scope === 'BAND_MODE') { $dupeKey .= '|' . ($modeNorm ?? ''); } } $tsUtc = $qso->time_on ? Carbon::parse($qso->time_on)->utc() : null; // Out-of-window se řeší per QSO, ale v agregaci může vést až k DQ celého logu. $outOfWindow = $matcher->isOutOfWindow($tsUtc, $round->start_time, $round->end_time); WorkingQso::updateOrCreate( [ 'evaluation_run_id' => $run->id, 'log_qso_id' => $qso->id, ], [ 'log_id' => $qso->log_id, 'ts_utc' => $tsUtc, 'call_norm' => $callNorm ?: null, 'rcall_norm' => $rcallNorm ?: null, 'loc_norm' => $locNorm, 'rloc_norm' => $rlocNorm, 'band_id' => $bandId, 'mode' => $modeNorm, 'match_key' => $matchKey, 'dupe_key' => $dupeKey, 'out_of_window' => $outOfWindow, 'errors' => $errors ?: null, ] ); if ($processed - $lastReported >= 100 || $processed === $total) { $delta = $processed - $lastReported; if ($delta > 0) { EvaluationRun::where('id', $run->id)->increment('progress_done', $delta); $lastReported = $processed; } } if ($processed % 500 === 0 || $processed === $total) { $coordinator->eventInfo($run, "Working set: {$processed}/{$total}", [ 'step' => 'build_working_set', 'round_id' => $run->round_id, 'step_progress_done' => $processed, 'step_progress_total' => $total, ]); } } }); } catch (Throwable $e) { $coordinator->eventError($run, 'Working set log: krok selhal.', [ 'step' => 'build_working_set', 'round_id' => $run->round_id, 'log_id' => $this->logId, 'error' => $e->getMessage(), ]); throw $e; } } protected function normalizeLocator(?string $value): ?string { if (! $value) { return null; } $normalized = strtoupper(trim($value)); $normalized = preg_replace('/\\s+/', '', $normalized) ?? ''; $normalized = substr($normalized, 0, 6); if (! preg_match('/^[A-R]{2}[0-9]{2}([A-X]{2})?$/', $normalized)) { return null; } return $normalized; } protected function resolveBandId(LogQso $qso, Round $round): ?int { $bandValue = $qso->band; if ($bandValue) { $pbandVal = mb_strtolower(trim($bandValue)); $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; } } $freqKHz = $qso->freq_khz; if (! $freqKHz) { return null; } $mhz = $freqKHz / 1000; $bandMatch = Band::where('edi_band_begin', '<=', $mhz) ->where('edi_band_end', '>=', $mhz) ->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 resolveBandIdFromPband(?string $pband, Round $round): ?int { if (! $pband) { return null; } $pbandVal = mb_strtolower(trim($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; } if (preg_match('/([0-9]+(?:[\\.,][0-9]+)?)/', $pbandVal, $m)) { $mhz = (float) str_replace(',', '.', $m[1]); $bandMatch = Band::where('edi_band_begin', '<=', $mhz) ->where('edi_band_end', '>=', $mhz) ->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; } return null; } }