292 lines
10 KiB
PHP
292 lines
10 KiB
PHP
<?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;
|
||
}
|
||
}
|