809 lines
30 KiB
PHP
809 lines
30 KiB
PHP
<?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 má být výkonnostně bezpečný (chunking, minimalizace N+1).
|
|
* - Pokud scoring jedné skupiny selže, má 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 až 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);
|
|
}
|
|
}
|