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

292 lines
10 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Jobs;
use App\Models\Band;
use App\Models\EdiBand;
use App\Models\EvaluationRun;
use App\Models\EvaluationRuleSet;
use App\Models\Log;
use App\Models\LogOverride;
use App\Models\LogQso;
use App\Models\Round;
use App\Models\WorkingQso;
use App\Services\Evaluation\EvaluationCoordinator;
use App\Services\Evaluation\MatchingService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Bus\Batchable;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Carbon;
use Throwable;
/**
* Job: BuildWorkingSetLogJob
*
* Účel:
* - Vytvoří working set pro jeden log_id.
*/
class BuildWorkingSetLogJob implements ShouldQueue
{
use Batchable;
use Queueable;
public int $tries = 3;
public array $backoff = [30, 120, 300];
protected int $logId;
public function __construct(
protected int $evaluationRunId,
int $logId
) {
$this->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;
}
}