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

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