Initial commit
This commit is contained in:
429
app/Jobs/AggregateLogResultsJob.php
Normal file
429
app/Jobs/AggregateLogResultsJob.php
Normal file
@@ -0,0 +1,429 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\QsoErrorCode;
|
||||
use App\Models\EvaluationRun;
|
||||
use App\Models\EvaluationRuleSet;
|
||||
use App\Models\LogQso;
|
||||
use App\Models\LogResult;
|
||||
use App\Models\QsoResult;
|
||||
use App\Services\Evaluation\EvaluationCoordinator;
|
||||
use App\Services\Evaluation\OperatingWindowService;
|
||||
use Illuminate\Bus\Batchable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Job: AggregateLogResultsJob
|
||||
*
|
||||
* Účel:
|
||||
* - Agreguje výsledky pro jeden log_id v rámci evaluation runu.
|
||||
*/
|
||||
class AggregateLogResultsJob implements ShouldQueue
|
||||
{
|
||||
use Batchable;
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 2;
|
||||
public array $backoff = [60];
|
||||
|
||||
public function __construct(
|
||||
protected int $evaluationRunId,
|
||||
protected int $logId
|
||||
) {
|
||||
}
|
||||
|
||||
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, 'Agregace nelze spustit: chybí ruleset.', [
|
||||
'step' => 'aggregate',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$timeDiffThresholdSec = $ruleSet->time_diff_dq_threshold_sec;
|
||||
$timeDiffThresholdPercent = $ruleSet->time_diff_dq_threshold_percent;
|
||||
$badQsoThresholdPercent = $ruleSet->bad_qso_dq_threshold_percent;
|
||||
|
||||
$logResult = LogResult::firstOrNew([
|
||||
'evaluation_run_id' => $run->id,
|
||||
'log_id' => $this->logId,
|
||||
]);
|
||||
|
||||
// 6H je omezení agregace, nikoli matchingu; dříve to byl jen flag bez operating-window logiky.
|
||||
$useOperatingWindow = $logResult->sixhr_category
|
||||
&& $ruleSet->operating_window_mode === 'BEST_CONTIGUOUS'
|
||||
&& (int) $ruleSet->operating_window_hours === 6;
|
||||
if ($useOperatingWindow) {
|
||||
$service = new OperatingWindowService();
|
||||
$window = $service->pickBestOperatingWindow($run->id, $this->logId, 6, $ruleSet);
|
||||
if ($window) {
|
||||
$this->applyOperatingWindow($run->id, $logResult, $window);
|
||||
} else {
|
||||
$this->resetOperatingWindow($run->id, $logResult);
|
||||
}
|
||||
} else {
|
||||
$this->resetOperatingWindow($run->id, $logResult);
|
||||
}
|
||||
|
||||
$stats = [
|
||||
'base_score' => 0,
|
||||
'penalty_score' => 0,
|
||||
'valid_qso_count' => 0,
|
||||
'dupe_qso_count' => 0,
|
||||
'busted_qso_count' => 0,
|
||||
'other_error_qso_count' => 0,
|
||||
'out_of_window_qso_count' => 0,
|
||||
'total_qso_count' => 0,
|
||||
'discarded_qso_count' => 0,
|
||||
'discarded_points' => 0,
|
||||
'unique_qso_count' => 0,
|
||||
'bad_qso_count' => 0,
|
||||
'matched_qso_count' => 0,
|
||||
'time_diff_over_threshold_count' => 0,
|
||||
'multipliers' => [],
|
||||
];
|
||||
|
||||
$query = QsoResult::query()
|
||||
->where('qso_results.evaluation_run_id', $run->id)
|
||||
->join('log_qsos', 'log_qsos.id', '=', 'qso_results.log_qso_id')
|
||||
->leftJoin('working_qsos', function ($join) use ($run) {
|
||||
$join->on('working_qsos.log_qso_id', '=', 'qso_results.log_qso_id')
|
||||
->where('working_qsos.evaluation_run_id', '=', $run->id);
|
||||
})
|
||||
->where('log_qsos.log_id', $this->logId)
|
||||
->select([
|
||||
'qso_results.id',
|
||||
'qso_results.log_qso_id',
|
||||
'qso_results.points',
|
||||
'qso_results.penalty_points',
|
||||
'qso_results.time_diff_sec',
|
||||
'qso_results.error_code',
|
||||
'qso_results.error_side',
|
||||
'qso_results.is_nil',
|
||||
'qso_results.is_duplicate',
|
||||
'qso_results.is_busted_call',
|
||||
'qso_results.is_busted_rst',
|
||||
'qso_results.is_busted_exchange',
|
||||
'qso_results.is_time_out_of_window',
|
||||
'qso_results.is_valid',
|
||||
'qso_results.matched_qso_id',
|
||||
'qso_results.wwl',
|
||||
'qso_results.dxcc',
|
||||
'qso_results.country',
|
||||
'qso_results.section',
|
||||
'working_qsos.band_id as band_id',
|
||||
]);
|
||||
|
||||
if ($useOperatingWindow) {
|
||||
$query->where('qso_results.is_operating_window_excluded', false);
|
||||
}
|
||||
|
||||
$query->chunkById(1000, function ($rows) use (&$stats, $ruleSet, $timeDiffThresholdSec, $run) {
|
||||
foreach ($rows as $row) {
|
||||
if (EvaluationRun::isCanceledRun($run->id)) {
|
||||
return false;
|
||||
}
|
||||
$stats['total_qso_count']++;
|
||||
|
||||
if ($row->is_valid) {
|
||||
$stats['base_score'] += (int) $row->points;
|
||||
}
|
||||
|
||||
$errorCode = $row->error_code;
|
||||
$errorSide = $row->error_side ?? 'NONE';
|
||||
|
||||
$isNil = (bool) $row->is_nil
|
||||
|| in_array($errorCode, [QsoErrorCode::NOT_IN_COUNTERPART_LOG, QsoErrorCode::NO_COUNTERPART_LOG], true);
|
||||
$isUnique = $errorCode === QsoErrorCode::UNIQUE;
|
||||
$isDuplicate = (bool) $row->is_duplicate || $errorCode === QsoErrorCode::DUP;
|
||||
$isBusted = (bool) $row->is_busted_call
|
||||
|| (bool) $row->is_busted_rst
|
||||
|| (bool) $row->is_busted_exchange
|
||||
|| (in_array($errorCode, [
|
||||
QsoErrorCode::BUSTED_CALL,
|
||||
QsoErrorCode::BUSTED_RST,
|
||||
QsoErrorCode::BUSTED_SERIAL,
|
||||
QsoErrorCode::BUSTED_LOCATOR,
|
||||
], true)
|
||||
&& $errorSide !== 'TX');
|
||||
$isTimeMismatch = $errorCode === QsoErrorCode::TIME_MISMATCH;
|
||||
$isOutOfWindow = (bool) $row->is_time_out_of_window;
|
||||
|
||||
$isValid = (bool) $row->is_valid;
|
||||
if ($isValid) {
|
||||
$stats['valid_qso_count']++;
|
||||
} else {
|
||||
$stats['discarded_qso_count']++;
|
||||
$stats['discarded_points'] += (int) ($row->points ?? 0);
|
||||
}
|
||||
|
||||
if ($isDuplicate) {
|
||||
$stats['dupe_qso_count']++;
|
||||
}
|
||||
if ($isBusted) {
|
||||
$stats['busted_qso_count']++;
|
||||
}
|
||||
if ($isOutOfWindow) {
|
||||
$stats['out_of_window_qso_count']++;
|
||||
}
|
||||
if ($isNil || $isOutOfWindow || $isUnique) {
|
||||
$stats['other_error_qso_count']++;
|
||||
}
|
||||
if ($isDuplicate || $isBusted || $isOutOfWindow || $isTimeMismatch || $isUnique) {
|
||||
$stats['bad_qso_count']++;
|
||||
}
|
||||
if ($isUnique) {
|
||||
$stats['unique_qso_count']++;
|
||||
}
|
||||
if ($row->matched_qso_id !== null) {
|
||||
$stats['matched_qso_count']++;
|
||||
if (
|
||||
$timeDiffThresholdSec !== null
|
||||
&& $row->time_diff_sec !== null
|
||||
&& (int) $row->time_diff_sec > (int) $timeDiffThresholdSec
|
||||
) {
|
||||
$stats['time_diff_over_threshold_count']++;
|
||||
}
|
||||
}
|
||||
|
||||
$penalty = (int) ($row->penalty_points ?? 0);
|
||||
if ($row->is_valid && $penalty !== 0) {
|
||||
$stats['penalty_score'] -= $penalty;
|
||||
}
|
||||
|
||||
if ($ruleSet->usesMultipliers()) {
|
||||
$bandKey = $ruleSet->multiplier_scope === 'PER_BAND'
|
||||
? (int) ($row->band_id ?? 0)
|
||||
: 0;
|
||||
|
||||
if (! isset($stats['multipliers'][$bandKey])) {
|
||||
$stats['multipliers'][$bandKey] = [];
|
||||
}
|
||||
|
||||
$eligible = false;
|
||||
if ($ruleSet->multiplier_source === 'VALID_ONLY') {
|
||||
$eligible = ! $isNil && ! $isDuplicate && ! $isBusted && ! $isOutOfWindow && (bool) $row->is_valid;
|
||||
} elseif ($ruleSet->multiplier_source === 'ALL_MATCHED') {
|
||||
$eligible = $row->matched_qso_id !== null
|
||||
&& ! $isNil
|
||||
&& ! $isDuplicate
|
||||
&& ! $isBusted
|
||||
&& ! $isOutOfWindow;
|
||||
}
|
||||
|
||||
if ($eligible) {
|
||||
$multiplier = null;
|
||||
if ($ruleSet->multiplier_type === 'WWL') {
|
||||
$multiplier = $row->wwl;
|
||||
} elseif ($ruleSet->multiplier_type === 'DXCC') {
|
||||
$multiplier = $row->dxcc;
|
||||
} elseif ($ruleSet->multiplier_type === 'COUNTRY') {
|
||||
$multiplier = $row->country;
|
||||
} elseif ($ruleSet->multiplier_type === 'SECTION') {
|
||||
$multiplier = $row->section;
|
||||
}
|
||||
|
||||
if ($multiplier) {
|
||||
$stats['multipliers'][$bandKey][$multiplier] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 'qso_results.id', 'id');
|
||||
|
||||
if ($stats['total_qso_count'] === 0) {
|
||||
$logResult->update([
|
||||
'base_score' => 0,
|
||||
'penalty_score' => 0,
|
||||
'multiplier_count' => 0,
|
||||
'multiplier_score' => 0,
|
||||
'official_score' => 0,
|
||||
'valid_qso_count' => 0,
|
||||
'dupe_qso_count' => 0,
|
||||
'busted_qso_count' => 0,
|
||||
'other_error_qso_count' => 0,
|
||||
'total_qso_count' => 0,
|
||||
'discarded_qso_count' => 0,
|
||||
'discarded_points' => 0,
|
||||
'discarded_qso_percent' => 0,
|
||||
'unique_qso_count' => 0,
|
||||
'score_per_qso' => null,
|
||||
]);
|
||||
EvaluationRun::where('id', $run->id)->increment('progress_done');
|
||||
return;
|
||||
}
|
||||
|
||||
$multiplierCount = 1;
|
||||
if ($ruleSet->usesMultipliers()) {
|
||||
$multiplierCount = 0;
|
||||
foreach ($stats['multipliers'] as $values) {
|
||||
$multiplierCount += count($values);
|
||||
}
|
||||
}
|
||||
|
||||
$baseScore = (int) $stats['base_score'];
|
||||
$penaltyScore = (int) $stats['penalty_score'];
|
||||
$scoreBeforeMultiplier = $baseScore + $penaltyScore;
|
||||
if (! $ruleSet->usesMultipliers()) {
|
||||
$multiplierCount = 1;
|
||||
}
|
||||
$multiplierScore = $ruleSet->usesMultipliers() ? $scoreBeforeMultiplier * $multiplierCount : $scoreBeforeMultiplier;
|
||||
$officialScore = max(0, $multiplierScore);
|
||||
$totalQsoCount = (int) ($stats['total_qso_count'] ?? 0);
|
||||
$discardedQsoCount = (int) ($stats['discarded_qso_count'] ?? 0);
|
||||
$discardedPercent = $totalQsoCount > 0
|
||||
? round(($discardedQsoCount / $totalQsoCount) * 100, 2)
|
||||
: 0;
|
||||
$validQsoCount = (int) ($stats['valid_qso_count'] ?? 0);
|
||||
$scorePerQso = $validQsoCount > 0 ? round($officialScore / $validQsoCount, 2) : null;
|
||||
|
||||
$update = [
|
||||
'base_score' => $baseScore,
|
||||
'penalty_score' => $penaltyScore,
|
||||
'multiplier_count' => $multiplierCount,
|
||||
'multiplier_score' => $multiplierScore,
|
||||
'official_score' => $officialScore,
|
||||
'valid_qso_count' => $stats['valid_qso_count'],
|
||||
'dupe_qso_count' => $stats['dupe_qso_count'],
|
||||
'busted_qso_count' => $stats['busted_qso_count'],
|
||||
'other_error_qso_count' => $stats['other_error_qso_count'],
|
||||
'total_qso_count' => $totalQsoCount,
|
||||
'discarded_qso_count' => $discardedQsoCount,
|
||||
'discarded_points' => (int) ($stats['discarded_points'] ?? 0),
|
||||
'discarded_qso_percent' => $discardedPercent,
|
||||
'unique_qso_count' => (int) ($stats['unique_qso_count'] ?? 0),
|
||||
'score_per_qso' => $scorePerQso,
|
||||
];
|
||||
|
||||
$outOfWindowThreshold = $ruleSet->out_of_window_dq_threshold;
|
||||
if ($outOfWindowThreshold && in_array($logResult->status, ['OK', 'CHECK'], true)) {
|
||||
$outOfWindowCount = (int) ($stats['out_of_window_qso_count'] ?? 0);
|
||||
if ($outOfWindowCount >= (int) $outOfWindowThreshold) {
|
||||
$reason = 'OUT_OF_WINDOW >= ' . (int) $outOfWindowThreshold;
|
||||
$update['status'] = 'DQ';
|
||||
$update['status_reason'] = $logResult->status_reason
|
||||
? $logResult->status_reason . '; ' . $reason
|
||||
: $reason;
|
||||
}
|
||||
}
|
||||
if (
|
||||
$timeDiffThresholdSec !== null
|
||||
&& $timeDiffThresholdPercent !== null
|
||||
&& in_array($logResult->status, ['OK', 'CHECK'], true)
|
||||
) {
|
||||
$matchedCount = (int) ($stats['matched_qso_count'] ?? 0);
|
||||
if ($matchedCount > 0) {
|
||||
$overCount = (int) ($stats['time_diff_over_threshold_count'] ?? 0);
|
||||
$percent = ($overCount / $matchedCount) * 100;
|
||||
if ($percent > (float) $timeDiffThresholdPercent) {
|
||||
$percentLabel = rtrim(rtrim(number_format($percent, 1, '.', ''), '0'), '.');
|
||||
$reason = sprintf(
|
||||
'TIME_DIFF > %ss (%s/%s = %s%%)',
|
||||
(int) $timeDiffThresholdSec,
|
||||
$overCount,
|
||||
$matchedCount,
|
||||
$percentLabel
|
||||
);
|
||||
$update['status'] = 'DQ';
|
||||
$update['status_reason'] = $logResult->status_reason
|
||||
? $logResult->status_reason . '; ' . $reason
|
||||
: $reason;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($badQsoThresholdPercent !== null && in_array($logResult->status, ['OK', 'CHECK'], true)) {
|
||||
$totalCount = (int) ($stats['total_qso_count'] ?? 0);
|
||||
if ($totalCount > 0) {
|
||||
$badCount = (int) ($stats['bad_qso_count'] ?? 0);
|
||||
$percent = ($badCount / $totalCount) * 100;
|
||||
if ($percent >= (float) $badQsoThresholdPercent) {
|
||||
$percentLabel = rtrim(rtrim(number_format($percent, 1, '.', ''), '0'), '.');
|
||||
$reason = sprintf(
|
||||
'BAD_QSO >= %s%% (%s/%s = %s%%)',
|
||||
(int) $badQsoThresholdPercent,
|
||||
$badCount,
|
||||
$totalCount,
|
||||
$percentLabel
|
||||
);
|
||||
$update['status'] = 'DQ';
|
||||
$update['status_reason'] = $logResult->status_reason
|
||||
? $logResult->status_reason . '; ' . $reason
|
||||
: $reason;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$logResult->update($update);
|
||||
EvaluationRun::where('id', $run->id)->increment('progress_done');
|
||||
} catch (Throwable $e) {
|
||||
$coordinator->eventError($run, 'Aggregate log: krok selhal.', [
|
||||
'step' => 'aggregate',
|
||||
'round_id' => $run->round_id,
|
||||
'log_id' => $this->logId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
protected function applyOperatingWindow(int $runId, LogResult $logResult, array $window): void
|
||||
{
|
||||
$logQsoIds = LogQso::where('log_id', $logResult->log_id)->pluck('id')->all();
|
||||
if (! $logQsoIds) {
|
||||
$this->resetOperatingWindow($runId, $logResult);
|
||||
return;
|
||||
}
|
||||
|
||||
$logResult->update([
|
||||
'operating_window_start_utc' => $window['startUtc'],
|
||||
'operating_window_end_utc' => $window['endUtc'],
|
||||
'operating_window_2_start_utc' => $window['secondStartUtc'] ?? null,
|
||||
'operating_window_2_end_utc' => $window['secondEndUtc'] ?? null,
|
||||
'operating_window_hours' => 6,
|
||||
'operating_window_qso_count' => $window['qsoCount'],
|
||||
]);
|
||||
|
||||
QsoResult::where('evaluation_run_id', $runId)
|
||||
->whereIn('log_qso_id', $logQsoIds)
|
||||
->update(['is_operating_window_excluded' => true]);
|
||||
|
||||
if (! empty($window['includedLogQsoIds'])) {
|
||||
QsoResult::where('evaluation_run_id', $runId)
|
||||
->whereIn('log_qso_id', $window['includedLogQsoIds'])
|
||||
->update(['is_operating_window_excluded' => false]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function resetOperatingWindow(int $runId, LogResult $logResult): void
|
||||
{
|
||||
$logResult->update([
|
||||
'operating_window_start_utc' => null,
|
||||
'operating_window_end_utc' => null,
|
||||
'operating_window_2_start_utc' => null,
|
||||
'operating_window_2_end_utc' => null,
|
||||
'operating_window_hours' => null,
|
||||
'operating_window_qso_count' => null,
|
||||
]);
|
||||
|
||||
$logQsoIds = LogQso::where('log_id', $logResult->log_id)->pluck('id')->all();
|
||||
if (! $logQsoIds) {
|
||||
return;
|
||||
}
|
||||
|
||||
QsoResult::where('evaluation_run_id', $runId)
|
||||
->whereIn('log_qso_id', $logQsoIds)
|
||||
->update(['is_operating_window_excluded' => false]);
|
||||
}
|
||||
}
|
||||
159
app/Jobs/ApplyLogOverridesJob.php
Normal file
159
app/Jobs/ApplyLogOverridesJob.php
Normal file
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\EvaluationRun;
|
||||
use App\Models\Log;
|
||||
use App\Models\LogOverride;
|
||||
use App\Models\LogResult;
|
||||
use App\Services\Evaluation\EvaluationCoordinator;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Job: ApplyLogOverridesJob
|
||||
*
|
||||
* Použije ruční override nad log_results po agregaci, aby se do pořadí
|
||||
* promítl rozhodčí stav/kategorie/power, ale agregované skóre zůstalo zachováno.
|
||||
*/
|
||||
class ApplyLogOverridesJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 2;
|
||||
public array $backoff = [60];
|
||||
|
||||
public function __construct(
|
||||
protected int $evaluationRunId
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$run = EvaluationRun::find($this->evaluationRunId);
|
||||
if (! $run || $run->isCanceled()) {
|
||||
return;
|
||||
}
|
||||
$coordinator = new EvaluationCoordinator();
|
||||
|
||||
try {
|
||||
$coordinator->eventInfo($run, 'Apply log overrides: krok spuštěn.', [
|
||||
'step' => 'apply_log_overrides',
|
||||
'round_id' => $run->round_id,
|
||||
]);
|
||||
$overrides = LogOverride::where('evaluation_run_id', $this->evaluationRunId)->get();
|
||||
if ($overrides->isEmpty()) {
|
||||
$coordinator->eventInfo($run, 'Apply log overrides: nic ke zpracování.', [
|
||||
'step' => 'apply_log_overrides',
|
||||
'round_id' => $run->round_id,
|
||||
]);
|
||||
$coordinator->eventInfo($run, 'Apply log overrides: krok dokončen.', [
|
||||
'step' => 'apply_log_overrides',
|
||||
'round_id' => $run->round_id,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($overrides as $override) {
|
||||
if (EvaluationRun::isCanceledRun($run->id)) {
|
||||
return;
|
||||
}
|
||||
$data = [];
|
||||
|
||||
if ($override->forced_log_status && $override->forced_log_status !== 'AUTO') {
|
||||
$data['status'] = $override->forced_log_status;
|
||||
}
|
||||
|
||||
if ($override->forced_band_id !== null) {
|
||||
$data['band_id'] = $override->forced_band_id;
|
||||
}
|
||||
|
||||
if ($override->forced_category_id !== null) {
|
||||
$data['category_id'] = $override->forced_category_id;
|
||||
}
|
||||
|
||||
if ($override->forced_power_category_id !== null) {
|
||||
$data['power_category_id'] = $override->forced_power_category_id;
|
||||
}
|
||||
|
||||
if ($override->forced_sixhr_category !== null) {
|
||||
$data['sixhr_category'] = $override->forced_sixhr_category;
|
||||
}
|
||||
|
||||
if (! $data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$logResult = LogResult::where('evaluation_run_id', $this->evaluationRunId)
|
||||
->where('log_id', $override->log_id)
|
||||
->first();
|
||||
if (! $logResult) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$bandId = $data['band_id'] ?? $logResult->band_id;
|
||||
$sixhrCategory = $data['sixhr_category'] ?? $logResult->sixhr_category;
|
||||
if ($sixhrCategory && ! $this->isSixHourBand($bandId)) {
|
||||
$this->addSixHourRemark($override->log_id);
|
||||
}
|
||||
|
||||
$logResult->update($data);
|
||||
}
|
||||
|
||||
$coordinator->eventInfo($run, 'Apply log overrides: krok dokončen.', [
|
||||
'step' => 'apply_log_overrides',
|
||||
'round_id' => $run->round_id,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
$coordinator->eventError($run, 'Apply log overrides: krok selhal.', [
|
||||
'step' => 'apply_log_overrides',
|
||||
'round_id' => $run->round_id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
protected function isSixHourBand(?int $bandId): bool
|
||||
{
|
||||
if (! $bandId) {
|
||||
return false;
|
||||
}
|
||||
return in_array($bandId, [1, 2], true);
|
||||
}
|
||||
|
||||
protected function addSixHourRemark(int $logId): void
|
||||
{
|
||||
$log = Log::find($logId);
|
||||
if (! $log) {
|
||||
return;
|
||||
}
|
||||
$remarksEval = $this->decodeRemarksEval($log->remarks_eval);
|
||||
$message = '6H kategorie je povolena jen pro pásma 145 MHz a 435 MHz.';
|
||||
if (! in_array($message, $remarksEval, true)) {
|
||||
$remarksEval[] = $message;
|
||||
$log->remarks_eval = $this->encodeRemarksEval($remarksEval);
|
||||
$log->save();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
291
app/Jobs/BuildWorkingSetLogJob.php
Normal file
291
app/Jobs/BuildWorkingSetLogJob.php
Normal file
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Band;
|
||||
use App\Models\EdiBand;
|
||||
use App\Models\EvaluationRun;
|
||||
use App\Models\EvaluationRuleSet;
|
||||
use App\Models\Log;
|
||||
use App\Models\LogOverride;
|
||||
use App\Models\LogQso;
|
||||
use App\Models\Round;
|
||||
use App\Models\WorkingQso;
|
||||
use App\Services\Evaluation\EvaluationCoordinator;
|
||||
use App\Services\Evaluation\MatchingService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Bus\Batchable;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Job: BuildWorkingSetLogJob
|
||||
*
|
||||
* Účel:
|
||||
* - Vytvoří working set pro jeden log_id.
|
||||
*/
|
||||
class BuildWorkingSetLogJob implements ShouldQueue
|
||||
{
|
||||
use Batchable;
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
public array $backoff = [30, 120, 300];
|
||||
|
||||
protected int $logId;
|
||||
|
||||
public function __construct(
|
||||
protected int $evaluationRunId,
|
||||
int $logId
|
||||
) {
|
||||
$this->logId = $logId;
|
||||
}
|
||||
|
||||
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, 'Working set nelze připravit: chybí ruleset.', [
|
||||
'step' => 'build_working_set',
|
||||
'round_id' => $run->round_id,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$round = Round::with(['bands'])->find($run->round_id);
|
||||
if (! $round) {
|
||||
return;
|
||||
}
|
||||
|
||||
$log = Log::find($this->logId);
|
||||
if (! $log || (int) $log->round_id !== (int) $run->round_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$override = LogOverride::where('evaluation_run_id', $run->id)
|
||||
->where('log_id', $this->logId)
|
||||
->first();
|
||||
if ($override && $override->forced_log_status === 'IGNORED') {
|
||||
return;
|
||||
}
|
||||
|
||||
$logLocator = $log->pwwlo;
|
||||
$logCallsign = $log->pcall;
|
||||
$logBand = $log->pband;
|
||||
$total = LogQso::where('log_id', $this->logId)->count();
|
||||
if ($total === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$matcher = new MatchingService();
|
||||
$processed = 0;
|
||||
$lastReported = 0;
|
||||
|
||||
LogQso::where('log_id', $this->logId)
|
||||
->chunkById(200, function ($qsos) use ($run, $round, $ruleSet, $matcher, $total, &$processed, &$lastReported, $override, $logLocator, $logCallsign, $logBand, $coordinator) {
|
||||
foreach ($qsos as $qso) {
|
||||
if (EvaluationRun::isCanceledRun($run->id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$processed++;
|
||||
$errors = [];
|
||||
|
||||
$rawMyCall = $qso->my_call ?: ($logCallsign ?? '');
|
||||
$callNorm = $matcher->normalizeCallsign($rawMyCall, $ruleSet);
|
||||
$rcallNorm = $matcher->normalizeCallsign($qso->dx_call ?? '', $ruleSet);
|
||||
|
||||
// Lokátor může být v QSO nebo jen v hlavičce logu (PWWLo) – ber jako fallback.
|
||||
$rawLocator = $qso->my_locator ?: ($logLocator ?? null);
|
||||
$locNorm = $this->normalizeLocator($rawLocator);
|
||||
if ($rawLocator && $locNorm === null) {
|
||||
$errors[] = 'INVALID_LOCATOR';
|
||||
}
|
||||
|
||||
$rlocNorm = $this->normalizeLocator($qso->rx_wwl);
|
||||
if ($qso->rx_wwl && $rlocNorm === null) {
|
||||
$errors[] = 'INVALID_RLOCATOR';
|
||||
}
|
||||
|
||||
$bandId = $override && $override->forced_band_id
|
||||
? (int) $override->forced_band_id
|
||||
: $this->resolveBandId($qso, $round);
|
||||
if (! $bandId) {
|
||||
$bandId = $this->resolveBandIdFromPband($logBand ?? null, $round);
|
||||
}
|
||||
$mode = $qso->mode_code ?: $qso->mode;
|
||||
$modeNorm = $mode ? mb_strtoupper(trim($mode)) : null;
|
||||
|
||||
$matchKey = $bandId && $callNorm && $rcallNorm
|
||||
? $bandId . '|' . $callNorm . '|' . $rcallNorm
|
||||
: null;
|
||||
|
||||
// Klíč pro detekci duplicit – závisí na dupe_scope v rulesetu.
|
||||
$dupeKey = null;
|
||||
if ($bandId && $rcallNorm) {
|
||||
$dupeKey = $bandId . '|' . $rcallNorm;
|
||||
if ($ruleSet->dupe_scope === 'BAND_MODE') {
|
||||
$dupeKey .= '|' . ($modeNorm ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
$tsUtc = $qso->time_on ? Carbon::parse($qso->time_on)->utc() : null;
|
||||
// Out-of-window se řeší per QSO, ale v agregaci může vést až k DQ celého logu.
|
||||
$outOfWindow = $matcher->isOutOfWindow($tsUtc, $round->start_time, $round->end_time);
|
||||
|
||||
WorkingQso::updateOrCreate(
|
||||
[
|
||||
'evaluation_run_id' => $run->id,
|
||||
'log_qso_id' => $qso->id,
|
||||
],
|
||||
[
|
||||
'log_id' => $qso->log_id,
|
||||
'ts_utc' => $tsUtc,
|
||||
'call_norm' => $callNorm ?: null,
|
||||
'rcall_norm' => $rcallNorm ?: null,
|
||||
'loc_norm' => $locNorm,
|
||||
'rloc_norm' => $rlocNorm,
|
||||
'band_id' => $bandId,
|
||||
'mode' => $modeNorm,
|
||||
'match_key' => $matchKey,
|
||||
'dupe_key' => $dupeKey,
|
||||
'out_of_window' => $outOfWindow,
|
||||
'errors' => $errors ?: null,
|
||||
]
|
||||
);
|
||||
|
||||
if ($processed - $lastReported >= 100 || $processed === $total) {
|
||||
$delta = $processed - $lastReported;
|
||||
if ($delta > 0) {
|
||||
EvaluationRun::where('id', $run->id)->increment('progress_done', $delta);
|
||||
$lastReported = $processed;
|
||||
}
|
||||
}
|
||||
if ($processed % 500 === 0 || $processed === $total) {
|
||||
$coordinator->eventInfo($run, "Working set: {$processed}/{$total}", [
|
||||
'step' => 'build_working_set',
|
||||
'round_id' => $run->round_id,
|
||||
'step_progress_done' => $processed,
|
||||
'step_progress_total' => $total,
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (Throwable $e) {
|
||||
$coordinator->eventError($run, 'Working set log: krok selhal.', [
|
||||
'step' => 'build_working_set',
|
||||
'round_id' => $run->round_id,
|
||||
'log_id' => $this->logId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
protected function normalizeLocator(?string $value): ?string
|
||||
{
|
||||
if (! $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = strtoupper(trim($value));
|
||||
$normalized = preg_replace('/\\s+/', '', $normalized) ?? '';
|
||||
$normalized = substr($normalized, 0, 6);
|
||||
|
||||
if (! preg_match('/^[A-R]{2}[0-9]{2}([A-X]{2})?$/', $normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
protected function resolveBandId(LogQso $qso, Round $round): ?int
|
||||
{
|
||||
$bandValue = $qso->band;
|
||||
if ($bandValue) {
|
||||
$pbandVal = mb_strtolower(trim($bandValue));
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
$freqKHz = $qso->freq_khz;
|
||||
if (! $freqKHz) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mhz = $freqKHz / 1000;
|
||||
$bandMatch = Band::where('edi_band_begin', '<=', $mhz)
|
||||
->where('edi_band_end', '>=', $mhz)
|
||||
->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 resolveBandIdFromPband(?string $pband, Round $round): ?int
|
||||
{
|
||||
if (! $pband) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$pbandVal = mb_strtolower(trim($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;
|
||||
}
|
||||
|
||||
if (preg_match('/([0-9]+(?:[\\.,][0-9]+)?)/', $pbandVal, $m)) {
|
||||
$mhz = (float) str_replace(',', '.', $m[1]);
|
||||
$bandMatch = Band::where('edi_band_begin', '<=', $mhz)
|
||||
->where('edi_band_end', '>=', $mhz)
|
||||
->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;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
95
app/Jobs/DispatchAggregateResultsJobsJob.php
Normal file
95
app/Jobs/DispatchAggregateResultsJobsJob.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\EvaluationRun;
|
||||
use App\Models\LogResult;
|
||||
use App\Services\Evaluation\EvaluationCoordinator;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Job: DispatchAggregateResultsJobsJob
|
||||
*
|
||||
* Účel:
|
||||
* - Rozdělí agregaci výsledků na menší joby podle log_id.
|
||||
* - Spustí batch jobů AggregateLogResultsJob a po dokončení naváže
|
||||
* ApplyLogOverridesJob + RecalculateOfficialRanksJob + PauseEvaluationRunJob.
|
||||
*/
|
||||
class DispatchAggregateResultsJobsJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 2;
|
||||
public array $backoff = [60];
|
||||
|
||||
public function __construct(
|
||||
protected int $evaluationRunId
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$run = EvaluationRun::find($this->evaluationRunId);
|
||||
if (! $run || $run->isCanceled()) {
|
||||
return;
|
||||
}
|
||||
$coordinator = new EvaluationCoordinator();
|
||||
|
||||
try {
|
||||
$coordinator->eventInfo($run, 'Aggregate: krok spuštěn.', [
|
||||
'step' => 'aggregate',
|
||||
'round_id' => $run->round_id,
|
||||
]);
|
||||
|
||||
$logIds = LogResult::where('evaluation_run_id', $run->id)
|
||||
->pluck('log_id')
|
||||
->all();
|
||||
|
||||
$run->update([
|
||||
'status' => 'RUNNING',
|
||||
'current_step' => 'aggregate',
|
||||
'progress_total' => count($logIds),
|
||||
'progress_done' => 0,
|
||||
]);
|
||||
|
||||
$jobs = [];
|
||||
foreach ($logIds as $logId) {
|
||||
$jobs[] = new AggregateLogResultsJob($run->id, (int) $logId);
|
||||
}
|
||||
|
||||
$next = function () use ($run) {
|
||||
Bus::chain([
|
||||
new ApplyLogOverridesJob($run->id),
|
||||
new RecalculateOfficialRanksJob($run->id),
|
||||
new PauseEvaluationRunJob(
|
||||
$run->id,
|
||||
'WAITING_REVIEW_SCORE',
|
||||
'waiting_review_score',
|
||||
'Čeká na kontrolu skóre.'
|
||||
),
|
||||
])->onQueue('evaluation')->dispatch();
|
||||
};
|
||||
|
||||
if (! $jobs) {
|
||||
$next();
|
||||
return;
|
||||
}
|
||||
|
||||
$batch = Bus::batch($jobs)
|
||||
->then($next)
|
||||
->onQueue('evaluation')
|
||||
->dispatch();
|
||||
$run->update(['batch_id' => $batch->id]);
|
||||
} catch (Throwable $e) {
|
||||
$coordinator->eventError($run, 'Aggregate: krok selhal.', [
|
||||
'step' => 'aggregate',
|
||||
'round_id' => $run->round_id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
129
app/Jobs/DispatchBuildWorkingSetJobsJob.php
Normal file
129
app/Jobs/DispatchBuildWorkingSetJobsJob.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\EvaluationRun;
|
||||
use App\Models\EvaluationRuleSet;
|
||||
use App\Models\Log;
|
||||
use App\Models\LogOverride;
|
||||
use App\Models\LogQso;
|
||||
use App\Models\WorkingQso;
|
||||
use App\Services\Evaluation\EvaluationCoordinator;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Job: DispatchBuildWorkingSetJobsJob
|
||||
*
|
||||
* Účel:
|
||||
* - Rozdělí build working set do menších jobů podle log_id.
|
||||
* - Spustí batch jobů BuildWorkingSetLogJob a po dokončení pokračuje pipeline.
|
||||
*/
|
||||
class DispatchBuildWorkingSetJobsJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 2;
|
||||
public array $backoff = [30];
|
||||
|
||||
public function __construct(
|
||||
protected int $evaluationRunId
|
||||
) {
|
||||
}
|
||||
|
||||
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, 'Working set nelze připravit: chybí ruleset.', [
|
||||
'step' => 'build_working_set',
|
||||
'round_id' => $run->round_id,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$coordinator->eventInfo($run, 'Working set: krok spuštěn.', [
|
||||
'step' => 'build_working_set',
|
||||
'round_id' => $run->round_id,
|
||||
]);
|
||||
|
||||
$run->update([
|
||||
'status' => 'RUNNING',
|
||||
'current_step' => 'build_working_set',
|
||||
'progress_total' => 0,
|
||||
'progress_done' => 0,
|
||||
]);
|
||||
|
||||
WorkingQso::where('evaluation_run_id', $run->id)->delete();
|
||||
|
||||
$logIds = Log::where('round_id', $run->round_id)->pluck('id');
|
||||
$logOverrides = LogOverride::where('evaluation_run_id', $run->id)->get()->keyBy('log_id');
|
||||
$ignoredLogIds = $logOverrides
|
||||
->filter(fn ($override) => $override->forced_log_status === 'IGNORED')
|
||||
->keys()
|
||||
->all();
|
||||
if ($ignoredLogIds) {
|
||||
$logIds = $logIds->reject(fn ($id) => in_array($id, $ignoredLogIds, true))->values();
|
||||
}
|
||||
|
||||
$total = $logIds->isEmpty()
|
||||
? 0
|
||||
: LogQso::whereIn('log_id', $logIds)->count();
|
||||
|
||||
$run->update([
|
||||
'progress_total' => $total,
|
||||
'progress_done' => 0,
|
||||
]);
|
||||
$coordinator->eventInfo($run, 'Příprava working setu.', [
|
||||
'step' => 'build_working_set',
|
||||
'round_id' => $run->round_id,
|
||||
'step_progress_done' => 0,
|
||||
'step_progress_total' => $total,
|
||||
]);
|
||||
|
||||
$jobs = [];
|
||||
foreach ($logIds as $logId) {
|
||||
$jobs[] = new BuildWorkingSetLogJob($run->id, (int) $logId);
|
||||
}
|
||||
|
||||
$next = function () use ($run) {
|
||||
Bus::chain([
|
||||
new PauseEvaluationRunJob(
|
||||
$run->id,
|
||||
'WAITING_REVIEW_INPUT',
|
||||
'waiting_review_input',
|
||||
'Čeká na kontrolu vstupů.'
|
||||
),
|
||||
])->onQueue('evaluation')->dispatch();
|
||||
};
|
||||
|
||||
if (! $jobs) {
|
||||
$next();
|
||||
return;
|
||||
}
|
||||
|
||||
$batch = Bus::batch($jobs)
|
||||
->then($next)
|
||||
->onQueue('evaluation')
|
||||
->dispatch();
|
||||
$run->update(['batch_id' => $batch->id]);
|
||||
} catch (Throwable $e) {
|
||||
$coordinator->eventError($run, 'Working set: krok selhal.', [
|
||||
'step' => 'build_working_set',
|
||||
'round_id' => $run->round_id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
71
app/Jobs/DispatchMatchJobsJob.php
Normal file
71
app/Jobs/DispatchMatchJobsJob.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\EvaluationRun;
|
||||
use App\Services\Evaluation\EvaluationCoordinator;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Job: DispatchMatchJobsJob
|
||||
*
|
||||
* Účel:
|
||||
* - Koordinuje matchovací část pipeline (PASS 1 + PASS 2) po skupinách.
|
||||
* - Nastaví progress pro krok match a připraví navazující kroky
|
||||
* (UnpairedClassificationJob, DuplicateResolutionJob).
|
||||
*
|
||||
* Vstup:
|
||||
* - evaluation_run_id
|
||||
*
|
||||
* Výstup:
|
||||
* - Spuštěné batch joby MatchQsoBucketJob (PASS 1/2),
|
||||
* následné joby pro klasifikaci a duplicity.
|
||||
*
|
||||
* Co job NEDĚLÁ:
|
||||
* - neprovádí matching ani scoring,
|
||||
* - nezapisuje QSO výsledky,
|
||||
* - neagreguje výsledky.
|
||||
*/
|
||||
class DispatchMatchJobsJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 2;
|
||||
public array $backoff = [30];
|
||||
|
||||
public function __construct(
|
||||
protected int $evaluationRunId
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$run = EvaluationRun::find($this->evaluationRunId);
|
||||
if (! $run || $run->isCanceled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$coordinator = app(EvaluationCoordinator::class);
|
||||
$coordinator->eventInfo($run, 'Dispatch match: krok spuštěn.', [
|
||||
'step' => 'dispatch_match',
|
||||
'round_id' => $run->round_id,
|
||||
]);
|
||||
|
||||
try {
|
||||
$coordinator->dispatchStep($run, 'match');
|
||||
$coordinator->eventInfo($run, 'Dispatch match: krok dokončen.', [
|
||||
'step' => 'dispatch_match',
|
||||
'round_id' => $run->round_id,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
$coordinator->eventError($run, 'Dispatch match: krok selhal.', [
|
||||
'step' => 'dispatch_match',
|
||||
'round_id' => $run->round_id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
97
app/Jobs/DispatchParseLogsJobsJob.php
Normal file
97
app/Jobs/DispatchParseLogsJobsJob.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\EvaluationRun;
|
||||
use App\Models\Log;
|
||||
use App\Jobs\DispatchBuildWorkingSetJobsJob;
|
||||
use App\Jobs\RecalculateClaimedRanksJob;
|
||||
use App\Services\Evaluation\EvaluationCoordinator;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Job: DispatchParseLogsJobsJob
|
||||
*
|
||||
* Účel:
|
||||
* - Rozdělí parsování logů do menších jobů podle log_id.
|
||||
* - Spustí batch jobů ParseLogJob a po dokončení pokračuje pipeline.
|
||||
*/
|
||||
class DispatchParseLogsJobsJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 2;
|
||||
public array $backoff = [30];
|
||||
|
||||
public function __construct(
|
||||
protected int $evaluationRunId
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$run = EvaluationRun::find($this->evaluationRunId);
|
||||
if (! $run || $run->isCanceled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$coordinator = new EvaluationCoordinator();
|
||||
|
||||
try {
|
||||
$coordinator->eventInfo($run, 'Parsování logů: krok spuštěn.', [
|
||||
'step' => 'parse_logs',
|
||||
'round_id' => $run->round_id,
|
||||
]);
|
||||
|
||||
$logIds = Log::where('round_id', $run->round_id)->pluck('id');
|
||||
$total = $logIds->count();
|
||||
|
||||
$run->update([
|
||||
'status' => 'RUNNING',
|
||||
'current_step' => 'parse_logs',
|
||||
'progress_total' => $total,
|
||||
'progress_done' => 0,
|
||||
'started_at' => $run->started_at ?? Carbon::now(),
|
||||
]);
|
||||
|
||||
$jobs = [];
|
||||
foreach ($logIds as $logId) {
|
||||
$jobs[] = new ParseLogJob($run->id, (int) $logId);
|
||||
}
|
||||
|
||||
$next = function () use ($run) {
|
||||
if ($run->rules_version === 'CLAIMED') {
|
||||
RecalculateClaimedRanksJob::dispatch($run->id)
|
||||
->delay(now()->addSeconds(10))
|
||||
->onQueue('evaluation');
|
||||
}
|
||||
Bus::chain([
|
||||
new DispatchBuildWorkingSetJobsJob($run->id),
|
||||
])->onQueue('evaluation')->dispatch();
|
||||
};
|
||||
|
||||
if (! $jobs) {
|
||||
$next();
|
||||
return;
|
||||
}
|
||||
|
||||
$batch = Bus::batch($jobs)
|
||||
->then($next)
|
||||
->onQueue('evaluation')
|
||||
->dispatch();
|
||||
$run->update(['batch_id' => $batch->id]);
|
||||
} catch (Throwable $e) {
|
||||
$message = "DispatchParseLogsJobsJob selhal (run {$run->id}): {$e->getMessage()}";
|
||||
\Log::error($message);
|
||||
$coordinator->eventError($run, $message, [
|
||||
'step' => 'parse_logs',
|
||||
'round_id' => $run->round_id,
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
56
app/Jobs/DispatchScoreJobsJob.php
Normal file
56
app/Jobs/DispatchScoreJobsJob.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\EvaluationRun;
|
||||
use App\Services\Evaluation\EvaluationCoordinator;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Job: DispatchScoreJobsJob
|
||||
*
|
||||
* Připraví batch scoring jobů a nastaví korektní progress pro krok score.
|
||||
*/
|
||||
class DispatchScoreJobsJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 2;
|
||||
public array $backoff = [30];
|
||||
|
||||
public function __construct(
|
||||
protected int $evaluationRunId
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$run = EvaluationRun::find($this->evaluationRunId);
|
||||
if (! $run || $run->isCanceled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$coordinator = app(EvaluationCoordinator::class);
|
||||
$coordinator->eventInfo($run, 'Dispatch score: krok spuštěn.', [
|
||||
'step' => 'dispatch_score',
|
||||
'round_id' => $run->round_id,
|
||||
]);
|
||||
|
||||
try {
|
||||
$coordinator->dispatchStep($run, 'score');
|
||||
$coordinator->eventInfo($run, 'Dispatch score: krok dokončen.', [
|
||||
'step' => 'dispatch_score',
|
||||
'round_id' => $run->round_id,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
$coordinator->eventError($run, 'Dispatch score: krok selhal.', [
|
||||
'step' => 'dispatch_score',
|
||||
'round_id' => $run->round_id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
105
app/Jobs/DispatchUnpairedJobsJob.php
Normal file
105
app/Jobs/DispatchUnpairedJobsJob.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\EvaluationRun;
|
||||
use App\Models\WorkingQso;
|
||||
use App\Services\Evaluation\EvaluationCoordinator;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Job: DispatchUnpairedJobsJob
|
||||
*
|
||||
* Účel:
|
||||
* - Rozdělí klasifikaci nenapárovaných QSO do bucketů podle band_id + rcall_norm.
|
||||
* - Spustí batch jobů UnpairedClassificationBucketJob a po dokončení naváže
|
||||
* DuplicateResolutionJob.
|
||||
*/
|
||||
class DispatchUnpairedJobsJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 2;
|
||||
public array $backoff = [30];
|
||||
|
||||
public function __construct(
|
||||
protected int $evaluationRunId
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$run = EvaluationRun::find($this->evaluationRunId);
|
||||
if (! $run || $run->isCanceled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$coordinator = new EvaluationCoordinator();
|
||||
$coordinator->eventInfo($run, 'Dispatch unpaired: krok spuštěn.', [
|
||||
'step' => 'dispatch_unpaired',
|
||||
'round_id' => $run->round_id,
|
||||
]);
|
||||
|
||||
try {
|
||||
$buckets = WorkingQso::where('evaluation_run_id', $run->id)
|
||||
->whereNotNull('rcall_norm')
|
||||
->distinct()
|
||||
->get(['band_id', 'rcall_norm']);
|
||||
|
||||
$jobs = [];
|
||||
foreach ($buckets as $bucket) {
|
||||
$jobs[] = new UnpairedClassificationBucketJob(
|
||||
$run->id,
|
||||
$bucket->band_id !== null ? (int) $bucket->band_id : null,
|
||||
$bucket->rcall_norm
|
||||
);
|
||||
}
|
||||
|
||||
$run->refresh();
|
||||
$progressDone = (int) $run->progress_done;
|
||||
$progressTotal = $progressDone + count($jobs) + 1;
|
||||
$run->update([
|
||||
'progress_total' => $progressTotal,
|
||||
'progress_done' => $progressDone,
|
||||
]);
|
||||
|
||||
$next = function () use ($run) {
|
||||
Bus::chain([
|
||||
new DuplicateResolutionJob($run->id),
|
||||
new PauseEvaluationRunJob(
|
||||
$run->id,
|
||||
'WAITING_REVIEW_MATCH',
|
||||
'waiting_review_match',
|
||||
'Čeká na kontrolu matchingu.'
|
||||
),
|
||||
])->onQueue('evaluation')->dispatch();
|
||||
};
|
||||
|
||||
if (! $jobs) {
|
||||
$next();
|
||||
return;
|
||||
}
|
||||
|
||||
$batch = Bus::batch($jobs)
|
||||
->then($next)
|
||||
->onQueue('evaluation')
|
||||
->dispatch();
|
||||
$run->update(['batch_id' => $batch->id]);
|
||||
|
||||
$coordinator->eventInfo($run, 'Dispatch unpaired: krok dokončen.', [
|
||||
'step' => 'dispatch_unpaired',
|
||||
'round_id' => $run->round_id,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
$coordinator->eventError($run, 'Dispatch unpaired: krok selhal.', [
|
||||
'step' => 'dispatch_unpaired',
|
||||
'round_id' => $run->round_id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
163
app/Jobs/DuplicateResolutionJob.php
Normal file
163
app/Jobs/DuplicateResolutionJob.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\EvaluationRun;
|
||||
use App\Models\EvaluationRuleSet;
|
||||
use App\Models\QsoResult;
|
||||
use App\Models\WorkingQso;
|
||||
use App\Enums\QsoErrorCode;
|
||||
use App\Services\Evaluation\EvaluationCoordinator;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Job: DuplicateResolutionJob
|
||||
*
|
||||
* WHY:
|
||||
* - Duplicitní QSO se musí rozhodnout až po matchingu, aby bylo jasné,
|
||||
* které záznamy jsou spárované a v jakém pořadí.
|
||||
* ORDER:
|
||||
* - Spouští se po UnpairedClassificationJob (match je hotový, error_code stabilní).
|
||||
* - Krok je nevratný: duplicitní QSO jsou označena DUP a další kroky
|
||||
* už jen počítají body podle policy.
|
||||
*
|
||||
* Vstup:
|
||||
* - WorkingQso (dupe_key per log)
|
||||
* - QsoResult s error_code/matched_log_qso_id
|
||||
* - EvaluationRuleSet (dup_resolution_strategy)
|
||||
*
|
||||
* Výstup:
|
||||
* - Nastavení DUP u všech „non-survivor“ QSO
|
||||
*
|
||||
* Co job NEDĚLÁ:
|
||||
* - neprovádí matching protistanic,
|
||||
* - nepočítá body ani penalizace,
|
||||
* - neupravuje původní log_qsos.
|
||||
*/
|
||||
class DuplicateResolutionJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 2;
|
||||
public array $backoff = [60];
|
||||
|
||||
public function __construct(
|
||||
protected int $evaluationRunId
|
||||
) {
|
||||
}
|
||||
|
||||
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, 'Duplicitní QSO nelze vyhodnotit: chybí ruleset.', [
|
||||
'step' => 'duplicate_resolution',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$coordinator->eventInfo($run, 'Duplicate: krok spuštěn.', [
|
||||
'step' => 'duplicate_resolution',
|
||||
'round_id' => $run->round_id,
|
||||
]);
|
||||
|
||||
$coordinator->eventInfo($run, 'Detekce duplicitních QSO.', [
|
||||
'step' => 'match',
|
||||
'round_id' => $run->round_id,
|
||||
'step_progress_done' => null,
|
||||
'step_progress_total' => $run->progress_total,
|
||||
]);
|
||||
|
||||
$strategy = $ruleSet->dupResolutionStrategy();
|
||||
|
||||
$working = WorkingQso::where('evaluation_run_id', $run->id)->get();
|
||||
$byLog = $working->groupBy('log_id');
|
||||
|
||||
foreach ($byLog as $logId => $items) {
|
||||
if (EvaluationRun::isCanceledRun($run->id)) {
|
||||
return;
|
||||
}
|
||||
$byDupeKey = $items->groupBy('dupe_key');
|
||||
foreach ($byDupeKey as $dupeKey => $dupes) {
|
||||
if (! $dupeKey || $dupes->count() < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sorted = $dupes->sort(function ($a, $b) use ($strategy, $run) {
|
||||
$resultA = QsoResult::where('evaluation_run_id', $run->id)
|
||||
->where('log_qso_id', $a->log_qso_id)
|
||||
->first();
|
||||
$resultB = QsoResult::where('evaluation_run_id', $run->id)
|
||||
->where('log_qso_id', $b->log_qso_id)
|
||||
->first();
|
||||
|
||||
foreach ($strategy as $rule) {
|
||||
if ($rule === 'paired_first') {
|
||||
$aPaired = $resultA && $resultA->matched_log_qso_id !== null;
|
||||
$bPaired = $resultB && $resultB->matched_log_qso_id !== null;
|
||||
if ($aPaired !== $bPaired) {
|
||||
return $aPaired ? -1 : 1;
|
||||
}
|
||||
}
|
||||
if ($rule === 'ok_first') {
|
||||
$aOk = $resultA && $resultA->error_code === QsoErrorCode::OK;
|
||||
$bOk = $resultB && $resultB->error_code === QsoErrorCode::OK;
|
||||
if ($aOk !== $bOk) {
|
||||
return $aOk ? -1 : 1;
|
||||
}
|
||||
}
|
||||
if ($rule === 'earlier_time') {
|
||||
$tsA = $a->ts_utc?->getTimestamp() ?? PHP_INT_MAX;
|
||||
$tsB = $b->ts_utc?->getTimestamp() ?? PHP_INT_MAX;
|
||||
if ($tsA !== $tsB) {
|
||||
return $tsA <=> $tsB;
|
||||
}
|
||||
}
|
||||
if ($rule === 'lower_id') {
|
||||
if ($a->log_qso_id !== $b->log_qso_id) {
|
||||
return $a->log_qso_id <=> $b->log_qso_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $a->log_qso_id <=> $b->log_qso_id;
|
||||
})->values();
|
||||
|
||||
$survivor = $sorted->shift();
|
||||
foreach ($sorted as $dupe) {
|
||||
QsoResult::where('evaluation_run_id', $run->id)
|
||||
->where('log_qso_id', $dupe->log_qso_id)
|
||||
->update([
|
||||
'is_duplicate' => true,
|
||||
'is_valid' => false,
|
||||
'error_code' => QsoErrorCode::DUP,
|
||||
'error_side' => 'NONE',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EvaluationRun::where('id', $run->id)->increment('progress_done');
|
||||
$coordinator->eventInfo($run, 'Duplicate: krok dokončen.', [
|
||||
'step' => 'duplicate_resolution',
|
||||
'round_id' => $run->round_id,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
$coordinator->eventError($run, 'Duplicate: krok selhal.', [
|
||||
'step' => 'duplicate_resolution',
|
||||
'round_id' => $run->round_id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
141
app/Jobs/FinalizeRunJob.php
Normal file
141
app/Jobs/FinalizeRunJob.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\EvaluationLock;
|
||||
use App\Models\EvaluationRun;
|
||||
use App\Services\Evaluation\EvaluationCoordinator;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Job: FinalizeRunJob
|
||||
*
|
||||
* Odpovědnost:
|
||||
* - Finální krok vyhodnocovací pipeline pro jeden EvaluationRun.
|
||||
* - Přepíná vyhodnocovací běh z technického stavu "rozpracováno" do
|
||||
* konzistentního, uzavřeného stavu, který je připraven k prezentaci
|
||||
* nebo publikaci výsledků.
|
||||
*
|
||||
* Kontext:
|
||||
* - Spouští se po úspěšném dokončení agregace výsledků
|
||||
* (AggregateLogResultsJob / DispatchAggregateResultsJobsJob).
|
||||
* - Pracuje výhradně s agregovanými daty svázanými s evaluation_run_id.
|
||||
*
|
||||
* Co job dělá (typicky):
|
||||
* - Ověří, že všechny předchozí kroky pipeline byly úspěšně dokončeny.
|
||||
* - Provede finální validace výsledků (např. konzistence součtů).
|
||||
* - Označí agregované výsledky jako finální pro daný EvaluationRun:
|
||||
* - nastaví příznaky typu is_final / is_official
|
||||
* - případně provede přesun z dočasných (staging) struktur
|
||||
* do finálních tabulek
|
||||
* - Aktualizuje stav EvaluationRun:
|
||||
* - status -> SUCCEEDED (nebo FAILED při chybě)
|
||||
* - nastaví finished_at
|
||||
* - Uvolní locky držené pro scope vyhodnocení.
|
||||
* - Zapíše závěrečné auditní události (EvaluationRunEvent).
|
||||
*
|
||||
* Co job NEDĚLÁ:
|
||||
* - nepočítá body ani skóre
|
||||
* - nemění výsledky jednotlivých QSO
|
||||
* - neřeší export ani prezentaci v UI
|
||||
*
|
||||
* Zásady návrhu:
|
||||
* - Tento krok musí být atomický z pohledu stavu vyhodnocení.
|
||||
* - Při selhání musí být EvaluationRun jednoznačně označen jako FAILED
|
||||
* a nesmí zůstat v nekonzistentním stavu.
|
||||
* - Veškerá logika patří do service layer (např. EvaluationFinalizerService).
|
||||
*
|
||||
* Queue:
|
||||
* - Spouští se ve frontě "evaluation".
|
||||
* - Nemá běžet paralelně nad stejným EvaluationRun.
|
||||
*/
|
||||
class FinalizeRunJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 2;
|
||||
public array $backoff = [60];
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(
|
||||
protected int $evaluationRunId,
|
||||
protected ?string $lockKey = null
|
||||
)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalizuje vyhodnocovací běh.
|
||||
*
|
||||
* Metoda handle():
|
||||
* - provede závěrečné kontroly konzistence dat
|
||||
* - označí výsledky jako finální / oficiální
|
||||
* - přepne EvaluationRun do koncového stavu (SUCCEEDED / FAILED)
|
||||
* - uvolní zdroje a locky držené během vyhodnocení
|
||||
*
|
||||
* Poznámky:
|
||||
* - Tento job je posledním krokem pipeline.
|
||||
* - Po jeho úspěšném dokončení musí být možné výsledky bezpečně
|
||||
* zobrazit nebo exportovat.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
$run = EvaluationRun::find($this->evaluationRunId);
|
||||
if (! $run || $run->isCanceled()) {
|
||||
return;
|
||||
}
|
||||
$coordinator = new EvaluationCoordinator();
|
||||
|
||||
try {
|
||||
$coordinator->eventInfo($run, 'Finalize: krok spuštěn.', [
|
||||
'step' => 'finalize',
|
||||
'round_id' => $run->round_id,
|
||||
]);
|
||||
// Po ručních zásazích může být potřeba znovu aplikovat override a přepočítat pořadí.
|
||||
(new ApplyLogOverridesJob($run->id))->handle();
|
||||
(new RecalculateOfficialRanksJob($run->id))->handle();
|
||||
$coordinator->eventInfo($run, 'Před finalizací byl znovu aplikován override a přepočítáno pořadí.', [
|
||||
'step' => 'finalize',
|
||||
'round_id' => $run->round_id,
|
||||
]);
|
||||
|
||||
$run->update([
|
||||
'status' => 'SUCCEEDED',
|
||||
'current_step' => 'finalize',
|
||||
'progress_total' => 1,
|
||||
'progress_done' => 1,
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
$coordinator->eventInfo($run, 'Vyhodnocení dokončeno.', [
|
||||
'step' => 'finalize',
|
||||
'round_id' => $run->round_id,
|
||||
'step_progress_done' => 1,
|
||||
'step_progress_total' => 1,
|
||||
]);
|
||||
$coordinator->eventInfo($run, 'Finalize: krok dokončen.', [
|
||||
'step' => 'finalize',
|
||||
'round_id' => $run->round_id,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
$run->update([
|
||||
'status' => 'FAILED',
|
||||
'error' => $e->getMessage(),
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
$coordinator->eventError($run, "FinalizeRunJob selhal: {$e->getMessage()}", [
|
||||
'step' => 'finalize',
|
||||
'round_id' => $run->round_id,
|
||||
]);
|
||||
throw $e;
|
||||
} finally {
|
||||
// Lock musí být uvolněn vždy, i při chybě – jinak zablokuje další běhy.
|
||||
$lockKey = $this->lockKey ?? "evaluation:round:{$run->round_id}";
|
||||
EvaluationLock::release($lockKey, $run);
|
||||
}
|
||||
}
|
||||
}
|
||||
432
app/Jobs/MatchQsoBucketJob.php
Normal file
432
app/Jobs/MatchQsoBucketJob.php
Normal file
@@ -0,0 +1,432 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\QsoErrorCode;
|
||||
use App\Models\EvaluationRun;
|
||||
use App\Models\EvaluationRuleSet;
|
||||
use App\Models\LogOverride;
|
||||
use App\Models\LogQso;
|
||||
use App\Models\QsoOverride;
|
||||
use App\Models\QsoResult;
|
||||
use App\Models\Round;
|
||||
use App\Models\WorkingQso;
|
||||
use App\Services\Evaluation\EvaluationCoordinator;
|
||||
use Illuminate\Bus\Batchable;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Job: MatchQsoBucketJob
|
||||
*
|
||||
* Odpovědnost:
|
||||
* - Provede matching QSO v bucketu definovaném kombinací band_id + call_norm.
|
||||
* - Bucket obsahuje QSO se shodným call_norm (zdrojová strana),
|
||||
* kandidáti se berou z QSO se shodným rcall_norm.
|
||||
*
|
||||
* Poznámka:
|
||||
* - Logika matchingu zůstává shodná s MatchQsoGroupJob, jen pracuje nad menšími
|
||||
* podmnožinami dat pro kratší dobu běhu jednoho jobu.
|
||||
*/
|
||||
class MatchQsoBucketJob extends MatchQsoGroupJob
|
||||
{
|
||||
use Batchable;
|
||||
|
||||
public int $tries = 3;
|
||||
public array $backoff = [30, 120, 300];
|
||||
protected ?int $bandId;
|
||||
protected ?string $callNorm;
|
||||
|
||||
public function __construct(
|
||||
int $evaluationRunId,
|
||||
?int $bandId,
|
||||
?string $callNorm,
|
||||
int $pass = 1
|
||||
) {
|
||||
parent::__construct($evaluationRunId, null, null, $pass);
|
||||
$this->bandId = $bandId;
|
||||
$this->callNorm = $callNorm;
|
||||
}
|
||||
|
||||
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, 'Matching nelze spustit: chybí ruleset.', [
|
||||
'step' => 'match',
|
||||
'round_id' => $run->round_id,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$round = Round::with(['bands', 'categories', 'powerCategories'])->find($run->round_id);
|
||||
if (! $round) {
|
||||
return;
|
||||
}
|
||||
|
||||
$coordinator->eventInfo($run, 'Matching bucket.', [
|
||||
'step' => 'match',
|
||||
'round_id' => $run->round_id,
|
||||
'band_id' => $this->bandId,
|
||||
'call_norm' => $this->callNorm,
|
||||
'pass' => $this->pass,
|
||||
]);
|
||||
|
||||
$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);
|
||||
$groupKey = 'b' . ($this->bandId ?? 0);
|
||||
$logIds = $groupLogIds[$groupKey] ?? [];
|
||||
if (! $logIds) {
|
||||
$coordinator->progressTick($run, 1);
|
||||
return;
|
||||
}
|
||||
|
||||
$sourceQsos = WorkingQso::where('evaluation_run_id', $run->id)
|
||||
->whereIn('log_id', $logIds)
|
||||
->when($this->bandId !== null, fn ($q) => $q->where('band_id', $this->bandId), fn ($q) => $q->whereNull('band_id'))
|
||||
->when($this->callNorm !== null, fn ($q) => $q->where('call_norm', $this->callNorm), fn ($q) => $q->whereNull('call_norm'))
|
||||
->get();
|
||||
|
||||
if ($sourceQsos->isEmpty()) {
|
||||
$coordinator->progressTick($run, 1);
|
||||
return;
|
||||
}
|
||||
|
||||
$candidateQsos = WorkingQso::where('evaluation_run_id', $run->id)
|
||||
->whereIn('log_id', $logIds)
|
||||
->when($this->bandId !== null, fn ($q) => $q->where('band_id', $this->bandId), fn ($q) => $q->whereNull('band_id'))
|
||||
->when($this->callNorm !== null, fn ($q) => $q->where('rcall_norm', $this->callNorm), fn ($q) => $q->whereNull('rcall_norm'))
|
||||
->get();
|
||||
|
||||
$workingQsos = $sourceQsos->concat($candidateQsos)->unique('log_qso_id');
|
||||
$logQsoIds = $workingQsos->pluck('log_qso_id')->all();
|
||||
$logQsoMap = LogQso::whereIn('id', $logQsoIds)->get()->keyBy('id');
|
||||
$workingMap = $workingQsos->keyBy('log_qso_id');
|
||||
|
||||
$alreadyMatched = [];
|
||||
if ($this->pass > 1) {
|
||||
$alreadyMatched = QsoResult::where('evaluation_run_id', $run->id)
|
||||
->whereNotNull('matched_log_qso_id')
|
||||
->pluck('log_qso_id')
|
||||
->all();
|
||||
$alreadyMatched = array_fill_keys($alreadyMatched, true);
|
||||
}
|
||||
|
||||
$byMatchKey = [];
|
||||
$byBandRcall = [];
|
||||
foreach ($candidateQsos as $wqso) {
|
||||
if ($wqso->match_key) {
|
||||
$byMatchKey[$wqso->match_key][] = $wqso;
|
||||
}
|
||||
if ($wqso->band_id && $wqso->rcall_norm) {
|
||||
$byBandRcall[$wqso->band_id . '|' . $wqso->rcall_norm][] = $wqso;
|
||||
}
|
||||
}
|
||||
|
||||
$timeTolerance = $ruleSet->time_tolerance_sec !== null
|
||||
? (int) $ruleSet->time_tolerance_sec
|
||||
: 300;
|
||||
$forcedMap = [];
|
||||
foreach ($qsoOverrides as $override) {
|
||||
if ($override->forced_matched_log_qso_id) {
|
||||
$forcedMap[$override->log_qso_id] = (int) $override->forced_matched_log_qso_id;
|
||||
}
|
||||
}
|
||||
$forcedBackMap = [];
|
||||
$forcedConflicts = [];
|
||||
foreach ($forcedMap as $a => $b) {
|
||||
if (isset($forcedMap[$b]) && $forcedMap[$b] !== $a) {
|
||||
$forcedConflicts[$a] = true;
|
||||
$forcedConflicts[$b] = true;
|
||||
}
|
||||
if (! isset($forcedMap[$b])) {
|
||||
$forcedBackMap[$b] = $a;
|
||||
}
|
||||
}
|
||||
|
||||
$paired = [];
|
||||
foreach ($sourceQsos as $wqso) {
|
||||
if ($this->pass > 1 && isset($alreadyMatched[$wqso->log_qso_id])) {
|
||||
continue;
|
||||
}
|
||||
$reverseKey = $wqso->band_id && $wqso->call_norm && $wqso->rcall_norm
|
||||
? $wqso->band_id . '|' . $wqso->rcall_norm . '|' . $wqso->call_norm
|
||||
: null;
|
||||
|
||||
$candidates = [];
|
||||
if ($reverseKey && isset($byMatchKey[$reverseKey])) {
|
||||
$candidates = $byMatchKey[$reverseKey];
|
||||
}
|
||||
if ($wqso->band_id && $wqso->call_norm) {
|
||||
$fuzzyKey = $wqso->band_id . '|' . $wqso->call_norm;
|
||||
if (isset($byBandRcall[$fuzzyKey])) {
|
||||
$candidates = array_merge($candidates, $byBandRcall[$fuzzyKey]);
|
||||
}
|
||||
}
|
||||
if ($candidates) {
|
||||
$unique = [];
|
||||
foreach ($candidates as $candidate) {
|
||||
$unique[$candidate->log_qso_id] = $candidate;
|
||||
}
|
||||
$candidates = array_values($unique);
|
||||
}
|
||||
|
||||
$best = null;
|
||||
$bestDecision = null;
|
||||
$tiebreakOrder = $this->resolveTiebreakOrder($ruleSet);
|
||||
$forcedMatchId = $forcedMap[$wqso->log_qso_id]
|
||||
?? $forcedBackMap[$wqso->log_qso_id]
|
||||
?? null;
|
||||
$forcedMissing = false;
|
||||
$forcedDecision = null;
|
||||
if ($forcedMatchId) {
|
||||
if (! $workingMap->has($forcedMatchId)) {
|
||||
$forcedWorking = WorkingQso::where('evaluation_run_id', $run->id)
|
||||
->where('log_qso_id', $forcedMatchId)
|
||||
->first();
|
||||
if ($forcedWorking) {
|
||||
$workingMap->put($forcedMatchId, $forcedWorking);
|
||||
if (! $logQsoMap->has($forcedMatchId)) {
|
||||
$forcedLogQso = LogQso::find($forcedMatchId);
|
||||
if ($forcedLogQso) {
|
||||
$logQsoMap->put($forcedMatchId, $forcedLogQso);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$best = $workingMap->get($forcedMatchId);
|
||||
if (! $best || $best->log_id === $wqso->log_id) {
|
||||
$best = null;
|
||||
$forcedMissing = true;
|
||||
}
|
||||
if ($best) {
|
||||
$forcedDecision = $this->evaluateMatchDecision(
|
||||
$wqso,
|
||||
$best,
|
||||
$logQsoMap,
|
||||
$ruleSet,
|
||||
$timeTolerance
|
||||
);
|
||||
$bestDecision = $forcedDecision;
|
||||
}
|
||||
} else {
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($candidate->log_id === $wqso->log_id) {
|
||||
continue;
|
||||
}
|
||||
if (isset($paired[$candidate->log_qso_id]) && $paired[$candidate->log_qso_id] !== $wqso->log_qso_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$decision = $this->evaluateMatchDecision(
|
||||
$wqso,
|
||||
$candidate,
|
||||
$logQsoMap,
|
||||
$ruleSet,
|
||||
$timeTolerance
|
||||
);
|
||||
if (! $decision) {
|
||||
continue;
|
||||
}
|
||||
if ($this->pass === 1 && ($decision['match_type'] ?? '') !== 'MATCH_EXACT') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($bestDecision === null || $decision['rank'] < $bestDecision['rank']) {
|
||||
$best = $candidate;
|
||||
$bestDecision = $decision;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($decision['rank'] === $bestDecision['rank']) {
|
||||
$cmp = $this->compareCandidates(
|
||||
$decision['tiebreak'],
|
||||
$bestDecision['tiebreak'],
|
||||
$tiebreakOrder
|
||||
);
|
||||
if ($cmp < 0) {
|
||||
$best = $candidate;
|
||||
$bestDecision = $decision;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$timeDiffSec = null;
|
||||
if ($best && $wqso->ts_utc && $best->ts_utc) {
|
||||
$timeDiffSec = abs($best->ts_utc->getTimestamp() - $wqso->ts_utc->getTimestamp());
|
||||
}
|
||||
$isNil = $best === null;
|
||||
$isDuplicate = false;
|
||||
$isBustedExchange = false;
|
||||
$isBustedCall = false;
|
||||
$isBustedRst = false;
|
||||
$bustedCallTx = false;
|
||||
$bustedRstTx = false;
|
||||
$bustedExchangeReason = null;
|
||||
$customMismatch = false;
|
||||
$isBustedExchangeOnly = false;
|
||||
$bustedSerialRx = false;
|
||||
$bustedSerialTx = false;
|
||||
$bustedWwlRx = false;
|
||||
$bustedWwlTx = false;
|
||||
$matchType = null;
|
||||
$errorFlags = [];
|
||||
$timeMismatch = false;
|
||||
$isOutOfWindow = (bool) $wqso->out_of_window;
|
||||
$errorCode = null;
|
||||
|
||||
if (! $isNil && $best && $bestDecision) {
|
||||
$a = $logQsoMap->get($wqso->log_qso_id);
|
||||
$b = $logQsoMap->get($best->log_qso_id);
|
||||
$aWork = $workingMap->get($wqso->log_qso_id);
|
||||
$bWork = $workingMap->get($best->log_qso_id);
|
||||
|
||||
if ($a && $b) {
|
||||
$exchange = $this->evaluateExchange($a, $b, $aWork, $bWork, $ruleSet);
|
||||
$bustedCallTx = $exchange['sent_call_mismatch'] && $ruleSet->discard_qso_sent_diff_call;
|
||||
$isBustedCall = $exchange['recv_call_mismatch'] && $ruleSet->discard_qso_rec_diff_call;
|
||||
|
||||
$bustedRstTx = ($ruleSet->exchange_requires_report ?? false)
|
||||
&& $exchange['report_sent_mismatch']
|
||||
&& $ruleSet->discard_qso_sent_diff_rst;
|
||||
$isBustedRst = ($ruleSet->exchange_requires_report ?? false)
|
||||
&& $exchange['report_recv_mismatch']
|
||||
&& $ruleSet->discard_qso_rec_diff_rst;
|
||||
|
||||
$discardSerialRx = $ruleSet->discard_qso_rec_diff_serial ?? $ruleSet->discard_qso_rec_diff_code;
|
||||
$discardSerialTx = $ruleSet->discard_qso_sent_diff_serial ?? $ruleSet->discard_qso_sent_diff_code;
|
||||
$discardWwlRx = $ruleSet->discard_qso_rec_diff_wwl ?? $ruleSet->discard_qso_rec_diff_code;
|
||||
$discardWwlTx = $ruleSet->discard_qso_sent_diff_wwl ?? $ruleSet->discard_qso_sent_diff_code;
|
||||
|
||||
$customMismatch = (bool) ($exchange['custom_mismatch'] ?? false);
|
||||
$bustedSerialRx = ($exchange['serial_recv_mismatch'] || $exchange['missing_serial'] || $customMismatch) && $discardSerialRx;
|
||||
$bustedSerialTx = ($exchange['serial_sent_mismatch'] || $customMismatch) && $discardSerialTx;
|
||||
$bustedWwlRx = ($exchange['locator_recv_mismatch'] || $exchange['missing_wwl']) && $discardWwlRx;
|
||||
$bustedWwlTx = $exchange['locator_sent_mismatch'] && $discardWwlTx;
|
||||
|
||||
$isBustedExchangeOnly = ($customMismatch && $ruleSet->discard_qso_rec_diff_code);
|
||||
$isBustedExchange = $isBustedExchangeOnly || $bustedSerialRx || $bustedWwlRx;
|
||||
$bustedExchangeReason = $exchange['busted_exchange_reason'] ?? null;
|
||||
$matchType = $bestDecision['match_type'] ?? null;
|
||||
$errorFlags = $bestDecision['error_flags'] ?? [];
|
||||
$timeMismatch = (bool) ($bestDecision['time_mismatch'] ?? false);
|
||||
}
|
||||
}
|
||||
|
||||
$isValid = ! $isNil && ! $isBustedCall && ! $isBustedRst && ! $isBustedExchange;
|
||||
$errorDetail = null;
|
||||
$bustedCallRx = $isBustedCall;
|
||||
$bustedRstRx = $isBustedRst;
|
||||
|
||||
if ($forcedMissing) {
|
||||
$errorDetail = 'FORCED_MATCH_MISSING';
|
||||
} elseif (isset($forcedConflicts[$wqso->log_qso_id])) {
|
||||
$errorDetail = 'FORCED_MATCH_CONFLICT';
|
||||
} elseif ($isBustedExchange && $bustedExchangeReason) {
|
||||
$errorDetail = $bustedExchangeReason;
|
||||
}
|
||||
|
||||
$errorSide = $this->resolveErrorSide(
|
||||
$bustedCallRx,
|
||||
$bustedCallTx,
|
||||
$bustedRstRx,
|
||||
$bustedRstTx,
|
||||
$bustedSerialRx,
|
||||
$bustedSerialTx,
|
||||
$bustedWwlRx,
|
||||
$bustedWwlTx,
|
||||
$timeMismatch
|
||||
);
|
||||
|
||||
$override = $qsoOverrides->get($wqso->log_qso_id);
|
||||
if ($override && $override->forced_status && $override->forced_status !== 'AUTO') {
|
||||
$this->applyForcedStatus(
|
||||
$override->forced_status,
|
||||
$isValid,
|
||||
$isDuplicate,
|
||||
$isNil,
|
||||
$isBustedCall,
|
||||
$isBustedRst,
|
||||
$isBustedExchange,
|
||||
$isOutOfWindow,
|
||||
$errorCode,
|
||||
$errorSide
|
||||
);
|
||||
}
|
||||
$matchConfidence = $this->resolveMatchConfidence($bestDecision['match_type'] ?? null, $timeMismatch);
|
||||
|
||||
if (! $errorCode) {
|
||||
if ($timeMismatch) {
|
||||
$errorCode = QsoErrorCode::TIME_MISMATCH;
|
||||
} elseif ($isBustedCall || $bustedCallTx) {
|
||||
$errorCode = QsoErrorCode::BUSTED_CALL;
|
||||
} elseif ($isBustedRst || $bustedRstTx) {
|
||||
$errorCode = QsoErrorCode::BUSTED_RST;
|
||||
} elseif ($bustedSerialRx || $bustedSerialTx) {
|
||||
$errorCode = QsoErrorCode::BUSTED_SERIAL;
|
||||
} elseif ($bustedWwlRx || $bustedWwlTx) {
|
||||
$errorCode = QsoErrorCode::BUSTED_LOCATOR;
|
||||
} elseif ($isBustedExchangeOnly) {
|
||||
$errorCode = QsoErrorCode::BUSTED_SERIAL;
|
||||
} elseif (! $isNil && ! $isDuplicate && ! $isBustedExchange && ! $isOutOfWindow) {
|
||||
$errorCode = QsoErrorCode::OK;
|
||||
}
|
||||
}
|
||||
|
||||
QsoResult::updateOrCreate(
|
||||
[
|
||||
'evaluation_run_id' => $run->id,
|
||||
'log_qso_id' => $wqso->log_qso_id,
|
||||
],
|
||||
[
|
||||
'is_valid' => $isValid,
|
||||
'is_duplicate' => $isDuplicate,
|
||||
'is_nil' => $isNil,
|
||||
'is_busted_call' => $isBustedCall,
|
||||
'is_busted_rst' => $isBustedRst,
|
||||
'is_busted_exchange' => $isBustedExchange,
|
||||
'is_time_out_of_window' => $isOutOfWindow,
|
||||
'points' => 0,
|
||||
'distance_km' => null,
|
||||
'time_diff_sec' => $timeDiffSec,
|
||||
'wwl' => null,
|
||||
'dxcc' => null,
|
||||
'matched_qso_id' => $best?->log_qso_id,
|
||||
'matched_log_qso_id' => $best?->log_qso_id,
|
||||
'match_type' => $matchType,
|
||||
'match_confidence' => $matchConfidence,
|
||||
'error_code' => $errorCode,
|
||||
'error_side' => $errorSide,
|
||||
'error_detail' => $errorDetail,
|
||||
'error_flags' => $errorFlags ?: null,
|
||||
]
|
||||
);
|
||||
|
||||
if ($best && $best->log_qso_id) {
|
||||
$paired[$wqso->log_qso_id] = $best->log_qso_id;
|
||||
$paired[$best->log_qso_id] = $wqso->log_qso_id;
|
||||
}
|
||||
}
|
||||
|
||||
$coordinator->progressTick($run, 1);
|
||||
} catch (Throwable $e) {
|
||||
$coordinator->eventError($run, 'Matching bucket: krok selhal.', [
|
||||
'step' => 'match',
|
||||
'round_id' => $run->round_id,
|
||||
'band_id' => $this->bandId,
|
||||
'call_norm' => $this->callNorm,
|
||||
'pass' => $this->pass,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
1349
app/Jobs/MatchQsoGroupJob.php
Normal file
1349
app/Jobs/MatchQsoGroupJob.php
Normal file
File diff suppressed because it is too large
Load Diff
120
app/Jobs/ParseLogJob.php
Normal file
120
app/Jobs/ParseLogJob.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Http\Controllers\LogController;
|
||||
use App\Models\EvaluationLock;
|
||||
use App\Models\EvaluationRun;
|
||||
use App\Models\Log;
|
||||
use App\Services\Evaluation\EvaluationCoordinator;
|
||||
use App\Jobs\UpsertClaimedLogResultJob;
|
||||
use Illuminate\Bus\Batchable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Job: ParseLogJob
|
||||
*
|
||||
* Účel:
|
||||
* - Naparsuje jeden EDI log a uloží Log/LogQso.
|
||||
*/
|
||||
class ParseLogJob implements ShouldQueue
|
||||
{
|
||||
use Batchable;
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
public array $backoff = [30, 120, 300];
|
||||
|
||||
public function __construct(
|
||||
protected int $evaluationRunId,
|
||||
protected int $logId
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$run = EvaluationRun::find($this->evaluationRunId);
|
||||
if (! $run || $run->isCanceled()) {
|
||||
return;
|
||||
}
|
||||
$coordinator = new EvaluationCoordinator();
|
||||
|
||||
$lock = null;
|
||||
$lockKey = "evaluation:parse:round:{$run->round_id}:log:{$this->logId}";
|
||||
try {
|
||||
$lock = EvaluationLock::acquire(
|
||||
key: $lockKey,
|
||||
run: $run,
|
||||
ttl: 1800
|
||||
);
|
||||
|
||||
if (! $lock) {
|
||||
throw new \RuntimeException("ParseLogJob nelze spustit – lock je držen (log_id={$this->logId}).");
|
||||
}
|
||||
|
||||
$log = Log::with('file')->find($this->logId);
|
||||
if (! $log || (int) $log->round_id !== (int) $run->round_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $log->file || ! $log->file->path) {
|
||||
$coordinator->eventWarn($run, "Log #{$log->id} nemá soubor, parser přeskočen.", [
|
||||
'step' => 'parse_logs',
|
||||
'round_id' => $run->round_id,
|
||||
'log_id' => $log->id,
|
||||
]);
|
||||
EvaluationRun::where('id', $run->id)->increment('progress_done');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
LogController::parseUploadedFile($log, $log->file->path);
|
||||
} catch (Throwable $e) {
|
||||
$coordinator->eventError($run, "Chyba parsování logu #{$log->id}: {$e->getMessage()}", [
|
||||
'step' => 'parse_logs',
|
||||
'round_id' => $run->round_id,
|
||||
'log_id' => $log->id,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($run->rules_version === 'CLAIMED') {
|
||||
try {
|
||||
UpsertClaimedLogResultJob::dispatchSync($log->id);
|
||||
} catch (Throwable $e) {
|
||||
$coordinator->eventError($run, "Chyba deklarace výsledků logu #{$log->id}: {$e->getMessage()}", [
|
||||
'step' => 'claimed_upsert',
|
||||
'round_id' => $run->round_id,
|
||||
'log_id' => $log->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
EvaluationRun::where('id', $run->id)->increment('progress_done');
|
||||
$progressTotal = (int) ($run->progress_total ?? 0);
|
||||
$done = $progressTotal > 0
|
||||
? (int) EvaluationRun::where('id', $run->id)->value('progress_done')
|
||||
: 0;
|
||||
if ($progressTotal > 0 && $done % 10 === 0) {
|
||||
$coordinator->eventInfo($run, "Parsování logů: {$done}/{$progressTotal}", [
|
||||
'step' => 'parse_logs',
|
||||
'round_id' => $run->round_id,
|
||||
'step_progress_done' => $done,
|
||||
'step_progress_total' => $progressTotal,
|
||||
]);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$coordinator->eventError($run, "ParseLogJob selhal: {$e->getMessage()}", [
|
||||
'step' => 'parse_logs',
|
||||
'round_id' => $run->round_id,
|
||||
'log_id' => $this->logId,
|
||||
]);
|
||||
throw $e;
|
||||
} finally {
|
||||
if ($lock) {
|
||||
EvaluationLock::release($lockKey, $run);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
56
app/Jobs/PauseEvaluationRunJob.php
Normal file
56
app/Jobs/PauseEvaluationRunJob.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\EvaluationRun;
|
||||
use App\Services\Evaluation\EvaluationCoordinator;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Throwable;
|
||||
|
||||
class PauseEvaluationRunJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
protected int $evaluationRunId,
|
||||
protected string $status,
|
||||
protected string $step,
|
||||
protected string $message
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$run = EvaluationRun::find($this->evaluationRunId);
|
||||
if (! $run || $run->isCanceled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$coordinator = app(EvaluationCoordinator::class);
|
||||
try {
|
||||
$coordinator->eventInfo($run, 'Pause: krok spuštěn.', [
|
||||
'step' => $this->step,
|
||||
'round_id' => $run->round_id,
|
||||
]);
|
||||
$coordinator->transition($run, '*', $this->status, $this->step);
|
||||
|
||||
// WAITING_* stavy umožňují manuální zásah rozhodčího mezi fázemi pipeline.
|
||||
$coordinator->eventInfo($run, $this->message, [
|
||||
'step' => $this->step,
|
||||
'round_id' => $run->round_id,
|
||||
]);
|
||||
$coordinator->eventInfo($run, 'Pause: krok dokončen.', [
|
||||
'step' => $this->step,
|
||||
'round_id' => $run->round_id,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
$coordinator->eventError($run, 'Pause: krok selhal.', [
|
||||
'step' => $this->step,
|
||||
'round_id' => $run->round_id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
379
app/Jobs/PrepareRunJob.php
Normal file
379
app/Jobs/PrepareRunJob.php
Normal file
@@ -0,0 +1,379 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\EvaluationLock;
|
||||
use App\Models\Band;
|
||||
use App\Models\EdiBand;
|
||||
use App\Models\EdiCategory;
|
||||
use App\Models\EvaluationRun;
|
||||
use App\Models\Log;
|
||||
use App\Models\LogOverride;
|
||||
use App\Models\LogResult;
|
||||
use App\Models\QsoResult;
|
||||
use App\Models\Round;
|
||||
use App\Models\WorkingQso;
|
||||
use App\Services\Evaluation\EvaluationCoordinator;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Job: PrepareRunJob
|
||||
*
|
||||
* Odpovědnost:
|
||||
* - Přípravný krok vyhodnocovací pipeline pro jeden EvaluationRun.
|
||||
* - Cílem je připravit konzistentní pracovní prostředí pro následné kroky
|
||||
* (parsing, matching, scoring), aby tyto kroky mohly běžet deterministicky.
|
||||
*
|
||||
* Kontext:
|
||||
* - Spouští se pouze jako součást vyhodnocovacího běhu (EvaluationRun)
|
||||
* a typicky je prvním krokem po StartEvaluationRunJob.
|
||||
* - Pracuje nad konkrétním rozsahem dat (nejčastěji jedno kolo závodu).
|
||||
*
|
||||
* Co job dělá (typicky):
|
||||
* - Ověří existenci a stav EvaluationRun (např. RUNNING).
|
||||
* - Načte použité EvaluationRuleSet a zvaliduje základní konfiguraci.
|
||||
* - Připraví/synchronizuje „scope“ běhu (bandy, kategorie, power kategorie).
|
||||
* - Provede úklid dočasných/staging dat z předchozího běhu stejného runu
|
||||
* (nebo pro stejný scope), aby nedocházelo k míchání výsledků.
|
||||
* - Inicializuje počítadla progressu (progress_total/progress_done) a nastaví
|
||||
* current_step pro monitoring.
|
||||
* - Zapíše auditní události (EvaluationRunEvent) pro UI.
|
||||
*
|
||||
* Co job NEDĚLÁ:
|
||||
* - neparsuje EDI soubory
|
||||
* - neprovádí matching QSO
|
||||
* - nepočítá skóre
|
||||
* - nezapisuje finální výsledky
|
||||
*
|
||||
* Zásady návrhu:
|
||||
* - Job musí být idempotentní (opakované spuštění nesmí poškodit stav).
|
||||
* - Veškerá komplexní logika patří do service layer (např. EvaluationCoordinator).
|
||||
* - Tento krok by měl být rychlý; těžká práce patří do následných jobů.
|
||||
*
|
||||
* Queue:
|
||||
* - Spouští se ve frontě "evaluation".
|
||||
* - Nemá běžet paralelně nad stejným scope (ochrana lockem / WithoutOverlapping).
|
||||
*/
|
||||
class PrepareRunJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(
|
||||
protected int $evaluationRunId
|
||||
)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Připraví vyhodnocovací běh na zpracování.
|
||||
*
|
||||
* Metoda handle():
|
||||
* - nastaví krok běhu (current_step)
|
||||
* - provede validace konfigurace a rozsahu (scope)
|
||||
* - vyčistí dočasná/staging data relevantní pro tento běh
|
||||
* - inicializuje progress a auditní události pro UI
|
||||
*
|
||||
* Poznámky:
|
||||
* - Tento job má mít minimální vedlejší efekty mimo svůj scope.
|
||||
* - Pokud příprava selže, má selhat celý běh (přepnout run do FAILED)
|
||||
* a poskytnout čitelnou diagnostiku v error / run events.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
$run = EvaluationRun::find($this->evaluationRunId);
|
||||
if (! $run || $run->isCanceled()) {
|
||||
return;
|
||||
}
|
||||
$coordinator = new EvaluationCoordinator();
|
||||
$coordinator->eventInfo($run, 'Prepare: krok spuštěn.', [
|
||||
'step' => 'prepare',
|
||||
'round_id' => $run->round_id,
|
||||
]);
|
||||
|
||||
try {
|
||||
$lockKey = "evaluation:round:{$run->round_id}";
|
||||
$existingLock = EvaluationLock::where('key', $lockKey)->first();
|
||||
if ($existingLock && (int) $existingLock->evaluation_run_id !== (int) $run->id) {
|
||||
$run->update([
|
||||
'status' => 'FAILED',
|
||||
'error' => 'Nelze spustit vyhodnocení: probíhá jiný běh pro stejné kolo.',
|
||||
]);
|
||||
$coordinator->eventError($run, 'PrepareRunJob selhal: lock je držen jiným během.', [
|
||||
'step' => 'prepare',
|
||||
'round_id' => $run->round_id,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $existingLock) {
|
||||
EvaluationLock::acquire(
|
||||
key: $lockKey,
|
||||
run: $run,
|
||||
ttl: 7200
|
||||
);
|
||||
}
|
||||
|
||||
$run->update([
|
||||
'status' => 'RUNNING',
|
||||
'current_step' => 'prepare',
|
||||
'started_at' => $run->started_at ?? now(),
|
||||
]);
|
||||
|
||||
// Idempotence: vyčisti staging data pro tento run a připrav čistý start.
|
||||
QsoResult::where('evaluation_run_id', $run->id)->delete();
|
||||
LogResult::where('evaluation_run_id', $run->id)->delete();
|
||||
WorkingQso::where('evaluation_run_id', $run->id)->delete();
|
||||
|
||||
$round = Round::with(['bands', 'categories', 'powerCategories'])->find($run->round_id);
|
||||
if (! $round) {
|
||||
$run->update([
|
||||
'status' => 'FAILED',
|
||||
'error' => 'Kolo nebylo nalezeno.',
|
||||
]);
|
||||
$coordinator->eventError($run, 'PrepareRunJob selhal: kolo nebylo nalezeno.', [
|
||||
'step' => 'prepare',
|
||||
'round_id' => $run->round_id,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Scope určuje kombinace skupin (band/category/power), které se budou hodnotit.
|
||||
$scope = $run->scope ?? [];
|
||||
$bandIds = $scope['band_ids'] ?? $round->bands->pluck('id')->all();
|
||||
$categoryIds = $scope['category_ids'] ?? $round->categories->pluck('id')->all();
|
||||
$powerCategoryIds = $scope['power_category_ids'] ?? $round->powerCategories->pluck('id')->all();
|
||||
|
||||
$bandIds = $bandIds ?: [null];
|
||||
$categoryIds = $categoryIds ?: [null];
|
||||
$powerCategoryIds = $powerCategoryIds ?: [null];
|
||||
|
||||
$groups = [];
|
||||
foreach ($bandIds as $bandId) {
|
||||
foreach ($categoryIds as $categoryId) {
|
||||
foreach ($powerCategoryIds as $powerCategoryId) {
|
||||
$groups[] = [
|
||||
'key' => 'b' . ($bandId ?? 0) . '-c' . ($categoryId ?? 0) . '-p' . ($powerCategoryId ?? 0),
|
||||
'band_id' => $bandId,
|
||||
'category_id' => $categoryId,
|
||||
'power_category_id' => $powerCategoryId,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$scope['band_ids'] = array_values(array_filter($bandIds));
|
||||
$scope['category_ids'] = array_values(array_filter($categoryIds));
|
||||
$scope['power_category_ids'] = array_values(array_filter($powerCategoryIds));
|
||||
$scope['groups'] = $groups;
|
||||
|
||||
$run->update([
|
||||
'scope' => $scope,
|
||||
'progress_total' => count($groups),
|
||||
'progress_done' => 0,
|
||||
]);
|
||||
|
||||
$logsQuery = Log::where('round_id', $run->round_id);
|
||||
$logOverrides = LogOverride::where('evaluation_run_id', $run->id)->get()->keyBy('log_id');
|
||||
|
||||
// Skeleton log_results umožní pozdější agregaci a ranking bez podmíněného "create".
|
||||
$logsQuery->chunkById(200, function ($logs) use ($run, $round, $logOverrides) {
|
||||
foreach ($logs as $log) {
|
||||
$override = $logOverrides->get($log->id);
|
||||
$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;
|
||||
$sixhrCategory = $override && $override->forced_sixhr_category !== null
|
||||
? (bool) $override->forced_sixhr_category
|
||||
: $log->sixhr_category;
|
||||
if ($sixhrCategory && ! $this->isSixHourBand($bandId)) {
|
||||
$this->addSixHourRemark($log);
|
||||
}
|
||||
|
||||
LogResult::updateOrCreate(
|
||||
[
|
||||
'evaluation_run_id' => $run->id,
|
||||
'log_id' => $log->id,
|
||||
],
|
||||
[
|
||||
'status' => 'OK',
|
||||
'band_id' => $bandId,
|
||||
'category_id' => $categoryId,
|
||||
'power_category_id' => $powerCategoryId,
|
||||
'sixhr_category' => $sixhrCategory,
|
||||
'claimed_qso_count' => $log->claimed_qso_count,
|
||||
'claimed_score' => $log->claimed_score,
|
||||
]
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
$coordinator->eventInfo($run, 'Příprava vyhodnocení.', [
|
||||
'step' => 'prepare',
|
||||
'round_id' => $run->round_id,
|
||||
'groups_total' => count($groups),
|
||||
'step_progress_done' => 1,
|
||||
'step_progress_total' => 1,
|
||||
]);
|
||||
$coordinator->eventInfo($run, 'Prepare: krok dokončen.', [
|
||||
'step' => 'prepare',
|
||||
'round_id' => $run->round_id,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
$coordinator->eventError($run, 'Prepare: krok selhal.', [
|
||||
'step' => 'prepare',
|
||||
'round_id' => $run->round_id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
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 isSixHourBand(?int $bandId): bool
|
||||
{
|
||||
if (! $bandId) {
|
||||
return false;
|
||||
}
|
||||
return in_array($bandId, [1, 2], true);
|
||||
}
|
||||
|
||||
protected function addSixHourRemark(Log $log): void
|
||||
{
|
||||
$remarksEval = $this->decodeRemarksEval($log->remarks_eval);
|
||||
$message = '6H kategorie je povolena jen pro pásma 145 MHz a 435 MHz.';
|
||||
if (! in_array($message, $remarksEval, true)) {
|
||||
$remarksEval[] = $message;
|
||||
$log->remarks_eval = $this->encodeRemarksEval($remarksEval);
|
||||
$log->save();
|
||||
}
|
||||
}
|
||||
|
||||
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 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): ?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 = 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;
|
||||
}
|
||||
}
|
||||
77
app/Jobs/RebuildClaimedLogResultsJob.php
Normal file
77
app/Jobs/RebuildClaimedLogResultsJob.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\EvaluationLock;
|
||||
use App\Models\EvaluationRun;
|
||||
use App\Models\Log;
|
||||
use App\Jobs\UpsertClaimedLogResultJob;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
/**
|
||||
* Job: RebuildClaimedLogResultsJob
|
||||
*
|
||||
* Znovu postaví claimed projekci pro celé kolo a přepočítá pořadí.
|
||||
*/
|
||||
class RebuildClaimedLogResultsJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
protected int $evaluationRunId
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$run = EvaluationRun::find($this->evaluationRunId);
|
||||
if (! $run) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lock = EvaluationLock::acquire(
|
||||
key: "evaluation:claimed-rebuild:round:{$run->round_id}",
|
||||
run: $run,
|
||||
ttl: 1800
|
||||
);
|
||||
|
||||
if (! $lock) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Rebuild je deterministická rekonstrukce claimed projekce pro celé kolo.
|
||||
$total = Log::where('round_id', $run->round_id)->count();
|
||||
$run->update([
|
||||
'status' => 'RUNNING',
|
||||
'current_step' => 'rebuild_claimed',
|
||||
'progress_total' => $total,
|
||||
'progress_done' => 0,
|
||||
'started_at' => $run->started_at ?? now(),
|
||||
]);
|
||||
|
||||
$processed = 0;
|
||||
Log::where('round_id', $run->round_id)
|
||||
->chunkById(50, function ($logs) use (&$processed, $run) {
|
||||
foreach ($logs as $log) {
|
||||
$processed++;
|
||||
// Projekce claimed výsledků je synchronní, aby rebuild skončil konzistentně.
|
||||
UpsertClaimedLogResultJob::dispatchSync($log->id);
|
||||
$run->update(['progress_done' => $processed]);
|
||||
}
|
||||
});
|
||||
|
||||
// Po projekci je nutné přepočítat pořadí claimed scoreboardu.
|
||||
RecalculateClaimedRanksJob::dispatchSync($run->id);
|
||||
|
||||
$run->update([
|
||||
'status' => 'SUCCEEDED',
|
||||
'current_step' => 'rebuild_claimed_done',
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
} finally {
|
||||
EvaluationLock::release("evaluation:claimed-rebuild:round:{$run->round_id}", $run);
|
||||
}
|
||||
}
|
||||
}
|
||||
212
app/Jobs/RecalculateClaimedRanksJob.php
Normal file
212
app/Jobs/RecalculateClaimedRanksJob.php
Normal file
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\EvaluationLock;
|
||||
use App\Models\EvaluationRun;
|
||||
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: RecalculateClaimedRanksJob
|
||||
*
|
||||
* Přepočítá pořadí deklarovaných výsledků (CLAIMED) 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 logy se nepočítají
|
||||
*/
|
||||
class RecalculateClaimedRanksJob implements ShouldQueue, ShouldBeUnique
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $uniqueFor = 30;
|
||||
public int $tries = 2;
|
||||
public array $backoff = [60];
|
||||
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Zabraňuje souběžnému přepočtu pro stejné kolo (claimed scoreboard).
|
||||
$lock = EvaluationLock::acquire(
|
||||
key: "evaluation:claimed-ranks:round:{$run->round_id}",
|
||||
run: $run,
|
||||
ttl: 300
|
||||
);
|
||||
if (! $lock) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Vynuluje pořadí, aby staré hodnoty neovlivnily nový přepočet.
|
||||
LogResult::where('evaluation_run_id', $run->id)
|
||||
->update([
|
||||
'rank_overall' => null,
|
||||
'rank_in_category' => null,
|
||||
'rank_overall_ok' => null,
|
||||
'rank_in_category_ok' => null,
|
||||
]);
|
||||
|
||||
// Načte všechny deklarované výsledky včetně vazeb pro kategorii a výkon.
|
||||
$results = LogResult::with(['log', 'category', 'powerCategory'])
|
||||
->where('evaluation_run_id', $run->id)
|
||||
->get();
|
||||
|
||||
// Do pořadí vstupují jen logy se statusem OK a kategorií SINGLE/MULTI.
|
||||
$eligible = $results->filter(function (LogResult $r) {
|
||||
return $r->status === 'OK' && $this->getCategoryType($r) !== null;
|
||||
});
|
||||
|
||||
// Celkové pořadí: podle pásma + SINGLE/MULTI + 6H/standard.
|
||||
$allOverall = $eligible->groupBy(fn (LogResult $r) => $r->band_id . '|' . $this->getCategoryBucket($r) . '|' . $this->getSixHourBucket($r));
|
||||
foreach ($allOverall as $items) {
|
||||
$this->applyRanking($items, 'rank_overall');
|
||||
}
|
||||
|
||||
// Pořadí výkonových kategorií: pásmo + SINGLE/MULTI + výkon (jen LP/QRP/N) + 6H/standard.
|
||||
$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) {
|
||||
$this->applyRanking($items, 'rank_in_category');
|
||||
}
|
||||
|
||||
// Česká podmnožina (OK/OL) pro národní pořadí.
|
||||
$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) {
|
||||
$this->applyRanking($items, 'rank_overall_ok');
|
||||
}
|
||||
|
||||
// České pořadí výkonových kategorií: stejné jako power, ale jen OK/OL a 6H/standard.
|
||||
$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) {
|
||||
$this->applyRanking($items, 'rank_in_category_ok');
|
||||
}
|
||||
|
||||
} finally {
|
||||
EvaluationLock::release("evaluation:claimed-ranks:round:{$run->round_id}", $run);
|
||||
}
|
||||
}
|
||||
|
||||
protected function applyRanking(Collection $items, string $rankField): void
|
||||
{
|
||||
// Řazení podle claimed_score (desc), pak QSO (desc), pak log_id (asc) kvůli stabilitě.
|
||||
$sorted = $items->sort(function (LogResult $a, LogResult $b) {
|
||||
$scoreA = $a->claimed_score ?? 0;
|
||||
$scoreB = $b->claimed_score ?? 0;
|
||||
if ($scoreA !== $scoreB) {
|
||||
return $scoreB <=> $scoreA;
|
||||
}
|
||||
$qsoA = $a->claimed_qso_count ?? 0;
|
||||
$qsoB = $b->claimed_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->claimed_score ?? 0;
|
||||
$qso = $result->claimed_qso_count ?? 0;
|
||||
|
||||
// Shodný výsledek (stejné skóre + QSO) = stejné pořadí.
|
||||
if ($score === $lastScore && $qso === $lastQso) {
|
||||
$rank = $lastRank;
|
||||
} else {
|
||||
$rank = $index + 1;
|
||||
}
|
||||
|
||||
$result->{$rankField} = $rank;
|
||||
$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;
|
||||
}
|
||||
return $this->getSixHourBucket($r) === '6H' ? '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';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
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';
|
||||
}
|
||||
}
|
||||
808
app/Jobs/ScoreGroupJob.php
Normal file
808
app/Jobs/ScoreGroupJob.php
Normal file
@@ -0,0 +1,808 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
80
app/Jobs/StartEvaluationRunJob.php
Normal file
80
app/Jobs/StartEvaluationRunJob.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\EvaluationRun;
|
||||
use App\Services\Evaluation\EvaluationCoordinator;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
/**
|
||||
* Job: StartEvaluationRunJob
|
||||
*
|
||||
* Odpovědnost:
|
||||
* - Slouží jako ORCHESTRÁTOR vyhodnocovacího běhu (EvaluationRun).
|
||||
* - Tento job NEPROVÁDÍ samotné vyhodnocení QSO ani výpočty bodů.
|
||||
* - Je zodpovědný za bezpečné spuštění pipeline kroků vyhodnocení
|
||||
* ve správném pořadí.
|
||||
*
|
||||
* Typický životní cyklus:
|
||||
* 1) Job je dispatchnut po kliknutí v administraci nebo backendovou akcí.
|
||||
* 2) Ověří, že EvaluationRun existuje a je ve stavu PENDING.
|
||||
* 3) Získá lock nad rozsahem dat (typicky round_id), aby zabránil
|
||||
* souběžnému vyhodnocení stejných dat.
|
||||
* 4) Přepne EvaluationRun do stavu RUNNING a zapíše start běhu.
|
||||
* 5) Sestaví sekvenci (chain / batch) dílčích jobů:
|
||||
* - příprava dat
|
||||
* - parsing logů
|
||||
* - matching QSO
|
||||
* - výpočty skóre
|
||||
* - agregace výsledků
|
||||
* - finalizace a publikace
|
||||
* 6) Předá řízení jednotlivým krokům; tento job poté končí.
|
||||
*
|
||||
* Důležité zásady:
|
||||
* - Tento job musí být IDEMPOTENTNÍ (opakované spuštění nesmí rozbít stav).
|
||||
* - Nesmí obsahovat výpočetní logiku.
|
||||
* - Nesmí zapisovat výsledky vyhodnocení.
|
||||
* - Veškerá byznys logika patří do service layer a dílčích jobů.
|
||||
*
|
||||
* Queue:
|
||||
* - Spouští se ve frontě "evaluation".
|
||||
* - Používá se společně s ochranou proti souběhu (lock / WithoutOverlapping).
|
||||
*/
|
||||
class StartEvaluationRunJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(
|
||||
protected int $evaluationRunId
|
||||
)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Spuštění vyhodnocovacího běhu.
|
||||
*
|
||||
* Metoda handle():
|
||||
* - inicializuje vyhodnocovací běh
|
||||
* - zajistí exkluzivitu nad daty
|
||||
* - připraví a dispatchne navazující joby
|
||||
*
|
||||
* Poznámka:
|
||||
* - Tato metoda by měla být krátká a čitelná.
|
||||
* - Veškerá komplexní logika má být delegována
|
||||
* do EvaluationCoordinator / service layer.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
$run = EvaluationRun::find($this->evaluationRunId);
|
||||
if (! $run || $run->isCanceled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(EvaluationCoordinator::class)->start($run);
|
||||
}
|
||||
}
|
||||
147
app/Jobs/UnpairedClassificationBucketJob.php
Normal file
147
app/Jobs/UnpairedClassificationBucketJob.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\QsoErrorCode;
|
||||
use App\Models\EvaluationRun;
|
||||
use App\Models\EvaluationRuleSet;
|
||||
use App\Models\QsoResult;
|
||||
use App\Models\WorkingQso;
|
||||
use App\Services\Evaluation\EvaluationCoordinator;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Bus\Batchable;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Job: UnpairedClassificationBucketJob
|
||||
*
|
||||
* Účel:
|
||||
* - Klasifikace nenapárovaných QSO v bucketu band_id + rcall_norm.
|
||||
* - Udržuje kratší dobu běhu jednoho jobu.
|
||||
*/
|
||||
class UnpairedClassificationBucketJob implements ShouldQueue
|
||||
{
|
||||
use Batchable;
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 2;
|
||||
public array $backoff = [60];
|
||||
|
||||
protected ?int $bandId;
|
||||
protected string $rcallNorm;
|
||||
|
||||
public function __construct(
|
||||
protected int $evaluationRunId,
|
||||
?int $bandId,
|
||||
string $rcallNorm
|
||||
) {
|
||||
$this->bandId = $bandId;
|
||||
$this->rcallNorm = $rcallNorm;
|
||||
}
|
||||
|
||||
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, 'Unpaired klasifikace nelze spustit: chybí ruleset.', [
|
||||
'step' => 'unpaired_classification',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$coordinator->eventInfo($run, 'Unpaired bucket: krok spuštěn.', [
|
||||
'step' => 'unpaired_classification',
|
||||
'round_id' => $run->round_id,
|
||||
'band_id' => $this->bandId,
|
||||
'rcall_norm' => $this->rcallNorm,
|
||||
]);
|
||||
|
||||
$workingQuery = WorkingQso::where('evaluation_run_id', $run->id)
|
||||
->where('rcall_norm', $this->rcallNorm);
|
||||
if ($this->bandId !== null) {
|
||||
$workingQuery->where('band_id', $this->bandId);
|
||||
} else {
|
||||
$workingQuery->whereNull('band_id');
|
||||
}
|
||||
$logQsoIds = $workingQuery->pluck('log_qso_id')->all();
|
||||
if (! $logQsoIds) {
|
||||
return;
|
||||
}
|
||||
|
||||
QsoResult::where('evaluation_run_id', $run->id)
|
||||
->whereNull('matched_log_qso_id')
|
||||
->whereIn('log_qso_id', $logQsoIds)
|
||||
->chunkById(500, function ($results) use ($run, $ruleSet) {
|
||||
foreach ($results as $result) {
|
||||
$wqso = WorkingQso::where('evaluation_run_id', $run->id)
|
||||
->where('log_qso_id', $result->log_qso_id)
|
||||
->first();
|
||||
if (! $wqso) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hasCounterpartLog = false;
|
||||
if ($wqso->band_id && $wqso->rcall_norm) {
|
||||
$hasCounterpartLog = WorkingQso::where('evaluation_run_id', $run->id)
|
||||
->where('band_id', $wqso->band_id)
|
||||
->where('call_norm', $wqso->rcall_norm)
|
||||
->exists();
|
||||
}
|
||||
|
||||
if ($hasCounterpartLog) {
|
||||
$result->error_code = QsoErrorCode::NOT_IN_COUNTERPART_LOG;
|
||||
$result->is_nil = true;
|
||||
} else {
|
||||
$isUnique = false;
|
||||
if ($ruleSet->uniqueQsoEnabled() && $wqso->rcall_norm) {
|
||||
$uniqueQuery = WorkingQso::where('evaluation_run_id', $run->id)
|
||||
->where('rcall_norm', $wqso->rcall_norm);
|
||||
if ($wqso->band_id) {
|
||||
$uniqueQuery->where('band_id', $wqso->band_id);
|
||||
}
|
||||
$count = $uniqueQuery->count();
|
||||
$isUnique = $count === 1;
|
||||
}
|
||||
|
||||
if ($isUnique) {
|
||||
$result->error_code = QsoErrorCode::UNIQUE;
|
||||
$result->is_nil = false;
|
||||
} else {
|
||||
$result->error_code = QsoErrorCode::NO_COUNTERPART_LOG;
|
||||
$result->is_nil = true;
|
||||
}
|
||||
}
|
||||
|
||||
$result->is_valid = false;
|
||||
$result->error_side = 'NONE';
|
||||
$result->save();
|
||||
}
|
||||
});
|
||||
|
||||
EvaluationRun::where('id', $run->id)->increment('progress_done');
|
||||
$coordinator->eventInfo($run, 'Unpaired bucket: krok dokončen.', [
|
||||
'step' => 'unpaired_classification',
|
||||
'round_id' => $run->round_id,
|
||||
'band_id' => $this->bandId,
|
||||
'rcall_norm' => $this->rcallNorm,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
$coordinator->eventError($run, 'Unpaired bucket: krok selhal.', [
|
||||
'step' => 'unpaired_classification',
|
||||
'round_id' => $run->round_id,
|
||||
'band_id' => $this->bandId,
|
||||
'rcall_norm' => $this->rcallNorm,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
141
app/Jobs/UnpairedClassificationJob.php
Normal file
141
app/Jobs/UnpairedClassificationJob.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\EvaluationRun;
|
||||
use App\Models\EvaluationRuleSet;
|
||||
use App\Models\QsoResult;
|
||||
use App\Models\WorkingQso;
|
||||
use App\Enums\QsoErrorCode;
|
||||
use App\Services\Evaluation\EvaluationCoordinator;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Job: UnpairedClassificationJob
|
||||
*
|
||||
* WHY:
|
||||
* - Odděluje klasifikaci nenapárovaných QSO od samotného matchingu.
|
||||
* ORDER:
|
||||
* - Musí běžet po PASS 1 + PASS 2 matchingu, protože vychází z finální
|
||||
* informace, že QSO opravdu nemá protistranu.
|
||||
* - Krok je nevratný: následné kroky (duplicate/scoring) už jen vycházejí
|
||||
* z výsledné error_code.
|
||||
*
|
||||
* Vstup:
|
||||
* - QsoResult bez matched_log_qso_id
|
||||
* - WorkingQso (pro identifikaci protistanice)
|
||||
* - EvaluationRuleSet (unique_qso)
|
||||
*
|
||||
* Výstup:
|
||||
* - error_code: NOT_IN_COUNTERPART_LOG / NO_COUNTERPART_LOG / UNIQUE
|
||||
* - is_nil/is_valid + error_side
|
||||
*
|
||||
* Co job NEDĚLÁ:
|
||||
* - neprovádí matching ani scoring,
|
||||
* - neupravuje původní log_qsos.
|
||||
*/
|
||||
class UnpairedClassificationJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
protected int $evaluationRunId
|
||||
) {
|
||||
}
|
||||
|
||||
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, 'Unpaired klasifikace nelze spustit: chybí ruleset.', [
|
||||
'step' => 'unpaired_classification',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$coordinator->eventInfo($run, 'Unpaired: krok spuštěn.', [
|
||||
'step' => 'unpaired_classification',
|
||||
'round_id' => $run->round_id,
|
||||
]);
|
||||
|
||||
$coordinator->eventInfo($run, 'Klasifikace nenapárovaných QSO.', [
|
||||
'step' => 'match',
|
||||
'round_id' => $run->round_id,
|
||||
'step_progress_done' => null,
|
||||
'step_progress_total' => $run->progress_total,
|
||||
]);
|
||||
|
||||
QsoResult::where('evaluation_run_id', $run->id)
|
||||
->whereNull('matched_log_qso_id')
|
||||
->chunkById(500, function ($results) use ($run, $ruleSet) {
|
||||
foreach ($results as $result) {
|
||||
$wqso = WorkingQso::where('evaluation_run_id', $run->id)
|
||||
->where('log_qso_id', $result->log_qso_id)
|
||||
->first();
|
||||
if (! $wqso) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hasCounterpartLog = false;
|
||||
if ($wqso->band_id && $wqso->rcall_norm) {
|
||||
$hasCounterpartLog = WorkingQso::where('evaluation_run_id', $run->id)
|
||||
->where('band_id', $wqso->band_id)
|
||||
->where('call_norm', $wqso->rcall_norm)
|
||||
->exists();
|
||||
}
|
||||
|
||||
if ($hasCounterpartLog) {
|
||||
$result->error_code = QsoErrorCode::NOT_IN_COUNTERPART_LOG;
|
||||
$result->is_nil = true;
|
||||
} else {
|
||||
$isUnique = false;
|
||||
// UNIQUE je globální v rámci runu (min. evaluation_run_id + band_id).
|
||||
if ($ruleSet->uniqueQsoEnabled() && $wqso->rcall_norm) {
|
||||
$uniqueQuery = WorkingQso::where('evaluation_run_id', $run->id)
|
||||
->where('rcall_norm', $wqso->rcall_norm);
|
||||
if ($wqso->band_id) {
|
||||
$uniqueQuery->where('band_id', $wqso->band_id);
|
||||
}
|
||||
$count = $uniqueQuery->count();
|
||||
$isUnique = $count === 1;
|
||||
}
|
||||
|
||||
if ($isUnique) {
|
||||
$result->error_code = QsoErrorCode::UNIQUE;
|
||||
$result->is_nil = false;
|
||||
} else {
|
||||
$result->error_code = QsoErrorCode::NO_COUNTERPART_LOG;
|
||||
$result->is_nil = true;
|
||||
}
|
||||
}
|
||||
|
||||
$result->error_side = 'NONE';
|
||||
$result->is_valid = false;
|
||||
$result->save();
|
||||
}
|
||||
});
|
||||
|
||||
EvaluationRun::where('id', $run->id)->increment('progress_done');
|
||||
$coordinator->eventInfo($run, 'Unpaired: krok dokončen.', [
|
||||
'step' => 'unpaired_classification',
|
||||
'round_id' => $run->round_id,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
$coordinator->eventError($run, 'Unpaired: krok selhal.', [
|
||||
'step' => 'unpaired_classification',
|
||||
'round_id' => $run->round_id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
329
app/Jobs/UpsertClaimedLogResultJob.php
Normal file
329
app/Jobs/UpsertClaimedLogResultJob.php
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user