Initial commit

This commit is contained in:
Zdeněk Burda
2026-01-09 21:26:40 +01:00
parent e83aec6dca
commit 41e3ce6f25
404 changed files with 61250 additions and 28 deletions

View File

@@ -0,0 +1,392 @@
<?php
namespace App\Http\Controllers;
use App\Models\Round;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller as BaseController;
use App\Jobs\RebuildClaimedLogResultsJob;
use App\Services\Evaluation\ClaimedRunResolver;
use App\Models\EvaluationRun;
use App\Jobs\StartEvaluationRunJob;
use App\Models\Contest;
use App\Models\LogOverride;
use App\Models\QsoOverride;
class RoundController extends BaseController
{
use AuthorizesRequests, ValidatesRequests;
public function __construct()
{
// zápisové operace jen pro přihlášené
$this->middleware('auth:sanctum')->only(['store', 'update', 'destroy']);
}
/**
* Seznam kol (rounds) stránkovaně.
*/
public function index(Request $request): JsonResponse
{
$perPage = (int) $request->get('per_page', 100);
$contestId = $request->query('contest_id');
$onlyActive = (bool) $request->query('only_active', false);
$includeTests = filter_var($request->query('include_tests', true), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($includeTests === null) {
$includeTests = true;
}
$items = Round::query()
->with([
'contest',
'bands',
'categories',
'powerCategories',
'ruleSet',
])
->when($contestId, fn ($q) => $q->where('contest_id', $contestId))
->when($onlyActive, fn ($q) => $q->where('is_active', true))
->when(! $includeTests, fn ($q) => $q->where('is_test', false))
->orderByDesc('start_time')
->orderByDesc('end_time')
->paginate($perPage);
return response()->json($items);
}
/**
* Vytvoření nového kola.
* Autorizace přes RoundPolicy@create.
*/
public function store(Request $request): JsonResponse
{
$this->authorize('create', Round::class);
$data = $this->validateData($request);
$relations = $this->validateRelations($request);
if (! array_key_exists('rule_set_id', $data) || $data['rule_set_id'] === null) {
$contestRuleSetId = Contest::where('id', $data['contest_id'])->value('rule_set_id');
$data['rule_set_id'] = $contestRuleSetId;
}
$round = Round::create($data);
$this->syncRelations($round, $relations);
$round->load([
'contest',
'bands',
'categories',
'powerCategories',
'ruleSet',
]);
return response()->json($round, 201);
}
/**
* Detail kola.
*/
public function show(Round $round): JsonResponse
{
$round->load([
'contest',
'bands',
'categories',
'powerCategories',
'ruleSet',
]);
return response()->json($round);
}
/**
* Aktualizace kola (partial update).
* Autorizace přes RoundPolicy@update.
*/
public function update(Request $request, Round $round): JsonResponse
{
$this->authorize('update', $round);
$data = $this->validateData($request, partial: true);
$relations = $this->validateRelations($request);
$round->fill($data);
$round->save();
$this->syncRelations($round, $relations);
$round->load([
'contest',
'bands',
'categories',
'powerCategories',
'ruleSet',
]);
return response()->json($round);
}
/**
* Smazání kola.
* Autorizace přes RoundPolicy@delete.
*/
public function destroy(Round $round): JsonResponse
{
$this->authorize('delete', $round);
$round->delete();
return response()->json(null, 204);
}
/**
* Ručně spustí rebuild deklarovaných výsledků pro kolo.
*/
public function recalculateClaimed(Request $request, Round $round): JsonResponse
{
$this->authorize('update', $round);
$run = ClaimedRunResolver::createNewForRound($round->id, auth()->id());
RebuildClaimedLogResultsJob::dispatch($run->id)->onQueue('evaluation');
return response()->json([
'status' => 'queued',
'message' => 'Přepočet deklarovaných výsledků byl spuštěn.',
], 202);
}
/**
* Spustí kompletní vyhodnocovací pipeline pro nové EvaluationRun.
*/
public function startEvaluation(Request $request, Round $round): JsonResponse
{
$this->authorize('update', $round);
$data = $request->validate([
'rule_set_id' => ['sometimes', 'nullable', 'integer', 'exists:evaluation_rule_sets,id'],
'rules_version' => ['sometimes', 'nullable', 'string', 'in:OFFICIAL,TEST,CLAIMED'],
'result_type' => ['sometimes', 'nullable', 'string', 'in:PRELIMINARY,FINAL,TEST'],
'name' => ['sometimes', 'nullable', 'string', 'max:100'],
'is_official' => ['sometimes', 'boolean'],
'scope' => ['sometimes', 'array'],
'scope.band_ids' => ['sometimes', 'array'],
'scope.band_ids.*' => ['integer', 'exists:bands,id'],
'scope.category_ids' => ['sometimes', 'array'],
'scope.category_ids.*' => ['integer', 'exists:categories,id'],
'scope.power_category_ids' => ['sometimes', 'array'],
'scope.power_category_ids.*' => ['integer', 'exists:power_categories,id'],
]);
$rulesVersion = $data['rules_version'] ?? 'OFFICIAL';
$resultType = $data['result_type']
?? ($rulesVersion === 'TEST' ? 'TEST' : 'PRELIMINARY');
$run = EvaluationRun::create([
'round_id' => $round->id,
'rule_set_id' => $data['rule_set_id'] ?? $round->rule_set_id,
'rules_version' => $rulesVersion,
'result_type' => $rulesVersion === 'CLAIMED' ? null : $resultType,
'name' => $data['name'] ?? 'Vyhodnocení',
'is_official' => $data['is_official'] ?? ($resultType === 'FINAL'),
'scope' => $data['scope'] ?? null,
'status' => 'PENDING',
'created_by_user_id' => auth()->id(),
]);
StartEvaluationRunJob::dispatch($run->id)->onQueue('evaluation');
$run->load(['round']);
return response()->json($run, 201);
}
/**
* Spustí nový EvaluationRun jako re-run s převzetím override z posledního běhu.
*/
public function startEvaluationIncremental(Request $request, Round $round): JsonResponse
{
$this->authorize('update', $round);
$data = $request->validate([
'source_run_id' => ['sometimes', 'nullable', 'integer', 'exists:evaluation_runs,id'],
'rule_set_id' => ['sometimes', 'nullable', 'integer', 'exists:evaluation_rule_sets,id'],
'rules_version' => ['sometimes', 'nullable', 'string', 'in:OFFICIAL,TEST'],
'result_type' => ['sometimes', 'nullable', 'string', 'in:PRELIMINARY,FINAL,TEST'],
'name' => ['sometimes', 'nullable', 'string', 'max:100'],
'scope' => ['sometimes', 'array'],
'scope.band_ids' => ['sometimes', 'array'],
'scope.band_ids.*' => ['integer', 'exists:bands,id'],
'scope.category_ids' => ['sometimes', 'array'],
'scope.category_ids.*' => ['integer', 'exists:categories,id'],
'scope.power_category_ids' => ['sometimes', 'array'],
'scope.power_category_ids.*' => ['integer', 'exists:power_categories,id'],
]);
$sourceRun = null;
if (! empty($data['source_run_id'])) {
$sourceRun = EvaluationRun::where('round_id', $round->id)
->where('id', (int) $data['source_run_id'])
->first();
}
if (! $sourceRun) {
$sourceRun = EvaluationRun::where('round_id', $round->id)
->where('rules_version', '!=', 'CLAIMED')
->orderByDesc('id')
->first();
}
$rulesVersion = $data['rules_version']
?? ($sourceRun?->rules_version ?? 'OFFICIAL');
$resultType = $data['result_type']
?? ($rulesVersion === 'TEST' ? 'TEST' : 'PRELIMINARY');
$run = EvaluationRun::create([
'round_id' => $round->id,
'rule_set_id' => $data['rule_set_id'] ?? ($sourceRun?->rule_set_id ?? $round->rule_set_id),
'rules_version' => $rulesVersion,
'result_type' => $resultType,
'name' => $data['name'] ?? 'Vyhodnocení (re-run)',
'is_official' => $resultType === 'FINAL',
'scope' => $data['scope'] ?? ($sourceRun?->scope ?? null),
'status' => 'PENDING',
'created_by_user_id' => auth()->id(),
]);
if ($sourceRun) {
$this->cloneOverrides($sourceRun->id, $run->id, auth()->id());
}
StartEvaluationRunJob::dispatch($run->id)->onQueue('evaluation');
$run->load(['round']);
return response()->json($run, 201);
}
protected function cloneOverrides(int $sourceRunId, int $targetRunId, ?int $userId = null): void
{
$logOverrides = LogOverride::where('evaluation_run_id', $sourceRunId)->get();
if ($logOverrides->isNotEmpty()) {
$rows = $logOverrides->map(function ($override) use ($targetRunId, $userId) {
return [
'evaluation_run_id' => $targetRunId,
'log_id' => $override->log_id,
'forced_log_status' => $override->forced_log_status,
'forced_band_id' => $override->forced_band_id,
'forced_category_id' => $override->forced_category_id,
'forced_power_category_id' => $override->forced_power_category_id,
'forced_sixhr_category' => $override->forced_sixhr_category,
'forced_power_w' => $override->forced_power_w,
'reason' => $override->reason,
'context' => $this->encodeContext($override->context),
'created_by_user_id' => $override->created_by_user_id ?? $userId,
'created_at' => now(),
'updated_at' => now(),
];
})->all();
LogOverride::insert($rows);
}
$qsoOverrides = QsoOverride::where('evaluation_run_id', $sourceRunId)->get();
if ($qsoOverrides->isNotEmpty()) {
$rows = $qsoOverrides->map(function ($override) use ($targetRunId, $userId) {
return [
'evaluation_run_id' => $targetRunId,
'log_qso_id' => $override->log_qso_id,
'forced_matched_log_qso_id' => $override->forced_matched_log_qso_id,
'forced_status' => $override->forced_status,
'forced_points' => $override->forced_points,
'forced_penalty' => $override->forced_penalty,
'reason' => $override->reason,
'context' => $this->encodeContext($override->context),
'created_by_user_id' => $override->created_by_user_id ?? $userId,
'created_at' => now(),
'updated_at' => now(),
];
})->all();
QsoOverride::insert($rows);
}
}
protected function encodeContext(mixed $context): ?string
{
if ($context === null) {
return null;
}
$encoded = json_encode($context);
return $encoded === false ? null : $encoded;
}
/**
* Validace vstupu pro store / update.
*/
protected function validateData(Request $request, bool $partial = false): array
{
$required = $partial ? 'sometimes' : 'required';
return $request->validate([
'contest_id' => [$required, 'integer', 'exists:contests,id'],
// name/description pokud používáš překlady jako u Contest:
'name' => [$required, 'array'],
'name.*' => ['string', 'max:255'],
'description' => ['sometimes', 'nullable', 'array'],
'description.*' => ['string'],
'start_time' => [$required, 'date'],
'end_time' => [$required, 'date', 'after:start_time'],
'logs_deadline' => [$required, 'date'],
'is_active' => ['sometimes', 'boolean'],
'is_test' => ['sometimes', 'boolean'],
'is_sixhr' => ['sometimes', 'boolean'],
'first_check' => ['sometimes', 'nullable', 'date'],
'second_check' => ['sometimes', 'nullable', 'date'],
'unique_qso_check' => ['sometimes', 'nullable', 'date'],
'third_check' => ['sometimes', 'nullable', 'date'],
'fourth_check' => ['sometimes', 'nullable', 'date'],
'prelimitary_results'=> ['sometimes', 'nullable', 'date'],
'rule_set_id' => ['sometimes', 'nullable', 'integer', 'exists:evaluation_rule_sets,id'],
]);
}
/**
* Validace ID navázaných entit pro belongsToMany vztahy.
*/
protected function validateRelations(Request $request): array
{
return $request->validate([
'band_ids' => ['sometimes', 'array'],
'band_ids.*' => ['integer', 'exists:bands,id'],
'category_ids' => ['sometimes', 'array'],
'category_ids.*' => ['integer', 'exists:categories,id'],
'power_category_ids' => ['sometimes', 'array'],
'power_category_ids.*' => ['integer', 'exists:power_categories,id'],
]);
}
/**
* Sync vazeb pro belongsToMany vztahy.
*/
protected function syncRelations(Round $round, array $relations): void
{
if (array_key_exists('band_ids', $relations)) {
$round->bands()->sync($relations['band_ids']);
}
if (array_key_exists('category_ids', $relations)) {
$round->categories()->sync($relations['category_ids']);
}
if (array_key_exists('power_category_ids', $relations)) {
$round->powerCategories()->sync($relations['power_category_ids']);
}
}
}