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,429 @@
<?php
namespace App\Jobs;
use App\Enums\QsoErrorCode;
use App\Models\EvaluationRun;
use App\Models\EvaluationRuleSet;
use App\Models\LogQso;
use App\Models\LogResult;
use App\Models\QsoResult;
use App\Services\Evaluation\EvaluationCoordinator;
use App\Services\Evaluation\OperatingWindowService;
use Illuminate\Bus\Batchable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Throwable;
/**
* Job: AggregateLogResultsJob
*
* Účel:
* - Agreguje výsledky pro jeden log_id v rámci evaluation runu.
*/
class AggregateLogResultsJob implements ShouldQueue
{
use Batchable;
use Queueable;
public int $tries = 2;
public array $backoff = [60];
public function __construct(
protected int $evaluationRunId,
protected int $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, 'Agregace nelze spustit: chybí ruleset.', [
'step' => 'aggregate',
]);
return;
}
$timeDiffThresholdSec = $ruleSet->time_diff_dq_threshold_sec;
$timeDiffThresholdPercent = $ruleSet->time_diff_dq_threshold_percent;
$badQsoThresholdPercent = $ruleSet->bad_qso_dq_threshold_percent;
$logResult = LogResult::firstOrNew([
'evaluation_run_id' => $run->id,
'log_id' => $this->logId,
]);
// 6H je omezení agregace, nikoli matchingu; dříve to byl jen flag bez operating-window logiky.
$useOperatingWindow = $logResult->sixhr_category
&& $ruleSet->operating_window_mode === 'BEST_CONTIGUOUS'
&& (int) $ruleSet->operating_window_hours === 6;
if ($useOperatingWindow) {
$service = new OperatingWindowService();
$window = $service->pickBestOperatingWindow($run->id, $this->logId, 6, $ruleSet);
if ($window) {
$this->applyOperatingWindow($run->id, $logResult, $window);
} else {
$this->resetOperatingWindow($run->id, $logResult);
}
} else {
$this->resetOperatingWindow($run->id, $logResult);
}
$stats = [
'base_score' => 0,
'penalty_score' => 0,
'valid_qso_count' => 0,
'dupe_qso_count' => 0,
'busted_qso_count' => 0,
'other_error_qso_count' => 0,
'out_of_window_qso_count' => 0,
'total_qso_count' => 0,
'discarded_qso_count' => 0,
'discarded_points' => 0,
'unique_qso_count' => 0,
'bad_qso_count' => 0,
'matched_qso_count' => 0,
'time_diff_over_threshold_count' => 0,
'multipliers' => [],
];
$query = QsoResult::query()
->where('qso_results.evaluation_run_id', $run->id)
->join('log_qsos', 'log_qsos.id', '=', 'qso_results.log_qso_id')
->leftJoin('working_qsos', function ($join) use ($run) {
$join->on('working_qsos.log_qso_id', '=', 'qso_results.log_qso_id')
->where('working_qsos.evaluation_run_id', '=', $run->id);
})
->where('log_qsos.log_id', $this->logId)
->select([
'qso_results.id',
'qso_results.log_qso_id',
'qso_results.points',
'qso_results.penalty_points',
'qso_results.time_diff_sec',
'qso_results.error_code',
'qso_results.error_side',
'qso_results.is_nil',
'qso_results.is_duplicate',
'qso_results.is_busted_call',
'qso_results.is_busted_rst',
'qso_results.is_busted_exchange',
'qso_results.is_time_out_of_window',
'qso_results.is_valid',
'qso_results.matched_qso_id',
'qso_results.wwl',
'qso_results.dxcc',
'qso_results.country',
'qso_results.section',
'working_qsos.band_id as band_id',
]);
if ($useOperatingWindow) {
$query->where('qso_results.is_operating_window_excluded', false);
}
$query->chunkById(1000, function ($rows) use (&$stats, $ruleSet, $timeDiffThresholdSec, $run) {
foreach ($rows as $row) {
if (EvaluationRun::isCanceledRun($run->id)) {
return false;
}
$stats['total_qso_count']++;
if ($row->is_valid) {
$stats['base_score'] += (int) $row->points;
}
$errorCode = $row->error_code;
$errorSide = $row->error_side ?? 'NONE';
$isNil = (bool) $row->is_nil
|| in_array($errorCode, [QsoErrorCode::NOT_IN_COUNTERPART_LOG, QsoErrorCode::NO_COUNTERPART_LOG], true);
$isUnique = $errorCode === QsoErrorCode::UNIQUE;
$isDuplicate = (bool) $row->is_duplicate || $errorCode === QsoErrorCode::DUP;
$isBusted = (bool) $row->is_busted_call
|| (bool) $row->is_busted_rst
|| (bool) $row->is_busted_exchange
|| (in_array($errorCode, [
QsoErrorCode::BUSTED_CALL,
QsoErrorCode::BUSTED_RST,
QsoErrorCode::BUSTED_SERIAL,
QsoErrorCode::BUSTED_LOCATOR,
], true)
&& $errorSide !== 'TX');
$isTimeMismatch = $errorCode === QsoErrorCode::TIME_MISMATCH;
$isOutOfWindow = (bool) $row->is_time_out_of_window;
$isValid = (bool) $row->is_valid;
if ($isValid) {
$stats['valid_qso_count']++;
} else {
$stats['discarded_qso_count']++;
$stats['discarded_points'] += (int) ($row->points ?? 0);
}
if ($isDuplicate) {
$stats['dupe_qso_count']++;
}
if ($isBusted) {
$stats['busted_qso_count']++;
}
if ($isOutOfWindow) {
$stats['out_of_window_qso_count']++;
}
if ($isNil || $isOutOfWindow || $isUnique) {
$stats['other_error_qso_count']++;
}
if ($isDuplicate || $isBusted || $isOutOfWindow || $isTimeMismatch || $isUnique) {
$stats['bad_qso_count']++;
}
if ($isUnique) {
$stats['unique_qso_count']++;
}
if ($row->matched_qso_id !== null) {
$stats['matched_qso_count']++;
if (
$timeDiffThresholdSec !== null
&& $row->time_diff_sec !== null
&& (int) $row->time_diff_sec > (int) $timeDiffThresholdSec
) {
$stats['time_diff_over_threshold_count']++;
}
}
$penalty = (int) ($row->penalty_points ?? 0);
if ($row->is_valid && $penalty !== 0) {
$stats['penalty_score'] -= $penalty;
}
if ($ruleSet->usesMultipliers()) {
$bandKey = $ruleSet->multiplier_scope === 'PER_BAND'
? (int) ($row->band_id ?? 0)
: 0;
if (! isset($stats['multipliers'][$bandKey])) {
$stats['multipliers'][$bandKey] = [];
}
$eligible = false;
if ($ruleSet->multiplier_source === 'VALID_ONLY') {
$eligible = ! $isNil && ! $isDuplicate && ! $isBusted && ! $isOutOfWindow && (bool) $row->is_valid;
} elseif ($ruleSet->multiplier_source === 'ALL_MATCHED') {
$eligible = $row->matched_qso_id !== null
&& ! $isNil
&& ! $isDuplicate
&& ! $isBusted
&& ! $isOutOfWindow;
}
if ($eligible) {
$multiplier = null;
if ($ruleSet->multiplier_type === 'WWL') {
$multiplier = $row->wwl;
} elseif ($ruleSet->multiplier_type === 'DXCC') {
$multiplier = $row->dxcc;
} elseif ($ruleSet->multiplier_type === 'COUNTRY') {
$multiplier = $row->country;
} elseif ($ruleSet->multiplier_type === 'SECTION') {
$multiplier = $row->section;
}
if ($multiplier) {
$stats['multipliers'][$bandKey][$multiplier] = true;
}
}
}
}
}, 'qso_results.id', 'id');
if ($stats['total_qso_count'] === 0) {
$logResult->update([
'base_score' => 0,
'penalty_score' => 0,
'multiplier_count' => 0,
'multiplier_score' => 0,
'official_score' => 0,
'valid_qso_count' => 0,
'dupe_qso_count' => 0,
'busted_qso_count' => 0,
'other_error_qso_count' => 0,
'total_qso_count' => 0,
'discarded_qso_count' => 0,
'discarded_points' => 0,
'discarded_qso_percent' => 0,
'unique_qso_count' => 0,
'score_per_qso' => null,
]);
EvaluationRun::where('id', $run->id)->increment('progress_done');
return;
}
$multiplierCount = 1;
if ($ruleSet->usesMultipliers()) {
$multiplierCount = 0;
foreach ($stats['multipliers'] as $values) {
$multiplierCount += count($values);
}
}
$baseScore = (int) $stats['base_score'];
$penaltyScore = (int) $stats['penalty_score'];
$scoreBeforeMultiplier = $baseScore + $penaltyScore;
if (! $ruleSet->usesMultipliers()) {
$multiplierCount = 1;
}
$multiplierScore = $ruleSet->usesMultipliers() ? $scoreBeforeMultiplier * $multiplierCount : $scoreBeforeMultiplier;
$officialScore = max(0, $multiplierScore);
$totalQsoCount = (int) ($stats['total_qso_count'] ?? 0);
$discardedQsoCount = (int) ($stats['discarded_qso_count'] ?? 0);
$discardedPercent = $totalQsoCount > 0
? round(($discardedQsoCount / $totalQsoCount) * 100, 2)
: 0;
$validQsoCount = (int) ($stats['valid_qso_count'] ?? 0);
$scorePerQso = $validQsoCount > 0 ? round($officialScore / $validQsoCount, 2) : null;
$update = [
'base_score' => $baseScore,
'penalty_score' => $penaltyScore,
'multiplier_count' => $multiplierCount,
'multiplier_score' => $multiplierScore,
'official_score' => $officialScore,
'valid_qso_count' => $stats['valid_qso_count'],
'dupe_qso_count' => $stats['dupe_qso_count'],
'busted_qso_count' => $stats['busted_qso_count'],
'other_error_qso_count' => $stats['other_error_qso_count'],
'total_qso_count' => $totalQsoCount,
'discarded_qso_count' => $discardedQsoCount,
'discarded_points' => (int) ($stats['discarded_points'] ?? 0),
'discarded_qso_percent' => $discardedPercent,
'unique_qso_count' => (int) ($stats['unique_qso_count'] ?? 0),
'score_per_qso' => $scorePerQso,
];
$outOfWindowThreshold = $ruleSet->out_of_window_dq_threshold;
if ($outOfWindowThreshold && in_array($logResult->status, ['OK', 'CHECK'], true)) {
$outOfWindowCount = (int) ($stats['out_of_window_qso_count'] ?? 0);
if ($outOfWindowCount >= (int) $outOfWindowThreshold) {
$reason = 'OUT_OF_WINDOW >= ' . (int) $outOfWindowThreshold;
$update['status'] = 'DQ';
$update['status_reason'] = $logResult->status_reason
? $logResult->status_reason . '; ' . $reason
: $reason;
}
}
if (
$timeDiffThresholdSec !== null
&& $timeDiffThresholdPercent !== null
&& in_array($logResult->status, ['OK', 'CHECK'], true)
) {
$matchedCount = (int) ($stats['matched_qso_count'] ?? 0);
if ($matchedCount > 0) {
$overCount = (int) ($stats['time_diff_over_threshold_count'] ?? 0);
$percent = ($overCount / $matchedCount) * 100;
if ($percent > (float) $timeDiffThresholdPercent) {
$percentLabel = rtrim(rtrim(number_format($percent, 1, '.', ''), '0'), '.');
$reason = sprintf(
'TIME_DIFF > %ss (%s/%s = %s%%)',
(int) $timeDiffThresholdSec,
$overCount,
$matchedCount,
$percentLabel
);
$update['status'] = 'DQ';
$update['status_reason'] = $logResult->status_reason
? $logResult->status_reason . '; ' . $reason
: $reason;
}
}
}
if ($badQsoThresholdPercent !== null && in_array($logResult->status, ['OK', 'CHECK'], true)) {
$totalCount = (int) ($stats['total_qso_count'] ?? 0);
if ($totalCount > 0) {
$badCount = (int) ($stats['bad_qso_count'] ?? 0);
$percent = ($badCount / $totalCount) * 100;
if ($percent >= (float) $badQsoThresholdPercent) {
$percentLabel = rtrim(rtrim(number_format($percent, 1, '.', ''), '0'), '.');
$reason = sprintf(
'BAD_QSO >= %s%% (%s/%s = %s%%)',
(int) $badQsoThresholdPercent,
$badCount,
$totalCount,
$percentLabel
);
$update['status'] = 'DQ';
$update['status_reason'] = $logResult->status_reason
? $logResult->status_reason . '; ' . $reason
: $reason;
}
}
}
$logResult->update($update);
EvaluationRun::where('id', $run->id)->increment('progress_done');
} catch (Throwable $e) {
$coordinator->eventError($run, 'Aggregate log: krok selhal.', [
'step' => 'aggregate',
'round_id' => $run->round_id,
'log_id' => $this->logId,
'error' => $e->getMessage(),
]);
throw $e;
}
}
protected function applyOperatingWindow(int $runId, LogResult $logResult, array $window): void
{
$logQsoIds = LogQso::where('log_id', $logResult->log_id)->pluck('id')->all();
if (! $logQsoIds) {
$this->resetOperatingWindow($runId, $logResult);
return;
}
$logResult->update([
'operating_window_start_utc' => $window['startUtc'],
'operating_window_end_utc' => $window['endUtc'],
'operating_window_2_start_utc' => $window['secondStartUtc'] ?? null,
'operating_window_2_end_utc' => $window['secondEndUtc'] ?? null,
'operating_window_hours' => 6,
'operating_window_qso_count' => $window['qsoCount'],
]);
QsoResult::where('evaluation_run_id', $runId)
->whereIn('log_qso_id', $logQsoIds)
->update(['is_operating_window_excluded' => true]);
if (! empty($window['includedLogQsoIds'])) {
QsoResult::where('evaluation_run_id', $runId)
->whereIn('log_qso_id', $window['includedLogQsoIds'])
->update(['is_operating_window_excluded' => false]);
}
}
protected function resetOperatingWindow(int $runId, LogResult $logResult): void
{
$logResult->update([
'operating_window_start_utc' => null,
'operating_window_end_utc' => null,
'operating_window_2_start_utc' => null,
'operating_window_2_end_utc' => null,
'operating_window_hours' => null,
'operating_window_qso_count' => null,
]);
$logQsoIds = LogQso::where('log_id', $logResult->log_id)->pluck('id')->all();
if (! $logQsoIds) {
return;
}
QsoResult::where('evaluation_run_id', $runId)
->whereIn('log_qso_id', $logQsoIds)
->update(['is_operating_window_excluded' => false]);
}
}

View File

