stejné body. * - Job musí být idempotentní: opakované spuštění přepíše/obnoví výstupy * pro daný run+group bez duplicit. * - Veškerá výpočetní logika patří do service layer (např. ScoringService). * - Job pouze načte kontext, deleguje výpočty a uloží výsledky. * * Queue: * - Spouští se ve frontě "evaluation". * - Musí být chráněn proti souběhu nad stejnou group (lock / WithoutOverlapping). */ class ScoreGroupJob implements ShouldQueue { use Batchable; use Queueable; public int $tries = 3; public array $backoff = [30, 120, 300]; protected array $ctyCache = []; /** * Create a new job instance. */ public function __construct( protected int $evaluationRunId, protected ?string $groupKey = null, protected ?array $group = null ) { // } /** * Provede výpočet bodů pro jednu skupinu (group). * * Metoda handle(): * - získá kontext EvaluationRun + group parametry * - načte mezivýsledky matchingu * - aplikuje pravidla EvaluationRuleSet a spočítá body * - zapíše mezivýsledky pro agregaci a finalizaci * - aktualizuje progress a auditní události pro UI * * Poznámky: * - Tento job má být výkonnostně bezpečný (chunking, minimalizace N+1). * - Pokud scoring jedné skupiny selže, má selhat job (retry), * protože bez kompletního scoringu nelze korektně agregovat výsledky. */ 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, 'Scoring nelze spustit: chybí ruleset.', [ 'step' => 'score', 'round_id' => $run->round_id, ]); return; } $round = Round::with(['bands', 'categories', 'powerCategories'])->find($run->round_id); if (! $round) { return; } $coordinator->eventInfo($run, 'Scoring: krok spuštěn.', [ 'step' => 'score', 'round_id' => $run->round_id, 'group_key' => $this->groupKey, ]); $run->update([ 'status' => 'RUNNING', 'current_step' => 'score', ]); $groups = []; $singleGroup = (bool) ($this->groupKey || $this->group); if ($this->groupKey || $this->group) { $groups[] = [ 'key' => $this->groupKey ?? 'custom', 'band_id' => $this->group['band_id'] ?? null, 'category_id' => $this->group['category_id'] ?? null, 'power_category_id' => $this->group['power_category_id'] ?? null, ]; } elseif (! empty($run->scope['groups']) && is_array($run->scope['groups'])) { $groups = $run->scope['groups']; } else { $groups[] = [ 'key' => 'all', 'band_id' => null, 'category_id' => null, 'power_category_id' => null, ]; } $total = count($groups); if (! $singleGroup) { $run->update([ 'progress_total' => $total, 'progress_done' => 0, ]); } $scoring = new ScoringService(); $logOverrides = LogOverride::where('evaluation_run_id', $run->id)->get()->keyBy('log_id'); $qsoOverrides = QsoOverride::where('evaluation_run_id', $run->id)->get()->keyBy('log_qso_id'); $groupLogIds = $this->groupLogsByKey($round, $ruleSet, $logOverrides); $processed = 0; foreach ($groups as $group) { if (EvaluationRun::isCanceledRun($run->id)) { return; } $processed++; $groupKey = $group['key'] ?? 'all'; $logIds = $groupLogIds[$groupKey] ?? []; $coordinator->eventInfo($run, 'Výpočet skóre.', [ 'step' => 'score', 'round_id' => $run->round_id, 'group_key' => $group['key'] ?? null, 'group' => [ 'band_id' => $group['band_id'] ?? null, 'category_id' => $group['category_id'] ?? null, 'power_category_id' => $group['power_category_id'] ?? null, ], 'group_logs' => count($logIds), 'step_progress_done' => $processed, 'step_progress_total' => $total, ]); if (! $logIds) { if ($singleGroup) { EvaluationRun::where('id', $run->id)->increment('progress_done'); } else { $run->update(['progress_done' => $processed]); } continue; } LogQso::whereIn('log_id', $logIds) ->chunkById(200, function ($qsos) use ($run, $ruleSet, $scoring, $qsoOverrides, $coordinator) { $qsoIds = $qsos->pluck('id')->all(); $working = WorkingQso::where('evaluation_run_id', $run->id) ->whereIn('log_qso_id', $qsoIds) ->get() ->keyBy('log_qso_id'); foreach ($qsos as $qso) { $result = QsoResult::firstOrNew([ 'evaluation_run_id' => $run->id, 'log_qso_id' => $qso->id, ]); // Ruční override může přepsat matching/validaci z předchozího kroku. $override = $qsoOverrides->get($qso->id); if ($override) { $this->applyQsoOverride($result, $override); } $workingQso = $working->get($qso->id); // Vzdálenost se počítá z lokátorů obou stran (pokud existují). $distanceKm = $workingQso ? $scoring->calculateDistanceKm($workingQso->loc_norm, $workingQso->rloc_norm) : null; // V některých soutěžích jsou lokátory povinné pro platné bodování. $requireLocators = $ruleSet->require_locators; $hasLocators = $workingQso && $workingQso->loc_norm && $workingQso->rloc_norm; $result->distance_km = $distanceKm; $points = $scoring->computeBasePoints($distanceKm, $ruleSet); $forcedStatus = $override?->forced_status; $applyPolicy = ! $forcedStatus || $forcedStatus === 'AUTO'; if ($applyPolicy) { $result->is_valid = true; } $result->penalty_points = 0; if ($applyPolicy && $requireLocators && ! $hasLocators) { $result->is_valid = false; } // Out-of-window policy určuje, jak bodovat QSO mimo časové okno. if ($applyPolicy && $result->is_time_out_of_window) { $policy = $scoring->outOfWindowDecision($ruleSet); $decision = $this->applyPolicyDecision($policy, $points, false); if ($result->is_valid) { $result->is_valid = $decision['is_valid']; } $points = $decision['points']; } $result->error_code = $this->resolveErrorCode($result); $errorCode = $result->error_code; $errorSide = $result->error_side ?? 'NONE'; if ($applyPolicy) { if ($errorCode && ! in_array($errorCode, QsoErrorCode::all(), true)) { $coordinator->eventWarn($run, 'Scoring: neznámý error_code.', [ 'step' => 'score', 'round_id' => $run->round_id, 'log_qso_id' => $qso->id, 'error_code' => $errorCode, ]); $result->is_valid = false; } else { $points = $this->applyErrorPolicy($ruleSet, $errorCode, $errorSide, $points, $result); } } $result->points = $points; $result->penalty_points = $result->is_valid ? $this->resolvePenaltyPoints($result, $ruleSet, $scoring) : 0; // Multiplikátory se ukládají per-QSO a agregují až v AggregateLogResultsJob. $this->applyMultipliers($result, $qso, $workingQso, $ruleSet); if ($override && $override->forced_points !== null) { // Ruční override má přednost před vypočtenými body. $result->points = (float) $override->forced_points; } $result->save(); } }); if ($singleGroup) { EvaluationRun::where('id', $run->id)->increment('progress_done'); } else { $run->update(['progress_done' => $processed]); } } $coordinator->eventInfo($run, 'Scoring: krok dokončen.', [ 'step' => 'score', 'round_id' => $run->round_id, 'group_key' => $this->groupKey, ]); } catch (Throwable $e) { $coordinator->eventError($run, 'Scoring: krok selhal.', [ 'step' => 'score', 'round_id' => $run->round_id, 'group_key' => $this->groupKey, 'error' => $e->getMessage(), ]); throw $e; } } protected function groupLogsByKey( Round $round, EvaluationRuleSet $ruleSet, \Illuminate\Support\Collection $logOverrides ): array { $logs = Log::where('round_id', $round->id)->get(); $map = []; foreach ($logs as $log) { if (! $ruleSet->checklog_matching && $this->isCheckLog($log)) { continue; } $override = $logOverrides->get($log->id); if ($override && $override->forced_log_status === 'IGNORED') { continue; } $bandId = $override && $override->forced_band_id ? (int) $override->forced_band_id : $this->resolveBandId($log, $round); $categoryId = $override && $override->forced_category_id ? (int) $override->forced_category_id : $this->resolveCategoryId($log, $round); $powerCategoryId = $override && $override->forced_power_category_id ? (int) $override->forced_power_category_id : $log->power_category_id; $key = 'b' . ($bandId ?? 0) . '-c' . ($categoryId ?? 0) . '-p' . ($powerCategoryId ?? 0); $map[$key][] = $log->id; } return $map; } protected function resolveCategoryId(Log $log, Round $round): ?int { $value = $log->psect; if (! $value) { return null; } $ediCat = EdiCategory::whereRaw('LOWER(value) = ?', [mb_strtolower(trim($value))])->first(); if (! $ediCat) { $ediCat = $this->matchEdiCategoryByRegex($value); } if (! $ediCat) { return null; } $mappedCategoryId = $ediCat->categories()->value('categories.id'); if (! $mappedCategoryId) { return null; } if ($round->categories()->count() === 0) { return $mappedCategoryId; } return $round->categories()->where('categories.id', $mappedCategoryId)->exists() ? $mappedCategoryId : null; } protected function matchEdiCategoryByRegex(string $value): ?EdiCategory { $candidates = EdiCategory::whereNotNull('regex_pattern')->get(); foreach ($candidates as $candidate) { $pattern = $candidate->regex_pattern; if (! $pattern) { continue; } $delimited = preg_match('/^([#\\/]).+\\1[imsxuADSUXJ]*$/', $pattern) ? $pattern : '/' . $pattern . '/i'; set_error_handler(function () { }); $matched = @preg_match($delimited, $value) === 1; restore_error_handler(); if ($matched) { return $candidate; } } return null; } protected function isCheckLog(Log $log): bool { $psect = trim((string) $log->psect); return $psect !== '' && preg_match('/\\bcheck\\b/i', $psect) === 1; } protected function resolveBandId(Log $log, Round $round): ?int { if (! $log->pband) { return null; } $pbandVal = mb_strtolower(trim($log->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; } $num = is_numeric($pbandVal) ? (float) $pbandVal : null; if ($num === null && $log->pband) { if (preg_match('/([0-9]+(?:[\\.,][0-9]+)?)/', $log->pband, $m)) { $num = (float) str_replace(',', '.', $m[1]); } } if ($num === null) { return null; } $bandMatch = \App\Models\Band::where('edi_band_begin', '<=', $num) ->where('edi_band_end', '>=', $num) ->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 applyPolicyDecision(string $policy, int $points, bool $keepPointsOnPenalty): array { $policy = strtoupper(trim($policy)); return match ($policy) { 'INVALID' => ['is_valid' => false, 'points' => $points], 'ZERO_POINTS' => ['is_valid' => true, 'points' => 0], 'FLAG_ONLY' => ['is_valid' => true, 'points' => $points], 'PENALTY' => ['is_valid' => true, 'points' => $keepPointsOnPenalty ? $points : 0], default => ['is_valid' => true, 'points' => $points], }; } protected function resolvePolicyForError(EvaluationRuleSet $ruleSet, ?string $errorCode): ?string { return match ($errorCode) { QsoErrorCode::DUP => $ruleSet->dup_qso_policy ?? 'ZERO_POINTS', QsoErrorCode::NO_COUNTERPART_LOG => $ruleSet->getString( 'no_counterpart_log_policy', $ruleSet->no_counterpart_log_policy ?? null, $ruleSet->nil_qso_policy ?? 'ZERO_POINTS' ), QsoErrorCode::NOT_IN_COUNTERPART_LOG => $ruleSet->getString( 'not_in_counterpart_log_policy', $ruleSet->not_in_counterpart_log_policy ?? null, $ruleSet->nil_qso_policy ?? 'ZERO_POINTS' ), QsoErrorCode::UNIQUE => $ruleSet->getString( 'unique_qso_policy', $ruleSet->unique_qso_policy ?? null, 'ZERO_POINTS' ), QsoErrorCode::BUSTED_CALL => $ruleSet->busted_call_policy ?? 'ZERO_POINTS', QsoErrorCode::BUSTED_RST => $ruleSet->busted_rst_policy ?? 'ZERO_POINTS', QsoErrorCode::BUSTED_SERIAL => $ruleSet->getString( 'busted_serial_policy', $ruleSet->busted_serial_policy ?? null, $ruleSet->busted_exchange_policy ?? 'ZERO_POINTS' ), QsoErrorCode::BUSTED_LOCATOR => $ruleSet->getString( 'busted_locator_policy', $ruleSet->busted_locator_policy ?? null, $ruleSet->busted_exchange_policy ?? 'ZERO_POINTS' ), QsoErrorCode::TIME_MISMATCH => $ruleSet->time_mismatch_policy ?? 'ZERO_POINTS', default => null, }; } /** * Scoring policy (error_code → policy → efekt). * * - INVALID → is_valid=false, points beze změny * - ZERO_POINTS → is_valid=true, points=0 * - FLAG_ONLY → is_valid=true, points beze změny * - PENALTY → is_valid=true, points=0 (u BUSTED_RST body ponechány) * * Poznámka: is_valid se určuje až ve scoringu, není přebíráno z matchingu. */ protected function applyErrorPolicy( EvaluationRuleSet $ruleSet, ?string $errorCode, string $errorSide, int $points, QsoResult $result ): int { if (! $errorCode || $errorCode === QsoErrorCode::OK) { return $points; } if (in_array($errorCode, [ QsoErrorCode::BUSTED_CALL, QsoErrorCode::BUSTED_RST, QsoErrorCode::BUSTED_SERIAL, QsoErrorCode::BUSTED_LOCATOR, ], true) && $errorSide === 'TX') { return $points; } $policy = $this->resolvePolicyForError($ruleSet, $errorCode); if (! $policy) { return $points; } $keepPointsOnPenalty = $errorCode === QsoErrorCode::BUSTED_RST; $decision = $this->applyPolicyDecision($policy, $points, $keepPointsOnPenalty); if ($result->is_valid) { $result->is_valid = $decision['is_valid']; } return $decision['points']; } protected function applyMultipliers( QsoResult $result, LogQso $qso, ?WorkingQso $workingQso, EvaluationRuleSet $ruleSet ): void { // Multiplikátor se ukládá do QSO a agreguje se až v AggregateLogResultsJob. $result->wwl = null; $result->dxcc = null; $result->country = null; $result->section = null; if (! $ruleSet->usesMultipliers()) { return; } if ($ruleSet->multiplier_type === 'WWL') { $result->wwl = $this->formatWwlMultiplier($workingQso?->rloc_norm, $ruleSet); return; } if ($ruleSet->multiplier_type === 'SECTION') { $result->section = $this->normalizeSection($qso->rx_exchange); return; } if (in_array($ruleSet->multiplier_type, ['DXCC', 'COUNTRY'], true)) { // DXCC/COUNTRY se odvozují z protistanice přes CTY prefix mapu. $call = $workingQso?->rcall_norm ?: $qso->dx_call; $cty = $this->resolveCtyForCall($call); if ($cty) { if ($ruleSet->multiplier_type === 'DXCC' && $cty->dxcc) { $result->dxcc = (string) $cty->dxcc; } if ($ruleSet->multiplier_type === 'COUNTRY') { $result->country = $cty->country_name; } } } } protected function normalizeSection(?string $value): ?string { $value = trim((string) $value); if ($value === '') { return null; } $value = strtoupper(preg_replace('/\s+/', '', $value) ?? ''); return $value !== '' ? substr($value, 0, 50) : null; } protected function resolveCtyForCall(?string $call): ?Cty { $call = strtoupper(trim((string) $call)); if ($call === '') { return null; } if (array_key_exists($call, $this->ctyCache)) { return $this->ctyCache[$call]; } // Nejprve zkus přesný match (precise=true), potom nejdelší prefix. $precise = Cty::where('prefix_norm', $call) ->where('precise', true) ->first(); if ($precise) { $this->ctyCache[$call] = $precise; return $precise; } $prefixes = []; $len = strlen($call); for ($i = $len; $i >= 1; $i--) { $prefixes[] = substr($call, 0, $i); } $match = Cty::whereIn('prefix_norm', $prefixes) ->where('precise', false) ->orderByRaw('LENGTH(prefix_norm) DESC') ->first(); $this->ctyCache[$call] = $match; return $match; } protected function applyQsoOverride(QsoResult $result, QsoOverride $override): void { if ($override->forced_matched_log_qso_id !== null) { $result->matched_qso_id = $override->forced_matched_log_qso_id; $result->matched_log_qso_id = $override->forced_matched_log_qso_id; $result->is_nil = false; } if (! $override->forced_status || $override->forced_status === 'AUTO') { return; } $result->is_valid = false; $result->is_duplicate = false; $result->is_nil = false; $result->is_busted_call = false; $result->is_busted_rst = false; $result->is_busted_exchange = false; $result->is_time_out_of_window = false; $result->error_code = null; $result->error_side = 'NONE'; $result->penalty_points = 0; switch ($override->forced_status) { case 'VALID': $result->is_valid = true; $result->error_code = QsoErrorCode::OK; break; case 'INVALID': $result->error_code = QsoErrorCode::NO_COUNTERPART_LOG; break; case 'NIL': $result->is_nil = true; $result->error_code = QsoErrorCode::NO_COUNTERPART_LOG; break; case 'DUPLICATE': $result->is_duplicate = true; $result->error_code = QsoErrorCode::DUP; break; case 'BUSTED_CALL': $result->is_busted_call = true; $result->error_code = QsoErrorCode::BUSTED_CALL; $result->error_side = 'RX'; break; case 'BUSTED_EXCHANGE': $result->is_busted_exchange = true; $result->error_code = QsoErrorCode::BUSTED_SERIAL; $result->error_side = 'RX'; break; case 'OUT_OF_WINDOW': $result->is_time_out_of_window = true; $result->error_code = null; break; } } protected function resolveErrorCode(QsoResult $result): ?string { if ($result->error_code) { return $result->error_code; } if ($result->is_duplicate) { return QsoErrorCode::DUP; } if ($result->is_nil) { return QsoErrorCode::NO_COUNTERPART_LOG; } if ($result->is_busted_call) { return QsoErrorCode::BUSTED_CALL; } if ($result->is_busted_rst) { return QsoErrorCode::BUSTED_RST; } if ($result->is_busted_exchange) { return QsoErrorCode::BUSTED_SERIAL; } return $result->is_valid ? QsoErrorCode::OK : null; } protected function resolvePenaltyPoints(QsoResult $result, EvaluationRuleSet $ruleSet, ScoringService $scoring): int { $penalty = 0; $errorSide = $result->error_side ?? 'NONE'; if ($result->error_code === QsoErrorCode::DUP && $ruleSet->dup_qso_policy === 'PENALTY') { $penalty += $scoring->penaltyPointsFor(QsoErrorCode::DUP, $ruleSet); } if ($result->error_code === QsoErrorCode::NO_COUNTERPART_LOG) { $policy = $ruleSet->getString( 'no_counterpart_log_policy', $ruleSet->no_counterpart_log_policy ?? null, $ruleSet->nil_qso_policy ?? 'ZERO_POINTS' ); if ($policy === 'PENALTY') { $penalty += $scoring->penaltyPointsFor('NIL', $ruleSet); } } if ($result->error_code === QsoErrorCode::NOT_IN_COUNTERPART_LOG) { $policy = $ruleSet->getString( 'not_in_counterpart_log_policy', $ruleSet->not_in_counterpart_log_policy ?? null, $ruleSet->nil_qso_policy ?? 'ZERO_POINTS' ); if ($policy === 'PENALTY') { $penalty += $scoring->penaltyPointsFor('NIL', $ruleSet); } } if ($result->error_code === QsoErrorCode::BUSTED_CALL && $errorSide !== 'TX' && $ruleSet->busted_call_policy === 'PENALTY') { $penalty += $scoring->penaltyPointsFor(QsoErrorCode::BUSTED_CALL, $ruleSet); } if ($result->error_code === QsoErrorCode::BUSTED_RST && $errorSide !== 'TX' && $ruleSet->busted_rst_policy === 'PENALTY') { $penalty += $scoring->penaltyPointsFor(QsoErrorCode::BUSTED_RST, $ruleSet); } if ($result->error_code === QsoErrorCode::BUSTED_SERIAL && $errorSide !== 'TX') { $policy = $ruleSet->getString( 'busted_serial_policy', $ruleSet->busted_serial_policy ?? null, $ruleSet->busted_exchange_policy ?? 'ZERO_POINTS' ); if ($policy === 'PENALTY') { $penalty += $scoring->penaltyPointsFor(QsoErrorCode::BUSTED_SERIAL, $ruleSet); } } if ($result->error_code === QsoErrorCode::BUSTED_LOCATOR && $errorSide !== 'TX') { $policy = $ruleSet->getString( 'busted_locator_policy', $ruleSet->busted_locator_policy ?? null, $ruleSet->busted_exchange_policy ?? 'ZERO_POINTS' ); if ($policy === 'PENALTY') { $penalty += $scoring->penaltyPointsFor(QsoErrorCode::BUSTED_LOCATOR, $ruleSet); } } if ($result->error_code === QsoErrorCode::TIME_MISMATCH && ($ruleSet->time_mismatch_policy ?? 'ZERO_POINTS') === 'PENALTY') { $penalty += $scoring->penaltyPointsFor(QsoErrorCode::TIME_MISMATCH, $ruleSet); } if ($result->is_time_out_of_window && $ruleSet->out_of_window_policy === 'PENALTY') { $penalty += $scoring->penaltyPointsFor(QsoErrorCode::OUT_OF_WINDOW, $ruleSet); } return $penalty; } protected function formatWwlMultiplier(?string $locator, EvaluationRuleSet $ruleSet): ?string { if (! $locator) { return null; } $value = strtoupper(trim($locator)); $value = preg_replace('/\s+/', '', $value) ?? ''; if ($value === '') { return null; } $length = match ($ruleSet->wwl_multiplier_level) { 'LOCATOR_2' => 2, 'LOCATOR_4' => 4, default => 6, }; if (strlen($value) < $length) { return null; } return substr($value, 0, $length); } }