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