@@ -0,0 +1,159 @@
<?php
namespace App\Jobs;
use App\Models\EvaluationRun;
use App\Models\Log;
use App\Models\LogOverride;
use App\Models\LogResult;
use App\Services\Evaluation\EvaluationCoordinator;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Throwable;
/**
* Job: ApplyLogOverridesJob
*
* Použije ruční override nad log_results po agregaci, aby se do pořadí
* promítl rozhodčí stav/kategorie/power, ale agregované skóre zůstalo zachováno.
*/
class ApplyLogOverridesJob implements ShouldQueue
{
use Queueable;
public int $tries = 2;
public array $backoff = [60];
public function __construct(
protected int $evaluationRunId
) {
}
public function handle(): void
{
$run = EvaluationRun::find($this->evaluationRunId);
if (! $run || $run->isCanceled()) {
return;
}
$coordinator = new EvaluationCoordinator();
try {
$coordinator->eventInfo($run, 'Apply log overrides: krok spuštěn.', [
'step' => 'apply_log_overrides',
'round_id' => $run->round_id,
]);
$overrides = LogOverride::where('evaluation_run_id', $this->evaluationRunId)->get();
if ($overrides->isEmpty()) {
$coordinator->eventInfo($run, 'Apply log overrides: nic ke zpracování.', [
'step' => 'apply_log_overrides',
'round_id' => $run->round_id,
]);
$coordinator->eventInfo($run, 'Apply log overrides: krok dokončen.', [
'step' => 'apply_log_overrides',
'round_id' => $run->round_id,
]);
return;
}
foreach ($overrides as $override) {
if (EvaluationRun::isCanceledRun($run->id)) {
return;
}
$data = [];
if ($override->forced_log_status && $override->forced_log_status !== 'AUTO') {
$data['status'] = $override->forced_log_status;
}
if ($override->forced_band_id !== null) {
$data['band_id'] = $override->forced_band_id;
}
if ($override->forced_category_id !== null) {
$data['category_id'] = $override->forced_category_id;
}
if ($override->forced_power_category_id !== null) {
$data['power_category_id'] = $override->forced_power_category_id;
}
if ($override->forced_sixhr_category !== null) {
$data['sixhr_category'] = $override->forced_sixhr_category;
}
if (! $data) {
continue;
}
$logResult = LogResult::where('evaluation_run_id', $this->evaluationRunId)
->where('log_id', $override->log_id)
->first();
if (! $logResult) {
continue;
}
$bandId = $data['band_id'] ?? $logResult->band_id;
$sixhrCategory = $data['sixhr_category'] ?? $logResult->sixhr_category;
if ($sixhrCategory && ! $this->isSixHourBand($bandId)) {
$this->addSixHourRemark($override->log_id);
}
$logResult->update($data);
}
$coordinator->eventInfo($run, 'Apply log overrides: krok dokončen.', [
'step' => 'apply_log_overrides',
'round_id' => $run->round_id,
]);
} catch (Throwable $e) {
$coordinator->eventError($run, 'Apply log overrides: krok selhal.', [
'step' => 'apply_log_overrides',
'round_id' => $run->round_id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
protected function isSixHourBand(?int $bandId): bool
{
if (! $bandId) {
return false;
}
return in_array($bandId, [1, 2], true);
}
protected function addSixHourRemark(int $logId): void
{
$log = Log::find($logId);
if (! $log) {
return;
}
$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);
}
}

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;
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace App\Jobs;
use App\Models\EvaluationRun;
use App\Models\LogResult;
use App\Services\Evaluation\EvaluationCoordinator;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Bus;
use Throwable;
/**
* Job: DispatchAggregateResultsJobsJob
*
* Účel:
* - Rozdělí agregaci výsledků na menší joby podle log_id.
* - Spustí batch jobů AggregateLogResultsJob a po dokončení naváže
* ApplyLogOverridesJob + RecalculateOfficialRanksJob + PauseEvaluationRunJob.
*/
class DispatchAggregateResultsJobsJob implements ShouldQueue
{
use Queueable;
public int $tries = 2;
public array $backoff = [60];
public function __construct(
protected int $evaluationRunId
) {
}
public function handle(): void
{
$run = EvaluationRun::find($this->evaluationRunId);
if (! $run || $run->isCanceled()) {
return;
}
$coordinator = new EvaluationCoordinator();
try {
$coordinator->eventInfo($run, 'Aggregate: krok spuštěn.', [
'step' => 'aggregate',
'round_id' => $run->round_id,
]);
$logIds = LogResult::where('evaluation_run_id', $run->id)
->pluck('log_id')
->all();
$run->update([
'status' => 'RUNNING',
'current_step' => 'aggregate',
'progress_total' => count($logIds),
'progress_done' => 0,
]);
$jobs = [];
foreach ($logIds as $logId) {
$jobs[] = new AggregateLogResultsJob($run->id, (int) $logId);
}
$next = function () use ($run) {
Bus::chain([
new ApplyLogOverridesJob($run->id),
new RecalculateOfficialRanksJob($run->id),
new PauseEvaluationRunJob(
$run->id,
'WAITING_REVIEW_SCORE',
'waiting_review_score',
'Čeká na kontrolu skóre.'
),
])->onQueue('evaluation')->dispatch();
};
if (! $jobs) {
$next();
return;
}
$batch = Bus::batch($jobs)
->then($next)
->onQueue('evaluation')
->dispatch();
$run->update(['batch_id' => $batch->id]);
} catch (Throwable $e) {
$coordinator->eventError($run, 'Aggregate: krok selhal.', [
'step' => 'aggregate',
'round_id' => $run->round_id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
}

View File

@@ -0,0 +1,129 @@
<?php
namespace App\Jobs;
use App\Models\EvaluationRun;
use App\Models\EvaluationRuleSet;
use App\Models\Log;
use App\Models\LogOverride;
use App\Models\LogQso;
use App\Models\WorkingQso;
use App\Services\Evaluation\EvaluationCoordinator;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Bus;
use Throwable;
/**
* Job: DispatchBuildWorkingSetJobsJob
*
* Účel:
* - Rozdělí build working set do menších jobů podle log_id.
* - Spustí batch jobů BuildWorkingSetLogJob a po dokončení pokračuje pipeline.
*/
class DispatchBuildWorkingSetJobsJob implements ShouldQueue
{
use Queueable;
public int $tries = 2;
public array $backoff = [30];
public function __construct(
protected int $evaluationRunId
) {
}
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;
}
$coordinator->eventInfo($run, 'Working set: krok spuštěn.', [
'step' => 'build_working_set',
'round_id' => $run->round_id,
]);
$run->update([
'status' => 'RUNNING',
'current_step' => 'build_working_set',
'progress_total' => 0,
'progress_done' => 0,
]);
WorkingQso::where('evaluation_run_id', $run->id)->delete();
$logIds = Log::where('round_id', $run->round_id)->pluck('id');
$logOverrides = LogOverride::where('evaluation_run_id', $run->id)->get()->keyBy('log_id');
$ignoredLogIds = $logOverrides
->filter(fn ($override) => $override->forced_log_status === 'IGNORED')
->keys()
->all();
if ($ignoredLogIds) {
$logIds = $logIds->reject(fn ($id) => in_array($id, $ignoredLogIds, true))->values();
}
$total = $logIds->isEmpty()
? 0
: LogQso::whereIn('log_id', $logIds)->count();
$run->update([
'progress_total' => $total,
'progress_done' => 0,
]);
$coordinator->eventInfo($run, 'Příprava working setu.', [
'step' => 'build_working_set',
'round_id' => $run->round_id,
'step_progress_done' => 0,
'step_progress_total' => $total,
]);
$jobs = [];
foreach ($logIds as $logId) {
$jobs[] = new BuildWorkingSetLogJob($run->id, (int) $logId);
}
$next = function () use ($run) {
Bus::chain([
new PauseEvaluationRunJob(
$run->id,
'WAITING_REVIEW_INPUT',
'waiting_review_input',
'Čeká na kontrolu vstupů.'
),
])->onQueue('evaluation')->dispatch();
};
if (! $jobs) {
$next();
return;
}
$batch = Bus::batch($jobs)
->then($next)
->onQueue('evaluation')
->dispatch();
$run->update(['batch_id' => $batch->id]);
} catch (Throwable $e) {
$coordinator->eventError($run, 'Working set: krok selhal.', [
'step' => 'build_working_set',
'round_id' => $run->round_id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Jobs;
use App\Models\EvaluationRun;
use App\Services\Evaluation\EvaluationCoordinator;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Throwable;
/**
* Job: DispatchMatchJobsJob
*
* Účel:
* - Koordinuje matchovací část pipeline (PASS 1 + PASS 2) po skupinách.
* - Nastaví progress pro krok match a připraví navazující kroky
* (UnpairedClassificationJob, DuplicateResolutionJob).
*
* Vstup:
* - evaluation_run_id
*
* Výstup:
* - Spuštěné batch joby MatchQsoBucketJob (PASS 1/2),
* následné joby pro klasifikaci a duplicity.
*
* Co job NEDĚLÁ:
* - neprovádí matching ani scoring,
* - nezapisuje QSO výsledky,
* - neagreguje výsledky.
*/
class DispatchMatchJobsJob implements ShouldQueue
{
use Queueable;
public int $tries = 2;
public array $backoff = [30];
public function __construct(
protected int $evaluationRunId
) {
}
public function handle(): void
{
$run = EvaluationRun::find($this->evaluationRunId);
if (! $run || $run->isCanceled()) {
return;
}
$coordinator = app(EvaluationCoordinator::class);
$coordinator->eventInfo($run, 'Dispatch match: krok spuštěn.', [
'step' => 'dispatch_match',
'round_id' => $run->round_id,
]);
try {
$coordinator->dispatchStep($run, 'match');
$coordinator->eventInfo($run, 'Dispatch match: krok dokončen.', [
'step' => 'dispatch_match',
'round_id' => $run->round_id,
]);
} catch (Throwable $e) {
$coordinator->eventError($run, 'Dispatch match: krok selhal.', [
'step' => 'dispatch_match',
'round_id' => $run->round_id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Jobs;
use App\Models\EvaluationRun;
use App\Models\Log;
use App\Jobs\DispatchBuildWorkingSetJobsJob;
use App\Jobs\RecalculateClaimedRanksJob;
use App\Services\Evaluation\EvaluationCoordinator;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Carbon;
use Throwable;
/**
* Job: DispatchParseLogsJobsJob
*
* Účel:
* - Rozdělí parsování logů do menších jobů podle log_id.
* - Spustí batch jobů ParseLogJob a po dokončení pokračuje pipeline.
*/
class DispatchParseLogsJobsJob implements ShouldQueue
{
use Queueable;
public int $tries = 2;
public array $backoff = [30];
public function __construct(
protected int $evaluationRunId
) {
}
public function handle(): void
{
$run = EvaluationRun::find($this->evaluationRunId);
if (! $run || $run->isCanceled()) {
return;
}
$coordinator = new EvaluationCoordinator();
try {
$coordinator->eventInfo($run, 'Parsování logů: krok spuštěn.', [
'step' => 'parse_logs',
'round_id' => $run->round_id,
]);
$logIds = Log::where('round_id', $run->round_id)->pluck('id');
$total = $logIds->count();
$run->update([
'status' => 'RUNNING',
'current_step' => 'parse_logs',
'progress_total' => $total,
'progress_done' => 0,
'started_at' => $run->started_at ?? Carbon::now(),
]);
$jobs = [];
foreach ($logIds as $logId) {
$jobs[] = new ParseLogJob($run->id, (int) $logId);
}
$next = function () use ($run) {
if ($run->rules_version === 'CLAIMED') {
RecalculateClaimedRanksJob::dispatch($run->id)
->delay(now()->addSeconds(10))
->onQueue('evaluation');
}
Bus::chain([
new DispatchBuildWorkingSetJobsJob($run->id),
])->onQueue('evaluation')->dispatch();
};
if (! $jobs) {
$next();
return;
}
$batch = Bus::batch($jobs)
->then($next)
->onQueue('evaluation')
->dispatch();
$run->update(['batch_id' => $batch->id]);
} catch (Throwable $e) {
$message = "DispatchParseLogsJobsJob selhal (run {$run->id}): {$e->getMessage()}";
\Log::error($message);
$coordinator->eventError($run, $message, [
'step' => 'parse_logs',
'round_id' => $run->round_id,
]);
throw $e;
}
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Jobs;
use App\Models\EvaluationRun;
use App\Services\Evaluation\EvaluationCoordinator;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Throwable;
/**
* Job: DispatchScoreJobsJob
*
* Připraví batch scoring jobů a nastaví korektní progress pro krok score.
*/
class DispatchScoreJobsJob implements ShouldQueue
{
use Queueable;
public int $tries = 2;
public array $backoff = [30];
public function __construct(
protected int $evaluationRunId
) {
}
public function handle(): void
{
$run = EvaluationRun::find($this->evaluationRunId);
if (! $run || $run->isCanceled()) {
return;
}
$coordinator = app(EvaluationCoordinator::class);
$coordinator->eventInfo($run, 'Dispatch score: krok spuštěn.', [
'step' => 'dispatch_score',
'round_id' => $run->round_id,
]);
try {
$coordinator->dispatchStep($run, 'score');
$coordinator->eventInfo($run, 'Dispatch score: krok dokončen.', [
'step' => 'dispatch_score',
'round_id' => $run->round_id,
]);
} catch (Throwable $e) {
$coordinator->eventError($run, 'Dispatch score: krok selhal.', [
'step' => 'dispatch_score',
'round_id' => $run->round_id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace App\Jobs;
use App\Models\EvaluationRun;
use App\Models\WorkingQso;
use App\Services\Evaluation\EvaluationCoordinator;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Bus;
use Throwable;
/**
* Job: DispatchUnpairedJobsJob
*
* Účel:
* - Rozdělí klasifikaci nenapárovaných QSO do bucketů podle band_id + rcall_norm.
* - Spustí batch jobů UnpairedClassificationBucketJob a po dokončení naváže
* DuplicateResolutionJob.
*/
class DispatchUnpairedJobsJob implements ShouldQueue
{
use Queueable;
public int $tries = 2;
public array $backoff = [30];
public function __construct(
protected int $evaluationRunId
) {
}
public function handle(): void
{
$run = EvaluationRun::find($this->evaluationRunId);
if (! $run || $run->isCanceled()) {
return;
}
$coordinator = new EvaluationCoordinator();
$coordinator->eventInfo($run, 'Dispatch unpaired: krok spuštěn.', [
'step' => 'dispatch_unpaired',
'round_id' => $run->round_id,
]);
try {
$buckets = WorkingQso::where('evaluation_run_id', $run->id)
->whereNotNull('rcall_norm')
->distinct()
->get(['band_id', 'rcall_norm']);
$jobs = [];
foreach ($buckets as $bucket) {
$jobs[] = new UnpairedClassificationBucketJob(
$run->id,
$bucket->band_id !== null ? (int) $bucket->band_id : null,
$bucket->rcall_norm
);
}
$run->refresh();
$progressDone = (int) $run->progress_done;
$progressTotal = $progressDone + count($jobs) + 1;
$run->update([
'progress_total' => $progressTotal,
'progress_done' => $progressDone,
]);
$next = function () use ($run) {
Bus::chain([
new DuplicateResolutionJob($run->id),
new PauseEvaluationRunJob(
$run->id,
'WAITING_REVIEW_MATCH',
'waiting_review_match',
'Čeká na kontrolu matchingu.'
),
])->onQueue('evaluation')->dispatch();
};
if (! $jobs) {
$next();
return;
}
$batch = Bus::batch($jobs)
->then($next)
->onQueue('evaluation')
->dispatch();
$run->update(['batch_id' => $batch->id]);
$coordinator->eventInfo($run, 'Dispatch unpaired: krok dokončen.', [
'step' => 'dispatch_unpaired',
'round_id' => $run->round_id,
]);
} catch (Throwable $e) {
$coordinator->eventError($run, 'Dispatch unpaired: krok selhal.', [
'step' => 'dispatch_unpaired',
'round_id' => $run->round_id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
}

View File

@@ -0,0 +1,163 @@
<?php
namespace App\Jobs;
use App\Models\EvaluationRun;
use App\Models\EvaluationRuleSet;
use App\Models\QsoResult;
use App\Models\WorkingQso;
use App\Enums\QsoErrorCode;
use App\Services\Evaluation\EvaluationCoordinator;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Throwable;
/**
* Job: DuplicateResolutionJob
*
* WHY:
* - Duplicitní QSO se musí rozhodnout po matchingu, aby bylo jasné,
* které záznamy jsou spárované a v jakém pořadí.
* ORDER:
* - Spouští se po UnpairedClassificationJob (match je hotový, error_code stabilní).
* - Krok je nevratný: duplicitní QSO jsou označena DUP a další kroky
* jen počítají body podle policy.
*
* Vstup:
* - WorkingQso (dupe_key per log)
* - QsoResult s error_code/matched_log_qso_id
* - EvaluationRuleSet (dup_resolution_strategy)
*
* Výstup:
* - Nastavení DUP u všech „non-survivor“ QSO
*
* Co job NEDĚLÁ:
* - neprovádí matching protistanic,
* - nepočítá body ani penalizace,
* - neupravuje původní log_qsos.
*/
class DuplicateResolutionJob implements ShouldQueue
{
use Queueable;
public int $tries = 2;
public array $backoff = [60];
public function __construct(
protected int $evaluationRunId
) {
}
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, 'Duplicitní QSO nelze vyhodnotit: chybí ruleset.', [
'step' => 'duplicate_resolution',
]);
return;
}
$coordinator->eventInfo($run, 'Duplicate: krok spuštěn.', [
'step' => 'duplicate_resolution',
'round_id' => $run->round_id,
]);
$coordinator->eventInfo($run, 'Detekce duplicitních QSO.', [
'step' => 'match',
'round_id' => $run->round_id,
'step_progress_done' => null,
'step_progress_total' => $run->progress_total,
]);
$strategy = $ruleSet->dupResolutionStrategy();
$working = WorkingQso::where('evaluation_run_id', $run->id)->get();
$byLog = $working->groupBy('log_id');
foreach ($byLog as $logId => $items) {
if (EvaluationRun::isCanceledRun($run->id)) {
return;
}
$byDupeKey = $items->groupBy('dupe_key');
foreach ($byDupeKey as $dupeKey => $dupes) {
if (! $dupeKey || $dupes->count() < 2) {
continue;
}
$sorted = $dupes->sort(function ($a, $b) use ($strategy, $run) {
$resultA = QsoResult::where('evaluation_run_id', $run->id)
->where('log_qso_id', $a->log_qso_id)
->first();
$resultB = QsoResult::where('evaluation_run_id', $run->id)
->where('log_qso_id', $b->log_qso_id)
->first();
foreach ($strategy as $rule) {
if ($rule === 'paired_first') {
$aPaired = $resultA && $resultA->matched_log_qso_id !== null;
$bPaired = $resultB && $resultB->matched_log_qso_id !== null;
if ($aPaired !== $bPaired) {
return $aPaired ? -1 : 1;
}
}
if ($rule === 'ok_first') {
$aOk = $resultA && $resultA->error_code === QsoErrorCode::OK;
$bOk = $resultB && $resultB->error_code === QsoErrorCode::OK;
if ($aOk !== $bOk) {
return $aOk ? -1 : 1;
}
}
if ($rule === 'earlier_time') {
$tsA = $a->ts_utc?->getTimestamp() ?? PHP_INT_MAX;
$tsB = $b->ts_utc?->getTimestamp() ?? PHP_INT_MAX;
if ($tsA !== $tsB) {
return $tsA <=> $tsB;
}
}
if ($rule === 'lower_id') {
if ($a->log_qso_id !== $b->log_qso_id) {
return $a->log_qso_id <=> $b->log_qso_id;
}
}
}
return $a->log_qso_id <=> $b->log_qso_id;
})->values();
$survivor = $sorted->shift();
foreach ($sorted as $dupe) {
QsoResult::where('evaluation_run_id', $run->id)
->where('log_qso_id', $dupe->log_qso_id)
->update([
'is_duplicate' => true,
'is_valid' => false,
'error_code' => QsoErrorCode::DUP,
'error_side' => 'NONE',
]);
}
}
}
EvaluationRun::where('id', $run->id)->increment('progress_done');
$coordinator->eventInfo($run, 'Duplicate: krok dokončen.', [
'step' => 'duplicate_resolution',
'round_id' => $run->round_id,
]);
} catch (Throwable $e) {
$coordinator->eventError($run, 'Duplicate: krok selhal.', [
'step' => 'duplicate_resolution',
'round_id' => $run->round_id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
}

141
app/Jobs/FinalizeRunJob.php Normal file
View File

@@ -0,0 +1,141 @@
<?php
namespace App\Jobs;
use App\Models\EvaluationLock;
use App\Models\EvaluationRun;
use App\Services\Evaluation\EvaluationCoordinator;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Throwable;
/**
* Job: FinalizeRunJob
*
* Odpovědnost:
* - Finální krok vyhodnocovací pipeline pro jeden EvaluationRun.
* - Přepíná vyhodnocovací běh z technického stavu "rozpracováno" do
* konzistentního, uzavřeného stavu, který je připraven k prezentaci
* nebo publikaci výsledků.
*
* Kontext:
* - Spouští se po úspěšném dokončení agregace výsledků
* (AggregateLogResultsJob / DispatchAggregateResultsJobsJob).
* - Pracuje výhradně s agregovanými daty svázanými s evaluation_run_id.
*
* Co job dělá (typicky):
* - Ověří, že všechny předchozí kroky pipeline byly úspěšně dokončeny.
* - Provede finální validace výsledků (např. konzistence součtů).
* - Označí agregované výsledky jako finální pro daný EvaluationRun:
* - nastaví příznaky typu is_final / is_official
* - případně provede přesun z dočasných (staging) struktur
* do finálních tabulek
* - Aktualizuje stav EvaluationRun:
* - status -> SUCCEEDED (nebo FAILED při chybě)
* - nastaví finished_at
* - Uvolní locky držené pro scope vyhodnocení.
* - Zapíše závěrečné auditní události (EvaluationRunEvent).
*
* Co job NEDĚLÁ:
* - nepočítá body ani skóre
* - nemění výsledky jednotlivých QSO
* - neřeší export ani prezentaci v UI
*
* Zásady návrhu:
* - Tento krok musí být atomický z pohledu stavu vyhodnocení.
* - Při selhání musí být EvaluationRun jednoznačně označen jako FAILED
* a nesmí zůstat v nekonzistentním stavu.
* - Veškerá logika patří do service layer (např. EvaluationFinalizerService).
*
* Queue:
* - Spouští se ve frontě "evaluation".
* - Nemá běžet paralelně nad stejným EvaluationRun.
*/
class FinalizeRunJob implements ShouldQueue
{
use Queueable;
public int $tries = 2;
public array $backoff = [60];
/**
* Create a new job instance.
*/
public function __construct(
protected int $evaluationRunId,
protected ?string $lockKey = null
)
{
//
}
/**
* Finalizuje vyhodnocovací běh.
*
* Metoda handle():
* - provede závěrečné kontroly konzistence dat
* - označí výsledky jako finální / oficiální
* - přepne EvaluationRun do koncového stavu (SUCCEEDED / FAILED)
* - uvolní zdroje a locky držené během vyhodnocení
*
* Poznámky:
* - Tento job je posledním krokem pipeline.
* - Po jeho úspěšném dokončení musí být možné výsledky bezpečně
* zobrazit nebo exportovat.
*/
public function handle(): void
{
$run = EvaluationRun::find($this->evaluationRunId);
if (! $run || $run->isCanceled()) {
return;
}
$coordinator = new EvaluationCoordinator();
try {
$coordinator->eventInfo($run, 'Finalize: krok spuštěn.', [
'step' => 'finalize',
'round_id' => $run->round_id,
]);
// Po ručních zásazích může být potřeba znovu aplikovat override a přepočítat pořadí.
(new ApplyLogOverridesJob($run->id))->handle();
(new RecalculateOfficialRanksJob($run->id))->handle();
$coordinator->eventInfo($run, 'Před finalizací byl znovu aplikován override a přepočítáno pořadí.', [
'step' => 'finalize',
'round_id' => $run->round_id,
]);
$run->update([
'status' => 'SUCCEEDED',
'current_step' => 'finalize',
'progress_total' => 1,
'progress_done' => 1,
'finished_at' => now(),
]);
$coordinator->eventInfo($run, 'Vyhodnocení dokončeno.', [
'step' => 'finalize',
'round_id' => $run->round_id,
'step_progress_done' => 1,
'step_progress_total' => 1,
]);
$coordinator->eventInfo($run, 'Finalize: krok dokončen.', [
'step' => 'finalize',
'round_id' => $run->round_id,
]);
} catch (Throwable $e) {
$run->update([
'status' => 'FAILED',
'error' => $e->getMessage(),
'finished_at' => now(),
]);
$coordinator->eventError($run, "FinalizeRunJob selhal: {$e->getMessage()}", [
'step' => 'finalize',
'round_id' => $run->round_id,
]);
throw $e;
} finally {
// Lock musí být uvolněn vždy, i při chybě jinak zablokuje další běhy.
$lockKey = $this->lockKey ?? "evaluation:round:{$run->round_id}";
EvaluationLock::release($lockKey, $run);
}
}
}

View File

@@ -0,0 +1,432 @@
<?php
namespace App\Jobs;
use App\Enums\QsoErrorCode;
use App\Models\EvaluationRun;
use App\Models\EvaluationRuleSet;
use App\Models\LogOverride;
use App\Models\LogQso;
use App\Models\QsoOverride;
use App\Models\QsoResult;
use App\Models\Round;
use App\Models\WorkingQso;
use App\Services\Evaluation\EvaluationCoordinator;
use Illuminate\Bus\Batchable;
use Throwable;
/**
* Job: MatchQsoBucketJob
*
* Odpovědnost:
* - Provede matching QSO v bucketu definovaném kombinací band_id + call_norm.
* - Bucket obsahuje QSO se shodným call_norm (zdrojová strana),
* kandidáti se berou z QSO se shodným rcall_norm.
*
* Poznámka:
* - Logika matchingu zůstává shodná s MatchQsoGroupJob, jen pracuje nad menšími
* podmnožinami dat pro kratší dobu běhu jednoho jobu.
*/
class MatchQsoBucketJob extends MatchQsoGroupJob
{
use Batchable;
public int $tries = 3;
public array $backoff = [30, 120, 300];
protected ?int $bandId;
protected ?string $callNorm;
public function __construct(
int $evaluationRunId,
?int $bandId,
?string $callNorm,
int $pass = 1
) {
parent::__construct($evaluationRunId, null, null, $pass);
$this->bandId = $bandId;
$this->callNorm = $callNorm;
}
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, 'Matching nelze spustit: chybí ruleset.', [
'step' => 'match',
'round_id' => $run->round_id,
]);
return;
}
$round = Round::with(['bands', 'categories', 'powerCategories'])->find($run->round_id);
if (! $round) {
return;
}
$coordinator->eventInfo($run, 'Matching bucket.', [
'step' => 'match',
'round_id' => $run->round_id,
'band_id' => $this->bandId,
'call_norm' => $this->callNorm,
'pass' => $this->pass,
]);
$logOverrides = LogOverride::where('evaluation_run_id', $run->id)->get()->keyBy('log_id');
$qsoOverrides = QsoOverride::where('evaluation_run_id', $run->id)->get()->keyBy('log_qso_id');
$groupLogIds = $this->groupLogsByKey($round, $ruleSet, $logOverrides);
$groupKey = 'b' . ($this->bandId ?? 0);
$logIds = $groupLogIds[$groupKey] ?? [];
if (! $logIds) {
$coordinator->progressTick($run, 1);
return;
}
$sourceQsos = WorkingQso::where('evaluation_run_id', $run->id)
->whereIn('log_id', $logIds)
->when($this->bandId !== null, fn ($q) => $q->where('band_id', $this->bandId), fn ($q) => $q->whereNull('band_id'))
->when($this->callNorm !== null, fn ($q) => $q->where('call_norm', $this->callNorm), fn ($q) => $q->whereNull('call_norm'))
->get();
if ($sourceQsos->isEmpty()) {
$coordinator->progressTick($run, 1);
return;
}
$candidateQsos = WorkingQso::where('evaluation_run_id', $run->id)
->whereIn('log_id', $logIds)
->when($this->bandId !== null, fn ($q) => $q->where('band_id', $this->bandId), fn ($q) => $q->whereNull('band_id'))
->when($this->callNorm !== null, fn ($q) => $q->where('rcall_norm', $this->callNorm), fn ($q) => $q->whereNull('rcall_norm'))
->get();
$workingQsos = $sourceQsos->concat($candidateQsos)->unique('log_qso_id');
$logQsoIds = $workingQsos->pluck('log_qso_id')->all();
$logQsoMap = LogQso::whereIn('id', $logQsoIds)->get()->keyBy('id');
$workingMap = $workingQsos->keyBy('log_qso_id');
$alreadyMatched = [];
if ($this->pass > 1) {
$alreadyMatched = QsoResult::where('evaluation_run_id', $run->id)
->whereNotNull('matched_log_qso_id')
->pluck('log_qso_id')
->all();
$alreadyMatched = array_fill_keys($alreadyMatched, true);
}
$byMatchKey = [];
$byBandRcall = [];
foreach ($candidateQsos as $wqso) {
if ($wqso->match_key) {
$byMatchKey[$wqso->match_key][] = $wqso;
}
if ($wqso->band_id && $wqso->rcall_norm) {
$byBandRcall[$wqso->band_id . '|' . $wqso->rcall_norm][] = $wqso;
}
}
$timeTolerance = $ruleSet->time_tolerance_sec !== null
? (int) $ruleSet->time_tolerance_sec
: 300;
$forcedMap = [];
foreach ($qsoOverrides as $override) {
if ($override->forced_matched_log_qso_id) {
$forcedMap[$override->log_qso_id] = (int) $override->forced_matched_log_qso_id;
}
}
$forcedBackMap = [];
$forcedConflicts = [];
foreach ($forcedMap as $a => $b) {
if (isset($forcedMap[$b]) && $forcedMap[$b] !== $a) {
$forcedConflicts[$a] = true;
$forcedConflicts[$b] = true;
}
if (! isset($forcedMap[$b])) {
$forcedBackMap[$b] = $a;
}
}
$paired = [];
foreach ($sourceQsos as $wqso) {
if ($this->pass > 1 && isset($alreadyMatched[$wqso->log_qso_id])) {
continue;
}
$reverseKey = $wqso->band_id && $wqso->call_norm && $wqso->rcall_norm
? $wqso->band_id . '|' . $wqso->rcall_norm . '|' . $wqso->call_norm
: null;
$candidates = [];
if ($reverseKey && isset($byMatchKey[$reverseKey])) {
$candidates = $byMatchKey[$reverseKey];
}
if ($wqso->band_id && $wqso->call_norm) {
$fuzzyKey = $wqso->band_id . '|' . $wqso->call_norm;
if (isset($byBandRcall[$fuzzyKey])) {
$candidates = array_merge($candidates, $byBandRcall[$fuzzyKey]);
}
}
if ($candidates) {
$unique = [];
foreach ($candidates as $candidate) {
$unique[$candidate->log_qso_id] = $candidate;
}
$candidates = array_values($unique);
}
$best = null;
$bestDecision = null;
$tiebreakOrder = $this->resolveTiebreakOrder($ruleSet);
$forcedMatchId = $forcedMap[$wqso->log_qso_id]
?? $forcedBackMap[$wqso->log_qso_id]
?? null;
$forcedMissing = false;
$forcedDecision = null;
if ($forcedMatchId) {
if (! $workingMap->has($forcedMatchId)) {
$forcedWorking = WorkingQso::where('evaluation_run_id', $run->id)
->where('log_qso_id', $forcedMatchId)
->first();
if ($forcedWorking) {
$workingMap->put($forcedMatchId, $forcedWorking);
if (! $logQsoMap->has($forcedMatchId)) {
$forcedLogQso = LogQso::find($forcedMatchId);
if ($forcedLogQso) {
$logQsoMap->put($forcedMatchId, $forcedLogQso);
}
}
}
}
$best = $workingMap->get($forcedMatchId);
if (! $best || $best->log_id === $wqso->log_id) {
$best = null;
$forcedMissing = true;
}
if ($best) {
$forcedDecision = $this->evaluateMatchDecision(
$wqso,
$best,
$logQsoMap,
$ruleSet,
$timeTolerance
);
$bestDecision = $forcedDecision;
}
} else {
foreach ($candidates as $candidate) {
if ($candidate->log_id === $wqso->log_id) {
continue;
}
if (isset($paired[$candidate->log_qso_id]) && $paired[$candidate->log_qso_id] !== $wqso->log_qso_id) {
continue;
}
$decision = $this->evaluateMatchDecision(
$wqso,
$candidate,
$logQsoMap,
$ruleSet,
$timeTolerance
);
if (! $decision) {
continue;
}
if ($this->pass === 1 && ($decision['match_type'] ?? '') !== 'MATCH_EXACT') {
continue;
}
if ($bestDecision === null || $decision['rank'] < $bestDecision['rank']) {
$best = $candidate;
$bestDecision = $decision;
continue;
}
if ($decision['rank'] === $bestDecision['rank']) {
$cmp = $this->compareCandidates(
$decision['tiebreak'],
$bestDecision['tiebreak'],
$tiebreakOrder
);
if ($cmp < 0) {
$best = $candidate;
$bestDecision = $decision;
}
}
}
}
$timeDiffSec = null;
if ($best && $wqso->ts_utc && $best->ts_utc) {
$timeDiffSec = abs($best->ts_utc->getTimestamp() - $wqso->ts_utc->getTimestamp());
}
$isNil = $best === null;
$isDuplicate = false;
$isBustedExchange = false;
$isBustedCall = false;
$isBustedRst = false;
$bustedCallTx = false;
$bustedRstTx = false;
$bustedExchangeReason = null;
$customMismatch = false;
$isBustedExchangeOnly = false;
$bustedSerialRx = false;
$bustedSerialTx = false;
$bustedWwlRx = false;
$bustedWwlTx = false;
$matchType = null;
$errorFlags = [];
$timeMismatch = false;
$isOutOfWindow = (bool) $wqso->out_of_window;
$errorCode = null;
if (! $isNil && $best && $bestDecision) {
$a = $logQsoMap->get($wqso->log_qso_id);
$b = $logQsoMap->get($best->log_qso_id);
$aWork = $workingMap->get($wqso->log_qso_id);
$bWork = $workingMap->get($best->log_qso_id);
if ($a && $b) {
$exchange = $this->evaluateExchange($a, $b, $aWork, $bWork, $ruleSet);
$bustedCallTx = $exchange['sent_call_mismatch'] && $ruleSet->discard_qso_sent_diff_call;
$isBustedCall = $exchange['recv_call_mismatch'] && $ruleSet->discard_qso_rec_diff_call;
$bustedRstTx = ($ruleSet->exchange_requires_report ?? false)
&& $exchange['report_sent_mismatch']
&& $ruleSet->discard_qso_sent_diff_rst;
$isBustedRst = ($ruleSet->exchange_requires_report ?? false)
&& $exchange['report_recv_mismatch']
&& $ruleSet->discard_qso_rec_diff_rst;
$discardSerialRx = $ruleSet->discard_qso_rec_diff_serial ?? $ruleSet->discard_qso_rec_diff_code;
$discardSerialTx = $ruleSet->discard_qso_sent_diff_serial ?? $ruleSet->discard_qso_sent_diff_code;
$discardWwlRx = $ruleSet->discard_qso_rec_diff_wwl ?? $ruleSet->discard_qso_rec_diff_code;
$discardWwlTx = $ruleSet->discard_qso_sent_diff_wwl ?? $ruleSet->discard_qso_sent_diff_code;
$customMismatch = (bool) ($exchange['custom_mismatch'] ?? false);
$bustedSerialRx = ($exchange['serial_recv_mismatch'] || $exchange['missing_serial'] || $customMismatch) && $discardSerialRx;
$bustedSerialTx = ($exchange['serial_sent_mismatch'] || $customMismatch) && $discardSerialTx;
$bustedWwlRx = ($exchange['locator_recv_mismatch'] || $exchange['missing_wwl']) && $discardWwlRx;
$bustedWwlTx = $exchange['locator_sent_mismatch'] && $discardWwlTx;
$isBustedExchangeOnly = ($customMismatch && $ruleSet->discard_qso_rec_diff_code);
$isBustedExchange = $isBustedExchangeOnly || $bustedSerialRx || $bustedWwlRx;
$bustedExchangeReason = $exchange['busted_exchange_reason'] ?? null;
$matchType = $bestDecision['match_type'] ?? null;
$errorFlags = $bestDecision['error_flags'] ?? [];
$timeMismatch = (bool) ($bestDecision['time_mismatch'] ?? false);
}
}
$isValid = ! $isNil && ! $isBustedCall && ! $isBustedRst && ! $isBustedExchange;
$errorDetail = null;
$bustedCallRx = $isBustedCall;
$bustedRstRx = $isBustedRst;
if ($forcedMissing) {
$errorDetail = 'FORCED_MATCH_MISSING';
} elseif (isset($forcedConflicts[$wqso->log_qso_id])) {
$errorDetail = 'FORCED_MATCH_CONFLICT';
} elseif ($isBustedExchange && $bustedExchangeReason) {
$errorDetail = $bustedExchangeReason;
}
$errorSide = $this->resolveErrorSide(
$bustedCallRx,
$bustedCallTx,
$bustedRstRx,
$bustedRstTx,
$bustedSerialRx,
$bustedSerialTx,
$bustedWwlRx,
$bustedWwlTx,
$timeMismatch
);
$override = $qsoOverrides->get($wqso->log_qso_id);
if ($override && $override->forced_status && $override->forced_status !== 'AUTO') {
$this->applyForcedStatus(
$override->forced_status,
$isValid,
$isDuplicate,
$isNil,
$isBustedCall,
$isBustedRst,
$isBustedExchange,
$isOutOfWindow,
$errorCode,
$errorSide
);
}
$matchConfidence = $this->resolveMatchConfidence($bestDecision['match_type'] ?? null, $timeMismatch);
if (! $errorCode) {
if ($timeMismatch) {
$errorCode = QsoErrorCode::TIME_MISMATCH;
} elseif ($isBustedCall || $bustedCallTx) {
$errorCode = QsoErrorCode::BUSTED_CALL;
} elseif ($isBustedRst || $bustedRstTx) {
$errorCode = QsoErrorCode::BUSTED_RST;
} elseif ($bustedSerialRx || $bustedSerialTx) {
$errorCode = QsoErrorCode::BUSTED_SERIAL;
} elseif ($bustedWwlRx || $bustedWwlTx) {
$errorCode = QsoErrorCode::BUSTED_LOCATOR;
} elseif ($isBustedExchangeOnly) {
$errorCode = QsoErrorCode::BUSTED_SERIAL;
} elseif (! $isNil && ! $isDuplicate && ! $isBustedExchange && ! $isOutOfWindow) {
$errorCode = QsoErrorCode::OK;
}
}
QsoResult::updateOrCreate(
[
'evaluation_run_id' => $run->id,
'log_qso_id' => $wqso->log_qso_id,
],
[
'is_valid' => $isValid,
'is_duplicate' => $isDuplicate,
'is_nil' => $isNil,
'is_busted_call' => $isBustedCall,
'is_busted_rst' => $isBustedRst,
'is_busted_exchange' => $isBustedExchange,
'is_time_out_of_window' => $isOutOfWindow,
'points' => 0,
'distance_km' => null,
'time_diff_sec' => $timeDiffSec,
'wwl' => null,
'dxcc' => null,
'matched_qso_id' => $best?->log_qso_id,
'matched_log_qso_id' => $best?->log_qso_id,
'match_type' => $matchType,
'match_confidence' => $matchConfidence,
'error_code' => $errorCode,
'error_side' => $errorSide,
'error_detail' => $errorDetail,
'error_flags' => $errorFlags ?: null,
]
);
if ($best && $best->log_qso_id) {
$paired[$wqso->log_qso_id] = $best->log_qso_id;
$paired[$best->log_qso_id] = $wqso->log_qso_id;
}
}
$coordinator->progressTick($run, 1);
} catch (Throwable $e) {
$coordinator->eventError($run, 'Matching bucket: krok selhal.', [
'step' => 'match',
'round_id' => $run->round_id,
'band_id' => $this->bandId,
'call_norm' => $this->callNorm,
'pass' => $this->pass,
'error' => $e->getMessage(),
]);
throw $e;
}
}
}

File diff suppressed because it is too large Load Diff

120
app/Jobs/ParseLogJob.php Normal file
View File

@@ -0,0 +1,120 @@
<?php
namespace App\Jobs;
use App\Http\Controllers\LogController;
use App\Models\EvaluationLock;
use App\Models\EvaluationRun;
use App\Models\Log;
use App\Services\Evaluation\EvaluationCoordinator;
use App\Jobs\UpsertClaimedLogResultJob;
use Illuminate\Bus\Batchable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Throwable;
/**
* Job: ParseLogJob
*
* Účel:
* - Naparsuje jeden EDI log a uloží Log/LogQso.
*/
class ParseLogJob implements ShouldQueue
{
use Batchable;
use Queueable;
public int $tries = 3;
public array $backoff = [30, 120, 300];
public function __construct(
protected int $evaluationRunId,
protected int $logId
) {
}
public function handle(): void
{
$run = EvaluationRun::find($this->evaluationRunId);
if (! $run || $run->isCanceled()) {
return;
}
$coordinator = new EvaluationCoordinator();
$lock = null;
$lockKey = "evaluation:parse:round:{$run->round_id}:log:{$this->logId}";
try {
$lock = EvaluationLock::acquire(
key: $lockKey,
run: $run,
ttl: 1800
);
if (! $lock) {
throw new \RuntimeException("ParseLogJob nelze spustit lock je držen (log_id={$this->logId}).");
}
$log = Log::with('file')->find($this->logId);
if (! $log || (int) $log->round_id !== (int) $run->round_id) {
return;
}
if (! $log->file || ! $log->file->path) {
$coordinator->eventWarn($run, "Log #{$log->id} nemá soubor, parser přeskočen.", [
'step' => 'parse_logs',
'round_id' => $run->round_id,
'log_id' => $log->id,
]);
EvaluationRun::where('id', $run->id)->increment('progress_done');
return;
}
try {
LogController::parseUploadedFile($log, $log->file->path);
} catch (Throwable $e) {
$coordinator->eventError($run, "Chyba parsování logu #{$log->id}: {$e->getMessage()}", [
'step' => 'parse_logs',
'round_id' => $run->round_id,
'log_id' => $log->id,
]);
}
if ($run->rules_version === 'CLAIMED') {
try {
UpsertClaimedLogResultJob::dispatchSync($log->id);
} catch (Throwable $e) {
$coordinator->eventError($run, "Chyba deklarace výsledků logu #{$log->id}: {$e->getMessage()}", [
'step' => 'claimed_upsert',
'round_id' => $run->round_id,
'log_id' => $log->id,
]);
}
}
EvaluationRun::where('id', $run->id)->increment('progress_done');
$progressTotal = (int) ($run->progress_total ?? 0);
$done = $progressTotal > 0
? (int) EvaluationRun::where('id', $run->id)->value('progress_done')
: 0;
if ($progressTotal > 0 && $done % 10 === 0) {
$coordinator->eventInfo($run, "Parsování logů: {$done}/{$progressTotal}", [
'step' => 'parse_logs',
'round_id' => $run->round_id,
'step_progress_done' => $done,
'step_progress_total' => $progressTotal,
]);
}
} catch (Throwable $e) {
$coordinator->eventError($run, "ParseLogJob selhal: {$e->getMessage()}", [
'step' => 'parse_logs',
'round_id' => $run->round_id,
'log_id' => $this->logId,
]);
throw $e;
} finally {
if ($lock) {
EvaluationLock::release($lockKey, $run);
}
}
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Jobs;
use App\Models\EvaluationRun;
use App\Services\Evaluation\EvaluationCoordinator;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Throwable;
class PauseEvaluationRunJob implements ShouldQueue
{
use Queueable;
public function __construct(
protected int $evaluationRunId,
protected string $status,
protected string $step,
protected string $message
) {
}
public function handle(): void
{
$run = EvaluationRun::find($this->evaluationRunId);
if (! $run || $run->isCanceled()) {
return;
}
$coordinator = app(EvaluationCoordinator::class);
try {
$coordinator->eventInfo($run, 'Pause: krok spuštěn.', [
'step' => $this->step,
'round_id' => $run->round_id,
]);
$coordinator->transition($run, '*', $this->status, $this->step);
// WAITING_* stavy umožňují manuální zásah rozhodčího mezi fázemi pipeline.
$coordinator->eventInfo($run, $this->message, [
'step' => $this->step,
'round_id' => $run->round_id,
]);
$coordinator->eventInfo($run, 'Pause: krok dokončen.', [
'step' => $this->step,
'round_id' => $run->round_id,
]);
} catch (Throwable $e) {
$coordinator->eventError($run, 'Pause: krok selhal.', [
'step' => $this->step,
'round_id' => $run->round_id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
}

379
app/Jobs/PrepareRunJob.php Normal file
View File

@@ -0,0 +1,379 @@
<?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ít minimální vedlejší efekty mimo svůj scope.
* - Pokud příprava selže, 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;
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Jobs;
use App\Models\EvaluationLock;
use App\Models\EvaluationRun;
use App\Models\Log;
use App\Jobs\UpsertClaimedLogResultJob;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/**
* Job: RebuildClaimedLogResultsJob
*
* Znovu postaví claimed projekci pro celé kolo a přepočítá pořadí.
*/
class RebuildClaimedLogResultsJob implements ShouldQueue
{
use Queueable;
public function __construct(
protected int $evaluationRunId
) {
}
public function handle(): void
{
$run = EvaluationRun::find($this->evaluationRunId);
if (! $run) {
return;
}
$lock = EvaluationLock::acquire(
key: "evaluation:claimed-rebuild:round:{$run->round_id}",
run: $run,
ttl: 1800
);
if (! $lock) {
return;
}
try {
// Rebuild je deterministická rekonstrukce claimed projekce pro celé kolo.
$total = Log::where('round_id', $run->round_id)->count();
$run->update([
'status' => 'RUNNING',
'current_step' => 'rebuild_claimed',
'progress_total' => $total,
'progress_done' => 0,
'started_at' => $run->started_at ?? now(),
]);
$processed = 0;
Log::where('round_id', $run->round_id)
->chunkById(50, function ($logs) use (&$processed, $run) {
foreach ($logs as $log) {
$processed++;
// Projekce claimed výsledků je synchronní, aby rebuild skončil konzistentně.
UpsertClaimedLogResultJob::dispatchSync($log->id);
$run->update(['progress_done' => $processed]);
}
});
// Po projekci je nutné přepočítat pořadí claimed scoreboardu.
RecalculateClaimedRanksJob::dispatchSync($run->id);
$run->update([
'status' => 'SUCCEEDED',
'current_step' => 'rebuild_claimed_done',
'finished_at' => now(),
]);
} finally {
EvaluationLock::release("evaluation:claimed-rebuild:round:{$run->round_id}", $run);
}
}
}

View File

@@ -0,0 +1,212 @@
<?php
namespace App\Jobs;
use App\Models\EvaluationLock;
use App\Models\EvaluationRun;
use App\Models\LogResult;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
/**
* Job: RecalculateClaimedRanksJob
*
* Přepočítá pořadí deklarovaných výsledků (CLAIMED) pro daný evaluation run.
* - rank_overall: pořadí podle band + (SINGLE|MULTI)
* - rank_in_category: pořadí podle band + (SINGLE|MULTI) + power (LP|QRP|N)
* - OK/OL pořadí: stejné výpočty pouze pro české účastníky (pcall začíná OK/OL)
* - CHECK logy se nepočítají
*/
class RecalculateClaimedRanksJob implements ShouldQueue, ShouldBeUnique
{
use Queueable;
public int $uniqueFor = 30;
public int $tries = 2;
public array $backoff = [60];
public function __construct(
protected int $evaluationRunId
) {
}
public function uniqueId(): string
{
return (string) $this->evaluationRunId;
}
public function handle(): void
{
$run = EvaluationRun::find($this->evaluationRunId);
if (! $run) {
return;
}
// Zabraňuje souběžnému přepočtu pro stejné kolo (claimed scoreboard).
$lock = EvaluationLock::acquire(
key: "evaluation:claimed-ranks:round:{$run->round_id}",
run: $run,
ttl: 300
);
if (! $lock) {
return;
}
try {
// Vynuluje pořadí, aby staré hodnoty neovlivnily nový přepočet.
LogResult::where('evaluation_run_id', $run->id)
->update([
'rank_overall' => null,
'rank_in_category' => null,
'rank_overall_ok' => null,
'rank_in_category_ok' => null,
]);
// Načte všechny deklarované výsledky včetně vazeb pro kategorii a výkon.
$results = LogResult::with(['log', 'category', 'powerCategory'])
->where('evaluation_run_id', $run->id)
->get();
// Do pořadí vstupují jen logy se statusem OK a kategorií SINGLE/MULTI.
$eligible = $results->filter(function (LogResult $r) {
return $r->status === 'OK' && $this->getCategoryType($r) !== null;
});
// Celkové pořadí: podle pásma + SINGLE/MULTI + 6H/standard.
$allOverall = $eligible->groupBy(fn (LogResult $r) => $r->band_id . '|' . $this->getCategoryBucket($r) . '|' . $this->getSixHourBucket($r));
foreach ($allOverall as $items) {
$this->applyRanking($items, 'rank_overall');
}
// Pořadí výkonových kategorií: pásmo + SINGLE/MULTI + výkon (jen LP/QRP/N) + 6H/standard.
$allPower = $eligible->filter(fn (LogResult $r) => $this->getPowerClass($r) !== null)
->groupBy(fn (LogResult $r) => $r->band_id . '|' . $this->getCategoryBucket($r) . '|' . $this->getPowerClass($r) . '|' . $this->getSixHourBucket($r));
foreach ($allPower as $items) {
$this->applyRanking($items, 'rank_in_category');
}
// Česká podmnožina (OK/OL) pro národní pořadí.
$okOnly = $eligible->filter(fn (LogResult $r) => $this->isOkCall($r));
$okOverall = $okOnly->groupBy(fn (LogResult $r) => $r->band_id . '|' . $this->getCategoryBucket($r) . '|' . $this->getSixHourBucket($r));
foreach ($okOverall as $items) {
$this->applyRanking($items, 'rank_overall_ok');
}
// České pořadí výkonových kategorií: stejné jako power, ale jen OK/OL a 6H/standard.
$okPower = $okOnly->filter(fn (LogResult $r) => $this->getPowerClass($r) !== null)
->groupBy(fn (LogResult $r) => $r->band_id . '|' . $this->getCategoryBucket($r) . '|' . $this->getPowerClass($r) . '|' . $this->getSixHourBucket($r));
foreach ($okPower as $items) {
$this->applyRanking($items, 'rank_in_category_ok');
}
} finally {
EvaluationLock::release("evaluation:claimed-ranks:round:{$run->round_id}", $run);
}
}
protected function applyRanking(Collection $items, string $rankField): void
{
// Řazení podle claimed_score (desc), pak QSO (desc), pak log_id (asc) kvůli stabilitě.
$sorted = $items->sort(function (LogResult $a, LogResult $b) {
$scoreA = $a->claimed_score ?? 0;
$scoreB = $b->claimed_score ?? 0;
if ($scoreA !== $scoreB) {
return $scoreB <=> $scoreA;
}
$qsoA = $a->claimed_qso_count ?? 0;
$qsoB = $b->claimed_qso_count ?? 0;
if ($qsoA !== $qsoB) {
return $qsoB <=> $qsoA;
}
return $a->log_id <=> $b->log_id;
})->values();
$lastScore = null;
$lastQso = null;
$lastRank = 0;
foreach ($sorted as $index => $result) {
$score = $result->claimed_score ?? 0;
$qso = $result->claimed_qso_count ?? 0;
// Shodný výsledek (stejné skóre + QSO) = stejné pořadí.
if ($score === $lastScore && $qso === $lastQso) {
$rank = $lastRank;
} else {
$rank = $index + 1;
}
$result->{$rankField} = $rank;
$result->save();
$lastScore = $score;
$lastQso = $qso;
$lastRank = $rank;
}
}
protected function getCategoryType(LogResult $r): ?string
{
$name = $r->category?->name;
if (! $name) {
return null;
}
$lower = mb_strtolower($name);
if (str_contains($lower, 'single')) {
return 'SINGLE';
}
if (str_contains($lower, 'multi')) {
return 'MULTI';
}
return null;
}
protected function getCategoryBucket(LogResult $r): ?string
{
$type = $this->getCategoryType($r);
if ($type === null) {
return null;
}
return $this->getSixHourBucket($r) === '6H' ? 'ALL' : $type;
}
protected function getPowerClass(LogResult $r): ?string
{
$name = $r->powerCategory?->name;
if (! $name) {
return null;
}
$upper = mb_strtoupper($name);
if (in_array($upper, ['LP', 'QRP', 'N'], true)) {
return $upper;
}
return null;
}
protected function isOkCall(LogResult $r): bool
{
$call = $this->normalizeCallsign($r->log?->pcall ?? '');
return Str::startsWith($call, ['OK', 'OL']);
}
protected function normalizeCallsign(string $call): string
{
$value = mb_strtoupper(trim($call));
$value = preg_replace('/\s+/', '', $value);
return $value ?? '';
}
protected function getSixHourBucket(LogResult $r): string
{
$sixh = $r->sixhr_category;
if ($sixh === null) {
$sixh = $r->log?->sixhr_category;
}
return $sixh ? '6H' : 'STD';
}
}

View File

@@ -0,0 +1,224 @@
<?php
namespace App\Jobs;
use App\Models\EvaluationLock;
use App\Models\EvaluationRun;
use App\Models\EvaluationRuleSet;
use App\Models\LogResult;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
/**
* Job: RecalculateOfficialRanksJob
*
* Přepočítá pořadí finálních (official) výsledků pro daný evaluation run.
* - rank_overall: pořadí podle band + (SINGLE|MULTI)
* - rank_in_category: pořadí podle band + (SINGLE|MULTI) + power (LP|QRP|N)
* - OK/OL pořadí: stejné výpočty pouze pro české účastníky (pcall začíná OK/OL)
* - CHECK/DQ/IGNORED logy se nepočítají
*/
class RecalculateOfficialRanksJob implements ShouldQueue, ShouldBeUnique
{
use Queueable;
public int $uniqueFor = 30;
public int $tries = 2;
public array $backoff = [60];
protected string $sixhrRankingMode = 'IARU';
public function __construct(
protected int $evaluationRunId
) {
}
public function uniqueId(): string
{
return (string) $this->evaluationRunId;
}
public function handle(): void
{
$run = EvaluationRun::find($this->evaluationRunId);
if (! $run || $run->isCanceled()) {
return;
}
$ruleSet = EvaluationRuleSet::find($run->rule_set_id);
if ($ruleSet && $ruleSet->sixhr_ranking_mode) {
$this->sixhrRankingMode = strtoupper((string) $ruleSet->sixhr_ranking_mode);
}
// Krátký lock brání souběžnému přepočtu pořadí nad stejným kolem.
$lock = EvaluationLock::acquire(
key: "evaluation:official-ranks:round:{$run->round_id}",
run: $run,
ttl: 300
);
if (! $lock) {
return;
}
try {
LogResult::where('evaluation_run_id', $run->id)
->update([
'rank_overall' => null,
'rank_in_category' => null,
'rank_overall_ok' => null,
'rank_in_category_ok' => null,
]);
$results = LogResult::with(['log', 'category', 'powerCategory'])
->where('evaluation_run_id', $run->id)
->get();
// Do pořadí jdou jen logy ve stavu OK a s rozpoznanou kategorií.
$eligible = $results->filter(function (LogResult $r) {
return $r->status === 'OK' && $this->getCategoryType($r) !== null;
});
$allOverall = $eligible->groupBy(fn (LogResult $r) => $r->band_id . '|' . $this->getCategoryBucket($r) . '|' . $this->getSixHourBucket($r));
foreach ($allOverall as $items) {
if (EvaluationRun::isCanceledRun($run->id)) {
return;
}
$this->applyRanking($items, 'rank_overall');
}
$allPower = $eligible->filter(fn (LogResult $r) => $this->getPowerClass($r) !== null)
->groupBy(fn (LogResult $r) => $r->band_id . '|' . $this->getCategoryBucket($r) . '|' . $this->getPowerClass($r) . '|' . $this->getSixHourBucket($r));
foreach ($allPower as $items) {
if (EvaluationRun::isCanceledRun($run->id)) {
return;
}
$this->applyRanking($items, 'rank_in_category');
}
$okOnly = $eligible->filter(fn (LogResult $r) => $this->isOkCall($r));
$okOverall = $okOnly->groupBy(fn (LogResult $r) => $r->band_id . '|' . $this->getCategoryBucket($r) . '|' . $this->getSixHourBucket($r));
foreach ($okOverall as $items) {
if (EvaluationRun::isCanceledRun($run->id)) {
return;
}
$this->applyRanking($items, 'rank_overall_ok');
}
$okPower = $okOnly->filter(fn (LogResult $r) => $this->getPowerClass($r) !== null)
->groupBy(fn (LogResult $r) => $r->band_id . '|' . $this->getCategoryBucket($r) . '|' . $this->getPowerClass($r) . '|' . $this->getSixHourBucket($r));
foreach ($okPower as $items) {
if (EvaluationRun::isCanceledRun($run->id)) {
return;
}
$this->applyRanking($items, 'rank_in_category_ok');
}
} finally {
EvaluationLock::release("evaluation:official-ranks:round:{$run->round_id}", $run);
}
}
protected function applyRanking(Collection $items, string $rankField): void
{
// Deterministický sort: skóre -> valid QSO -> log_id.
$sorted = $items->sort(function (LogResult $a, LogResult $b) {
$scoreA = $a->official_score ?? 0;
$scoreB = $b->official_score ?? 0;
if ($scoreA !== $scoreB) {
return $scoreB <=> $scoreA;
}
$qsoA = $a->valid_qso_count ?? 0;
$qsoB = $b->valid_qso_count ?? 0;
if ($qsoA !== $qsoB) {
return $qsoB <=> $qsoA;
}
return $a->log_id <=> $b->log_id;
})->values();
$lastScore = null;
$lastQso = null;
$lastRank = 0;
foreach ($sorted as $index => $result) {
$score = $result->official_score ?? 0;
$qso = $result->valid_qso_count ?? 0;
if ($score === $lastScore && $qso === $lastQso) {
$rank = $lastRank;
} else {
$rank = $index + 1;
}
$result->{$rankField} = $rank;
$result->sixhr_ranking_bucket = $this->getCategoryBucket($result);
$result->save();
$lastScore = $score;
$lastQso = $qso;
$lastRank = $rank;
}
}
protected function getCategoryType(LogResult $r): ?string
{
$name = $r->category?->name;
if (! $name) {
return null;
}
$lower = mb_strtolower($name);
if (str_contains($lower, 'single')) {
return 'SINGLE';
}
if (str_contains($lower, 'multi')) {
return 'MULTI';
}
return null;
}
protected function getCategoryBucket(LogResult $r): ?string
{
$type = $this->getCategoryType($r);
if ($type === null) {
return null;
}
if ($this->getSixHourBucket($r) !== '6H') {
return $type;
}
return $this->sixhrRankingMode === 'IARU' ? 'ALL' : $type;
}
protected function getPowerClass(LogResult $r): ?string
{
$name = $r->powerCategory?->name;
if (! $name) {
return null;
}
$upper = mb_strtoupper($name);
if (in_array($upper, ['LP', 'QRP', 'N'], true)) {
return $upper;
}
return null;
}
protected function isOkCall(LogResult $r): bool
{
$call = $this->normalizeCallsign($r->log?->pcall ?? '');
return Str::startsWith($call, ['OK', 'OL']);
}
protected function normalizeCallsign(string $call): string
{
$value = mb_strtoupper(trim($call));
$value = preg_replace('/\s+/', '', $value);
return $value ?? '';
}
protected function getSixHourBucket(LogResult $r): string
{
$sixh = $r->sixhr_category;
if ($sixh === null) {
$sixh = $r->log?->sixhr_category;
}
return $sixh ? '6H' : 'STD';
}
}

808
app/Jobs/ScoreGroupJob.php Normal file
View File

@@ -0,0 +1,808 @@
<?php
namespace App\Jobs;
use App\Models\EdiBand;
use App\Models\Cty;
use App\Models\EdiCategory;
use App\Models\EvaluationRuleSet;
use App\Models\EvaluationRun;
use App\Models\Log;
use App\Models\LogOverride;
use App\Models\LogQso;
use App\Models\QsoResult;
use App\Models\QsoOverride;
use App\Models\Round;
use App\Models\WorkingQso;
use App\Enums\QsoErrorCode;
use App\Services\Evaluation\EvaluationCoordinator;
use App\Services\Evaluation\ScoringService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Bus\Batchable;
use Illuminate\Foundation\Queue\Queueable;
use Throwable;
/**
* Job: ScoreGroupJob
*
* Odpovědnost:
* - Vypočítá bodové ohodnocení (scoring) pro konkrétní skupinu dat (group)
* v rámci jednoho vyhodnocovacího běhu (EvaluationRun).
* - Skupina typicky odpovídá kombinaci scope parametrů, např.:
* - round_id
* - band_id
* - category_id
* - power_category_id
* nebo jednodušší agregaci (např. pouze band).
*
* Kontext:
* - Spouští se po dokončení matchingu (MatchQsoGroupJob) pro stejnou skupinu.
* - Používá mezivýsledky matchingu (např. QsoResult s evaluation_run_id)
* a pravidla z EvaluationRuleSet (scoring_mode, multipliers, policy flagy).
*
* Co job dělá (typicky):
* - Načte matched QSO pro daný run+group.
* - Aplikuje pravidla bodování podle EvaluationRuleSet:
* - DISTANCE: body = km * points_per_km
* - FIXED_POINTS: body = points_per_qso
* - Aplikuje policy pro problematická QSO:
* - duplicity (dup_qso_policy)
* - NIL (nil_qso_policy)
* - busted_call (busted_call_policy)
* - busted_exchange (busted_exchange_policy)
* - (Volitelně) připraví/označí multiplikátory (WWL/DXCC/SECTION/COUNTRY)
* tak, aby šly později agregovat na úrovni logu.
* - Zapíše mezivýsledky skóre do staging struktur/tabulek svázaných s runem:
* - per-QSO body
*
* Co job NEDĚLÁ:
* - neprovádí matching QSO (to je MatchQsoGroupJob)
* - neagreguje finální pořadí a výsledkové listiny (to je Aggregate/Finalize)
* - nepublikuje výsledky
*
* Zásady návrhu:
* - Scoring musí být deterministický: stejné vstupy => stejné body.
* - Job musí být idempotentní: opakované spuštění přepíše/obnoví výstupy
* pro daný run+group bez duplicit.
* - Veškerá výpočetní logika patří do service layer (např. ScoringService).
* - Job pouze načte kontext, deleguje výpočty a uloží výsledky.
*
* Queue:
* - Spouští se ve frontě "evaluation".
* - Musí být chráněn proti souběhu nad stejnou group (lock / WithoutOverlapping).
*/
class ScoreGroupJob implements ShouldQueue
{
use Batchable;
use Queueable;
public int $tries = 3;
public array $backoff = [30, 120, 300];
protected array $ctyCache = [];
/**
* Create a new job instance.
*/
public function __construct(
protected int $evaluationRunId,
protected ?string $groupKey = null,
protected ?array $group = null
) {
//
}
/**
* Provede výpočet bodů pro jednu skupinu (group).
*
* Metoda handle():
* - získá kontext EvaluationRun + group parametry
* - načte mezivýsledky matchingu
* - aplikuje pravidla EvaluationRuleSet a spočítá body
* - zapíše mezivýsledky pro agregaci a finalizaci
* - aktualizuje progress a auditní události pro UI
*
* Poznámky:
* - Tento job být výkonnostně bezpečný (chunking, minimalizace N+1).
* - Pokud scoring jedné skupiny selže, selhat job (retry),
* protože bez kompletního scoringu nelze korektně agregovat výsledky.
*/
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, 'Scoring nelze spustit: chybí ruleset.', [
'step' => 'score',
'round_id' => $run->round_id,
]);
return;
}
$round = Round::with(['bands', 'categories', 'powerCategories'])->find($run->round_id);
if (! $round) {
return;
}
$coordinator->eventInfo($run, 'Scoring: krok spuštěn.', [
'step' => 'score',
'round_id' => $run->round_id,
'group_key' => $this->groupKey,
]);
$run->update([
'status' => 'RUNNING',
'current_step' => 'score',
]);
$groups = [];
$singleGroup = (bool) ($this->groupKey || $this->group);
if ($this->groupKey || $this->group) {
$groups[] = [
'key' => $this->groupKey ?? 'custom',
'band_id' => $this->group['band_id'] ?? null,
'category_id' => $this->group['category_id'] ?? null,
'power_category_id' => $this->group['power_category_id'] ?? null,
];
} elseif (! empty($run->scope['groups']) && is_array($run->scope['groups'])) {
$groups = $run->scope['groups'];
} else {
$groups[] = [
'key' => 'all',
'band_id' => null,
'category_id' => null,
'power_category_id' => null,
];
}
$total = count($groups);
if (! $singleGroup) {
$run->update([
'progress_total' => $total,
'progress_done' => 0,
]);
}
$scoring = new ScoringService();
$logOverrides = LogOverride::where('evaluation_run_id', $run->id)->get()->keyBy('log_id');
$qsoOverrides = QsoOverride::where('evaluation_run_id', $run->id)->get()->keyBy('log_qso_id');
$groupLogIds = $this->groupLogsByKey($round, $ruleSet, $logOverrides);
$processed = 0;
foreach ($groups as $group) {
if (EvaluationRun::isCanceledRun($run->id)) {
return;
}
$processed++;
$groupKey = $group['key'] ?? 'all';
$logIds = $groupLogIds[$groupKey] ?? [];
$coordinator->eventInfo($run, 'Výpočet skóre.', [
'step' => 'score',
'round_id' => $run->round_id,
'group_key' => $group['key'] ?? null,
'group' => [
'band_id' => $group['band_id'] ?? null,
'category_id' => $group['category_id'] ?? null,
'power_category_id' => $group['power_category_id'] ?? null,
],
'group_logs' => count($logIds),
'step_progress_done' => $processed,
'step_progress_total' => $total,
]);
if (! $logIds) {
if ($singleGroup) {
EvaluationRun::where('id', $run->id)->increment('progress_done');
} else {
$run->update(['progress_done' => $processed]);
}
continue;
}
LogQso::whereIn('log_id', $logIds)
->chunkById(200, function ($qsos) use ($run, $ruleSet, $scoring, $qsoOverrides, $coordinator) {
$qsoIds = $qsos->pluck('id')->all();
$working = WorkingQso::where('evaluation_run_id', $run->id)
->whereIn('log_qso_id', $qsoIds)
->get()
->keyBy('log_qso_id');
foreach ($qsos as $qso) {
$result = QsoResult::firstOrNew([
'evaluation_run_id' => $run->id,
'log_qso_id' => $qso->id,
]);
// Ruční override může přepsat matching/validaci z předchozího kroku.
$override = $qsoOverrides->get($qso->id);
if ($override) {
$this->applyQsoOverride($result, $override);
}
$workingQso = $working->get($qso->id);
// Vzdálenost se počítá z lokátorů obou stran (pokud existují).
$distanceKm = $workingQso
? $scoring->calculateDistanceKm($workingQso->loc_norm, $workingQso->rloc_norm)
: null;
// V některých soutěžích jsou lokátory povinné pro platné bodování.
$requireLocators = $ruleSet->require_locators;
$hasLocators = $workingQso && $workingQso->loc_norm && $workingQso->rloc_norm;
$result->distance_km = $distanceKm;
$points = $scoring->computeBasePoints($distanceKm, $ruleSet);
$forcedStatus = $override?->forced_status;
$applyPolicy = ! $forcedStatus || $forcedStatus === 'AUTO';
if ($applyPolicy) {
$result->is_valid = true;
}
$result->penalty_points = 0;
if ($applyPolicy && $requireLocators && ! $hasLocators) {
$result->is_valid = false;
}
// Out-of-window policy určuje, jak bodovat QSO mimo časové okno.
if ($applyPolicy && $result->is_time_out_of_window) {
$policy = $scoring->outOfWindowDecision($ruleSet);
$decision = $this->applyPolicyDecision($policy, $points, false);
if ($result->is_valid) {
$result->is_valid = $decision['is_valid'];
}
$points = $decision['points'];
}
$result->error_code = $this->resolveErrorCode($result);
$errorCode = $result->error_code;
$errorSide = $result->error_side ?? 'NONE';
if ($applyPolicy) {
if ($errorCode && ! in_array($errorCode, QsoErrorCode::all(), true)) {
$coordinator->eventWarn($run, 'Scoring: neznámý error_code.', [
'step' => 'score',
'round_id' => $run->round_id,
'log_qso_id' => $qso->id,
'error_code' => $errorCode,
]);
$result->is_valid = false;
} else {
$points = $this->applyErrorPolicy($ruleSet, $errorCode, $errorSide, $points, $result);
}
}
$result->points = $points;
$result->penalty_points = $result->is_valid
? $this->resolvePenaltyPoints($result, $ruleSet, $scoring)
: 0;
// Multiplikátory se ukládají per-QSO a agregují až v AggregateLogResultsJob.
$this->applyMultipliers($result, $qso, $workingQso, $ruleSet);
if ($override && $override->forced_points !== null) {
// Ruční override má přednost před vypočtenými body.
$result->points = (float) $override->forced_points;
}
$result->save();
}
});
if ($singleGroup) {
EvaluationRun::where('id', $run->id)->increment('progress_done');
} else {
$run->update(['progress_done' => $processed]);
}
}
$coordinator->eventInfo($run, 'Scoring: krok dokončen.', [
'step' => 'score',
'round_id' => $run->round_id,
'group_key' => $this->groupKey,
]);
} catch (Throwable $e) {
$coordinator->eventError($run, 'Scoring: krok selhal.', [
'step' => 'score',
'round_id' => $run->round_id,
'group_key' => $this->groupKey,
'error' => $e->getMessage(),
]);
throw $e;
}
}
protected function groupLogsByKey(
Round $round,
EvaluationRuleSet $ruleSet,
\Illuminate\Support\Collection $logOverrides
): array
{
$logs = Log::where('round_id', $round->id)->get();
$map = [];
foreach ($logs as $log) {
if (! $ruleSet->checklog_matching && $this->isCheckLog($log)) {
continue;
}
$override = $logOverrides->get($log->id);
if ($override && $override->forced_log_status === 'IGNORED') {
continue;
}
$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;
$key = 'b' . ($bandId ?? 0) . '-c' . ($categoryId ?? 0) . '-p' . ($powerCategoryId ?? 0);
$map[$key][] = $log->id;
}
return $map;
}
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 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 isCheckLog(Log $log): bool
{
$psect = trim((string) $log->psect);
return $psect !== '' && preg_match('/\\bcheck\\b/i', $psect) === 1;
}
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 = \App\Models\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;
}
protected function applyPolicyDecision(string $policy, int $points, bool $keepPointsOnPenalty): array
{
$policy = strtoupper(trim($policy));
return match ($policy) {
'INVALID' => ['is_valid' => false, 'points' => $points],
'ZERO_POINTS' => ['is_valid' => true, 'points' => 0],
'FLAG_ONLY' => ['is_valid' => true, 'points' => $points],
'PENALTY' => ['is_valid' => true, 'points' => $keepPointsOnPenalty ? $points : 0],
default => ['is_valid' => true, 'points' => $points],
};
}
protected function resolvePolicyForError(EvaluationRuleSet $ruleSet, ?string $errorCode): ?string
{
return match ($errorCode) {
QsoErrorCode::DUP => $ruleSet->dup_qso_policy ?? 'ZERO_POINTS',
QsoErrorCode::NO_COUNTERPART_LOG => $ruleSet->getString(
'no_counterpart_log_policy',
$ruleSet->no_counterpart_log_policy ?? null,
$ruleSet->nil_qso_policy ?? 'ZERO_POINTS'
),
QsoErrorCode::NOT_IN_COUNTERPART_LOG => $ruleSet->getString(
'not_in_counterpart_log_policy',
$ruleSet->not_in_counterpart_log_policy ?? null,
$ruleSet->nil_qso_policy ?? 'ZERO_POINTS'
),
QsoErrorCode::UNIQUE => $ruleSet->getString(
'unique_qso_policy',
$ruleSet->unique_qso_policy ?? null,
'ZERO_POINTS'
),
QsoErrorCode::BUSTED_CALL => $ruleSet->busted_call_policy ?? 'ZERO_POINTS',
QsoErrorCode::BUSTED_RST => $ruleSet->busted_rst_policy ?? 'ZERO_POINTS',
QsoErrorCode::BUSTED_SERIAL => $ruleSet->getString(
'busted_serial_policy',
$ruleSet->busted_serial_policy ?? null,
$ruleSet->busted_exchange_policy ?? 'ZERO_POINTS'
),
QsoErrorCode::BUSTED_LOCATOR => $ruleSet->getString(
'busted_locator_policy',
$ruleSet->busted_locator_policy ?? null,
$ruleSet->busted_exchange_policy ?? 'ZERO_POINTS'
),
QsoErrorCode::TIME_MISMATCH => $ruleSet->time_mismatch_policy ?? 'ZERO_POINTS',
default => null,
};
}
/**
* Scoring policy (error_code policy efekt).
*
* - INVALID is_valid=false, points beze změny
* - ZERO_POINTS is_valid=true, points=0
* - FLAG_ONLY is_valid=true, points beze změny
* - PENALTY is_valid=true, points=0 (u BUSTED_RST body ponechány)
*
* Poznámka: is_valid se určuje ve scoringu, není přebíráno z matchingu.
*/
protected function applyErrorPolicy(
EvaluationRuleSet $ruleSet,
?string $errorCode,
string $errorSide,
int $points,
QsoResult $result
): int {
if (! $errorCode || $errorCode === QsoErrorCode::OK) {
return $points;
}
if (in_array($errorCode, [
QsoErrorCode::BUSTED_CALL,
QsoErrorCode::BUSTED_RST,
QsoErrorCode::BUSTED_SERIAL,
QsoErrorCode::BUSTED_LOCATOR,
], true) && $errorSide === 'TX') {
return $points;
}
$policy = $this->resolvePolicyForError($ruleSet, $errorCode);
if (! $policy) {
return $points;
}
$keepPointsOnPenalty = $errorCode === QsoErrorCode::BUSTED_RST;
$decision = $this->applyPolicyDecision($policy, $points, $keepPointsOnPenalty);
if ($result->is_valid) {
$result->is_valid = $decision['is_valid'];
}
return $decision['points'];
}
protected function applyMultipliers(
QsoResult $result,
LogQso $qso,
?WorkingQso $workingQso,
EvaluationRuleSet $ruleSet
): void {
// Multiplikátor se ukládá do QSO a agreguje se až v AggregateLogResultsJob.
$result->wwl = null;
$result->dxcc = null;
$result->country = null;
$result->section = null;
if (! $ruleSet->usesMultipliers()) {
return;
}
if ($ruleSet->multiplier_type === 'WWL') {
$result->wwl = $this->formatWwlMultiplier($workingQso?->rloc_norm, $ruleSet);
return;
}
if ($ruleSet->multiplier_type === 'SECTION') {
$result->section = $this->normalizeSection($qso->rx_exchange);
return;
}
if (in_array($ruleSet->multiplier_type, ['DXCC', 'COUNTRY'], true)) {
// DXCC/COUNTRY se odvozují z protistanice přes CTY prefix mapu.
$call = $workingQso?->rcall_norm ?: $qso->dx_call;
$cty = $this->resolveCtyForCall($call);
if ($cty) {
if ($ruleSet->multiplier_type === 'DXCC' && $cty->dxcc) {
$result->dxcc = (string) $cty->dxcc;
}
if ($ruleSet->multiplier_type === 'COUNTRY') {
$result->country = $cty->country_name;
}
}
}
}
protected function normalizeSection(?string $value): ?string
{
$value = trim((string) $value);
if ($value === '') {
return null;
}
$value = strtoupper(preg_replace('/\s+/', '', $value) ?? '');
return $value !== '' ? substr($value, 0, 50) : null;
}
protected function resolveCtyForCall(?string $call): ?Cty
{
$call = strtoupper(trim((string) $call));
if ($call === '') {
return null;
}
if (array_key_exists($call, $this->ctyCache)) {
return $this->ctyCache[$call];
}
// Nejprve zkus přesný match (precise=true), potom nejdelší prefix.
$precise = Cty::where('prefix_norm', $call)
->where('precise', true)
->first();
if ($precise) {
$this->ctyCache[$call] = $precise;
return $precise;
}
$prefixes = [];
$len = strlen($call);
for ($i = $len; $i >= 1; $i--) {
$prefixes[] = substr($call, 0, $i);
}
$match = Cty::whereIn('prefix_norm', $prefixes)
->where('precise', false)
->orderByRaw('LENGTH(prefix_norm) DESC')
->first();
$this->ctyCache[$call] = $match;
return $match;
}
protected function applyQsoOverride(QsoResult $result, QsoOverride $override): void
{
if ($override->forced_matched_log_qso_id !== null) {
$result->matched_qso_id = $override->forced_matched_log_qso_id;
$result->matched_log_qso_id = $override->forced_matched_log_qso_id;
$result->is_nil = false;
}
if (! $override->forced_status || $override->forced_status === 'AUTO') {
return;
}
$result->is_valid = false;
$result->is_duplicate = false;
$result->is_nil = false;
$result->is_busted_call = false;
$result->is_busted_rst = false;
$result->is_busted_exchange = false;
$result->is_time_out_of_window = false;
$result->error_code = null;
$result->error_side = 'NONE';
$result->penalty_points = 0;
switch ($override->forced_status) {
case 'VALID':
$result->is_valid = true;
$result->error_code = QsoErrorCode::OK;
break;
case 'INVALID':
$result->error_code = QsoErrorCode::NO_COUNTERPART_LOG;
break;
case 'NIL':
$result->is_nil = true;
$result->error_code = QsoErrorCode::NO_COUNTERPART_LOG;
break;
case 'DUPLICATE':
$result->is_duplicate = true;
$result->error_code = QsoErrorCode::DUP;
break;
case 'BUSTED_CALL':
$result->is_busted_call = true;
$result->error_code = QsoErrorCode::BUSTED_CALL;
$result->error_side = 'RX';
break;
case 'BUSTED_EXCHANGE':
$result->is_busted_exchange = true;
$result->error_code = QsoErrorCode::BUSTED_SERIAL;
$result->error_side = 'RX';
break;
case 'OUT_OF_WINDOW':
$result->is_time_out_of_window = true;
$result->error_code = null;
break;
}
}
protected function resolveErrorCode(QsoResult $result): ?string
{
if ($result->error_code) {
return $result->error_code;
}
if ($result->is_duplicate) {
return QsoErrorCode::DUP;
}
if ($result->is_nil) {
return QsoErrorCode::NO_COUNTERPART_LOG;
}
if ($result->is_busted_call) {
return QsoErrorCode::BUSTED_CALL;
}
if ($result->is_busted_rst) {
return QsoErrorCode::BUSTED_RST;
}
if ($result->is_busted_exchange) {
return QsoErrorCode::BUSTED_SERIAL;
}
return $result->is_valid ? QsoErrorCode::OK : null;
}
protected function resolvePenaltyPoints(QsoResult $result, EvaluationRuleSet $ruleSet, ScoringService $scoring): int
{
$penalty = 0;
$errorSide = $result->error_side ?? 'NONE';
if ($result->error_code === QsoErrorCode::DUP && $ruleSet->dup_qso_policy === 'PENALTY') {
$penalty += $scoring->penaltyPointsFor(QsoErrorCode::DUP, $ruleSet);
}
if ($result->error_code === QsoErrorCode::NO_COUNTERPART_LOG) {
$policy = $ruleSet->getString(
'no_counterpart_log_policy',
$ruleSet->no_counterpart_log_policy ?? null,
$ruleSet->nil_qso_policy ?? 'ZERO_POINTS'
);
if ($policy === 'PENALTY') {
$penalty += $scoring->penaltyPointsFor('NIL', $ruleSet);
}
}
if ($result->error_code === QsoErrorCode::NOT_IN_COUNTERPART_LOG) {
$policy = $ruleSet->getString(
'not_in_counterpart_log_policy',
$ruleSet->not_in_counterpart_log_policy ?? null,
$ruleSet->nil_qso_policy ?? 'ZERO_POINTS'
);
if ($policy === 'PENALTY') {
$penalty += $scoring->penaltyPointsFor('NIL', $ruleSet);
}
}
if ($result->error_code === QsoErrorCode::BUSTED_CALL
&& $errorSide !== 'TX'
&& $ruleSet->busted_call_policy === 'PENALTY') {
$penalty += $scoring->penaltyPointsFor(QsoErrorCode::BUSTED_CALL, $ruleSet);
}
if ($result->error_code === QsoErrorCode::BUSTED_RST
&& $errorSide !== 'TX'
&& $ruleSet->busted_rst_policy === 'PENALTY') {
$penalty += $scoring->penaltyPointsFor(QsoErrorCode::BUSTED_RST, $ruleSet);
}
if ($result->error_code === QsoErrorCode::BUSTED_SERIAL
&& $errorSide !== 'TX') {
$policy = $ruleSet->getString(
'busted_serial_policy',
$ruleSet->busted_serial_policy ?? null,
$ruleSet->busted_exchange_policy ?? 'ZERO_POINTS'
);
if ($policy === 'PENALTY') {
$penalty += $scoring->penaltyPointsFor(QsoErrorCode::BUSTED_SERIAL, $ruleSet);
}
}
if ($result->error_code === QsoErrorCode::BUSTED_LOCATOR
&& $errorSide !== 'TX') {
$policy = $ruleSet->getString(
'busted_locator_policy',
$ruleSet->busted_locator_policy ?? null,
$ruleSet->busted_exchange_policy ?? 'ZERO_POINTS'
);
if ($policy === 'PENALTY') {
$penalty += $scoring->penaltyPointsFor(QsoErrorCode::BUSTED_LOCATOR, $ruleSet);
}
}
if ($result->error_code === QsoErrorCode::TIME_MISMATCH
&& ($ruleSet->time_mismatch_policy ?? 'ZERO_POINTS') === 'PENALTY') {
$penalty += $scoring->penaltyPointsFor(QsoErrorCode::TIME_MISMATCH, $ruleSet);
}
if ($result->is_time_out_of_window && $ruleSet->out_of_window_policy === 'PENALTY') {
$penalty += $scoring->penaltyPointsFor(QsoErrorCode::OUT_OF_WINDOW, $ruleSet);
}
return $penalty;
}
protected function formatWwlMultiplier(?string $locator, EvaluationRuleSet $ruleSet): ?string
{
if (! $locator) {
return null;
}
$value = strtoupper(trim($locator));
$value = preg_replace('/\s+/', '', $value) ?? '';
if ($value === '') {
return null;
}
$length = match ($ruleSet->wwl_multiplier_level) {
'LOCATOR_2' => 2,
'LOCATOR_4' => 4,
default => 6,
};
if (strlen($value) < $length) {
return null;
}
return substr($value, 0, $length);
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Jobs;
use App\Models\EvaluationRun;
use App\Services\Evaluation\EvaluationCoordinator;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/**
* Job: StartEvaluationRunJob
*
* Odpovědnost:
* - Slouží jako ORCHESTRÁTOR vyhodnocovacího běhu (EvaluationRun).
* - Tento job NEPROVÁDÍ samotné vyhodnocení QSO ani výpočty bodů.
* - Je zodpovědný za bezpečné spuštění pipeline kroků vyhodnocení
* ve správném pořadí.
*
* Typický životní cyklus:
* 1) Job je dispatchnut po kliknutí v administraci nebo backendovou akcí.
* 2) Ověří, že EvaluationRun existuje a je ve stavu PENDING.
* 3) Získá lock nad rozsahem dat (typicky round_id), aby zabránil
* souběžnému vyhodnocení stejných dat.
* 4) Přepne EvaluationRun do stavu RUNNING a zapíše start běhu.
* 5) Sestaví sekvenci (chain / batch) dílčích jobů:
* - příprava dat
* - parsing logů
* - matching QSO
* - výpočty skóre
* - agregace výsledků
* - finalizace a publikace
* 6) Předá řízení jednotlivým krokům; tento job poté končí.
*
* Důležité zásady:
* - Tento job musí být IDEMPOTENTNÍ (opakované spuštění nesmí rozbít stav).
* - Nesmí obsahovat výpočetní logiku.
* - Nesmí zapisovat výsledky vyhodnocení.
* - Veškerá byznys logika patří do service layer a dílčích jobů.
*
* Queue:
* - Spouští se ve frontě "evaluation".
* - Používá se společně s ochranou proti souběhu (lock / WithoutOverlapping).
*/
class StartEvaluationRunJob implements ShouldQueue
{
use Queueable;
/**
* Create a new job instance.
*/
public function __construct(
protected int $evaluationRunId
)
{
//
}
/**
* Spuštění vyhodnocovacího běhu.
*
* Metoda handle():
* - inicializuje vyhodnocovací běh
* - zajistí exkluzivitu nad daty
* - připraví a dispatchne navazující joby
*
* Poznámka:
* - Tato metoda by měla být krátká a čitelná.
* - Veškerá komplexní logika být delegována
* do EvaluationCoordinator / service layer.
*/
public function handle(): void
{
$run = EvaluationRun::find($this->evaluationRunId);
if (! $run || $run->isCanceled()) {
return;
}
app(EvaluationCoordinator::class)->start($run);
}
}

View File

@@ -0,0 +1,147 @@
<?php
namespace App\Jobs;
use App\Enums\QsoErrorCode;
use App\Models\EvaluationRun;
use App\Models\EvaluationRuleSet;
use App\Models\QsoResult;
use App\Models\WorkingQso;
use App\Services\Evaluation\EvaluationCoordinator;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Bus\Batchable;
use Illuminate\Foundation\Queue\Queueable;
use Throwable;
/**
* Job: UnpairedClassificationBucketJob
*
* Účel:
* - Klasifikace nenapárovaných QSO v bucketu band_id + rcall_norm.
* - Udržuje kratší dobu běhu jednoho jobu.
*/
class UnpairedClassificationBucketJob implements ShouldQueue
{
use Batchable;
use Queueable;
public int $tries = 2;
public array $backoff = [60];
protected ?int $bandId;
protected string $rcallNorm;
public function __construct(
protected int $evaluationRunId,
?int $bandId,
string $rcallNorm
) {
$this->bandId = $bandId;
$this->rcallNorm = $rcallNorm;
}
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, 'Unpaired klasifikace nelze spustit: chybí ruleset.', [
'step' => 'unpaired_classification',
]);
return;
}
$coordinator->eventInfo($run, 'Unpaired bucket: krok spuštěn.', [
'step' => 'unpaired_classification',
'round_id' => $run->round_id,
'band_id' => $this->bandId,
'rcall_norm' => $this->rcallNorm,
]);
$workingQuery = WorkingQso::where('evaluation_run_id', $run->id)
->where('rcall_norm', $this->rcallNorm);
if ($this->bandId !== null) {
$workingQuery->where('band_id', $this->bandId);
} else {
$workingQuery->whereNull('band_id');
}
$logQsoIds = $workingQuery->pluck('log_qso_id')->all();
if (! $logQsoIds) {
return;
}
QsoResult::where('evaluation_run_id', $run->id)
->whereNull('matched_log_qso_id')
->whereIn('log_qso_id', $logQsoIds)
->chunkById(500, function ($results) use ($run, $ruleSet) {
foreach ($results as $result) {
$wqso = WorkingQso::where('evaluation_run_id', $run->id)
->where('log_qso_id', $result->log_qso_id)
->first();
if (! $wqso) {
continue;
}
$hasCounterpartLog = false;
if ($wqso->band_id && $wqso->rcall_norm) {
$hasCounterpartLog = WorkingQso::where('evaluation_run_id', $run->id)
->where('band_id', $wqso->band_id)
->where('call_norm', $wqso->rcall_norm)
->exists();
}
if ($hasCounterpartLog) {
$result->error_code = QsoErrorCode::NOT_IN_COUNTERPART_LOG;
$result->is_nil = true;
} else {
$isUnique = false;
if ($ruleSet->uniqueQsoEnabled() && $wqso->rcall_norm) {
$uniqueQuery = WorkingQso::where('evaluation_run_id', $run->id)
->where('rcall_norm', $wqso->rcall_norm);
if ($wqso->band_id) {
$uniqueQuery->where('band_id', $wqso->band_id);
}
$count = $uniqueQuery->count();
$isUnique = $count === 1;
}
if ($isUnique) {
$result->error_code = QsoErrorCode::UNIQUE;
$result->is_nil = false;
} else {
$result->error_code = QsoErrorCode::NO_COUNTERPART_LOG;
$result->is_nil = true;
}
}
$result->is_valid = false;
$result->error_side = 'NONE';
$result->save();
}
});
EvaluationRun::where('id', $run->id)->increment('progress_done');
$coordinator->eventInfo($run, 'Unpaired bucket: krok dokončen.', [
'step' => 'unpaired_classification',
'round_id' => $run->round_id,
'band_id' => $this->bandId,
'rcall_norm' => $this->rcallNorm,
]);
} catch (Throwable $e) {
$coordinator->eventError($run, 'Unpaired bucket: krok selhal.', [
'step' => 'unpaired_classification',
'round_id' => $run->round_id,
'band_id' => $this->bandId,
'rcall_norm' => $this->rcallNorm,
'error' => $e->getMessage(),
]);
throw $e;
}
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace App\Jobs;
use App\Models\EvaluationRun;
use App\Models\EvaluationRuleSet;
use App\Models\QsoResult;
use App\Models\WorkingQso;
use App\Enums\QsoErrorCode;
use App\Services\Evaluation\EvaluationCoordinator;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Throwable;
/**
* Job: UnpairedClassificationJob
*
* WHY:
* - Odděluje klasifikaci nenapárovaných QSO od samotného matchingu.
* ORDER:
* - Musí běžet po PASS 1 + PASS 2 matchingu, protože vychází z finální
* informace, že QSO opravdu nemá protistranu.
* - Krok je nevratný: následné kroky (duplicate/scoring) jen vycházejí
* z výsledné error_code.
*
* Vstup:
* - QsoResult bez matched_log_qso_id
* - WorkingQso (pro identifikaci protistanice)
* - EvaluationRuleSet (unique_qso)
*
* Výstup:
* - error_code: NOT_IN_COUNTERPART_LOG / NO_COUNTERPART_LOG / UNIQUE
* - is_nil/is_valid + error_side
*
* Co job NEDĚLÁ:
* - neprovádí matching ani scoring,
* - neupravuje původní log_qsos.
*/
class UnpairedClassificationJob implements ShouldQueue
{
use Queueable;
public function __construct(
protected int $evaluationRunId
) {
}
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, 'Unpaired klasifikace nelze spustit: chybí ruleset.', [
'step' => 'unpaired_classification',
]);
return;
}
$coordinator->eventInfo($run, 'Unpaired: krok spuštěn.', [
'step' => 'unpaired_classification',
'round_id' => $run->round_id,
]);
$coordinator->eventInfo($run, 'Klasifikace nenapárovaných QSO.', [
'step' => 'match',
'round_id' => $run->round_id,
'step_progress_done' => null,
'step_progress_total' => $run->progress_total,
]);
QsoResult::where('evaluation_run_id', $run->id)
->whereNull('matched_log_qso_id')
->chunkById(500, function ($results) use ($run, $ruleSet) {
foreach ($results as $result) {
$wqso = WorkingQso::where('evaluation_run_id', $run->id)
->where('log_qso_id', $result->log_qso_id)
->first();
if (! $wqso) {
continue;
}
$hasCounterpartLog = false;
if ($wqso->band_id && $wqso->rcall_norm) {
$hasCounterpartLog = WorkingQso::where('evaluation_run_id', $run->id)
->where('band_id', $wqso->band_id)
->where('call_norm', $wqso->rcall_norm)
->exists();
}
if ($hasCounterpartLog) {
$result->error_code = QsoErrorCode::NOT_IN_COUNTERPART_LOG;
$result->is_nil = true;
} else {
$isUnique = false;
// UNIQUE je globální v rámci runu (min. evaluation_run_id + band_id).
if ($ruleSet->uniqueQsoEnabled() && $wqso->rcall_norm) {
$uniqueQuery = WorkingQso::where('evaluation_run_id', $run->id)
->where('rcall_norm', $wqso->rcall_norm);
if ($wqso->band_id) {
$uniqueQuery->where('band_id', $wqso->band_id);
}
$count = $uniqueQuery->count();
$isUnique = $count === 1;
}
if ($isUnique) {
$result->error_code = QsoErrorCode::UNIQUE;
$result->is_nil = false;
} else {
$result->error_code = QsoErrorCode::NO_COUNTERPART_LOG;
$result->is_nil = true;
}
}
$result->error_side = 'NONE';
$result->is_valid = false;
$result->save();
}
});
EvaluationRun::where('id', $run->id)->increment('progress_done');
$coordinator->eventInfo($run, 'Unpaired: krok dokončen.', [
'step' => 'unpaired_classification',
'round_id' => $run->round_id,
]);
} catch (Throwable $e) {
$coordinator->eventError($run, 'Unpaired: krok selhal.', [
'step' => 'unpaired_classification',
'round_id' => $run->round_id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
}

View File

@@ -0,0 +1,329 @@
<?php
namespace App\Jobs;
use App\Models\EdiBand;
use App\Models\EdiCategory;
use App\Models\Log;
use App\Models\LogResult;
use App\Models\Round;
use App\Services\Evaluation\ClaimedRunResolver;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/**
* Job: UpsertClaimedLogResultJob
*
* Projekce deklarovaných výsledků (claimed) do log_results
* pro CLAIMED evaluation run.
*/
class UpsertClaimedLogResultJob implements ShouldQueue
{
use Queueable;
public function __construct(
protected int $logId
) {
}
public function handle(): void
{
$log = Log::find($this->logId);
if (! $log) {
return;
}
$round = Round::with(['bands', 'categories', 'powerCategories'])->find($log->round_id);
$remarksEval = $this->decodeRemarksEval($log->remarks_eval);
// TDate validace hlídá, že log odpovídá termínu kola.
$tDateInvalid = ! $this->isTDateWithinRound($log->tdate, $round);
if ($tDateInvalid) {
$this->addRemark($remarksEval, 'Datum v TDate neodpovídá termínu závodu.');
$log->remarks_eval = $this->encodeRemarksEval($remarksEval);
$log->save();
}
$categoryId = $this->resolveCategoryId($log, $round, $remarksEval);
$bandId = $this->resolveBandId($log, $round, $remarksEval);
[$powerCategoryId, $powerMismatch] = $this->resolvePowerCategoryId($log, $round, $remarksEval);
// 6H kategorie je povolená jen pro vybraná pásma.
if ($log->sixhr_category && ! $this->isSixHourBand($bandId)) {
$remarksEval[] = '6H kategorie je povolena jen pro pásma 145 MHz a 435 MHz.';
}
$missingClaimedQso = $log->claimed_qso_count === null;
$missingClaimedScore = $log->claimed_score === null;
if ($missingClaimedQso) {
$remarksEval[] = 'Nebyl načten CQSOs.';
}
if ($missingClaimedScore) {
$remarksEval[] = 'Nebyl načten CToSc.';
}
$log->remarks_eval = $this->encodeRemarksEval($remarksEval);
$log->save();
$claimedRun = ClaimedRunResolver::forRound($log->round_id);
$claimedQsoCount = $log->claimed_qso_count ?? 0;
$claimedScore = $log->claimed_score ?? 0;
$scorePerQso = $claimedQsoCount > 0 ? round($claimedScore / $claimedQsoCount, 2) : null;
$status = 'OK';
$statusReason = null;
if ($this->isCheckLog($log)) {
$status = 'CHECK';
}
// IGNORED = log nelze bezpečně zařadit do claimed scoreboardu.
if ($tDateInvalid || $categoryId === null || $bandId === null || $missingClaimedQso || $missingClaimedScore || $powerMismatch) {
$status = 'IGNORED';
$reasons = [];
if ($tDateInvalid) {
$reasons[] = 'TDate mimo termín závodu.';
}
if ($categoryId === null) {
$reasons[] = 'Kategorie nebyla rozpoznána.';
}
if ($bandId === null) {
$reasons[] = 'Pásmo nebylo rozpoznáno.';
}
if ($missingClaimedQso) {
$reasons[] = 'Chybí CQSOs.';
}
if ($missingClaimedScore) {
$reasons[] = 'Chybí CToSc.';
}
if ($powerMismatch) {
$reasons[] = 'Výkon neodpovídá zvolené kategorii.';
}
$statusReason = implode(' ', $reasons);
}
LogResult::updateOrCreate(
['log_id' => $log->id, 'evaluation_run_id' => $claimedRun->id],
[
'evaluation_run_id' => $claimedRun->id,
'category_id' => $categoryId,
'band_id' => $bandId,
'power_category_id' => $powerCategoryId,
'claimed_qso_count' => $log->claimed_qso_count,
'claimed_score' => $log->claimed_score,
'total_qso_count' => $claimedQsoCount,
'discarded_qso_count' => 0,
'discarded_points' => 0,
'discarded_qso_percent' => 0,
'unique_qso_count' => 0,
'score_per_qso' => $scorePerQso,
'official_score' => 0,
'penalty_score' => 0,
'status' => $status,
'status_reason' => $statusReason,
]
);
}
protected function resolveCategoryId(Log $log, ?Round $round, array &$remarksEval): ?int
{
$resolveCategory = function (?string $value) use ($round): ?int {
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 && $round->categories()->where('categories.id', $mappedCategoryId)->exists()) {
return $mappedCategoryId;
}
if (! $round || $round->categories()->count() === 0) {
return $mappedCategoryId;
}
return null;
};
// V PSect může být více tokenů zkoušíme je postupně.
$categoryId = null;
if ($log->psect) {
$categoryId = $resolveCategory($log->psect);
if ($categoryId === null) {
$parts = preg_split('/\\s+/', trim((string) $log->psect)) ?: [];
if (count($parts) > 1) {
foreach ($parts as $part) {
$categoryId = $resolveCategory($part);
if ($categoryId !== null) {
break;
}
}
}
}
}
if ($categoryId === null) {
$remarksEval[] = 'Kategorie nebyla rozpoznána.';
}
return $categoryId;
}
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, array &$remarksEval): ?int
{
$bandId = null;
if ($log->pband) {
// Nejprve přímá mapa přes EDI bandy, fallback je interval v MHz.
$pbandVal = mb_strtolower(trim($log->pband));
$ediBand = EdiBand::whereRaw('LOWER(value) = ?', [$pbandVal])->first();
if ($ediBand) {
$mappedBandId = $ediBand->bands()->value('bands.id');
if ($mappedBandId) {
if ($round && $round->bands()->where('bands.id', $mappedBandId)->exists()) {
$bandId = $mappedBandId;
} elseif (! $round || $round->bands()->count() === 0) {
$bandId = $mappedBandId;
}
}
} else {
$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) {
$bandMatch = \App\Models\Band::where('edi_band_begin', '<=', $num)
->where('edi_band_end', '>=', $num)
->first();
if ($bandMatch) {
if ($round && $round->bands()->where('bands.id', $bandMatch->id)->exists()) {
$bandId = $bandMatch->id;
} elseif (! $round || $round->bands()->count() === 0) {
$bandId = $bandMatch->id;
}
}
}
}
}
if ($bandId === null) {
$remarksEval[] = 'Pásmo nebylo rozpoznáno.';
}
return $bandId;
}
protected function resolvePowerCategoryId(Log $log, ?Round $round, array &$remarksEval): array
{
$powerCategoryId = null;
$powerMismatch = false;
if ($log->power_category_id) {
$powerCategoryId = $log->power_category_id;
}
if ($round && $round->powerCategories()->count() > 0) {
$exists = $round->powerCategories()->where('power_categories.id', $powerCategoryId)->exists();
if (! $exists) {
$powerMismatch = true;
}
}
return [$powerCategoryId, $powerMismatch];
}
protected function isSixHourBand(?int $bandId): bool
{
if (! $bandId) {
return false;
}
return in_array($bandId, [1, 2], true);
}
protected function isCheckLog(Log $log): bool
{
$psect = trim((string) $log->psect);
return $psect !== '' && preg_match('/\\bcheck\\b/i', $psect) === 1;
}
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 addRemark(array &$remarksEval, string $message): void
{
if (! in_array($message, $remarksEval, true)) {
$remarksEval[] = $message;
}
}
protected function isTDateWithinRound(?string $tdate, ?Round $round): bool
{
if (! $tdate || ! $round || ! $round->start_time || ! $round->end_time) {
return true;
}
$parts = explode(';', $tdate);
if (count($parts) !== 2) {
return false;
}
if (!preg_match('/^\\d{8}$/', $parts[0]) || !preg_match('/^\\d{8}$/', $parts[1])) {
return false;
}
$start = \Carbon\Carbon::createFromFormat('Ymd', $parts[0])->startOfDay();
$end = \Carbon\Carbon::createFromFormat('Ymd', $parts[1])->endOfDay();
return $start->lte($round->end_time) && $end->gte($round->start_time);
}
}