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,338 @@
<?php
namespace App\Http\Controllers;
use App\Models\EvaluationRun;
use App\Models\EvaluationLock;
use App\Models\EvaluationRunEvent;
use App\Services\Evaluation\EvaluationCoordinator;
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 Illuminate\Support\Facades\Bus;
/**
* Controller: EvaluationRunController
*
* Účel:
* - HTTP API vrstva pro práci s vyhodnocovacími běhy (EvaluationRun).
* - Slouží pro:
* - monitoring průběhu
* - zobrazení detailu běhu
* - ruční řízení (resume/cancel) a CRUD nad záznamem běhu
*
* Kontext v architektuře:
* - Controller je tenká vrstva mezi frontendem a aplikační logikou.
* - Neobsahuje byznys logiku vyhodnocení.
* - Orchestrace a výpočty jsou delegovány na background joby
* (PrepareRunJob, ParseLogJob, , FinalizeRunJob) a na RoundController,
* který spouští celý pipeline.
*
* Typické endpointy (konceptuálně):
* - POST /api/rounds/{round}/evaluation-runs/start
* spustí vyhodnocovací pipeline (RoundController)
* - GET /api/evaluation-runs
* vrátí seznam běžících / dokončených běhů (monitoring)
* - GET /api/evaluation-runs/{id}
* detail konkrétního běhu (stav, progress, kroky, chyby)
* - POST /api/evaluation-runs/{id}/cancel
* (volitelně) požádá o zrušení běhu
* - POST /api/evaluation-runs/{id}/resume
* pokračuje v pipeline po manuální kontrole
*
* Odpovědnosti controlleru:
* - validace vstupních dat (request objekty)
* - autorizace přístupu (Policies / Gates)
* - vytvoření EvaluationRun záznamu
* - nespouští pipeline přímo (to dělá RoundController)
* - serializace odpovědí do JSON (DTO / Resource)
*
* Co controller NEDĚLÁ:
* - neparsuje EDI logy
* - neprovádí matching ani scoring
* - nečeká synchronně na dokončení vyhodnocení
*
* Zásady návrhu:
* - Všechny operace musí být rychlé (non-blocking).
* - Spuštění vyhodnocení je vždy asynchronní.
* - Stav běhu je čitelný pouze z EvaluationRun + souvisejících entit.
*/
class EvaluationRunController 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 evaluation runů filtrování podle round_id, is_official.
*/
public function index(Request $request): JsonResponse
{
$perPage = (int) $request->get('per_page', 100);
// Seznam běhů slouží hlavně pro monitoring na RoundDetailPage.
$query = EvaluationRun::query()
->with(['round']);
if ($request->filled('round_id')) {
$query->where('round_id', (int) $request->get('round_id'));
}
if ($request->filled('is_official')) {
$query->where(
'is_official',
filter_var($request->get('is_official'), FILTER_VALIDATE_BOOL)
);
}
if ($request->filled('result_type')) {
$query->where('result_type', $request->get('result_type'));
}
$items = $query
->orderByDesc('created_at')
->paginate($perPage);
return response()->json($items);
}
/**
* Vytvoření nového evaluation runu.
* Typicky před samotným spuštěním vyhodnocovače.
*/
public function store(Request $request): JsonResponse
{
$this->authorize('create', EvaluationRun::class);
$data = $this->validateData($request);
// Samotné spuštění pipeline zajišťuje StartEvaluationRunJob.
$run = EvaluationRun::create($data);
$run->load(['round']);
return response()->json($run, 201);
}
/**
* Detail jednoho evaluation runu včetně vazeb a výsledků.
*/
public function show(EvaluationRun $evaluationRun): JsonResponse
{
// Detail běhu včetně výsledků je náročný používej s rozumem (paging/limit).
$evaluationRun->load([
'round',
'logResults',
'qsoResults',
]);
return response()->json($evaluationRun);
}
/**
* Aktualizace evaluation runu (např. změna názvu, poznámky,
* příznaku is_official).
*/
public function update(Request $request, EvaluationRun $evaluationRun): JsonResponse
{
$this->authorize('update', $evaluationRun);
$data = $this->validateData($request, partial: true);
$evaluationRun->fill($data);
$evaluationRun->save();
$evaluationRun->load(['round']);
return response()->json($evaluationRun);
}
/**
* Označí běh jako TEST/PRELIMINARY/FINAL a aktualizuje ukazatele v kole.
*/
public function setResultType(Request $request, EvaluationRun $evaluationRun): JsonResponse
{
$this->authorize('update', $evaluationRun);
$data = $request->validate([
'result_type' => ['required', 'string', 'in:TEST,PRELIMINARY,FINAL'],
]);
$resultType = $data['result_type'];
$evaluationRun->update([
'result_type' => $resultType,
'is_official' => $resultType === 'FINAL',
]);
$round = $evaluationRun->round;
if ($round) {
if ($resultType === 'FINAL') {
$round->official_evaluation_run_id = $evaluationRun->id;
$round->preliminary_evaluation_run_id = null;
$round->test_evaluation_run_id = null;
} elseif ($resultType === 'PRELIMINARY') {
$round->preliminary_evaluation_run_id = $evaluationRun->id;
$round->official_evaluation_run_id = null;
$round->test_evaluation_run_id = null;
} elseif ($resultType === 'TEST') {
$round->test_evaluation_run_id = $evaluationRun->id;
$round->official_evaluation_run_id = null;
$round->preliminary_evaluation_run_id = null;
}
$round->save();
}
$evaluationRun->load(['round']);
return response()->json($evaluationRun);
}
/**
* Smazání evaluation runu (včetně log_results / qso_results přes FK).
*/
public function destroy(EvaluationRun $evaluationRun): JsonResponse
{
$this->authorize('delete', $evaluationRun);
$evaluationRun->delete();
return response()->json(null, 204);
}
/**
* Zruší běh vyhodnocení (pokud je stále aktivní).
*/
public function cancel(EvaluationRun $evaluationRun): JsonResponse
{
$this->authorize('update', $evaluationRun);
// Cancel je povolený jen pro běhy, které ještě neskončily.
$activeStatuses = ['PENDING', 'RUNNING', 'WAITING_REVIEW_INPUT', 'WAITING_REVIEW_MATCH', 'WAITING_REVIEW_SCORE'];
if (! in_array($evaluationRun->status, $activeStatuses, true)) {
return response()->json([
'message' => 'Běh nelze zrušit v aktuálním stavu.',
], 409);
}
$evaluationRun->update([
'status' => 'CANCELED',
'finished_at' => now(),
]);
if ($evaluationRun->batch_id) {
$batch = Bus::findBatch($evaluationRun->batch_id);
if ($batch) {
$batch->cancel();
}
}
EvaluationRunEvent::create([
'evaluation_run_id' => $evaluationRun->id,
'level' => 'warning',
'message' => 'Vyhodnocení bylo zrušeno uživatelem.',
'context' => [
'step' => 'cancel',
'round_id' => $evaluationRun->round_id,
'user_id' => auth()->id(),
],
]);
// Uvolní lock, aby mohl běh navázat nebo se spustit nový.
EvaluationLock::where('evaluation_run_id', $evaluationRun->id)->delete();
return response()->json([
'status' => 'canceled',
], 200);
}
/**
* Vrátí poslední události běhu vyhodnocení.
*/
public function events(EvaluationRun $evaluationRun, Request $request): JsonResponse
{
$this->authorize('update', $evaluationRun);
$limit = (int) $request->get('limit', 10);
if ($limit < 1) {
$limit = 1;
} elseif ($limit > 100) {
$limit = 100;
}
$minLevel = $request->get('min_level');
$levels = ['debug', 'info', 'warning', 'error'];
if (! in_array($minLevel, $levels, true)) {
$minLevel = null;
}
$events = $evaluationRun->events()
->when($minLevel, function ($query) use ($minLevel, $levels) {
$query->whereIn('level', array_slice($levels, array_search($minLevel, $levels, true)));
})
->orderByDesc('id')
->limit($limit)
->get();
return response()->json($events);
}
/**
* Pokračuje v běhu vyhodnocení po manuální kontrole.
*/
public function resume(Request $request, EvaluationRun $evaluationRun): JsonResponse
{
$this->authorize('update', $evaluationRun);
if ($evaluationRun->isCanceled()) {
return response()->json([
'message' => 'Běh byl zrušen.',
], 409);
}
$ok = app(EvaluationCoordinator::class)->resume($evaluationRun, [
'rebuild_working_set' => $request->boolean('rebuild_working_set'),
]);
if ($ok) {
return response()->json([
'status' => 'queued',
], 202);
}
return response()->json([
'message' => 'Běh není ve stavu čekání na kontrolu.',
], 409);
}
/**
* Validace vstupů pro store / update.
*/
protected function validateData(Request $request, bool $partial = false): array
{
$required = $partial ? 'sometimes' : 'required';
return $request->validate([
'round_id' => [$required, 'integer', 'exists:rounds,id'],
'rule_set_id' => ['sometimes', 'nullable', 'integer', 'exists:evaluation_rule_sets,id'],
'name' => ['sometimes', 'nullable', 'string', 'max:100'],
'rules_version' => ['sometimes', 'nullable', 'string', 'in:OFFICIAL,TEST,CLAIMED'],
'result_type' => ['sometimes', 'nullable', 'string', 'in:PRELIMINARY,FINAL,TEST'],
'is_official' => ['sometimes', 'boolean'],
'notes' => ['sometimes', 'nullable', 'string'],
'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'],
]);
}
}