Initial commit

This commit is contained in:
Zdeněk Burda
2026-01-09 21:26:40 +01:00
parent e83aec6dca
commit 41e3ce6f25
404 changed files with 61250 additions and 28 deletions

View File

@@ -0,0 +1,291 @@
<?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;
}
}