Files
vkv/app/Jobs/PrepareRunJob.php
Zdeněk Burda 41e3ce6f25 Initial commit
2026-01-09 21:26:40 +01:00

380 lines
13 KiB
PHP

<?php
namespace App\Jobs;
use App\Models\EvaluationLock;
use App\Models\Band;
use App\Models\EdiBand;
use App\Models\EdiCategory;
use App\Models\EvaluationRun;
use App\Models\Log;
use App\Models\LogOverride;
use App\Models\LogResult;
use App\Models\QsoResult;
use App\Models\Round;
use App\Models\WorkingQso;
use App\Services\Evaluation\EvaluationCoordinator;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Throwable;
/**
* Job: PrepareRunJob
*
* Odpovědnost:
* - Přípravný krok vyhodnocovací pipeline pro jeden EvaluationRun.
* - Cílem je připravit konzistentní pracovní prostředí pro následné kroky
* (parsing, matching, scoring), aby tyto kroky mohly běžet deterministicky.
*
* Kontext:
* - Spouští se pouze jako součást vyhodnocovacího běhu (EvaluationRun)
* a typicky je prvním krokem po StartEvaluationRunJob.
* - Pracuje nad konkrétním rozsahem dat (nejčastěji jedno kolo závodu).
*
* Co job dělá (typicky):
* - Ověří existenci a stav EvaluationRun (např. RUNNING).
* - Načte použité EvaluationRuleSet a zvaliduje základní konfiguraci.
* - Připraví/synchronizuje „scope“ běhu (bandy, kategorie, power kategorie).
* - Provede úklid dočasných/staging dat z předchozího běhu stejného runu
* (nebo pro stejný scope), aby nedocházelo k míchání výsledků.
* - Inicializuje počítadla progressu (progress_total/progress_done) a nastaví
* current_step pro monitoring.
* - Zapíše auditní události (EvaluationRunEvent) pro UI.
*
* Co job NEDĚLÁ:
* - neparsuje EDI soubory
* - neprovádí matching QSO
* - nepočítá skóre
* - nezapisuje finální výsledky
*
* Zásady návrhu:
* - Job musí být idempotentní (opakované spuštění nesmí poškodit stav).
* - Veškerá komplexní logika patří do service layer (např. EvaluationCoordinator).
* - Tento krok by měl být rychlý; těžká práce patří do následných jobů.
*
* Queue:
* - Spouští se ve frontě "evaluation".
* - Nemá běžet paralelně nad stejným scope (ochrana lockem / WithoutOverlapping).
*/
class PrepareRunJob implements ShouldQueue
{
use Queueable;
/**
* Create a new job instance.
*/
public function __construct(
protected int $evaluationRunId
)
{
//
}
/**
* Připraví vyhodnocovací běh na zpracování.
*
* Metoda handle():
* - nastaví krok běhu (current_step)
* - provede validace konfigurace a rozsahu (scope)
* - vyčistí dočasná/staging data relevantní pro tento běh
* - inicializuje progress a auditní události pro UI
*
* Poznámky:
* - Tento job má mít minimální vedlejší efekty mimo svůj scope.
* - Pokud příprava selže, má selhat celý běh (přepnout run do FAILED)
* a poskytnout čitelnou diagnostiku v error / run events.
*/
public function handle(): void
{
$run = EvaluationRun::find($this->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;
}
}