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

225 lines
7.4 KiB
PHP

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