380 lines
13 KiB
PHP
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;
|
|
}
|
|
}
|