Initial commit
This commit is contained in:
224
app/Jobs/RecalculateOfficialRanksJob.php
Normal file
224
app/Jobs/RecalculateOfficialRanksJob.php
Normal 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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user