evaluationRunId); if (! $run || $run->isCanceled()) { return; } $coordinator = new EvaluationCoordinator(); $coordinator->eventInfo($run, 'Prepare: krok spuštěn.', [ 'step' => 'prepare', 'round_id' => $run->round_id, ]); try { $lockKey = "evaluation:round:{$run->round_id}"; $existingLock = EvaluationLock::where('key', $lockKey)->first(); if ($existingLock && (int) $existingLock->evaluation_run_id !== (int) $run->id) { $run->update([ 'status' => 'FAILED', 'error' => 'Nelze spustit vyhodnocení: probíhá jiný běh pro stejné kolo.', ]); $coordinator->eventError($run, 'PrepareRunJob selhal: lock je držen jiným během.', [ 'step' => 'prepare', 'round_id' => $run->round_id, ]); return; } if (! $existingLock) { EvaluationLock::acquire( key: $lockKey, run: $run, ttl: 7200 ); } $run->update([ 'status' => 'RUNNING', 'current_step' => 'prepare', 'started_at' => $run->started_at ?? now(), ]); // Idempotence: vyčisti staging data pro tento run a připrav čistý start. QsoResult::where('evaluation_run_id', $run->id)->delete(); LogResult::where('evaluation_run_id', $run->id)->delete(); WorkingQso::where('evaluation_run_id', $run->id)->delete(); $round = Round::with(['bands', 'categories', 'powerCategories'])->find($run->round_id); if (! $round) { $run->update([ 'status' => 'FAILED', 'error' => 'Kolo nebylo nalezeno.', ]); $coordinator->eventError($run, 'PrepareRunJob selhal: kolo nebylo nalezeno.', [ 'step' => 'prepare', 'round_id' => $run->round_id, ]); return; } // Scope určuje kombinace skupin (band/category/power), které se budou hodnotit. $scope = $run->scope ?? []; $bandIds = $scope['band_ids'] ?? $round->bands->pluck('id')->all(); $categoryIds = $scope['category_ids'] ?? $round->categories->pluck('id')->all(); $powerCategoryIds = $scope['power_category_ids'] ?? $round->powerCategories->pluck('id')->all(); $bandIds = $bandIds ?: [null]; $categoryIds = $categoryIds ?: [null]; $powerCategoryIds = $powerCategoryIds ?: [null]; $groups = []; foreach ($bandIds as $bandId) { foreach ($categoryIds as $categoryId) { foreach ($powerCategoryIds as $powerCategoryId) { $groups[] = [ 'key' => 'b' . ($bandId ?? 0) . '-c' . ($categoryId ?? 0) . '-p' . ($powerCategoryId ?? 0), 'band_id' => $bandId, 'category_id' => $categoryId, 'power_category_id' => $powerCategoryId, ]; } } } $scope['band_ids'] = array_values(array_filter($bandIds)); $scope['category_ids'] = array_values(array_filter($categoryIds)); $scope['power_category_ids'] = array_values(array_filter($powerCategoryIds)); $scope['groups'] = $groups; $run->update([ 'scope' => $scope, 'progress_total' => count($groups), 'progress_done' => 0, ]); $logsQuery = Log::where('round_id', $run->round_id); $logOverrides = LogOverride::where('evaluation_run_id', $run->id)->get()->keyBy('log_id'); // Skeleton log_results umožní pozdější agregaci a ranking bez podmíněného "create". $logsQuery->chunkById(200, function ($logs) use ($run, $round, $logOverrides) { foreach ($logs as $log) { $override = $logOverrides->get($log->id); $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; $sixhrCategory = $override && $override->forced_sixhr_category !== null ? (bool) $override->forced_sixhr_category : $log->sixhr_category; if ($sixhrCategory && ! $this->isSixHourBand($bandId)) { $this->addSixHourRemark($log); } LogResult::updateOrCreate( [ 'evaluation_run_id' => $run->id, 'log_id' => $log->id, ], [ 'status' => 'OK', 'band_id' => $bandId, 'category_id' => $categoryId, 'power_category_id' => $powerCategoryId, 'sixhr_category' => $sixhrCategory, 'claimed_qso_count' => $log->claimed_qso_count, 'claimed_score' => $log->claimed_score, ] ); } }); $coordinator->eventInfo($run, 'Příprava vyhodnocení.', [ 'step' => 'prepare', 'round_id' => $run->round_id, 'groups_total' => count($groups), 'step_progress_done' => 1, 'step_progress_total' => 1, ]); $coordinator->eventInfo($run, 'Prepare: krok dokončen.', [ 'step' => 'prepare', 'round_id' => $run->round_id, ]); } catch (Throwable $e) { $coordinator->eventError($run, 'Prepare: krok selhal.', [ 'step' => 'prepare', 'round_id' => $run->round_id, 'error' => $e->getMessage(), ]); throw $e; } } 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 isSixHourBand(?int $bandId): bool { if (! $bandId) { return false; } return in_array($bandId, [1, 2], true); } protected function addSixHourRemark(Log $log): void { $remarksEval = $this->decodeRemarksEval($log->remarks_eval); $message = '6H kategorie je povolena jen pro pásma 145 MHz a 435 MHz.'; if (! in_array($message, $remarksEval, true)) { $remarksEval[] = $message; $log->remarks_eval = $this->encodeRemarksEval($remarksEval); $log->save(); } } protected function decodeRemarksEval(?string $value): array { if (! $value) { return []; } $decoded = json_decode($value, true); return is_array($decoded) ? $decoded : []; } protected function encodeRemarksEval(array $value): ?string { $filtered = array_values(array_filter($value, fn ($item) => is_string($item) && trim($item) !== '')); $filtered = array_values(array_unique($filtered)); if (count($filtered) === 0) { return null; } return json_encode($filtered, JSON_UNESCAPED_UNICODE); } 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 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 = 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; } }