Initial commit
This commit is contained in:
40
app/Actions/Fortify/CreateNewUser.php
Normal file
40
app/Actions/Fortify/CreateNewUser.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||
|
||||
class CreateNewUser implements CreatesNewUsers
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and create a newly registered user.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*/
|
||||
public function create(array $input): User
|
||||
{
|
||||
Validator::make($input, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique(User::class),
|
||||
],
|
||||
'password' => $this->passwordRules(),
|
||||
])->validate();
|
||||
|
||||
return User::create([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'password' => Hash::make($input['password']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
18
app/Actions/Fortify/PasswordValidationRules.php
Normal file
18
app/Actions/Fortify/PasswordValidationRules.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
trait PasswordValidationRules
|
||||
{
|
||||
/**
|
||||
* Get the validation rules used to validate passwords.
|
||||
*
|
||||
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
|
||||
*/
|
||||
protected function passwordRules(): array
|
||||
{
|
||||
return ['required', 'string', Password::default(), 'confirmed'];
|
||||
}
|
||||
}
|
||||
29
app/Actions/Fortify/ResetUserPassword.php
Normal file
29
app/Actions/Fortify/ResetUserPassword.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Laravel\Fortify\Contracts\ResetsUserPasswords;
|
||||
|
||||
class ResetUserPassword implements ResetsUserPasswords
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and reset the user's forgotten password.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*/
|
||||
public function reset(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'password' => $this->passwordRules(),
|
||||
])->validate();
|
||||
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($input['password']),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
32
app/Actions/Fortify/UpdateUserPassword.php
Normal file
32
app/Actions/Fortify/UpdateUserPassword.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
|
||||
|
||||
class UpdateUserPassword implements UpdatesUserPasswords
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and update the user's password.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*/
|
||||
public function update(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'current_password' => ['required', 'string', 'current_password:web'],
|
||||
'password' => $this->passwordRules(),
|
||||
], [
|
||||
'current_password.current_password' => __('The provided password does not match your current password.'),
|
||||
])->validateWithBag('updatePassword');
|
||||
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($input['password']),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
58
app/Actions/Fortify/UpdateUserProfileInformation.php
Normal file
58
app/Actions/Fortify/UpdateUserProfileInformation.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
|
||||
|
||||
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
{
|
||||
/**
|
||||
* Validate and update the given user's profile information.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*/
|
||||
public function update(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique('users')->ignore($user->id),
|
||||
],
|
||||
])->validateWithBag('updateProfileInformation');
|
||||
|
||||
if ($input['email'] !== $user->email &&
|
||||
$user instanceof MustVerifyEmail) {
|
||||
$this->updateVerifiedUser($user, $input);
|
||||
} else {
|
||||
$user->forceFill([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the given verified user's profile information.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*/
|
||||
protected function updateVerifiedUser(User $user, array $input): void
|
||||
{
|
||||
$user->forceFill([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'email_verified_at' => null,
|
||||
])->save();
|
||||
|
||||
$user->sendEmailVerificationNotification();
|
||||
}
|
||||
}
|
||||
35
app/Enums/QsoErrorCode.php
Normal file
35
app/Enums/QsoErrorCode.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
final class QsoErrorCode
|
||||
{
|
||||
public const OK = 'OK';
|
||||
public const DUP = 'DUP';
|
||||
public const UNIQUE = 'UNIQUE';
|
||||
public const NOT_IN_COUNTERPART_LOG = 'NOT_IN_COUNTERPART_LOG';
|
||||
public const NO_COUNTERPART_LOG = 'NO_COUNTERPART_LOG';
|
||||
public const BUSTED_CALL = 'BUSTED_CALL';
|
||||
public const BUSTED_RST = 'BUSTED_RST';
|
||||
public const BUSTED_SERIAL = 'BUSTED_SERIAL';
|
||||
public const BUSTED_LOCATOR = 'BUSTED_LOCATOR';
|
||||
public const TIME_MISMATCH = 'TIME_MISMATCH';
|
||||
public const OUT_OF_WINDOW = 'OUT_OF_WINDOW';
|
||||
|
||||
public static function all(): array
|
||||
{
|
||||
return [
|
||||
self::OK,
|
||||
self::DUP,
|
||||
self::UNIQUE,
|
||||
self::NOT_IN_COUNTERPART_LOG,
|
||||
self::NO_COUNTERPART_LOG,
|
||||
self::BUSTED_CALL,
|
||||
self::BUSTED_RST,
|
||||
self::BUSTED_SERIAL,
|
||||
self::BUSTED_LOCATOR,
|
||||
self::TIME_MISMATCH,
|
||||
self::OUT_OF_WINDOW,
|
||||
];
|
||||
}
|
||||
}
|
||||
132
app/Http/Controllers/BandController.php
Normal file
132
app/Http/Controllers/BandController.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Band;
|
||||
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;
|
||||
|
||||
class BandController extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth:sanctum')->except(['index', 'show']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seznam pásem (s stránkováním).
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 100);
|
||||
|
||||
$bands = Band::query()
|
||||
->with(['ediBands', 'contests'])
|
||||
->orderBy('order')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json($bands);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoří nové pásmo.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', Band::class);
|
||||
|
||||
$data = $this->validateData($request);
|
||||
|
||||
$relations = $request->validate([
|
||||
'edi_band_ids' => ['sometimes', 'array'],
|
||||
'edi_band_ids.*' => ['integer', 'exists:edi_bands,id'],
|
||||
'contest_ids' => ['sometimes', 'array'],
|
||||
'contest_ids.*' => ['integer', 'exists:contests,id'],
|
||||
]);
|
||||
|
||||
$band = Band::create($data);
|
||||
|
||||
if (array_key_exists('edi_band_ids', $relations)) {
|
||||
$band->ediBands()->sync($relations['edi_band_ids']);
|
||||
}
|
||||
|
||||
if (array_key_exists('contest_ids', $relations)) {
|
||||
$band->contests()->sync($relations['contest_ids']);
|
||||
}
|
||||
|
||||
return response()->json($band, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail jednoho pásma.
|
||||
*/
|
||||
public function show(Band $band): JsonResponse
|
||||
{
|
||||
$band->load(['ediBands', 'contests']);
|
||||
|
||||
return response()->json($band);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizace existujícího pásma.
|
||||
*/
|
||||
public function update(Request $request, Band $band): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $band);
|
||||
|
||||
$data = $this->validateData($request, partial: true);
|
||||
|
||||
$relations = $request->validate([
|
||||
'edi_band_ids' => ['sometimes', 'array'],
|
||||
'edi_band_ids.*' => ['integer', 'exists:edi_bands,id'],
|
||||
'contest_ids' => ['sometimes', 'array'],
|
||||
'contest_ids.*' => ['integer', 'exists:contests,id'],
|
||||
]);
|
||||
|
||||
$band->fill($data);
|
||||
$band->save();
|
||||
|
||||
if (array_key_exists('edi_band_ids', $relations)) {
|
||||
$band->ediBands()->sync($relations['edi_band_ids']);
|
||||
}
|
||||
|
||||
if (array_key_exists('contest_ids', $relations)) {
|
||||
$band->contests()->sync($relations['contest_ids']);
|
||||
}
|
||||
|
||||
return response()->json($band);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smazání pásma.
|
||||
*/
|
||||
public function destroy(Band $band): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $band);
|
||||
|
||||
$band->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Společná validace vstupu pro store/update.
|
||||
*/
|
||||
protected function validateData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
|
||||
return $request->validate([
|
||||
'name' => [$required, 'string', 'max:255'],
|
||||
'order' => [$required, 'integer'],
|
||||
'edi_band_begin' => [$required, 'integer'],
|
||||
'edi_band_end' => [$required, 'integer', 'gte:edi_band_begin'],
|
||||
'has_power_category' => [$required, 'boolean'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
138
app/Http/Controllers/CategoryController.php
Normal file
138
app/Http/Controllers/CategoryController.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Category;
|
||||
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;
|
||||
|
||||
class CategoryController extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// store / update / destroy jen pro přihlášené
|
||||
$this->middleware('auth:sanctum')->only(['store', 'update', 'destroy']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seznam kategorií (API, JSON).
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 100);
|
||||
|
||||
$categories = Category::query()
|
||||
->with(['ediCategories', 'contests'])
|
||||
->orderBy('order')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json($categories);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoření nové kategorie.
|
||||
* Autorizace přes CategoryPolicy@create.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', Category::class);
|
||||
|
||||
$data = $this->validateData($request);
|
||||
$relations = $this->validateRelations($request);
|
||||
|
||||
$category = Category::create($data);
|
||||
|
||||
if (array_key_exists('edi_category_ids', $relations)) {
|
||||
$category->ediCategories()->sync($relations['edi_category_ids']);
|
||||
}
|
||||
|
||||
if (array_key_exists('contest_ids', $relations)) {
|
||||
$category->contests()->sync($relations['contest_ids']);
|
||||
}
|
||||
|
||||
$category->load(['ediCategories', 'contests']);
|
||||
|
||||
return response()->json($category, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail jedné kategorie.
|
||||
*/
|
||||
public function show(Category $category): JsonResponse
|
||||
{
|
||||
$category->load(['ediCategories', 'contests']);
|
||||
|
||||
return response()->json($category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizace existující kategorie (partial update).
|
||||
* Autorizace přes CategoryPolicy@update.
|
||||
*/
|
||||
public function update(Request $request, Category $category): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $category);
|
||||
|
||||
$data = $this->validateData($request, partial: true);
|
||||
$relations = $this->validateRelations($request);
|
||||
|
||||
$category->fill($data);
|
||||
$category->save();
|
||||
|
||||
if (array_key_exists('edi_category_ids', $relations)) {
|
||||
$category->ediCategories()->sync($relations['edi_category_ids']);
|
||||
}
|
||||
|
||||
if (array_key_exists('contest_ids', $relations)) {
|
||||
$category->contests()->sync($relations['contest_ids']);
|
||||
}
|
||||
|
||||
$category->load(['ediCategories', 'contests']);
|
||||
|
||||
return response()->json($category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smazání kategorie.
|
||||
* Autorizace přes CategoryPolicy@delete.
|
||||
*/
|
||||
public function destroy(Category $category): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $category);
|
||||
|
||||
$category->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Společná validace dat pro store/update.
|
||||
*/
|
||||
protected function validateData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
|
||||
return $request->validate([
|
||||
'name' => [$required, 'string', 'max:255'],
|
||||
'order' => [$required, 'integer'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validace ID relací (EDI kategorie a soutěže).
|
||||
*/
|
||||
protected function validateRelations(Request $request): array
|
||||
{
|
||||
return $request->validate([
|
||||
'edi_category_ids' => ['sometimes', 'array'],
|
||||
'edi_category_ids.*' => ['integer', 'exists:edi_categories,id'],
|
||||
'contest_ids' => ['sometimes', 'array'],
|
||||
'contest_ids.*' => ['integer', 'exists:contests,id'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
287
app/Http/Controllers/ContestController.php
Normal file
287
app/Http/Controllers/ContestController.php
Normal file
@@ -0,0 +1,287 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Contest;
|
||||
use App\Models\EvaluationRuleSet;
|
||||
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;
|
||||
|
||||
class ContestController 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 soutěží (stránkovaný výstup).
|
||||
* Podporuje ?lang=cs / ?lang=en – name/description se vrací v daném jazyce.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 100);
|
||||
$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;
|
||||
}
|
||||
|
||||
$lang = $request->query('lang');
|
||||
if (! is_string($lang) || $lang === '') {
|
||||
$lang = app()->getLocale();
|
||||
}
|
||||
|
||||
$items = Contest::query()
|
||||
->with([
|
||||
'rounds',
|
||||
'parameters',
|
||||
'bands',
|
||||
'categories',
|
||||
'powerCategories',
|
||||
'ruleSet',
|
||||
])
|
||||
->when($onlyActive, fn ($q) => $q->where('is_active', true))
|
||||
->when(! $includeTests, fn ($q) => $q->where('is_test', false))
|
||||
->orderByDesc('created_at')
|
||||
->paginate($perPage);
|
||||
|
||||
// přemapování na konkrétní jazyk (stejný princip jako NewsPostController@index)
|
||||
$items->getCollection()->transform(function (Contest $contest) use ($lang) {
|
||||
$data = $contest->toArray();
|
||||
|
||||
$data['name'] = $contest->getTranslation('name', $lang, true);
|
||||
$data['description'] = $contest->getTranslation('description', $lang, true);
|
||||
|
||||
return $data;
|
||||
});
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Vytvoření nové soutěže.
|
||||
* Autorizace přes ContestPolicy@create.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', Contest::class);
|
||||
|
||||
$data = $this->validateData($request);
|
||||
$relations = $this->validateRelations($request);
|
||||
|
||||
if (! array_key_exists('rule_set_id', $data) || $data['rule_set_id'] === null) {
|
||||
$data['rule_set_id'] = $this->resolveDefaultRuleSetId();
|
||||
}
|
||||
|
||||
$contest = Contest::create($data);
|
||||
|
||||
$this->syncRelations($contest, $relations);
|
||||
|
||||
$contest->load([
|
||||
'rounds',
|
||||
'parameters',
|
||||
'bands',
|
||||
'categories',
|
||||
'powerCategories',
|
||||
'ruleSet',
|
||||
]);
|
||||
|
||||
return response()->json($contest, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail soutěže.
|
||||
* Můžeš volat i s ?lang=cs pro konkrétní jazyk.
|
||||
*/
|
||||
public function show(Request $request, Contest $contest): JsonResponse
|
||||
{
|
||||
$lang = $request->query('lang');
|
||||
if (! is_string($lang) || $lang === '') {
|
||||
$lang = app()->getLocale();
|
||||
}
|
||||
|
||||
$contest->load([
|
||||
'rounds',
|
||||
'parameters',
|
||||
'bands',
|
||||
'categories',
|
||||
'powerCategories',
|
||||
'ruleSet',
|
||||
]);
|
||||
|
||||
$data = $contest->toArray();
|
||||
|
||||
$data['name'] = $contest->getTranslation('name', $lang, true);
|
||||
$data['description'] = $contest->getTranslation('description', $lang, true);
|
||||
|
||||
return response()->json($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizace soutěže (partial update).
|
||||
* Autorizace přes ContestPolicy@update.
|
||||
*/
|
||||
public function update(Request $request, Contest $contest): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $contest);
|
||||
|
||||
$data = $this->validateData($request, partial: true);
|
||||
$relations = $this->validateRelations($request);
|
||||
|
||||
$contest->fill($data);
|
||||
$contest->save();
|
||||
|
||||
$this->syncRelations($contest, $relations);
|
||||
|
||||
$contest->load([
|
||||
'rounds',
|
||||
'parameters',
|
||||
'bands',
|
||||
'categories',
|
||||
'powerCategories',
|
||||
'ruleSet',
|
||||
]);
|
||||
|
||||
return response()->json($contest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smazání soutěže.
|
||||
* Autorizace přes ContestPolicy@delete.
|
||||
*/
|
||||
public function destroy(Contest $contest): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $contest);
|
||||
|
||||
$contest->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validace dat pro store / update.
|
||||
* Stejný princip jako u NewsPost – string nebo array { locale: value }.
|
||||
*/
|
||||
protected function validateData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
|
||||
return $request->validate([
|
||||
'name' => [
|
||||
$required,
|
||||
function (string $attribute, $value, \Closure $fail) {
|
||||
if (is_string($value)) {
|
||||
if (mb_strlen($value) > 255) {
|
||||
$fail('The '.$attribute.' may not be greater than 255 characters.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
foreach ($value as $locale => $text) {
|
||||
if (! is_string($text)) {
|
||||
$fail("The {$attribute}.{$locale} must be a string.");
|
||||
return;
|
||||
}
|
||||
if (mb_strlen($text) > 255) {
|
||||
$fail("The {$attribute}.{$locale} may not be greater than 255 characters.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$fail('The '.$attribute.' must be a string or an array of translated strings.');
|
||||
},
|
||||
],
|
||||
|
||||
'description' => [
|
||||
'sometimes',
|
||||
function (string $attribute, $value, \Closure $fail) {
|
||||
if ($value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
// max length pokud chceš, nebo bez omezení
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
foreach ($value as $locale => $text) {
|
||||
if (! is_string($text)) {
|
||||
$fail("The {$attribute}.{$locale} must be a string.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$fail('The '.$attribute.' must be null, a string, or an array of translated strings.');
|
||||
},
|
||||
],
|
||||
|
||||
'url' => ['sometimes', 'nullable', 'email', 'max:255'],
|
||||
'evaluator' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
'email' => ['sometimes', 'nullable', 'email', 'max:255'],
|
||||
'email2' => ['sometimes', 'nullable', 'email', 'max:255'],
|
||||
|
||||
'is_mcr' => ['sometimes', 'boolean'],
|
||||
'is_sixhr' => ['sometimes', 'boolean'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
|
||||
'start_time' => ['sometimes', 'date_format:H:i:s'],
|
||||
'duration' => ['sometimes', 'integer', 'min:1'],
|
||||
'logs_deadline_days' => ['sometimes', 'integer', 'min:0'],
|
||||
'rule_set_id' => ['sometimes', 'nullable', 'integer', 'exists:evaluation_rule_sets,id'],
|
||||
]);
|
||||
}
|
||||
|
||||
protected function resolveDefaultRuleSetId(): ?int
|
||||
{
|
||||
return EvaluationRuleSet::where('code', 'default_vhf_compat')->value('id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validace ID navázaných entit (bands, categories, powerCategories).
|
||||
*/
|
||||
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(Contest $contest, array $relations): void
|
||||
{
|
||||
if (array_key_exists('band_ids', $relations)) {
|
||||
$contest->bands()->sync($relations['band_ids']);
|
||||
}
|
||||
|
||||
if (array_key_exists('category_ids', $relations)) {
|
||||
$contest->categories()->sync($relations['category_ids']);
|
||||
}
|
||||
|
||||
if (array_key_exists('power_category_ids', $relations)) {
|
||||
$contest->powerCategories()->sync($relations['power_category_ids']);
|
||||
}
|
||||
}
|
||||
}
|
||||
119
app/Http/Controllers/ContestParameterController.php
Normal file
119
app/Http/Controllers/ContestParameterController.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ContestParameter;
|
||||
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;
|
||||
|
||||
class ContestParameterController 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 contest parametrů (stránkovaně).
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 100);
|
||||
|
||||
$items = ContestParameter::query()
|
||||
->with('contest')
|
||||
->orderBy('contest_id')
|
||||
->orderBy('log_type')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoření nových parametrů pro contest.
|
||||
* Autorizace přes ContestParameterPolicy@create.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', ContestParameter::class);
|
||||
|
||||
$data = $this->validateData($request);
|
||||
|
||||
$item = ContestParameter::create($data);
|
||||
|
||||
$item->load('contest');
|
||||
|
||||
return response()->json($item, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail parametrů.
|
||||
*/
|
||||
public function show(ContestParameter $contest_parameter): JsonResponse
|
||||
{
|
||||
$contest_parameter->load('contest');
|
||||
|
||||
return response()->json($contest_parameter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizace parametrů (partial update).
|
||||
* Autorizace přes ContestParameterPolicy@update.
|
||||
*/
|
||||
public function update(Request $request, ContestParameter $contest_parameter): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $contest_parameter);
|
||||
|
||||
$data = $this->validateData($request, partial: true);
|
||||
|
||||
$contest_parameter->fill($data);
|
||||
$contest_parameter->save();
|
||||
|
||||
$contest_parameter->load('contest');
|
||||
|
||||
return response()->json($contest_parameter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smazání parametrů.
|
||||
* Autorizace přes ContestParameterPolicy@delete.
|
||||
*/
|
||||
public function destroy(ContestParameter $contest_parameter): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $contest_parameter);
|
||||
|
||||
$contest_parameter->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'],
|
||||
'log_type' => [$required, 'in:STANDARD,CHECK'],
|
||||
'ignore_slash_part' => [$required, 'boolean'],
|
||||
'ignore_third_part' => [$required, 'boolean'],
|
||||
'letters_in_rst' => [$required, 'boolean'],
|
||||
'discard_qso_rec_diff_call' => [$required, 'boolean'],
|
||||
'discard_qso_sent_diff_call' => [$required, 'boolean'],
|
||||
'discard_qso_rec_diff_rst' => [$required, 'boolean'],
|
||||
'discard_qso_sent_diff_rst' => [$required, 'boolean'],
|
||||
'discard_qso_rec_diff_code' => [$required, 'boolean'],
|
||||
'discard_qso_sent_diff_code' => [$required, 'boolean'],
|
||||
'unique_qso' => [$required, 'boolean'],
|
||||
'time_tolerance' => [$required, 'integer', 'min:0'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
8
app/Http/Controllers/Controller.php
Normal file
8
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
101
app/Http/Controllers/CountryWwlController.php
Normal file
101
app/Http/Controllers/CountryWwlController.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\CountryWwl;
|
||||
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;
|
||||
|
||||
class CountryWwlController extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Create / Update / Delete pouze pro přihlášené
|
||||
$this->middleware('auth:sanctum')->only(['store', 'update', 'destroy']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seznam záznamů country-WWL (stránkovaně).
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 100);
|
||||
|
||||
$items = CountryWwl::query()
|
||||
->orderBy('country_name')
|
||||
->orderBy('wwl')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoření nového country-WWL záznamu.
|
||||
* Autorizace přes CountryWwlPolicy@create.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', CountryWwl::class);
|
||||
|
||||
$data = $this->validateData($request);
|
||||
|
||||
$item = CountryWwl::create($data);
|
||||
|
||||
return response()->json($item, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail jednoho country-WWL záznamu.
|
||||
*/
|
||||
public function show(CountryWwl $country_wwl): JsonResponse
|
||||
{
|
||||
return response()->json($country_wwl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizace existujícího country-WWL záznamu (partial update).
|
||||
* Autorizace přes CountryWwlPolicy@update.
|
||||
*/
|
||||
public function update(Request $request, CountryWwl $country_wwl): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $country_wwl);
|
||||
|
||||
$data = $this->validateData($request, partial: true);
|
||||
|
||||
$country_wwl->fill($data);
|
||||
$country_wwl->save();
|
||||
|
||||
return response()->json($country_wwl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smazání country-WWL záznamu.
|
||||
* Autorizace přes CountryWwlPolicy@delete.
|
||||
*/
|
||||
public function destroy(CountryWwl $country_wwl): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $country_wwl);
|
||||
|
||||
$country_wwl->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validace dat pro store / update.
|
||||
*/
|
||||
protected function validateData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
|
||||
return $request->validate([
|
||||
'country_name' => [$required, 'string', 'max:150'],
|
||||
'wwl' => [$required, 'string', 'size:4'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
110
app/Http/Controllers/CtyController.php
Normal file
110
app/Http/Controllers/CtyController.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Cty;
|
||||
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;
|
||||
|
||||
class CtyController extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Create / Update / Delete jen pro přihlášené
|
||||
$this->middleware('auth:sanctum')->only(['store', 'update', 'destroy']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seznam CTY záznamů (stránkovaný výstup).
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 100);
|
||||
|
||||
$items = Cty::query()
|
||||
->orderBy('country_name')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoření nového CTY záznamu.
|
||||
* Autorizace přes CtyPolicy@create (pokud ji používáš).
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', Cty::class);
|
||||
|
||||
$data = $this->validateData($request);
|
||||
|
||||
$item = Cty::create($data);
|
||||
|
||||
return response()->json($item, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail jednoho CTY záznamu.
|
||||
*/
|
||||
public function show(Cty $cty): JsonResponse
|
||||
{
|
||||
return response()->json($cty);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizace CTY záznamu (partial update).
|
||||
* Autorizace přes CtyPolicy@update.
|
||||
*/
|
||||
public function update(Request $request, Cty $cty): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $cty);
|
||||
|
||||
$data = $this->validateData($request, partial: true);
|
||||
|
||||
$cty->fill($data);
|
||||
$cty->save();
|
||||
|
||||
return response()->json($cty);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smazání CTY záznamu.
|
||||
* Autorizace přes CtyPolicy@delete.
|
||||
*/
|
||||
public function destroy(Cty $cty): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $cty);
|
||||
|
||||
$cty->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validace pro store / update.
|
||||
*/
|
||||
protected function validateData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
|
||||
return $request->validate([
|
||||
'country_name' => [$required, 'string', 'max:150'],
|
||||
'dxcc' => [$required, 'integer'],
|
||||
'cq_zone' => [$required, 'integer'],
|
||||
'itu_zone' => [$required, 'integer'],
|
||||
'continent' => [$required, 'string', 'size:2'],
|
||||
'latitude' => [$required, 'numeric'],
|
||||
'longitude' => [$required, 'numeric'],
|
||||
'time_offset' => [$required, 'numeric'],
|
||||
'prefix' => [$required, 'string', 'max:64'],
|
||||
'prefix_norm' => ['sometimes', 'nullable', 'string', 'max:64'],
|
||||
'precise' => [$required, 'boolean'],
|
||||
'source' => [$required, 'string', 'max:25'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
93
app/Http/Controllers/EdiBandController.php
Normal file
93
app/Http/Controllers/EdiBandController.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\EdiBand;
|
||||
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;
|
||||
|
||||
class EdiBandController extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth:sanctum')->except(['index', 'show']);
|
||||
}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 100);
|
||||
|
||||
$items = EdiBand::query()
|
||||
->with('bands') // eager load pokud chceš mít vazby
|
||||
->orderBy('value')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', EdiBand::class);
|
||||
|
||||
$data = $request->validate([
|
||||
'value' => ['required', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$relations = $request->validate([
|
||||
'band_ids' => ['sometimes', 'array'],
|
||||
'band_ids.*' => ['integer', 'exists:bands,id'],
|
||||
]);
|
||||
|
||||
$item = EdiBand::create($data);
|
||||
|
||||
if (array_key_exists('band_ids', $relations)) {
|
||||
$item->bands()->sync($relations['band_ids']);
|
||||
}
|
||||
|
||||
return response()->json($item, 201);
|
||||
}
|
||||
|
||||
public function show(EdiBand $edi_band): JsonResponse
|
||||
{
|
||||
$edi_band->load('bands');
|
||||
|
||||
return response()->json($edi_band);
|
||||
}
|
||||
|
||||
public function update(Request $request, EdiBand $edi_band): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $edi_band);
|
||||
|
||||
$data = $request->validate([
|
||||
'value' => ['sometimes', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$relations = $request->validate([
|
||||
'band_ids' => ['sometimes', 'array'],
|
||||
'band_ids.*' => ['integer', 'exists:bands,id'],
|
||||
]);
|
||||
|
||||
$edi_band->fill($data);
|
||||
$edi_band->save();
|
||||
|
||||
if (array_key_exists('band_ids', $relations)) {
|
||||
$edi_band->bands()->sync($relations['band_ids']);
|
||||
}
|
||||
|
||||
return response()->json($edi_band);
|
||||
}
|
||||
|
||||
public function destroy(EdiBand $edi_band): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $edi_band);
|
||||
|
||||
$edi_band->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
}
|
||||
128
app/Http/Controllers/EdiCategoryController.php
Normal file
128
app/Http/Controllers/EdiCategoryController.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\EdiCategory;
|
||||
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;
|
||||
|
||||
class EdiCategoryController 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 EDI kategorií (API, JSON).
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 100);
|
||||
|
||||
$items = EdiCategory::query()
|
||||
->with('categories') // n:m vazba na Category, pokud ji chceš mít v odpovědi
|
||||
->orderBy('value')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoření nové EDI kategorie.
|
||||
* Autorizace přes EdiCategoryPolicy@create.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', EdiCategory::class);
|
||||
|
||||
$data = $this->validateData($request);
|
||||
$relations = $this->validateRelations($request);
|
||||
|
||||
$item = EdiCategory::create($data);
|
||||
|
||||
if (array_key_exists('category_ids', $relations)) {
|
||||
$item->categories()->sync($relations['category_ids']);
|
||||
}
|
||||
|
||||
$item->load('categories');
|
||||
|
||||
return response()->json($item, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail jedné EDI kategorie.
|
||||
*/
|
||||
public function show(EdiCategory $edi_category): JsonResponse
|
||||
{
|
||||
$edi_category->load('categories');
|
||||
|
||||
return response()->json($edi_category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizace existující EDI kategorie (partial update).
|
||||
* Autorizace přes EdiCategoryPolicy@update.
|
||||
*/
|
||||
public function update(Request $request, EdiCategory $edi_category): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $edi_category);
|
||||
|
||||
$data = $this->validateData($request, partial: true);
|
||||
$relations = $this->validateRelations($request);
|
||||
|
||||
$edi_category->fill($data);
|
||||
$edi_category->save();
|
||||
|
||||
if (array_key_exists('category_ids', $relations)) {
|
||||
$edi_category->categories()->sync($relations['category_ids']);
|
||||
}
|
||||
|
||||
$edi_category->load('categories');
|
||||
|
||||
return response()->json($edi_category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smazání EDI kategorie.
|
||||
* Autorizace přes EdiCategoryPolicy@delete.
|
||||
*/
|
||||
public function destroy(EdiCategory $edi_category): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $edi_category);
|
||||
|
||||
$edi_category->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validace dat pro store / update.
|
||||
*/
|
||||
protected function validateData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
|
||||
return $request->validate([
|
||||
'value' => [$required, 'string', 'max:255'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validace ID navázaných kategorií (Category).
|
||||
*/
|
||||
protected function validateRelations(Request $request): array
|
||||
{
|
||||
return $request->validate([
|
||||
'category_ids' => ['sometimes', 'array'],
|
||||
'category_ids.*' => ['integer', 'exists:categories,id'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
202
app/Http/Controllers/EvaluationRuleSetController.php
Normal file
202
app/Http/Controllers/EvaluationRuleSetController.php
Normal file
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\EvaluationRuleSet;
|
||||
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;
|
||||
|
||||
class EvaluationRuleSetController extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// zápis jen pro přihlášené
|
||||
$this->middleware('auth:sanctum')->only(['store', 'update', 'destroy']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seznam rulesetů (stránkovaně).
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 100);
|
||||
|
||||
$items = EvaluationRuleSet::query()
|
||||
->orderBy('name')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoření nového rulesetu.
|
||||
* Autorizace přes EvaluationRuleSetPolicy@create.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', EvaluationRuleSet::class);
|
||||
|
||||
$data = $this->validateData($request);
|
||||
|
||||
$item = EvaluationRuleSet::create($data);
|
||||
|
||||
return response()->json($item, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail rulesetu.
|
||||
*/
|
||||
public function show(EvaluationRuleSet $evaluationRuleSet): JsonResponse
|
||||
{
|
||||
$evaluationRuleSet->load(['evaluationRuns']);
|
||||
|
||||
return response()->json($evaluationRuleSet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update (partial).
|
||||
* Autorizace přes EvaluationRuleSetPolicy@update.
|
||||
*/
|
||||
public function update(Request $request, EvaluationRuleSet $evaluationRuleSet): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $evaluationRuleSet);
|
||||
|
||||
$data = $this->validateData($request, partial: true);
|
||||
|
||||
$evaluationRuleSet->fill($data);
|
||||
$evaluationRuleSet->save();
|
||||
|
||||
return response()->json($evaluationRuleSet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smazání rulesetu.
|
||||
* Autorizace přes EvaluationRuleSetPolicy@delete.
|
||||
*/
|
||||
public function destroy(EvaluationRuleSet $evaluationRuleSet): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $evaluationRuleSet);
|
||||
|
||||
$evaluationRuleSet->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validace vstupů pro store/update.
|
||||
*/
|
||||
protected function validateData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => [$required, 'string', 'max:100'],
|
||||
'code' => [$required, 'string', 'max:50'],
|
||||
'description' => ['sometimes', 'nullable', 'string'],
|
||||
|
||||
'scoring_mode' => [$required, 'in:DISTANCE,FIXED_POINTS'],
|
||||
'points_per_qso' => ['sometimes', 'integer', 'min:0'],
|
||||
'points_per_km' => ['sometimes', 'numeric', 'min:0'],
|
||||
|
||||
'use_multipliers' => ['sometimes', 'boolean'],
|
||||
'multiplier_type' => [$required, 'in:NONE,WWL,DXCC,SECTION,COUNTRY'],
|
||||
|
||||
'dup_qso_policy' => [$required, 'in:ZERO_POINTS,PENALTY'],
|
||||
'nil_qso_policy' => [$required, 'in:ZERO_POINTS,PENALTY'],
|
||||
'no_counterpart_log_policy' => ['sometimes', 'in:INVALID,ZERO_POINTS,FLAG_ONLY,PENALTY'],
|
||||
'not_in_counterpart_log_policy' => ['sometimes', 'in:INVALID,ZERO_POINTS,FLAG_ONLY,PENALTY'],
|
||||
'unique_qso_policy' => ['sometimes', 'in:INVALID,ZERO_POINTS,FLAG_ONLY'],
|
||||
'busted_call_policy' => [$required, 'in:ZERO_POINTS,PENALTY'],
|
||||
'busted_exchange_policy'=> [$required, 'in:ZERO_POINTS,PENALTY'],
|
||||
'busted_serial_policy' => ['sometimes', 'in:ZERO_POINTS,PENALTY'],
|
||||
'busted_locator_policy' => ['sometimes', 'in:ZERO_POINTS,PENALTY'],
|
||||
|
||||
'penalty_dup_points' => ['sometimes', 'integer', 'min:0'],
|
||||
'penalty_nil_points' => ['sometimes', 'integer', 'min:0'],
|
||||
'penalty_busted_call_points' => ['sometimes', 'integer', 'min:0'],
|
||||
'penalty_busted_exchange_points' => ['sometimes', 'integer', 'min:0'],
|
||||
'penalty_busted_serial_points' => ['sometimes', 'integer', 'min:0'],
|
||||
'penalty_busted_locator_points' => ['sometimes', 'integer', 'min:0'],
|
||||
|
||||
'dupe_scope' => ['sometimes', 'in:BAND,BAND_MODE'],
|
||||
'callsign_normalization' => ['sometimes', 'in:STRICT,IGNORE_SUFFIX'],
|
||||
'distance_rounding' => ['sometimes', 'in:FLOOR,ROUND,CEIL'],
|
||||
'min_distance_km' => ['sometimes', 'nullable', 'integer', 'min:0'],
|
||||
'require_locators' => ['sometimes', 'boolean'],
|
||||
'out_of_window_policy' => ['sometimes', 'in:IGNORE,ZERO_POINTS,PENALTY,INVALID'],
|
||||
'penalty_out_of_window_points' => ['sometimes', 'integer', 'min:0'],
|
||||
|
||||
'exchange_type' => ['sometimes', 'in:SERIAL,WWL,SERIAL_WWL,CUSTOM'],
|
||||
'exchange_requires_wwl' => ['sometimes', 'boolean'],
|
||||
'exchange_requires_serial' => ['sometimes', 'boolean'],
|
||||
'exchange_requires_report' => ['sometimes', 'boolean'],
|
||||
'exchange_pattern' => ['sometimes', 'nullable', 'string', 'max:200'],
|
||||
|
||||
'ignore_slash_part' => ['sometimes', 'boolean'],
|
||||
'ignore_third_part' => ['sometimes', 'boolean'],
|
||||
'letters_in_rst' => ['sometimes', 'boolean'],
|
||||
'rst_ignore_third_char' => ['sometimes', 'boolean'],
|
||||
'discard_qso_rec_diff_call' => ['sometimes', 'boolean'],
|
||||
'discard_qso_sent_diff_call' => ['sometimes', 'boolean'],
|
||||
'discard_qso_rec_diff_rst' => ['sometimes', 'boolean'],
|
||||
'discard_qso_sent_diff_rst' => ['sometimes', 'boolean'],
|
||||
'discard_qso_rec_diff_code' => ['sometimes', 'boolean'],
|
||||
'discard_qso_sent_diff_code' => ['sometimes', 'boolean'],
|
||||
'discard_qso_rec_diff_serial' => ['sometimes', 'boolean'],
|
||||
'discard_qso_sent_diff_serial' => ['sometimes', 'boolean'],
|
||||
'discard_qso_rec_diff_wwl' => ['sometimes', 'boolean'],
|
||||
'discard_qso_sent_diff_wwl' => ['sometimes', 'boolean'],
|
||||
'busted_rst_policy' => ['sometimes', 'in:ZERO_POINTS,PENALTY'],
|
||||
'penalty_busted_rst_points' => ['sometimes', 'integer', 'min:0'],
|
||||
|
||||
'match_tiebreak_order' => ['sometimes', 'nullable', 'array'],
|
||||
'match_require_locator_match' => ['sometimes', 'boolean'],
|
||||
'match_require_exchange_match' => ['sometimes', 'boolean'],
|
||||
|
||||
'multiplier_scope' => ['sometimes', 'in:PER_BAND,OVERALL'],
|
||||
'multiplier_source' => ['sometimes', 'in:VALID_ONLY,ALL_MATCHED'],
|
||||
'wwl_multiplier_level' => ['sometimes', 'in:LOCATOR_2,LOCATOR_4,LOCATOR_6'],
|
||||
|
||||
'checklog_matching' => ['sometimes', 'boolean'],
|
||||
'out_of_window_dq_threshold' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
||||
'time_diff_dq_threshold_percent' => ['sometimes', 'nullable', 'integer', 'min:1', 'max:100'],
|
||||
'time_diff_dq_threshold_sec' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
||||
'bad_qso_dq_threshold_percent' => ['sometimes', 'nullable', 'integer', 'min:1', 'max:100'],
|
||||
|
||||
'time_tolerance_sec' => ['sometimes', 'nullable', 'integer', 'min:0'],
|
||||
'require_unique_qso' => ['sometimes', 'boolean'],
|
||||
'allow_time_shift_one_hour' => ['sometimes', 'boolean'],
|
||||
'time_shift_seconds' => ['sometimes', 'nullable', 'integer', 'min:0'],
|
||||
'time_mismatch_policy' => ['sometimes', 'nullable', 'in:INVALID,ZERO_POINTS,FLAG_ONLY,PENALTY'],
|
||||
'allow_time_mismatch_pairing' => ['sometimes', 'boolean'],
|
||||
'time_mismatch_max_sec' => ['sometimes', 'nullable', 'integer', 'min:0'],
|
||||
'callsign_suffix_max_len' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
||||
'callsign_levenshtein_max' => ['sometimes', 'nullable', 'integer', 'min:0', 'max:2'],
|
||||
'dup_resolution_strategy' => ['sometimes', 'nullable', 'array'],
|
||||
'operating_window_mode' => ['sometimes', 'in:NONE,BEST_CONTIGUOUS'],
|
||||
'operating_window_hours' => ['sometimes', 'nullable', 'integer', 'min:1', 'max:24'],
|
||||
'sixhr_ranking_mode' => ['sometimes', 'in:IARU,CRK'],
|
||||
|
||||
'options' => ['sometimes', 'nullable', 'array'],
|
||||
]);
|
||||
|
||||
if (array_key_exists('operating_window_hours', $data) && ! array_key_exists('operating_window_mode', $data)) {
|
||||
$data['operating_window_mode'] = 'BEST_CONTIGUOUS';
|
||||
}
|
||||
|
||||
if (array_key_exists('operating_window_mode', $data)) {
|
||||
if ($data['operating_window_mode'] === 'BEST_CONTIGUOUS') {
|
||||
$data['operating_window_hours'] = 6;
|
||||
} else {
|
||||
$data['operating_window_hours'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
338
app/Http/Controllers/EvaluationRunController.php
Normal file
338
app/Http/Controllers/EvaluationRunController.php
Normal 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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
348
app/Http/Controllers/FileController.php
Normal file
348
app/Http/Controllers/FileController.php
Normal file
@@ -0,0 +1,348 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\File;
|
||||
use App\Models\Log;
|
||||
use App\Models\Round;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
use App\Services\Evaluation\ClaimedRunResolver;
|
||||
use App\Jobs\ParseLogJob;
|
||||
use App\Jobs\RecalculateClaimedRanksJob;
|
||||
|
||||
class FileController extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth:sanctum')->only(['delete']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrátí seznam nahraných souborů (metadata) pro zobrazení v UI.
|
||||
* Výstupem je JSON kolekce záznamů bez interního pole "path".
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 100);
|
||||
|
||||
$files = File::query()
|
||||
->select([
|
||||
'id',
|
||||
'filename',
|
||||
'mimetype',
|
||||
'filesize',
|
||||
'hash',
|
||||
'uploaded_by',
|
||||
'created_at',
|
||||
])
|
||||
->orderByDesc('created_at')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json($files);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrátí metadata konkrétního souboru jako JSON.
|
||||
* Path k fyzickému souboru se z bezpečnostních důvodů nevrací.
|
||||
*
|
||||
* @param \App\Models\File $file
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function show(File $file): JsonResponse
|
||||
{
|
||||
// schválně nevracím path, je to interní implementační detail
|
||||
return response()->json([
|
||||
'id' => $file->id,
|
||||
'filename' => $file->filename,
|
||||
'mimetype' => $file->mimetype,
|
||||
'filesize' => $file->filesize,
|
||||
'hash' => $file->hash,
|
||||
'uploaded_by' => $file->uploaded_by,
|
||||
'created_at' => $file->created_at,
|
||||
'updated_at' => $file->updated_at,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrátí soubor ke stažení (HTTP download) s Content-Disposition: attachment.
|
||||
* Pokud soubor fyzicky neexistuje, vrátí HTTP 404.
|
||||
*
|
||||
* @param \App\Models\File $file
|
||||
* @return \Symfony\Component\HttpFoundation\StreamedResponse
|
||||
*/
|
||||
public function download(File $file): StreamedResponse
|
||||
{
|
||||
if (! Storage::exists($file->path)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return Storage::download(
|
||||
$file->path,
|
||||
$this->buildDownloadName($file),
|
||||
['Content-Type' => $file->mimetype]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrátí obsah souboru v HTTP odpovědi (např. pro náhled nebo další zpracování).
|
||||
* Content-Type je převzat z uloženého mimetype v DB.
|
||||
* Pokud soubor neexistuje, vrátí HTTP 404.
|
||||
*
|
||||
* @param \App\Models\File $file
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
public function content(File $file): Response
|
||||
{
|
||||
$content = $this->getFileContent($file);
|
||||
|
||||
return response($content, 200)
|
||||
->header('Content-Type', $file->mimetype);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interní helper pro načtení obsahu souboru pro interní použití v PHP kódu.
|
||||
* Při neexistenci souboru by měl konzistentně signalizovat chybu
|
||||
* stejně jako download()/content() – buď abort(404), nebo doménovou výjimkou.
|
||||
*
|
||||
* @param \App\Models\File $file
|
||||
* @return string binární obsah souboru
|
||||
*
|
||||
* @throws \RuntimeException pokud soubor neexistuje (aktuální stav)
|
||||
*/
|
||||
protected function getFileContent(File $file): string
|
||||
{
|
||||
if (! Storage::exists($file->path)) {
|
||||
throw new \RuntimeException('File not found.');
|
||||
}
|
||||
|
||||
return Storage::get($file->path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Přijme nahraný soubor z HTTP requestu, uloží ho na disk pod UUID názvem
|
||||
* do dvouúrovňové adresářové struktury (první znak / první dva znaky UUID),
|
||||
* spočítá hash obsahu a zapíše metadata do tabulky files.
|
||||
*
|
||||
* Vrací JSON s metadaty nově vytvořeného záznamu.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'file' => ['required', 'file', 'max:10240'],
|
||||
'round_id' => ['required', 'integer', 'exists:rounds,id'],
|
||||
]);
|
||||
|
||||
/** @var \Illuminate\Http\UploadedFile $uploaded */
|
||||
$uploaded = $validated['file'];
|
||||
$roundId = (int) $validated['round_id'];
|
||||
$round = Round::find($roundId);
|
||||
if (! $round) {
|
||||
return response()->json([
|
||||
'message' => 'Kolo nebylo nalezeno.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
if (! auth()->check()) {
|
||||
$deadline = $round->logs_deadline;
|
||||
if (! $deadline || now()->greaterThan($deadline)) {
|
||||
return response()->json([
|
||||
'message' => 'Termín pro nahrání logu již vypršel.',
|
||||
], 403);
|
||||
}
|
||||
}
|
||||
|
||||
$hash = hash_file('sha256', $uploaded->getRealPath());
|
||||
|
||||
// pokus o načtení PCall z EDI pro případnou náhradu existujícího logu
|
||||
$pcall = $this->extractPcallFromUploaded($uploaded);
|
||||
|
||||
// ověř existenci v DB
|
||||
$existing = File::where('hash', $hash)->first();
|
||||
if ($existing) {
|
||||
return response()->json([
|
||||
'message' => 'Duplicitní soubor již existuje.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
// pokud existuje log se stejnou PCall v daném kole, ale jiným hashem, nahraď ho novým
|
||||
if ($pcall) {
|
||||
$existingLog = Log::with('file')
|
||||
->where('round_id', $roundId)
|
||||
->whereRaw('UPPER(pcall) = ?', [mb_strtoupper($pcall)])
|
||||
->first();
|
||||
|
||||
if ($existingLog && $existingLog->file && $existingLog->file->hash === $hash) {
|
||||
return response()->json([
|
||||
'message' => 'Duplicitní soubor již existuje.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
if ($existingLog) {
|
||||
$this->deleteLogWithFile($existingLog);
|
||||
}
|
||||
}
|
||||
|
||||
$uuid = (string) Str::uuid();
|
||||
$extension = $uploaded->getClientOriginalExtension();
|
||||
$storedFilename = $uuid . ($extension ? '.' . $extension : '');
|
||||
|
||||
$level1 = substr($uuid, 0, 1);
|
||||
$level2 = substr($uuid, 0, 2);
|
||||
$directory = "uploads/{$level1}/{$level2}";
|
||||
|
||||
if (! Storage::exists($directory)) {
|
||||
Storage::makeDirectory($directory);
|
||||
}
|
||||
|
||||
$storedPath = $uploaded->storeAs($directory, $storedFilename);
|
||||
|
||||
try {
|
||||
$file = File::create([
|
||||
'path' => $storedPath,
|
||||
'filename' => $uploaded->getClientOriginalName(),
|
||||
'mimetype' => $uploaded->getMimeType() ?? 'application/octet-stream',
|
||||
'filesize' => $uploaded->getSize(),
|
||||
'hash' => $hash,
|
||||
'uploaded_by' => auth()->check() ? (string) auth()->id() : null,
|
||||
]);
|
||||
} catch (QueryException $e) {
|
||||
// hash už mezitím někdo vložil
|
||||
return response()->json([
|
||||
'message' => 'Duplicitní soubor již existuje.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
$log = Log::create([
|
||||
'round_id' => $roundId,
|
||||
'file_id' => $file->id,
|
||||
'ip_address' => $request->ip(),
|
||||
]);
|
||||
|
||||
// Předej parsování do asynchronní pipeline (ParseLogJob),
|
||||
// aby logiku bylo možné volat jednotně z evaluace.
|
||||
$claimedRun = ClaimedRunResolver::forRound($roundId);
|
||||
ParseLogJob::dispatch($claimedRun->id, $log->id)->onQueue('evaluation');
|
||||
RecalculateClaimedRanksJob::dispatch($claimedRun->id)
|
||||
->delay(now()->addSeconds(10))
|
||||
->onQueue('evaluation');
|
||||
|
||||
return response()->json($file, 201);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Smaže fyzický soubor z disku a odpovídající metadata z DB.
|
||||
* Pokud soubor neexistuje, vrací 404 (pouze pokud nechceš tichý success).
|
||||
*
|
||||
* @param \App\Models\File $file
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function destroy(File $file): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $file);
|
||||
|
||||
if (Storage::exists($file->path)) {
|
||||
Storage::delete($file->path);
|
||||
}
|
||||
|
||||
$file->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zkusí vytáhnout PCall z nahraného EDI souboru (bez plného parsování).
|
||||
*/
|
||||
protected function extractPcallFromUploaded(\Illuminate\Http\UploadedFile $uploaded): ?string
|
||||
{
|
||||
$contents = @file($uploaded->getRealPath(), FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
if (! $contents) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($contents as $line) {
|
||||
$trimmed = trim((string) $line);
|
||||
if (stripos($trimmed, 'PCALL=') === 0) {
|
||||
return trim(substr($trimmed, 6));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Smaže log, jeho QSO a výsledky, navázaný soubor a fyzický obsah.
|
||||
*/
|
||||
protected function deleteLogWithFile(Log $log): void
|
||||
{
|
||||
$file = $log->file;
|
||||
$filePath = $file?->path;
|
||||
|
||||
DB::transaction(function () use ($log, $file) {
|
||||
$log->logResults()->delete();
|
||||
$log->qsos()->delete();
|
||||
$log->delete();
|
||||
if ($file) {
|
||||
$file->delete();
|
||||
}
|
||||
});
|
||||
|
||||
if ($filePath && Storage::exists($filePath)) {
|
||||
Storage::delete($filePath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoří název souboru pro download ve formátu XXCALLSIGN_HASH.edi
|
||||
*/
|
||||
protected function buildDownloadName(File $file): string
|
||||
{
|
||||
$log = Log::where('file_id', $file->id)->first();
|
||||
if (! $log) {
|
||||
return $file->filename;
|
||||
}
|
||||
|
||||
$pcall = strtoupper(trim($log->pcall ?? ''));
|
||||
if ($pcall === '') {
|
||||
return $file->filename;
|
||||
}
|
||||
$psect = strtoupper(trim($log->psect ?? ''));
|
||||
$tokens = preg_split('/[\s;,_-]+/', $psect) ?: [];
|
||||
$hasCheck = in_array('CHECK', $tokens, true) || $psect === 'CHECK';
|
||||
$sixHour = ($log->sixhr_category ?? false) || in_array('6H', $tokens, true);
|
||||
$isSO = in_array('SO', $tokens, true) || in_array('SOLO', $tokens, true);
|
||||
$isMO = in_array('MO', $tokens, true) || in_array('MULTI', $tokens, true);
|
||||
|
||||
$prefix = '';
|
||||
if (! $hasCheck) {
|
||||
if ($sixHour && $isSO) {
|
||||
$prefix = '61';
|
||||
} elseif ($sixHour && $isMO) {
|
||||
$prefix = '62';
|
||||
} elseif ($isSO) {
|
||||
$prefix = '01';
|
||||
} elseif ($isMO) {
|
||||
$prefix = '02';
|
||||
}
|
||||
}
|
||||
|
||||
$hashPart = strtoupper(substr(hash('crc32', $file->hash ?? (string) $file->id), 0, 8));
|
||||
|
||||
return ($prefix ? $prefix : '') . $pcall . '_' . $hashPart . '.edi';
|
||||
}
|
||||
}
|
||||
344
app/Http/Controllers/LogController.php
Normal file
344
app/Http/Controllers/LogController.php
Normal file
@@ -0,0 +1,344 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Log;
|
||||
use App\Models\LogQso;
|
||||
use App\Models\EvaluationRun;
|
||||
use App\Models\QsoOverride;
|
||||
use App\Models\QsoResult;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
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;
|
||||
|
||||
class LogController 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 logů – s možností filtrování podle round_id, pcall, processed/accepted.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 100);
|
||||
|
||||
$query = Log::query()
|
||||
->with(['round', 'file'])
|
||||
->withExists(['logResults as parsed'])
|
||||
->withExists(['logResults as parsed_claimed' => function ($q) {
|
||||
$q->whereHas('evaluationRun', function ($runQuery) {
|
||||
$runQuery->where('rules_version', 'CLAIMED');
|
||||
});
|
||||
}]);
|
||||
|
||||
if ($request->filled('round_id')) {
|
||||
$query->where('round_id', (int) $request->get('round_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('pcall')) {
|
||||
$query->where('pcall', $request->get('pcall'));
|
||||
}
|
||||
|
||||
if ($request->filled('processed')) {
|
||||
$query->where('processed', filter_var($request->get('processed'), FILTER_VALIDATE_BOOL));
|
||||
}
|
||||
|
||||
if ($request->filled('accepted')) {
|
||||
$query->where('accepted', filter_var($request->get('accepted'), FILTER_VALIDATE_BOOL));
|
||||
}
|
||||
|
||||
$logs = $query
|
||||
->orderByRaw('parsed_claimed asc, pcall asc')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json($logs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoření logu.
|
||||
* Typicky voláno po úspěšném uploadu / parsování EDI ve službě.
|
||||
* Autorizace přes LogPolicy@create.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', Log::class);
|
||||
|
||||
$data = $this->validateData($request);
|
||||
|
||||
$log = Log::create($data);
|
||||
|
||||
$log->load(['round', 'file']);
|
||||
|
||||
return response()->json($log, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail jednoho logu včetně vazeb a počtu QSO.
|
||||
*/
|
||||
public function show(Request $request, Log $log): JsonResponse
|
||||
{
|
||||
$includeQsos = $request->boolean('include_qsos', false);
|
||||
$relations = ['round', 'file'];
|
||||
if ($includeQsos) {
|
||||
$relations[] = 'qsos';
|
||||
}
|
||||
$log->load($relations);
|
||||
|
||||
return response()->json($log);
|
||||
}
|
||||
|
||||
/**
|
||||
* QSO tabulka pro log: raw QSO + výsledky vyhodnocení + případné overrides.
|
||||
*/
|
||||
public function qsoTable(Request $request, Log $log): JsonResponse
|
||||
{
|
||||
$evalRunId = $request->filled('evaluation_run_id')
|
||||
? (int) $request->get('evaluation_run_id')
|
||||
: null;
|
||||
|
||||
if ($evalRunId) {
|
||||
$run = EvaluationRun::find($evalRunId);
|
||||
if (! $run) {
|
||||
$evalRunId = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $evalRunId) {
|
||||
$run = EvaluationRun::query()
|
||||
->where('round_id', $log->round_id)
|
||||
->where('status', 'SUCCEEDED')
|
||||
->where(function ($q) {
|
||||
$q->whereNull('rules_version')
|
||||
->orWhere('rules_version', '!=', 'CLAIMED');
|
||||
})
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
$evalRunId = $run?->id;
|
||||
}
|
||||
|
||||
$qsos = LogQso::query()
|
||||
->where('log_id', $log->id)
|
||||
->orderBy('qso_index')
|
||||
->orderBy('id')
|
||||
->get([
|
||||
'id',
|
||||
'qso_index',
|
||||
'time_on',
|
||||
'dx_call',
|
||||
'my_rst',
|
||||
'my_serial',
|
||||
'dx_rst',
|
||||
'dx_serial',
|
||||
'rx_wwl',
|
||||
'rx_exchange',
|
||||
'mode_code',
|
||||
'new_exchange',
|
||||
'new_wwl',
|
||||
'new_dxcc',
|
||||
'duplicate_qso',
|
||||
'points',
|
||||
]);
|
||||
|
||||
$qsoIds = $qsos->pluck('id')->all();
|
||||
$resultMap = collect();
|
||||
$overrideMap = collect();
|
||||
|
||||
if ($evalRunId && $qsoIds) {
|
||||
$resultMap = QsoResult::query()
|
||||
->where('evaluation_run_id', $evalRunId)
|
||||
->whereIn('log_qso_id', $qsoIds)
|
||||
->get([
|
||||
'log_qso_id',
|
||||
'points',
|
||||
'penalty_points',
|
||||
'error_code',
|
||||
'error_side',
|
||||
'match_confidence',
|
||||
'match_type',
|
||||
'error_flags',
|
||||
'is_valid',
|
||||
'is_duplicate',
|
||||
'is_nil',
|
||||
'is_busted_call',
|
||||
'is_busted_rst',
|
||||
'is_busted_exchange',
|
||||
'is_time_out_of_window',
|
||||
])
|
||||
->keyBy('log_qso_id');
|
||||
|
||||
$overrideMap = QsoOverride::query()
|
||||
->where('evaluation_run_id', $evalRunId)
|
||||
->whereIn('log_qso_id', $qsoIds)
|
||||
->get([
|
||||
'id',
|
||||
'log_qso_id',
|
||||
'forced_status',
|
||||
'forced_matched_log_qso_id',
|
||||
'forced_points',
|
||||
'forced_penalty',
|
||||
'reason',
|
||||
])
|
||||
->keyBy('log_qso_id');
|
||||
}
|
||||
|
||||
$data = $qsos->map(function (LogQso $qso) use ($resultMap, $overrideMap) {
|
||||
$result = $resultMap->get($qso->id);
|
||||
$override = $overrideMap->get($qso->id);
|
||||
|
||||
return [
|
||||
'id' => $qso->id,
|
||||
'qso_index' => $qso->qso_index,
|
||||
'time_on' => $qso->time_on,
|
||||
'dx_call' => $qso->dx_call,
|
||||
'my_rst' => $qso->my_rst,
|
||||
'my_serial' => $qso->my_serial,
|
||||
'dx_rst' => $qso->dx_rst,
|
||||
'dx_serial' => $qso->dx_serial,
|
||||
'rx_wwl' => $qso->rx_wwl,
|
||||
'rx_exchange' => $qso->rx_exchange,
|
||||
'mode_code' => $qso->mode_code,
|
||||
'new_exchange' => $qso->new_exchange,
|
||||
'new_wwl' => $qso->new_wwl,
|
||||
'new_dxcc' => $qso->new_dxcc,
|
||||
'duplicate_qso' => $qso->duplicate_qso,
|
||||
'points' => $qso->points,
|
||||
'remarks' => null,
|
||||
'result' => $result ? [
|
||||
'log_qso_id' => $result->log_qso_id,
|
||||
'points' => $result->points,
|
||||
'penalty_points' => $result->penalty_points,
|
||||
'error_code' => $result->error_code,
|
||||
'error_side' => $result->error_side,
|
||||
'match_confidence' => $result->match_confidence,
|
||||
'match_type' => $result->match_type,
|
||||
'error_flags' => $result->error_flags,
|
||||
'is_valid' => $result->is_valid,
|
||||
'is_duplicate' => $result->is_duplicate,
|
||||
'is_nil' => $result->is_nil,
|
||||
'is_busted_call' => $result->is_busted_call,
|
||||
'is_busted_rst' => $result->is_busted_rst,
|
||||
'is_busted_exchange' => $result->is_busted_exchange,
|
||||
'is_time_out_of_window' => $result->is_time_out_of_window,
|
||||
] : null,
|
||||
'override' => $override ? [
|
||||
'id' => $override->id,
|
||||
'log_qso_id' => $override->log_qso_id,
|
||||
'forced_status' => $override->forced_status,
|
||||
'forced_matched_log_qso_id' => $override->forced_matched_log_qso_id,
|
||||
'forced_points' => $override->forced_points,
|
||||
'forced_penalty' => $override->forced_penalty,
|
||||
'reason' => $override->reason,
|
||||
] : null,
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'evaluation_run_id' => $evalRunId,
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizace logu (partial update).
|
||||
* Typicky pro ruční úpravu flagů accepted/processed, případně oprav hlavičky.
|
||||
* Autorizace přes LogPolicy@update.
|
||||
*/
|
||||
public function update(Request $request, Log $log): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $log);
|
||||
|
||||
$data = $this->validateData($request, partial: true);
|
||||
|
||||
$log->fill($data);
|
||||
$log->save();
|
||||
|
||||
$log->load(['round', 'file']);
|
||||
|
||||
return response()->json($log);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smazání logu (včetně QSO přes FK ON DELETE CASCADE).
|
||||
* Autorizace přes LogPolicy@delete.
|
||||
*/
|
||||
public function destroy(Log $log): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $log);
|
||||
|
||||
// pokud je navázaný soubor, smaž i jeho fyzický obsah a záznam
|
||||
if ($log->file) {
|
||||
if ($log->file->path && Storage::exists($log->file->path)) {
|
||||
Storage::delete($log->file->path);
|
||||
}
|
||||
$log->file->delete();
|
||||
}
|
||||
|
||||
$log->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Jednoduchý parser nahraného souboru – aktuálně podporuje EDI.
|
||||
* Pokud jde o EDI, naplní základní pole Logu a uloží raw_header (bez sekce QSORecords).
|
||||
*/
|
||||
public static function parseUploadedFile(Log $log, string $path): void
|
||||
{
|
||||
app(\App\Services\Evaluation\EdiParserService::class)->parseLogFile($log, $path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validace vstupu pro store / update.
|
||||
* EDI parser bude typicky volat store/update s již připravenými daty.
|
||||
*/
|
||||
protected function validateData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
|
||||
return $request->validate([
|
||||
'round_id' => [$required, 'integer', 'exists:rounds,id'],
|
||||
'file_id' => ['sometimes', 'nullable', 'integer', 'exists:files,id'],
|
||||
|
||||
'accepted' => ['sometimes', 'boolean'],
|
||||
'processed' => ['sometimes', 'boolean'],
|
||||
'ip_address' => ['sometimes', 'nullable', 'string', 'max:45'],
|
||||
|
||||
'tname' => ['sometimes', 'nullable', 'string', 'max:100'],
|
||||
'tdate' => ['sometimes', 'nullable', 'string', 'max:50'],
|
||||
'pcall' => ['sometimes', 'nullable', 'string', 'max:20'],
|
||||
'pwwlo' => ['sometimes', 'nullable', 'string', 'max:6'],
|
||||
'pexch' => ['sometimes', 'nullable', 'string', 'max:10'],
|
||||
'psect' => ['sometimes', 'nullable', 'string', 'max:10'],
|
||||
'pband' => ['sometimes', 'nullable', 'string', 'max:10'],
|
||||
'pclub' => ['sometimes', 'nullable', 'string', 'max:50'],
|
||||
|
||||
'country_name' => ['sometimes', 'nullable', 'string', 'max:150'],
|
||||
'operator_name' => ['sometimes', 'nullable', 'string', 'max:100'],
|
||||
'locator' => ['sometimes', 'nullable', 'string', 'max:6'],
|
||||
|
||||
'power_watt' => ['sometimes', 'nullable', 'numeric', 'min:0'],
|
||||
'power_category' => ['sometimes', 'nullable', 'string', 'max:3'],
|
||||
'power_category_id' => ['sometimes', 'nullable', 'integer', 'exists:power_categories,id'],
|
||||
'sixhr_category' => ['sometimes', 'nullable', 'boolean'],
|
||||
|
||||
'claimed_qso_count' => ['sometimes', 'nullable', 'integer', 'min:0'],
|
||||
'claimed_score' => ['sometimes', 'nullable', 'integer', 'min:0'],
|
||||
'claimed_wwl' => ['sometimes', 'nullable', 'string', 'max:50'],
|
||||
'claimed_dxcc' => ['sometimes', 'nullable', 'string', 'max:50'],
|
||||
|
||||
'remarks' => ['sometimes', 'nullable', 'string', 'max:500'],
|
||||
'remarks_eval' => ['sometimes', 'nullable', 'string', 'max:500'],
|
||||
|
||||
'raw_header' => ['sometimes', 'nullable', 'string'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
416
app/Http/Controllers/LogOverrideController.php
Normal file
416
app/Http/Controllers/LogOverrideController.php
Normal file
@@ -0,0 +1,416 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Band;
|
||||
use App\Models\EdiBand;
|
||||
use App\Models\EdiCategory;
|
||||
use App\Models\LogOverride;
|
||||
use App\Models\LogResult;
|
||||
use App\Models\Round;
|
||||
use App\Models\EvaluationRun;
|
||||
use App\Jobs\RecalculateOfficialRanksJob;
|
||||
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;
|
||||
|
||||
class LogOverrideController 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 override záznamů – lze filtrovat podle evaluation_run_id/log_id.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 100);
|
||||
|
||||
$query = LogOverride::query()
|
||||
->with([
|
||||
'evaluationRun',
|
||||
'log',
|
||||
'forcedBand',
|
||||
'forcedCategory',
|
||||
'forcedPowerCategory',
|
||||
'createdByUser',
|
||||
]);
|
||||
|
||||
if ($request->filled('evaluation_run_id')) {
|
||||
$query->where('evaluation_run_id', (int) $request->get('evaluation_run_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('log_id')) {
|
||||
$query->where('log_id', (int) $request->get('log_id'));
|
||||
}
|
||||
|
||||
$items = $query->orderByDesc('id')->paginate($perPage);
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoření override záznamu.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', LogOverride::class);
|
||||
|
||||
$data = $this->validateData($request);
|
||||
$data['context'] = $this->mergeOriginalContext($data['context'] ?? null, $data['evaluation_run_id'], $data['log_id']);
|
||||
if (! isset($data['created_by_user_id']) && $request->user()) {
|
||||
$data['created_by_user_id'] = $request->user()->id;
|
||||
}
|
||||
|
||||
$item = LogOverride::create($data);
|
||||
$this->applyOverrideToLogResult($item);
|
||||
$statusChanged = array_key_exists('forced_log_status', $data);
|
||||
if ($this->shouldRecalculateRanks($item->evaluation_run_id, $statusChanged)) {
|
||||
RecalculateOfficialRanksJob::dispatch($item->evaluation_run_id)->onQueue('evaluation');
|
||||
}
|
||||
$item->load([
|
||||
'evaluationRun',
|
||||
'log',
|
||||
'forcedBand',
|
||||
'forcedCategory',
|
||||
'forcedPowerCategory',
|
||||
'createdByUser',
|
||||
]);
|
||||
|
||||
return response()->json($item, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail override záznamu.
|
||||
*/
|
||||
public function show(LogOverride $logOverride): JsonResponse
|
||||
{
|
||||
$logOverride->load([
|
||||
'evaluationRun',
|
||||
'log',
|
||||
'forcedBand',
|
||||
'forcedCategory',
|
||||
'forcedPowerCategory',
|
||||
'createdByUser',
|
||||
]);
|
||||
|
||||
return response()->json($logOverride);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizace override záznamu.
|
||||
*/
|
||||
public function update(Request $request, LogOverride $logOverride): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $logOverride);
|
||||
|
||||
$data = $this->validateData($request, partial: true);
|
||||
if (! array_key_exists('context', $data)) {
|
||||
$data['context'] = $this->mergeOriginalContext($logOverride->context, $logOverride->evaluation_run_id, $logOverride->log_id);
|
||||
} else {
|
||||
$data['context'] = $this->mergeOriginalContext($data['context'], $logOverride->evaluation_run_id, $logOverride->log_id);
|
||||
}
|
||||
$statusChanged = array_key_exists('forced_log_status', $data);
|
||||
|
||||
$logOverride->fill($data);
|
||||
$logOverride->save();
|
||||
$this->applyOverrideToLogResult($logOverride);
|
||||
if ($this->shouldRecalculateRanks($logOverride->evaluation_run_id, $statusChanged)) {
|
||||
RecalculateOfficialRanksJob::dispatch($logOverride->evaluation_run_id)->onQueue('evaluation');
|
||||
}
|
||||
|
||||
$logOverride->load([
|
||||
'evaluationRun',
|
||||
'log',
|
||||
'forcedBand',
|
||||
'forcedCategory',
|
||||
'forcedPowerCategory',
|
||||
'createdByUser',
|
||||
]);
|
||||
|
||||
return response()->json($logOverride);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smazání override záznamu.
|
||||
*/
|
||||
public function destroy(LogOverride $logOverride): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $logOverride);
|
||||
|
||||
$log = $logOverride->log;
|
||||
$round = $log ? Round::with(['bands', 'categories', 'powerCategories'])->find($log->round_id) : null;
|
||||
$bandId = $log && $round ? $this->resolveBandId($log, $round) : null;
|
||||
$categoryId = $log && $round ? $this->resolveCategoryId($log, $round) : null;
|
||||
$powerCategoryId = $log?->power_category_id;
|
||||
|
||||
$logOverride->delete();
|
||||
LogResult::where('evaluation_run_id', $logOverride->evaluation_run_id)
|
||||
->where('log_id', $logOverride->log_id)
|
||||
->update([
|
||||
'status' => 'OK',
|
||||
'band_id' => $bandId,
|
||||
'category_id' => $categoryId,
|
||||
'power_category_id' => $powerCategoryId,
|
||||
'sixhr_category' => $log?->sixhr_category,
|
||||
]);
|
||||
$statusChanged = $logOverride->forced_log_status && $logOverride->forced_log_status !== 'AUTO';
|
||||
if ($this->shouldRecalculateRanks($logOverride->evaluation_run_id, $statusChanged)) {
|
||||
RecalculateOfficialRanksJob::dispatch($logOverride->evaluation_run_id)->onQueue('evaluation');
|
||||
}
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
protected function resolveCategoryId(\App\Models\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 resolveBandId(\App\Models\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;
|
||||
}
|
||||
|
||||
protected function shouldRecalculateRanks(int $evaluationRunId, bool $statusChanged): bool
|
||||
{
|
||||
$run = EvaluationRun::find($evaluationRunId);
|
||||
if (! $run) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($run->status === 'SUCCEEDED') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ve WAITING_REVIEW_SCORE řešíme jen změny statutu (DQ/IGNORED/OK/CHECK),
|
||||
// aby se pořadí hned přepočítalo bez ručního pokračování pipeline.
|
||||
return $run->status === 'WAITING_REVIEW_SCORE' && $statusChanged;
|
||||
}
|
||||
|
||||
protected function validateData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
|
||||
return $request->validate([
|
||||
'evaluation_run_id' => [$required, 'integer', 'exists:evaluation_runs,id'],
|
||||
'log_id' => [$required, 'integer', 'exists:logs,id'],
|
||||
|
||||
'forced_log_status' => ['sometimes', 'string', 'in:AUTO,OK,CHECK,DQ,IGNORED'],
|
||||
|
||||
'forced_band_id' => ['sometimes', 'nullable', 'integer', 'exists:bands,id'],
|
||||
'forced_category_id' => ['sometimes', 'nullable', 'integer', 'exists:categories,id'],
|
||||
'forced_power_category_id' => ['sometimes', 'nullable', 'integer', 'exists:power_categories,id'],
|
||||
'forced_sixhr_category' => ['sometimes', 'nullable', 'boolean'],
|
||||
|
||||
'forced_power_w' => ['sometimes', 'nullable', 'integer', 'min:0'],
|
||||
|
||||
'reason' => ['sometimes', 'nullable', 'string', 'max:500'],
|
||||
'context' => ['sometimes', 'nullable', 'array'],
|
||||
|
||||
'created_by_user_id' => ['sometimes', 'nullable', 'integer', 'exists:users,id'],
|
||||
]);
|
||||
}
|
||||
|
||||
protected function applyOverrideToLogResult(LogOverride $override): void
|
||||
{
|
||||
$data = [];
|
||||
|
||||
if ($override->forced_log_status && $override->forced_log_status !== 'AUTO') {
|
||||
$data['status'] = $override->forced_log_status;
|
||||
if (in_array($override->forced_log_status, ['DQ', 'IGNORED', 'CHECK'], true)) {
|
||||
$data['rank_overall'] = null;
|
||||
$data['rank_in_category'] = null;
|
||||
$data['rank_overall_ok'] = null;
|
||||
$data['rank_in_category_ok'] = null;
|
||||
$data['status_reason'] = null;
|
||||
$data['official_score'] = 0;
|
||||
$data['penalty_score'] = 0;
|
||||
$data['base_score'] = 0;
|
||||
$data['multiplier_count'] = 0;
|
||||
$data['multiplier_score'] = 0;
|
||||
$data['valid_qso_count'] = 0;
|
||||
$data['dupe_qso_count'] = 0;
|
||||
$data['busted_qso_count'] = 0;
|
||||
$data['other_error_qso_count'] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
$this->resetLogResultToSource($override);
|
||||
return;
|
||||
}
|
||||
|
||||
LogResult::where('evaluation_run_id', $override->evaluation_run_id)
|
||||
->where('log_id', $override->log_id)
|
||||
->update($data);
|
||||
}
|
||||
|
||||
protected function resetLogResultToSource(LogOverride $override): void
|
||||
{
|
||||
$log = $override->log;
|
||||
if (! $log) {
|
||||
return;
|
||||
}
|
||||
$round = Round::with(['bands', 'categories', 'powerCategories'])->find($log->round_id);
|
||||
if (! $round) {
|
||||
return;
|
||||
}
|
||||
|
||||
$bandId = $this->resolveBandId($log, $round);
|
||||
$categoryId = $this->resolveCategoryId($log, $round);
|
||||
$powerCategoryId = $log->power_category_id;
|
||||
|
||||
LogResult::where('evaluation_run_id', $override->evaluation_run_id)
|
||||
->where('log_id', $override->log_id)
|
||||
->update([
|
||||
'status' => 'OK',
|
||||
'status_reason' => null,
|
||||
'band_id' => $bandId,
|
||||
'category_id' => $categoryId,
|
||||
'power_category_id' => $powerCategoryId,
|
||||
'sixhr_category' => $log->sixhr_category,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function mergeOriginalContext(?array $context, int $evaluationRunId, int $logId): array
|
||||
{
|
||||
$context = $context ?? [];
|
||||
if (isset($context['original']) && is_array($context['original'])) {
|
||||
return $context;
|
||||
}
|
||||
|
||||
$context['original'] = $this->snapshotLogResult($evaluationRunId, $logId);
|
||||
return $context;
|
||||
}
|
||||
|
||||
protected function snapshotLogResult(int $evaluationRunId, int $logId): array
|
||||
{
|
||||
$result = LogResult::where('evaluation_run_id', $evaluationRunId)
|
||||
->where('log_id', $logId)
|
||||
->first();
|
||||
if (! $result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => $result->status,
|
||||
'band_id' => $result->band_id,
|
||||
'category_id' => $result->category_id,
|
||||
'power_category_id' => $result->power_category_id,
|
||||
'sixhr_category' => $result->sixhr_category,
|
||||
];
|
||||
}
|
||||
}
|
||||
171
app/Http/Controllers/LogQsoController.php
Normal file
171
app/Http/Controllers/LogQsoController.php
Normal file
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\LogQso;
|
||||
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;
|
||||
|
||||
class LogQsoController 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 QSO – s filtrováním podle log_id, round_id, band, call_like, dx_call.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 100);
|
||||
|
||||
$query = LogQso::query()
|
||||
->with('log');
|
||||
|
||||
if ($request->filled('log_id')) {
|
||||
$query->where('log_id', (int) $request->get('log_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('round_id')) {
|
||||
$roundId = (int) $request->get('round_id');
|
||||
$query->whereHas('log', function ($q) use ($roundId) {
|
||||
$q->where('round_id', $roundId);
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('band')) {
|
||||
$query->where('band', $request->get('band'));
|
||||
}
|
||||
|
||||
if ($request->filled('call_like')) {
|
||||
$raw = strtoupper((string) $request->get('call_like'));
|
||||
$pattern = str_replace(['*', '?'], ['%', '_'], $raw);
|
||||
if (strpos($pattern, '%') === false && strpos($pattern, '_') === false) {
|
||||
$pattern = '%' . $pattern . '%';
|
||||
}
|
||||
$query->where(function ($q) use ($pattern) {
|
||||
$q->whereRaw('UPPER(my_call) LIKE ?', [$pattern])
|
||||
->orWhereRaw('UPPER(dx_call) LIKE ?', [$pattern]);
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('dx_call')) {
|
||||
$query->where('dx_call', $request->get('dx_call'));
|
||||
}
|
||||
|
||||
if ($request->filled('exclude_log_id')) {
|
||||
$query->where('log_id', '!=', (int) $request->get('exclude_log_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('exclude_log_qso_id')) {
|
||||
$query->where('id', '!=', (int) $request->get('exclude_log_qso_id'));
|
||||
}
|
||||
|
||||
$items = $query
|
||||
->orderBy('log_id')
|
||||
->orderBy('qso_index')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoření QSO řádku.
|
||||
* Typicky voláno parserem EDI, ne přímo z UI.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', LogQso::class);
|
||||
|
||||
$data = $this->validateData($request);
|
||||
|
||||
$item = LogQso::create($data);
|
||||
|
||||
$item->load('log');
|
||||
|
||||
return response()->json($item, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail jednoho QSO řádku.
|
||||
*/
|
||||
public function show(LogQso $logQso): JsonResponse
|
||||
{
|
||||
$logQso->load('log');
|
||||
|
||||
return response()->json($logQso);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizace QSO (partial update).
|
||||
* Praktické pro ruční korekce / debug.
|
||||
*/
|
||||
public function update(Request $request, LogQso $logQso): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $logQso);
|
||||
|
||||
$data = $this->validateData($request, partial: true);
|
||||
|
||||
$logQso->fill($data);
|
||||
$logQso->save();
|
||||
|
||||
$logQso->load('log');
|
||||
|
||||
return response()->json($logQso);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smazání QSO.
|
||||
*/
|
||||
public function destroy(LogQso $logQso): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $logQso);
|
||||
|
||||
$logQso->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validace pro store / update.
|
||||
*/
|
||||
protected function validateData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
|
||||
return $request->validate([
|
||||
'log_id' => [$required, 'integer', 'exists:logs,id'],
|
||||
'qso_index' => ['sometimes', 'nullable', 'integer', 'min:0'],
|
||||
|
||||
'time_on' => ['sometimes', 'nullable', 'date'],
|
||||
'band' => ['sometimes', 'nullable', 'string', 'max:10'],
|
||||
'freq_khz' => ['sometimes', 'nullable', 'integer', 'min:0'],
|
||||
'mode' => ['sometimes', 'nullable', 'string', 'max:5'],
|
||||
|
||||
'my_call' => ['sometimes', 'nullable', 'string', 'max:20'],
|
||||
'my_rst' => ['sometimes', 'nullable', 'string', 'max:10'],
|
||||
'my_serial' => ['sometimes', 'nullable', 'string', 'max:10'],
|
||||
'my_locator' => ['sometimes', 'nullable', 'string', 'max:6'],
|
||||
|
||||
'dx_call' => ['sometimes', 'nullable', 'string', 'max:20'],
|
||||
'dx_rst' => ['sometimes', 'nullable', 'string', 'max:10'],
|
||||
'dx_serial' => ['sometimes', 'nullable', 'string', 'max:10'],
|
||||
'dx_locator' => ['sometimes', 'nullable', 'string', 'max:6'],
|
||||
|
||||
'points' => ['sometimes', 'nullable', 'integer'],
|
||||
'wwl' => ['sometimes', 'nullable', 'string', 'max:6'],
|
||||
'dxcc' => ['sometimes', 'nullable', 'string', 'max:10'],
|
||||
'is_duplicate'=> ['sometimes', 'boolean'],
|
||||
'is_valid' => ['sometimes', 'boolean'],
|
||||
|
||||
'raw_line' => ['sometimes', 'nullable', 'string', 'max:500'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
239
app/Http/Controllers/LogResultController.php
Normal file
239
app/Http/Controllers/LogResultController.php
Normal file
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\LogResult;
|
||||
use App\Models\EvaluationRun;
|
||||
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 Illuminate\Support\Str;
|
||||
|
||||
class LogResultController 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 výsledků logů – filtrování podle evaluation_run_id,
|
||||
* log_id, band_id, category_id, status.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 100);
|
||||
$statusParam = $request->get('status');
|
||||
$isClaimedRequest = $statusParam === 'CLAIMED';
|
||||
|
||||
$query = LogResult::query()
|
||||
->with([
|
||||
'evaluationRun.ruleSet:id,sixhr_ranking_mode',
|
||||
'log',
|
||||
'band:id,name,order',
|
||||
'category:id,name,order',
|
||||
'powerCategory:id,name,order',
|
||||
]);
|
||||
|
||||
if ($request->filled('evaluation_run_id')) {
|
||||
$query->where('evaluation_run_id', (int) $request->get('evaluation_run_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('round_id')) {
|
||||
$roundId = (int) $request->get('round_id');
|
||||
$query->whereHas('log', function ($q) use ($roundId) {
|
||||
$q->where('round_id', $roundId);
|
||||
});
|
||||
if (! $request->filled('evaluation_run_id') && $request->filled('result_type')) {
|
||||
$round = Round::find($roundId);
|
||||
$resultType = strtoupper((string) $request->get('result_type'));
|
||||
$selectedRunId = null;
|
||||
if ($round) {
|
||||
if ($resultType === 'FINAL') {
|
||||
$selectedRunId = $round->official_evaluation_run_id;
|
||||
} elseif ($resultType === 'PRELIMINARY') {
|
||||
$selectedRunId = $round->preliminary_evaluation_run_id;
|
||||
} elseif ($resultType === 'TEST') {
|
||||
$selectedRunId = $round->test_evaluation_run_id;
|
||||
} elseif ($resultType === 'AUTO') {
|
||||
$selectedRunId = $round->official_evaluation_run_id
|
||||
?? $round->preliminary_evaluation_run_id;
|
||||
}
|
||||
}
|
||||
if ($selectedRunId) {
|
||||
$query->where('evaluation_run_id', $selectedRunId);
|
||||
} else {
|
||||
$query->whereRaw('1=0');
|
||||
}
|
||||
}
|
||||
if (! $request->filled('evaluation_run_id') && $isClaimedRequest) {
|
||||
$latestClaimedRunId = EvaluationRun::where('round_id', $roundId)
|
||||
->where('rules_version', 'CLAIMED')
|
||||
->orderByDesc('id')
|
||||
->value('id');
|
||||
if ($latestClaimedRunId) {
|
||||
$query->where('evaluation_run_id', $latestClaimedRunId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->filled('log_id')) {
|
||||
$query->where('log_id', (int) $request->get('log_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('band_id')) {
|
||||
$query->where('band_id', (int) $request->get('band_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('category_id')) {
|
||||
$query->where('category_id', (int) $request->get('category_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('status') && ! $isClaimedRequest) {
|
||||
$query->where('status', $statusParam);
|
||||
}
|
||||
|
||||
if ($request->boolean('only_ok', false)) {
|
||||
$pcallExpr = "UPPER(REPLACE(TRIM(pcall), ' ', ''))";
|
||||
$query->whereHas('log', function ($q) use ($pcallExpr) {
|
||||
$q->where(function ($sub) use ($pcallExpr) {
|
||||
$sub->whereRaw("{$pcallExpr} LIKE ?", ['OK%'])
|
||||
->orWhereRaw("{$pcallExpr} LIKE ?", ['OL%'])
|
||||
->orWhereRaw("{$pcallExpr} LIKE ?", ['%/OK%'])
|
||||
->orWhereRaw("{$pcallExpr} LIKE ?", ['%/OL%']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// implicitně řadit podle oficiálního skóre
|
||||
$items = $query
|
||||
->orderByDesc('official_score')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoření záznamu výsledku logu.
|
||||
* Typicky voláno vyhodnocovačem, ne přímo z UI.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', LogResult::class);
|
||||
|
||||
$data = $this->validateData($request);
|
||||
|
||||
$result = LogResult::create($data);
|
||||
|
||||
$result->load([
|
||||
'evaluationRun.ruleSet:id,sixhr_ranking_mode',
|
||||
'log',
|
||||
'band:id,name,order',
|
||||
'category:id,name,order',
|
||||
'powerCategory:id,name,order',
|
||||
]);
|
||||
|
||||
return response()->json($result, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail jednoho výsledku.
|
||||
*/
|
||||
public function show(LogResult $logResult): JsonResponse
|
||||
{
|
||||
$logResult->load([
|
||||
'evaluationRun.ruleSet:id,sixhr_ranking_mode',
|
||||
'log',
|
||||
'band:id,name,order',
|
||||
'category:id,name,order',
|
||||
'powerCategory:id,name,order',
|
||||
]);
|
||||
|
||||
return response()->json($logResult);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizace výsledku (partial).
|
||||
* Typicky pro ruční korekci statutu / poznámky.
|
||||
*/
|
||||
public function update(Request $request, LogResult $logResult): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $logResult);
|
||||
|
||||
$data = $this->validateData($request, partial: true);
|
||||
|
||||
$logResult->fill($data);
|
||||
$logResult->save();
|
||||
|
||||
$logResult->load([
|
||||
'evaluationRun.ruleSet:id,sixhr_ranking_mode',
|
||||
'log',
|
||||
'band:id,name,order',
|
||||
'category:id,name,order',
|
||||
'powerCategory:id,name,order',
|
||||
]);
|
||||
|
||||
return response()->json($logResult);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smazání výsledku.
|
||||
*/
|
||||
public function destroy(LogResult $logResult): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $logResult);
|
||||
|
||||
$logResult->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validace pro store / update.
|
||||
*/
|
||||
protected function validateData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
|
||||
return $request->validate([
|
||||
'evaluation_run_id' => [$required, 'integer', 'exists:evaluation_runs,id'],
|
||||
'log_id' => [$required, 'integer', 'exists:logs,id'],
|
||||
|
||||
'band_id' => ['sometimes', 'nullable', 'integer', 'exists:bands,id'],
|
||||
'category_id' => ['sometimes', 'nullable', 'integer', 'exists:categories,id'],
|
||||
'power_category_id' => ['sometimes', 'nullable', 'integer', 'exists:power_categories,id'],
|
||||
|
||||
'claimed_qso_count' => ['sometimes', 'nullable', 'integer', 'min:0'],
|
||||
'claimed_score' => ['sometimes', 'nullable', 'integer', 'min:0'],
|
||||
|
||||
'valid_qso_count' => ['sometimes', 'integer', 'min:0'],
|
||||
'dupe_qso_count' => ['sometimes', 'integer', 'min:0'],
|
||||
'busted_qso_count' => ['sometimes', 'integer', 'min:0'],
|
||||
'other_error_qso_count' => ['sometimes', 'integer', 'min:0'],
|
||||
'total_qso_count' => ['sometimes', 'integer', 'min:0'],
|
||||
'discarded_qso_count' => ['sometimes', 'integer', 'min:0'],
|
||||
'discarded_points' => ['sometimes', 'integer'],
|
||||
'discarded_qso_percent' => ['sometimes', 'numeric', 'min:0'],
|
||||
'unique_qso_count' => ['sometimes', 'integer', 'min:0'],
|
||||
|
||||
'official_score' => ['sometimes', 'integer'],
|
||||
'penalty_score' => ['sometimes', 'integer'],
|
||||
'base_score' => ['sometimes', 'integer'],
|
||||
'multiplier_count' => ['sometimes', 'integer', 'min:0'],
|
||||
'multiplier_score' => ['sometimes', 'integer'],
|
||||
'score_per_qso' => ['sometimes', 'numeric', 'min:0'],
|
||||
|
||||
'rank_overall' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
||||
'rank_in_category' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
||||
|
||||
'status' => ['sometimes', 'string', 'max:20'],
|
||||
'status_reason' => ['sometimes', 'nullable', 'string'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
39
app/Http/Controllers/LoginController.php
Normal file
39
app/Http/Controllers/LoginController.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
public function authenticate(Request $request)
|
||||
{
|
||||
$credentials = $request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required'],
|
||||
'remember' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
|
||||
$remember = $request->boolean('remember', false);
|
||||
|
||||
if (Auth::attempt([
|
||||
'email' => $credentials['email'],
|
||||
'password' => $credentials['password'],
|
||||
'is_active' => true,
|
||||
], $remember)) {
|
||||
if ($request->hasSession()) {
|
||||
$request->session()->regenerate();
|
||||
}
|
||||
$user = Auth::user();
|
||||
|
||||
return response()->json($user);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'errors' => [
|
||||
'email' => 'The provided credentials do not match our records.',
|
||||
]
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
309
app/Http/Controllers/NewsPostController.php
Normal file
309
app/Http/Controllers/NewsPostController.php
Normal file
@@ -0,0 +1,309 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\NewsPost;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class NewsPostController extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// zápis jen pro přihlášené (admin policy vyřešíš přes Policy)
|
||||
$this->middleware('auth:sanctum')->only(['store', 'update', 'destroy']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*
|
||||
* Podporuje volitelný dotazový parametr ?lang=cs / ?lang=en
|
||||
* Pokud je lang zadán, title/content/excerpt budou vráceny jen v daném jazyce.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 10);
|
||||
$limit = (int) $request->get('limit', 0);
|
||||
$includeUnpublished = $request->boolean('include_unpublished', false);
|
||||
|
||||
// volitelný jazyk – pokud není, použije se app locale
|
||||
$lang = $request->query('lang');
|
||||
if (! is_string($lang) || $lang === '') {
|
||||
$lang = app()->getLocale();
|
||||
}
|
||||
|
||||
$query = NewsPost::query()
|
||||
->orderByDesc('published_at');
|
||||
|
||||
if (! $includeUnpublished) {
|
||||
$query->where('is_published', true)
|
||||
->whereNotNull('published_at')
|
||||
->where('published_at', '<=', now());
|
||||
}
|
||||
|
||||
if ($limit > 0) {
|
||||
$items = $query->limit($limit)->get();
|
||||
} else {
|
||||
$items = $query->paginate($perPage);
|
||||
}
|
||||
|
||||
$mapTranslation = function (NewsPost $post) use ($lang) {
|
||||
$data = $post->toArray();
|
||||
|
||||
// getTranslation(attr, lang, useFallback=true)
|
||||
$data['title'] = $post->getTranslation('title', $lang, true);
|
||||
$data['content'] = $post->getTranslation('content', $lang, true);
|
||||
$data['excerpt'] = $post->getTranslation('excerpt', $lang, true);
|
||||
|
||||
return $data;
|
||||
};
|
||||
|
||||
if ($limit > 0) {
|
||||
$items = $items->map($mapTranslation);
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
$items->getCollection()->transform($mapTranslation);
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail novinky (přes slug).
|
||||
* Public – ale jen pokud je publikovaná, jinak 404.
|
||||
*/
|
||||
public function show(NewsPost $news): JsonResponse
|
||||
{
|
||||
if (
|
||||
! $news->is_published ||
|
||||
! $news->published_at ||
|
||||
$news->published_at->isFuture()
|
||||
) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return response()->json($news);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoření novinky (admin).
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', NewsPost::class);
|
||||
|
||||
$data = $this->validateData($request);
|
||||
|
||||
if (empty($data['slug'])) {
|
||||
$data['slug'] = $this->makeSlugFromTitle($data['title'] ?? null);
|
||||
}
|
||||
|
||||
if (! empty($data['is_published']) && empty($data['published_at'])) {
|
||||
$data['published_at'] = now();
|
||||
}
|
||||
|
||||
$data['author_id'] = $request->user()?->id;
|
||||
|
||||
$news = NewsPost::create($data);
|
||||
|
||||
return response()->json($news, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizace novinky (admin).
|
||||
*/
|
||||
public function update(Request $request, NewsPost $news): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $news);
|
||||
|
||||
$data = $this->validateData($request, partial: true);
|
||||
|
||||
// pokud přišla změna title a není explicitně zadaný slug, dopočítej ho
|
||||
if (
|
||||
array_key_exists('title', $data) &&
|
||||
(! array_key_exists('slug', $data) || empty($data['slug']))
|
||||
) {
|
||||
$generated = $this->makeSlugFromTitle($data['title']);
|
||||
if ($generated !== null) {
|
||||
$data['slug'] = $generated;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
array_key_exists('is_published', $data) &&
|
||||
$data['is_published'] &&
|
||||
empty($data['published_at'])
|
||||
) {
|
||||
$data['published_at'] = $news->published_at ?? now();
|
||||
}
|
||||
|
||||
$news->fill($data);
|
||||
$news->save();
|
||||
|
||||
return response()->json($news);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smazání novinky (admin).
|
||||
*/
|
||||
public function destroy(NewsPost $news): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $news);
|
||||
|
||||
$news->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validace dat.
|
||||
*
|
||||
* Podporuje:
|
||||
* - string hodnoty (jednotlivý překlad pro aktuální locale)
|
||||
* - pole překladů: { "cs": "...", "en": "..." }
|
||||
*/
|
||||
protected function validateData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
|
||||
/** @var NewsPost|null $routeNews */
|
||||
$routeNews = $request->route('news'); // může být null, nebo model díky route model bindingu
|
||||
|
||||
$rules = [
|
||||
'title' => [
|
||||
$required,
|
||||
function (string $attribute, $value, \Closure $fail) {
|
||||
if (is_string($value)) {
|
||||
if (mb_strlen($value) > 255) {
|
||||
$fail('The '.$attribute.' may not be greater than 255 characters.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
foreach ($value as $locale => $text) {
|
||||
if (! is_string($text)) {
|
||||
$fail("The {$attribute}.{$locale} must be a string.");
|
||||
return;
|
||||
}
|
||||
if (mb_strlen($text) > 255) {
|
||||
$fail("The {$attribute}.{$locale} may not be greater than 255 characters.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$fail('The '.$attribute.' must be a string or an array of translated strings.');
|
||||
},
|
||||
],
|
||||
|
||||
'slug' => [
|
||||
'sometimes',
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('news_posts', 'slug')->ignore($routeNews?->getKey()),
|
||||
],
|
||||
|
||||
'content' => [
|
||||
$required,
|
||||
function (string $attribute, $value, \Closure $fail) {
|
||||
if (is_string($value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
foreach ($value as $locale => $text) {
|
||||
if (! is_string($text)) {
|
||||
$fail("The {$attribute}.{$locale} must be a string.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$fail('The '.$attribute.' must be a string or an array of translated strings.');
|
||||
},
|
||||
],
|
||||
|
||||
'excerpt' => [
|
||||
'sometimes',
|
||||
function (string $attribute, $value, \Closure $fail) {
|
||||
if ($value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
if (mb_strlen($value) > 500) {
|
||||
$fail('The '.$attribute.' may not be greater than 500 characters.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
foreach ($value as $locale => $text) {
|
||||
if (! is_string($text)) {
|
||||
$fail("The {$attribute}.{$locale} must be a string.");
|
||||
return;
|
||||
}
|
||||
if (mb_strlen($text) > 500) {
|
||||
$fail("The {$attribute}.{$locale} may not be greater than 500 characters.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$fail('The '.$attribute.' must be null, a string, or an array of translated strings.');
|
||||
},
|
||||
],
|
||||
|
||||
'is_published' => ['sometimes', 'boolean'],
|
||||
'published_at' => ['sometimes', 'nullable', 'date'],
|
||||
];
|
||||
|
||||
return $request->validate($rules);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoří slug z titulku – umí pracovat jak se stringem, tak s polem překladů.
|
||||
*
|
||||
* - pokud je $title string → slug z něj
|
||||
* - pokud je $title array → použije se:
|
||||
* title[aktuální_locale] || title['en'] || první dostupná hodnota
|
||||
*/
|
||||
protected function makeSlugFromTitle(string|array|null $title): ?string
|
||||
{
|
||||
if ($title === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_array($title)) {
|
||||
$locale = app()->getLocale();
|
||||
|
||||
$base = $title[$locale]
|
||||
?? $title['en']
|
||||
?? reset($title);
|
||||
|
||||
if (! is_string($base) || $base === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Str::slug($base);
|
||||
}
|
||||
|
||||
if ($title === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Str::slug($title);
|
||||
}
|
||||
}
|
||||
101
app/Http/Controllers/PowerCategoryController.php
Normal file
101
app/Http/Controllers/PowerCategoryController.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\PowerCategory;
|
||||
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;
|
||||
|
||||
class PowerCategoryController extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// store / update / destroy pro přihlášené
|
||||
$this->middleware('auth:sanctum')->only(['store', 'update', 'destroy']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seznam power kategorií.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 50);
|
||||
|
||||
$items = PowerCategory::query()
|
||||
->orderBy('order')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoření nové power kategorie.
|
||||
* Autorizace přes PowerCategoryPolicy@create.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', PowerCategory::class);
|
||||
|
||||
$data = $this->validateData($request);
|
||||
|
||||
$item = PowerCategory::create($data);
|
||||
|
||||
return response()->json($item, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail power kategorie.
|
||||
*/
|
||||
public function show(PowerCategory $power_category): JsonResponse
|
||||
{
|
||||
return response()->json($power_category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizace power kategorie (partial update).
|
||||
* Autorizace přes PowerCategoryPolicy@update.
|
||||
*/
|
||||
public function update(Request $request, PowerCategory $power_category): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $power_category);
|
||||
|
||||
$data = $this->validateData($request, partial: true);
|
||||
|
||||
$power_category->fill($data);
|
||||
$power_category->save();
|
||||
|
||||
return response()->json($power_category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smazání power kategorie.
|
||||
* Autorizace přes PowerCategoryPolicy@delete.
|
||||
*/
|
||||
public function destroy(PowerCategory $power_category): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $power_category);
|
||||
|
||||
$power_category->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validace dat pro store / update.
|
||||
*/
|
||||
protected function validateData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
|
||||
return $request->validate([
|
||||
'name' => [$required, 'string', 'max:255'],
|
||||
'order' => [$required, 'integer'],
|
||||
'power_level' => [$required, 'integer'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
126
app/Http/Controllers/QsoOverrideController.php
Normal file
126
app/Http/Controllers/QsoOverrideController.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\QsoOverride;
|
||||
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;
|
||||
|
||||
class QsoOverrideController 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 override záznamů – lze filtrovat podle evaluation_run_id/log_qso_id.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 100);
|
||||
|
||||
$query = QsoOverride::query()
|
||||
->with(['evaluationRun', 'logQso', 'forcedMatchedLogQso', 'createdByUser']);
|
||||
|
||||
if ($request->filled('evaluation_run_id')) {
|
||||
$query->where('evaluation_run_id', (int) $request->get('evaluation_run_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('log_qso_id')) {
|
||||
$query->where('log_qso_id', (int) $request->get('log_qso_id'));
|
||||
}
|
||||
|
||||
$items = $query->orderByDesc('id')->paginate($perPage);
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoření override záznamu.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', QsoOverride::class);
|
||||
|
||||
$data = $this->validateData($request);
|
||||
if (! isset($data['created_by_user_id']) && $request->user()) {
|
||||
$data['created_by_user_id'] = $request->user()->id;
|
||||
}
|
||||
|
||||
$item = QsoOverride::create($data);
|
||||
$item->load(['evaluationRun', 'logQso', 'forcedMatchedLogQso', 'createdByUser']);
|
||||
|
||||
return response()->json($item, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail override záznamu.
|
||||
*/
|
||||
public function show(QsoOverride $qsoOverride): JsonResponse
|
||||
{
|
||||
$qsoOverride->load(['evaluationRun', 'logQso', 'forcedMatchedLogQso', 'createdByUser']);
|
||||
|
||||
return response()->json($qsoOverride);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizace override záznamu.
|
||||
*/
|
||||
public function update(Request $request, QsoOverride $qsoOverride): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $qsoOverride);
|
||||
|
||||
$data = $this->validateData($request, partial: true);
|
||||
|
||||
$qsoOverride->fill($data);
|
||||
$qsoOverride->save();
|
||||
|
||||
$qsoOverride->load(['evaluationRun', 'logQso', 'forcedMatchedLogQso', 'createdByUser']);
|
||||
|
||||
return response()->json($qsoOverride);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smazání override záznamu.
|
||||
*/
|
||||
public function destroy(QsoOverride $qsoOverride): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $qsoOverride);
|
||||
|
||||
$qsoOverride->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
protected function validateData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
|
||||
return $request->validate([
|
||||
'evaluation_run_id' => [$required, 'integer', 'exists:evaluation_runs,id'],
|
||||
'log_qso_id' => [$required, 'integer', 'exists:log_qsos,id'],
|
||||
|
||||
'forced_matched_log_qso_id' => ['sometimes', 'nullable', 'integer', 'exists:log_qsos,id'],
|
||||
'forced_status' => [
|
||||
'sometimes',
|
||||
'string',
|
||||
'in:AUTO,VALID,INVALID,NIL,DUPLICATE,BUSTED_CALL,BUSTED_EXCHANGE,OUT_OF_WINDOW',
|
||||
],
|
||||
|
||||
'forced_points' => ['sometimes', 'nullable', 'numeric'],
|
||||
'forced_penalty' => ['sometimes', 'nullable', 'numeric'],
|
||||
|
||||
'reason' => ['sometimes', 'nullable', 'string', 'max:500'],
|
||||
'context' => ['sometimes', 'nullable', 'array'],
|
||||
|
||||
'created_by_user_id' => ['sometimes', 'nullable', 'integer', 'exists:users,id'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
272
app/Http/Controllers/QsoResultController.php
Normal file
272
app/Http/Controllers/QsoResultController.php
Normal file
@@ -0,0 +1,272 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\QsoResult;
|
||||
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;
|
||||
|
||||
class QsoResultController extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// zápis pouze pro autentizované
|
||||
$this->middleware('auth:sanctum')->only(['store', 'update', 'destroy']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seznam QSO výsledků.
|
||||
* Filtrování podle evaluation_run_id, log_qso_id, log_id, call_like, matched_qso_id,
|
||||
* error_code, is_valid, is_duplicate, is_nil, only_ok.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = (int) $request->get('per_page', 200);
|
||||
|
||||
$evalRunId = $request->filled('evaluation_run_id')
|
||||
? (int) $request->get('evaluation_run_id')
|
||||
: null;
|
||||
|
||||
$query = QsoResult::query()
|
||||
->with(['evaluationRun', 'logQso', 'matchedQso'])
|
||||
->when($evalRunId, function ($q) use ($evalRunId) {
|
||||
$q->with(['workingQso' => function ($wq) use ($evalRunId) {
|
||||
$wq->where('evaluation_run_id', $evalRunId);
|
||||
}]);
|
||||
});
|
||||
|
||||
if ($evalRunId !== null) {
|
||||
$query->where('evaluation_run_id', $evalRunId);
|
||||
}
|
||||
|
||||
if ($request->filled('log_qso_id')) {
|
||||
$query->where('log_qso_id', (int) $request->get('log_qso_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('log_id')) {
|
||||
$logId = (int) $request->get('log_id');
|
||||
$query->whereHas('logQso', function ($q) use ($logId) {
|
||||
$q->where('log_id', $logId);
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('call_like')) {
|
||||
$raw = strtoupper((string) $request->get('call_like'));
|
||||
$pattern = str_replace(['*', '?'], ['%', '_'], $raw);
|
||||
if (strpos($pattern, '%') === false && strpos($pattern, '_') === false) {
|
||||
$pattern = '%' . $pattern . '%';
|
||||
}
|
||||
$query->where(function ($q) use ($pattern) {
|
||||
$q->whereHas('logQso', function ($qq) use ($pattern) {
|
||||
$qq->whereRaw('UPPER(my_call) LIKE ?', [$pattern])
|
||||
->orWhereRaw('UPPER(dx_call) LIKE ?', [$pattern]);
|
||||
})->orWhereHas('matchedQso', function ($qq) use ($pattern) {
|
||||
$qq->whereRaw('UPPER(my_call) LIKE ?', [$pattern])
|
||||
->orWhereRaw('UPPER(dx_call) LIKE ?', [$pattern]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('matched_qso_id')) {
|
||||
$query->where('matched_qso_id', (int) $request->get('matched_qso_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('error_code')) {
|
||||
$query->where('error_code', $request->get('error_code'));
|
||||
}
|
||||
|
||||
if ($request->filled('is_valid')) {
|
||||
$query->where(
|
||||
'is_valid',
|
||||
filter_var($request->get('is_valid'), FILTER_VALIDATE_BOOL)
|
||||
);
|
||||
}
|
||||
|
||||
if ($request->filled('is_duplicate')) {
|
||||
$query->where(
|
||||
'is_duplicate',
|
||||
filter_var($request->get('is_duplicate'), FILTER_VALIDATE_BOOL)
|
||||
);
|
||||
}
|
||||
|
||||
if ($request->filled('is_nil')) {
|
||||
$query->where(
|
||||
'is_nil',
|
||||
filter_var($request->get('is_nil'), FILTER_VALIDATE_BOOL)
|
||||
);
|
||||
}
|
||||
|
||||
if ($request->filled('is_time_out_of_window')) {
|
||||
$query->where(
|
||||
'is_time_out_of_window',
|
||||
filter_var($request->get('is_time_out_of_window'), FILTER_VALIDATE_BOOL)
|
||||
);
|
||||
}
|
||||
|
||||
if (filter_var($request->get('only_problems'), FILTER_VALIDATE_BOOL)) {
|
||||
$query->where(function ($q) {
|
||||
$q->where(function ($qq) {
|
||||
$qq->whereNotNull('error_code')
|
||||
->where('error_code', '!=', 'OK');
|
||||
})
|
||||
->orWhere('is_nil', true)
|
||||
->orWhere('is_duplicate', true)
|
||||
->orWhere('is_busted_call', true)
|
||||
->orWhere('is_busted_exchange', true)
|
||||
->orWhere('is_time_out_of_window', true);
|
||||
});
|
||||
}
|
||||
|
||||
if (filter_var($request->get('only_ok'), FILTER_VALIDATE_BOOL)) {
|
||||
$query->where(function ($q) {
|
||||
$q->whereNull('error_code')
|
||||
->orWhere('error_code', 'OK');
|
||||
})
|
||||
->where('is_nil', false)
|
||||
->where('is_duplicate', false)
|
||||
->where('is_busted_call', false)
|
||||
->where('is_busted_exchange', false)
|
||||
->where('is_time_out_of_window', false);
|
||||
}
|
||||
|
||||
if (filter_var($request->get('missing_locator'), FILTER_VALIDATE_BOOL)) {
|
||||
$query->whereHas('workingQso', function ($q) use ($evalRunId) {
|
||||
if ($evalRunId !== null) {
|
||||
$q->where('evaluation_run_id', $evalRunId);
|
||||
}
|
||||
$q->whereNull('loc_norm')
|
||||
->orWhereNull('rloc_norm')
|
||||
->orWhereJsonContains('errors', 'INVALID_LOCATOR')
|
||||
->orWhereJsonContains('errors', 'INVALID_RLOCATOR');
|
||||
});
|
||||
}
|
||||
|
||||
$items = $query
|
||||
->orderBy('evaluation_run_id')
|
||||
->orderBy('log_qso_id')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vytvoření QSO výsledku.
|
||||
* Typicky voláno vyhodnocovačem, ne přímo z UI.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', QsoResult::class);
|
||||
|
||||
$data = $this->validateData($request);
|
||||
|
||||
$result = QsoResult::create($data);
|
||||
|
||||
$result->load([
|
||||
'evaluationRun',
|
||||
'logQso',
|
||||
'matchedQso',
|
||||
'workingQso' => function ($q) use ($result) {
|
||||
$q->where('evaluation_run_id', $result->evaluation_run_id);
|
||||
},
|
||||
]);
|
||||
|
||||
return response()->json($result, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail jednoho QSO výsledku.
|
||||
*/
|
||||
public function show(QsoResult $qsoResult): JsonResponse
|
||||
{
|
||||
$qsoResult->load([
|
||||
'evaluationRun',
|
||||
'logQso',
|
||||
'matchedQso',
|
||||
'workingQso' => function ($q) use ($qsoResult) {
|
||||
$q->where('evaluation_run_id', $qsoResult->evaluation_run_id);
|
||||
},
|
||||
]);
|
||||
|
||||
return response()->json($qsoResult);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizace QSO výsledku (partial update).
|
||||
* Praktické pro ruční korekce / override.
|
||||
*/
|
||||
public function update(Request $request, QsoResult $qsoResult): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $qsoResult);
|
||||
|
||||
$data = $this->validateData($request, partial: true);
|
||||
|
||||
$qsoResult->fill($data);
|
||||
$qsoResult->save();
|
||||
|
||||
$qsoResult->load([
|
||||
'evaluationRun',
|
||||
'logQso',
|
||||
'matchedQso',
|
||||
'workingQso' => function ($q) use ($qsoResult) {
|
||||
$q->where('evaluation_run_id', $qsoResult->evaluation_run_id);
|
||||
},
|
||||
]);
|
||||
|
||||
return response()->json($qsoResult);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smazání QSO výsledku.
|
||||
*/
|
||||
public function destroy(QsoResult $qsoResult): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $qsoResult);
|
||||
|
||||
$qsoResult->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validace vstupu pro store / update.
|
||||
*/
|
||||
protected function validateData(Request $request, bool $partial = false): array
|
||||
{
|
||||
$required = $partial ? 'sometimes' : 'required';
|
||||
|
||||
return $request->validate([
|
||||
'evaluation_run_id' => [$required, 'integer', 'exists:evaluation_runs,id'],
|
||||
'log_qso_id' => [$required, 'integer', 'exists:log_qsos,id'],
|
||||
|
||||
'is_valid' => ['sometimes', 'boolean'],
|
||||
'is_duplicate' => ['sometimes', 'boolean'],
|
||||
'is_nil' => ['sometimes', 'boolean'],
|
||||
'is_busted_call' => ['sometimes', 'boolean'],
|
||||
'is_busted_rst' => ['sometimes', 'boolean'],
|
||||
'is_busted_exchange' => ['sometimes', 'boolean'],
|
||||
'is_time_out_of_window' => ['sometimes', 'boolean'],
|
||||
|
||||
'points' => ['sometimes', 'integer'],
|
||||
'penalty_points' => ['sometimes', 'integer'],
|
||||
'distance_km' => ['sometimes', 'nullable', 'integer', 'min:0'],
|
||||
|
||||
'wwl' => ['sometimes', 'nullable', 'string', 'max:6'],
|
||||
'dxcc' => ['sometimes', 'nullable', 'string', 'max:10'],
|
||||
'country' => ['sometimes', 'nullable', 'string', 'max:100'],
|
||||
'section' => ['sometimes', 'nullable', 'string', 'max:50'],
|
||||
|
||||
'matched_qso_id' => ['sometimes', 'nullable', 'integer', 'exists:log_qsos,id'],
|
||||
'matched_log_qso_id' => ['sometimes', 'nullable', 'integer', 'exists:log_qsos,id'],
|
||||
'match_confidence' => ['sometimes', 'nullable', 'string', 'max:20'],
|
||||
|
||||
'error_code' => ['sometimes', 'nullable', 'string', 'max:50'],
|
||||
'error_side' => ['sometimes', 'nullable', 'string', 'max:10'],
|
||||
'error_detail' => ['sometimes', 'nullable', 'string'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
392
app/Http/Controllers/RoundController.php
Normal file
392
app/Http/Controllers/RoundController.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
114
app/Http/Controllers/UserController.php
Normal file
114
app/Http/Controllers/UserController.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UserController extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth:sanctum');
|
||||
}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('viewAny', User::class);
|
||||
|
||||
$perPage = (int) $request->get('per_page', 20);
|
||||
$query = trim((string) $request->get('query', ''));
|
||||
|
||||
$users = User::query()
|
||||
->when($query !== '', function ($q) use ($query) {
|
||||
$q->where('name', 'like', '%' . $query . '%')
|
||||
->orWhere('email', 'like', '%' . $query . '%');
|
||||
})
|
||||
->orderBy('name')
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json($users);
|
||||
}
|
||||
|
||||
public function show(User $user): JsonResponse
|
||||
{
|
||||
$this->authorize('view', $user);
|
||||
|
||||
return response()->json($user);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', User::class);
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
|
||||
'password' => ['required', 'string', 'min:8'],
|
||||
'is_admin' => ['sometimes', 'boolean'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'password' => $data['password'],
|
||||
'is_admin' => (bool) ($data['is_admin'] ?? false),
|
||||
'is_active' => (bool) ($data['is_active'] ?? true),
|
||||
]);
|
||||
|
||||
return response()->json($user, 201);
|
||||
}
|
||||
|
||||
public function update(Request $request, User $user): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $user);
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique('users', 'email')->ignore($user->id),
|
||||
],
|
||||
'password' => ['nullable', 'string', 'min:8'],
|
||||
'is_admin' => ['sometimes', 'boolean'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'is_admin' => (bool) ($data['is_admin'] ?? $user->is_admin),
|
||||
'is_active' => (bool) ($data['is_active'] ?? $user->is_active),
|
||||
];
|
||||
if (! empty($data['password'])) {
|
||||
$payload['password'] = $data['password'];
|
||||
}
|
||||
|
||||
$user->update($payload);
|
||||
|
||||
return response()->json($user);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, User $user): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $user);
|
||||
|
||||
if ($request->user()?->id === $user->id) {
|
||||
return response()->json(['message' => 'Nelze deaktivovat vlastního uživatele.'], 422);
|
||||
}
|
||||
|
||||
$user->update(['is_active' => false]);
|
||||
|
||||
return response()->json($user);
|
||||
}
|
||||
}
|
||||
20
app/Http/Middleware/SetLocaleFromCookie.php
Normal file
20
app/Http/Middleware/SetLocaleFromCookie.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SetLocaleFromCookie
|
||||
{
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
$locale = $request->cookie('locale', config('app.locale'));
|
||||
|
||||
if (in_array($locale, ['cs', 'en'], true)) {
|
||||
app()->setLocale($locale);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
77
app/Http/Requests/StartEvaluationRunRequest.php
Normal file
77
app/Http/Requests/StartEvaluationRunRequest.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* Request: StartEvaluationRunRequest
|
||||
*
|
||||
* Účel:
|
||||
* - HTTP request objekt pro spuštění vyhodnocovacího běhu (EvaluationRun).
|
||||
* - Slouží k validaci a autorizaci vstupních dat při inicializaci vyhodnocení
|
||||
* z administrace (React SPA) nebo interní backendové akce.
|
||||
*
|
||||
* Kontext v architektuře:
|
||||
* - Používá se v EvaluationRunController při volání endpointu typu:
|
||||
* POST /api/rounds/{round}/evaluation-runs
|
||||
* - Je první validační vrstvou před vytvořením EvaluationRun záznamu
|
||||
* a před dispatchnutím StartEvaluationRunJob.
|
||||
*
|
||||
* Odpovědnosti requestu:
|
||||
* - Ověřit, že požadavek přichází od autorizovaného uživatele
|
||||
* (typicky administrátor nebo oprávněná role).
|
||||
* - Validovat vstupní parametry potřebné ke spuštění vyhodnocení,
|
||||
* např.:
|
||||
* - typ vyhodnocení (test / official)
|
||||
* - identifikace rule setu
|
||||
* - volitelný scope (bandy, kategorie, power kategorie)
|
||||
* - případné volby override (pokud jsou povoleny)
|
||||
*
|
||||
* Co request NEDĚLÁ:
|
||||
* - nespouští vyhodnocení
|
||||
* - neobsahuje byznys logiku
|
||||
* - nemění stav databáze
|
||||
*
|
||||
* Zásady návrhu:
|
||||
* - Autorizace a validace musí být striktní,
|
||||
* protože spuštění vyhodnocení je nákladná operace.
|
||||
* - Request má zůstat jednoduchý a čitelný;
|
||||
* komplexní logika patří do controlleru nebo service layer.
|
||||
*/
|
||||
class StartEvaluationRunRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Určuje, zda má aktuální uživatel oprávnění spustit vyhodnocovací běh.
|
||||
*
|
||||
* Typicky:
|
||||
* - pouze administrátor
|
||||
* - nebo uživatel s explicitním oprávněním ke správě závodu/kola
|
||||
*
|
||||
* Poznámka:
|
||||
* - Implementace má delegovat rozhodnutí na Policy nebo Gate.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validační pravidla pro spuštění vyhodnocení.
|
||||
*
|
||||
* Metoda definuje strukturu a povolené hodnoty vstupních dat,
|
||||
* která ovlivňují průběh vyhodnocovací pipeline.
|
||||
*
|
||||
* Poznámka:
|
||||
* - Konkrétní validační pravidla budou záviset
|
||||
* na požadavcích UI a pravidlech soutěže.
|
||||
* - Typicky se zde validují pouze technické parametry,
|
||||
* nikoli existence nebo stav doménových entit.
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
32
app/Models/Band.php
Normal file
32
app/Models/Band.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class Band extends Model
|
||||
{
|
||||
protected $table = 'bands';
|
||||
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'order',
|
||||
'edi_band_begin',
|
||||
'edi_band_end',
|
||||
'has_power_category'
|
||||
];
|
||||
|
||||
public function ediBands(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(EdiBand::class, 'bands_edi_bands', 'band_id', 'edi_band_id');
|
||||
}
|
||||
|
||||
public function contests(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Contest::class, 'contests_bands', 'band_id', 'contest_id');
|
||||
}
|
||||
}
|
||||
29
app/Models/Category.php
Normal file
29
app/Models/Category.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class Category extends Model
|
||||
{
|
||||
protected $table = 'categories';
|
||||
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'order'
|
||||
];
|
||||
|
||||
public function ediCategories(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(EdiCategory::class, 'categories_edi_categories', 'category_id', 'edi_category_id');
|
||||
}
|
||||
|
||||
public function contests(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Contest::class, 'contests_categories', 'category_id', 'category_id');
|
||||
}
|
||||
}
|
||||
86
app/Models/Contest.php
Normal file
86
app/Models/Contest.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Spatie\Translatable\HasTranslations;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use App\Models\EvaluationRuleSet;
|
||||
|
||||
class Contest extends Model
|
||||
{
|
||||
protected $table = 'contests';
|
||||
|
||||
use HasFactory;
|
||||
use HasTranslations;
|
||||
|
||||
public array $translatable = [
|
||||
'name',
|
||||
'description'
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
'url',
|
||||
'evaluator',
|
||||
'email',
|
||||
'email2',
|
||||
'is_mcr',
|
||||
'is_test',
|
||||
'is_sixhr',
|
||||
'is_active',
|
||||
'start_time',
|
||||
'duration',
|
||||
'logs_deadline_days',
|
||||
'rule_set_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'name' => 'array',
|
||||
'description' => 'array',
|
||||
'is_mcr' => 'boolean',
|
||||
'is_test' => 'boolean',
|
||||
'is_sixhr' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'duration' => 'integer',
|
||||
'logs_deadline_days' => 'integer',
|
||||
'rule_set_id' => 'integer',
|
||||
// 'start_time' => 'string', // pokud chceš čistý string; pro fancy práci s časem můžeš dát vlastní cast
|
||||
];
|
||||
|
||||
public function ruleSet(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EvaluationRuleSet::class, 'rule_set_id');
|
||||
}
|
||||
|
||||
public function rounds(): HasMany
|
||||
{
|
||||
return $this->hasMany(Round::class, 'contest_id')
|
||||
->orderByDesc('start_time')
|
||||
->orderByDesc('end_time');
|
||||
}
|
||||
|
||||
public function parameters(): HasMany
|
||||
{
|
||||
return $this->hasMany(ContestParameter::class, 'contest_id');
|
||||
}
|
||||
|
||||
public function bands(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Band::class, 'contests_bands', 'contest_id', 'band_id');
|
||||
}
|
||||
|
||||
public function categories(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Category::class, 'contests_categories', 'contest_id', 'category_id');
|
||||
}
|
||||
|
||||
public function powerCategories(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(PowerCategory::class, 'contests_power_categories', 'contest_id', 'power_category_id');
|
||||
}
|
||||
}
|
||||
50
app/Models/ContestParameter.php
Normal file
50
app/Models/ContestParameter.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ContestParameter extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'contests_parameters';
|
||||
|
||||
protected $fillable = [
|
||||
'contest_id',
|
||||
'log_type',
|
||||
'ignore_slash_part',
|
||||
'ignore_third_part',
|
||||
'letters_in_rst',
|
||||
'discard_qso_rec_diff_call',
|
||||
'discard_qso_sent_diff_call',
|
||||
'discard_qso_rec_diff_rst',
|
||||
'discard_qso_sent_diff_rst',
|
||||
'discard_qso_rec_diff_code',
|
||||
'discard_qso_sent_diff_code',
|
||||
'unique_qso',
|
||||
'time_tolerance',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'contest_id' => 'integer',
|
||||
'ignore_slash_part' => 'boolean',
|
||||
'ignore_third_part' => 'boolean',
|
||||
'letters_in_rst' => 'boolean',
|
||||
'discard_qso_rec_diff_call' => 'boolean',
|
||||
'discard_qso_sent_diff_call' => 'boolean',
|
||||
'discard_qso_rec_diff_rst' => 'boolean',
|
||||
'discard_qso_sent_diff_rst' => 'boolean',
|
||||
'discard_qso_rec_diff_code' => 'boolean',
|
||||
'discard_qso_sent_diff_code' => 'boolean',
|
||||
'unique_qso' => 'boolean',
|
||||
'time_tolerance' => 'integer',
|
||||
];
|
||||
|
||||
public function contest(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Contest::class, 'contest_id');
|
||||
}
|
||||
}
|
||||
31
app/Models/CountryWwl.php
Normal file
31
app/Models/CountryWwl.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class CountryWwl extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'countries_wwl';
|
||||
|
||||
// Nemáme primární klíč typu int → vypnout auto increment
|
||||
public $incrementing = false;
|
||||
|
||||
// Primární klíč není integer → type = string
|
||||
protected $keyType = 'string';
|
||||
|
||||
// Eloquent NEPODPORUJE composite PK → necháme primaryKey prázdné
|
||||
protected $primaryKey = null;
|
||||
|
||||
// Zakázat timestamps auto-handling? → ne, používáš timestamps v DB
|
||||
// Pokud bys je chtěl řídit sám:
|
||||
// public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'country_name',
|
||||
'wwl'
|
||||
];
|
||||
}
|
||||
38
app/Models/Cty.php
Normal file
38
app/Models/Cty.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
class Cty extends Model
|
||||
{
|
||||
protected $table = 'cty';
|
||||
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'country_name',
|
||||
'dxcc',
|
||||
'cq_zone',
|
||||
'itu_zone',
|
||||
'continent',
|
||||
'latitude',
|
||||
'longitude',
|
||||
'time_offset',
|
||||
'prefix',
|
||||
'prefix_norm',
|
||||
'precise',
|
||||
'source'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'dxcc' => 'integer',
|
||||
'cq_zone' => 'integer',
|
||||
'itu_zone' => 'integer',
|
||||
'latitude' => 'float',
|
||||
'longitude' => 'float',
|
||||
'time_offset' => 'float',
|
||||
'precise' => 'boolean',
|
||||
];
|
||||
}
|
||||
23
app/Models/EdiBand.php
Normal file
23
app/Models/EdiBand.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class EdiBand extends Model
|
||||
{
|
||||
protected $table = 'edi_bands';
|
||||
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'value'
|
||||
];
|
||||
|
||||
public function bands(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Band::class, 'bands_edi_bands', 'edi_band_id', 'band_id');
|
||||
}
|
||||
}
|
||||
24
app/Models/EdiCategory.php
Normal file
24
app/Models/EdiCategory.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class EdiCategory extends Model
|
||||
{
|
||||
protected $table = 'edi_categories';
|
||||
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'value',
|
||||
'regex_pattern',
|
||||
];
|
||||
|
||||
public function categories(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Category::class, 'categories_edi_categories', 'edi_category_id', 'category_id');
|
||||
}
|
||||
}
|
||||
148
app/Models/EvaluationLock.php
Normal file
148
app/Models/EvaluationLock.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Model: EvaluationLock
|
||||
*
|
||||
* Účel:
|
||||
* - Reprezentuje aplikační zámek (lock) pro vyhodnocovací procesy.
|
||||
* - Slouží k zabránění souběžného spuštění více vyhodnocovacích běhů
|
||||
* nad stejným rozsahem dat (scope).
|
||||
*
|
||||
* Kontext v architektuře:
|
||||
* - Používá se při spuštění vyhodnocení (StartEvaluationRunJob)
|
||||
* a během celé evaluation pipeline.
|
||||
* - Je součástí mechanismu ochrany proti race condition a kolizím
|
||||
* mezi paralelně běžícími background joby.
|
||||
*
|
||||
* Princip fungování:
|
||||
* - Každý lock je identifikován unikátním klíčem (`key`), který
|
||||
* reprezentuje zamčený rozsah dat, typicky např.:
|
||||
* - `round:{round_id}`
|
||||
* - `round:{round_id}:band:{band_id}`
|
||||
* - Při pokusu o vytvoření locku se spoléhá na unikátní index v DB.
|
||||
* Pokud záznam s daným klíčem již existuje, znamená to, že jiný
|
||||
* vyhodnocovací běh nad stejným scope již probíhá.
|
||||
*
|
||||
* Pole modelu:
|
||||
* - key:
|
||||
* Jednoznačný identifikátor zamčeného scope (unikátní v DB).
|
||||
* - evaluation_run_id:
|
||||
* Reference na EvaluationRun, který lock vlastní.
|
||||
* - locked_at:
|
||||
* Čas, kdy byl lock získán.
|
||||
* - expires_at:
|
||||
* Volitelný čas expirace locku (slouží jako bezpečnostní pojistka
|
||||
* proti "visícím" lockům při havárii procesu).
|
||||
*
|
||||
* Co model NEDĚLÁ:
|
||||
* - neřeší automatické obnovování nebo expirování locků
|
||||
* - neobsahuje byznys logiku vyhodnocení
|
||||
* - nesupluje databázové transakce
|
||||
*
|
||||
* Zásady návrhu:
|
||||
* - Lock musí být získáván a uvolňován atomicky.
|
||||
* - Locky musí být vždy uvolněny ve finálním kroku vyhodnocení
|
||||
* (FinalizeRunJob), případně při chybě.
|
||||
* - Existence locku je autoritativní zdroj informace o tom,
|
||||
* zda je daný scope právě zpracováván.
|
||||
*/
|
||||
class EvaluationLock extends Model
|
||||
{
|
||||
protected $table = 'evaluation_locks';
|
||||
|
||||
protected $fillable = [
|
||||
'key',
|
||||
'evaluation_run_id',
|
||||
'locked_at',
|
||||
'expires_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'evaluation_run_id' => 'integer',
|
||||
'locked_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function evaluationRun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EvaluationRun::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pokusí se získat lock pro daný klíč.
|
||||
*
|
||||
* - Pokud existuje expirovaný lock, před pokusem ho smaže.
|
||||
* - Pokud už aktivní lock existuje, vrátí null.
|
||||
*
|
||||
* @param string $key Jedinečný identifikátor scope (např. "round:5").
|
||||
* @param \App\Models\EvaluationRun|null $run Volitelný běh, ke kterému se lock váže.
|
||||
* @param \DateInterval|int|\Carbon\Carbon|null $ttl Doba platnosti (sekundy nebo DateInterval nebo konkrétní expirace).
|
||||
*/
|
||||
public static function acquire(string $key, ?EvaluationRun $run = null, \DateInterval|int|Carbon|null $ttl = null): ?self
|
||||
{
|
||||
$now = Carbon::now();
|
||||
|
||||
// uklidíme expirovaný lock se stejným klíčem, aby neblokoval unikátní index
|
||||
self::where('key', $key)
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<', $now)
|
||||
->delete();
|
||||
|
||||
$expiresAt = null;
|
||||
if ($ttl instanceof Carbon) {
|
||||
$expiresAt = $ttl;
|
||||
} elseif ($ttl instanceof \DateInterval) {
|
||||
$expiresAt = (clone $now)->add($ttl);
|
||||
} elseif (is_int($ttl)) {
|
||||
$expiresAt = (clone $now)->addSeconds($ttl);
|
||||
}
|
||||
|
||||
try {
|
||||
return self::create([
|
||||
'key' => $key,
|
||||
'evaluation_run_id' => $run?->id,
|
||||
'locked_at' => $now,
|
||||
'expires_at' => $expiresAt,
|
||||
]);
|
||||
} catch (QueryException $e) {
|
||||
// unikátní klíč porušen -> lock už drží někdo jiný
|
||||
if (str_contains(strtolower($e->getMessage()), 'duplicate') || str_contains(strtolower($e->getMessage()), 'unique')) {
|
||||
return null;
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uvolní lock podle klíče (a volitelně evaluation_run_id).
|
||||
*/
|
||||
public static function release(string $key, ?EvaluationRun $run = null): int
|
||||
{
|
||||
$query = self::where('key', $key);
|
||||
if ($run) {
|
||||
$query->where('evaluation_run_id', $run->id);
|
||||
}
|
||||
return $query->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Zjistí, zda lock existuje a neexpiroval.
|
||||
*/
|
||||
public static function isLocked(string $key): bool
|
||||
{
|
||||
$now = Carbon::now();
|
||||
return self::where('key', $key)
|
||||
->where(function ($q) use ($now) {
|
||||
$q->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', $now);
|
||||
})
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
479
app/Models/EvaluationRuleSet.php
Normal file
479
app/Models/EvaluationRuleSet.php
Normal file
@@ -0,0 +1,479 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class EvaluationRuleSet extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'evaluation_rule_sets';
|
||||
|
||||
/**
|
||||
* Atributy rulesetu používané při vyhodnocování.
|
||||
*
|
||||
* Skupiny:
|
||||
* - scoring_mode/points_per_*: způsob bodování.
|
||||
* - multipliers*: multiplikátory a jejich scope.
|
||||
* - *_policy / penalty_*: penalizace a způsob započtení chyb.
|
||||
* - exchange_* / discard_*: pravidla výměny a busted detekce.
|
||||
* - match_* / callsign_*: pravidla párování a normalizace.
|
||||
* - out_of_window_*: chování mimo časové okno.
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'code',
|
||||
'description',
|
||||
|
||||
|
||||
'scoring_mode', // DISTANCE / FIXED_POINTS
|
||||
'points_per_qso', // Fixní body za valid QSO
|
||||
'points_per_km', // Body za km v režimu DISTANCE
|
||||
|
||||
'use_multipliers', // Zapnout multiplikátory ve výsledku
|
||||
'multiplier_type', // NONE / WWL / DXCC / SECTION / COUNTRY
|
||||
|
||||
'dup_qso_policy', // ZERO_POINTS / PENALTY
|
||||
'nil_qso_policy', // ZERO_POINTS / PENALTY
|
||||
'no_counterpart_log_policy', // INVALID / ZERO_POINTS / FLAG_ONLY / PENALTY
|
||||
'not_in_counterpart_log_policy', // INVALID / ZERO_POINTS / FLAG_ONLY / PENALTY
|
||||
'unique_qso_policy', // INVALID / ZERO_POINTS / FLAG_ONLY
|
||||
'busted_call_policy', // ZERO_POINTS / PENALTY
|
||||
'busted_exchange_policy',// ZERO_POINTS / PENALTY
|
||||
'busted_serial_policy', // ZERO_POINTS / PENALTY
|
||||
'busted_locator_policy', // ZERO_POINTS / PENALTY
|
||||
'penalty_dup_points', // Penalizace za DUP
|
||||
'penalty_nil_points', // Penalizace za NIL
|
||||
'penalty_busted_call_points', // Penalizace za BUSTED_CALL
|
||||
'penalty_busted_exchange_points', // Penalizace za BUSTED_EXCHANGE
|
||||
'penalty_busted_serial_points', // Penalizace za BUSTED_SERIAL
|
||||
'penalty_busted_locator_points', // Penalizace za BUSTED_LOCATOR
|
||||
'dupe_scope', // BAND / BAND_MODE
|
||||
'callsign_normalization',// STRICT / IGNORE_SUFFIX
|
||||
'distance_rounding', // FLOOR / ROUND / CEIL
|
||||
'min_distance_km', // Minimum km pro bodované QSO
|
||||
'require_locators', // Bez lokátorů = nevalidní / bez bodů
|
||||
'out_of_window_policy', // IGNORE / ZERO_POINTS / PENALTY / INVALID
|
||||
'penalty_out_of_window_points', // Penalizace za OUT_OF_WINDOW
|
||||
|
||||
'exchange_type', // SERIAL / WWL / SERIAL_WWL / CUSTOM
|
||||
'exchange_requires_wwl', // WWL je povinná část výměny
|
||||
'exchange_requires_serial', // Serial je povinná část výměny
|
||||
'exchange_requires_report', // RST je povinná část výměny
|
||||
'exchange_pattern', // Regex pro CUSTOM výměnu
|
||||
|
||||
'ignore_slash_part', // Ignorovat suffix za lomítkem v callsign
|
||||
'ignore_third_part', // Ignorovat 3. část callsign
|
||||
'letters_in_rst', // Povolit písmena v RST
|
||||
'rst_ignore_third_char', // Ignorovat 3. znak v RST
|
||||
'discard_qso_rec_diff_call', // RX neshoda callsign -> BUSTED_CALL (error_side=RX)
|
||||
'discard_qso_sent_diff_call', // TX neshoda callsign -> BUSTED_CALL (error_side=TX)
|
||||
'discard_qso_rec_diff_rst', // RX neshoda RST -> BUSTED_RST (error_side=RX)
|
||||
'discard_qso_sent_diff_rst', // TX neshoda RST -> BUSTED_RST (error_side=TX)
|
||||
'discard_qso_rec_diff_code', // RX neshoda exchange -> BUSTED_SERIAL/LOCATOR (error_side=RX)
|
||||
'discard_qso_sent_diff_code',// TX neshoda exchange -> BUSTED_SERIAL/LOCATOR (error_side=TX)
|
||||
'discard_qso_rec_diff_serial', // RX neshoda serial -> BUSTED_SERIAL (error_side=RX)
|
||||
'discard_qso_sent_diff_serial',// TX neshoda serial -> BUSTED_SERIAL (error_side=TX)
|
||||
'discard_qso_rec_diff_wwl', // RX neshoda WWL -> BUSTED_LOCATOR (error_side=RX)
|
||||
'discard_qso_sent_diff_wwl', // TX neshoda WWL -> BUSTED_LOCATOR (error_side=TX)
|
||||
'busted_rst_policy', // ZERO_POINTS / PENALTY
|
||||
'penalty_busted_rst_points', // Penalizace za BUSTED_RST
|
||||
|
||||
'match_tiebreak_order', // Pořadí tiebreak kritérií
|
||||
'match_require_locator_match', // Matching vyžaduje lokátor
|
||||
'match_require_exchange_match',// Matching vyžaduje exchange
|
||||
|
||||
'multiplier_scope', // PER_BAND / OVERALL
|
||||
'multiplier_source', // VALID_ONLY / ALL_MATCHED
|
||||
'wwl_multiplier_level', // LOCATOR_2 / LOCATOR_4 / LOCATOR_6
|
||||
|
||||
'checklog_matching', // CHECK logy v matchingu
|
||||
'out_of_window_dq_threshold', // DQ logu při nadlimitních OOW QSO
|
||||
'time_diff_dq_threshold_percent', // DQ logu při nadlimitním % časového rozdílu
|
||||
'time_diff_dq_threshold_sec', // Prah časového rozdílu v sekundách
|
||||
'bad_qso_dq_threshold_percent', // DQ logu při nadlimitním % špatných QSO
|
||||
|
||||
'time_tolerance_sec', // Tolerance času v matchingu (sekundy)
|
||||
'require_unique_qso', // Zapnout detekci duplicit v logu
|
||||
'allow_time_shift_one_hour', // Povolit posun o 1 hodinu při matchingu
|
||||
'time_shift_seconds', // Velikost časového posunu v sekundách
|
||||
'time_mismatch_policy', // INVALID / ZERO_POINTS / FLAG_ONLY / PENALTY
|
||||
'callsign_suffix_max_len', // Max délka suffixu za /
|
||||
'callsign_levenshtein_max', // Maximální Levenshtein vzdálenost pro fuzzy match
|
||||
'allow_time_mismatch_pairing', // Povolit párování mimo toleranci
|
||||
'time_mismatch_max_sec', // Max. odchylka mimo toleranci (NULL = bez limitu)
|
||||
'dup_resolution_strategy', // Pořadí pravidel pro volbu survivor v duplicitách
|
||||
|
||||
'operating_window_mode', // NONE / BEST_CONTIGUOUS
|
||||
'operating_window_hours', // Délka okna v hodinách
|
||||
'sixhr_ranking_mode', // IARU / CRK
|
||||
|
||||
'options', // Volitelné JSON rozšíření pravidel
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
|
||||
'points_per_qso' => 'integer',
|
||||
'points_per_km' => 'float',
|
||||
|
||||
'use_multipliers' => 'boolean',
|
||||
'time_tolerance_sec' => 'integer',
|
||||
'require_unique_qso' => 'boolean',
|
||||
'penalty_dup_points' => 'integer',
|
||||
'penalty_nil_points' => 'integer',
|
||||
'penalty_busted_call_points' => 'integer',
|
||||
'penalty_busted_exchange_points' => 'integer',
|
||||
'penalty_busted_serial_points' => 'integer',
|
||||
'penalty_busted_locator_points' => 'integer',
|
||||
'min_distance_km' => 'integer',
|
||||
'require_locators' => 'boolean',
|
||||
'penalty_out_of_window_points' => 'integer',
|
||||
|
||||
'exchange_requires_wwl' => 'boolean',
|
||||
'exchange_requires_serial' => 'boolean',
|
||||
'exchange_requires_report' => 'boolean',
|
||||
'ignore_slash_part' => 'boolean',
|
||||
'ignore_third_part' => 'boolean',
|
||||
'letters_in_rst' => 'boolean',
|
||||
'rst_ignore_third_char' => 'boolean',
|
||||
'discard_qso_rec_diff_call' => 'boolean',
|
||||
'discard_qso_sent_diff_call' => 'boolean',
|
||||
'discard_qso_rec_diff_rst' => 'boolean',
|
||||
'discard_qso_sent_diff_rst' => 'boolean',
|
||||
'discard_qso_rec_diff_code' => 'boolean',
|
||||
'discard_qso_sent_diff_code' => 'boolean',
|
||||
'discard_qso_rec_diff_serial' => 'boolean',
|
||||
'discard_qso_sent_diff_serial' => 'boolean',
|
||||
'discard_qso_rec_diff_wwl' => 'boolean',
|
||||
'discard_qso_sent_diff_wwl' => 'boolean',
|
||||
'penalty_busted_rst_points' => 'integer',
|
||||
|
||||
'match_tiebreak_order' => 'array',
|
||||
'match_require_locator_match' => 'boolean',
|
||||
'match_require_exchange_match' => 'boolean',
|
||||
|
||||
'checklog_matching' => 'boolean',
|
||||
'out_of_window_dq_threshold' => 'integer',
|
||||
'time_diff_dq_threshold_percent' => 'integer',
|
||||
'time_diff_dq_threshold_sec' => 'integer',
|
||||
'bad_qso_dq_threshold_percent' => 'integer',
|
||||
'allow_time_shift_one_hour' => 'boolean',
|
||||
'time_shift_seconds' => 'integer',
|
||||
'callsign_suffix_max_len' => 'integer',
|
||||
'callsign_levenshtein_max' => 'integer',
|
||||
'allow_time_mismatch_pairing' => 'boolean',
|
||||
'time_mismatch_max_sec' => 'integer',
|
||||
'dup_resolution_strategy' => 'array',
|
||||
|
||||
'operating_window_mode' => 'string',
|
||||
'operating_window_hours' => 'integer',
|
||||
'sixhr_ranking_mode' => 'string',
|
||||
|
||||
'options' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
* Mapování: kde je jednotlivý atribut rulesetu použit.
|
||||
* Pokud je hodnota "unused", musí být explicitně zdůvodněno.
|
||||
*/
|
||||
public const FLAG_USAGE = [
|
||||
'name' => 'metadata (UI, identifikace rulesetu)',
|
||||
'code' => 'metadata (UI, identifikace rulesetu)',
|
||||
'description' => 'metadata (UI, identifikace rulesetu)',
|
||||
'scoring_mode' => 'ScoringService::computeBasePoints',
|
||||
'points_per_qso' => 'ScoringService::computeBasePoints',
|
||||
'points_per_km' => 'ScoringService::computeBasePoints',
|
||||
'use_multipliers' => 'ScoreGroupJob::applyMultipliers + AggregateLogResultsJob',
|
||||
'multiplier_type' => 'ScoreGroupJob::applyMultipliers + AggregateLogResultsJob',
|
||||
'dup_qso_policy' => 'ScoringService::penaltyPointsFor',
|
||||
'nil_qso_policy' => 'ScoringService::penaltyPointsFor',
|
||||
'no_counterpart_log_policy' => 'ScoreGroupJob (validita/bodování)',
|
||||
'not_in_counterpart_log_policy' => 'ScoreGroupJob (validita/bodování)',
|
||||
'unique_qso_policy' => 'ScoreGroupJob (validita/bodování)',
|
||||
'busted_call_policy' => 'ScoringService::penaltyPointsFor',
|
||||
'busted_exchange_policy' => 'ScoringService::penaltyPointsFor',
|
||||
'busted_serial_policy' => 'ScoringService::penaltyPointsFor',
|
||||
'busted_locator_policy' => 'ScoringService::penaltyPointsFor',
|
||||
'penalty_dup_points' => 'ScoringService::penaltyPointsFor',
|
||||
'penalty_nil_points' => 'ScoringService::penaltyPointsFor',
|
||||
'penalty_busted_call_points' => 'ScoringService::penaltyPointsFor',
|
||||
'penalty_busted_exchange_points' => 'ScoringService::penaltyPointsFor',
|
||||
'penalty_busted_serial_points' => 'ScoringService::penaltyPointsFor',
|
||||
'penalty_busted_locator_points' => 'ScoringService::penaltyPointsFor',
|
||||
'dupe_scope' => 'BuildWorkingSetLogJob::dupe_key',
|
||||
'callsign_normalization' => 'MatchingService::normalizeCallsign + MatchQsoGroupJob',
|
||||
'distance_rounding' => 'ScoringService::calculateDistanceKm',
|
||||
'min_distance_km' => 'ScoringService::calculateDistanceKm',
|
||||
'require_locators' => 'ScoreGroupJob (validita/bodování)',
|
||||
'out_of_window_policy' => 'ScoringService::outOfWindowDecision + ScoreGroupJob',
|
||||
'penalty_out_of_window_points' => 'ScoringService::penaltyPointsFor',
|
||||
'exchange_type' => 'MatchQsoGroupJob::resolveExchangeMatch',
|
||||
'exchange_requires_wwl' => 'MatchQsoGroupJob::resolveExchangeMatch',
|
||||
'exchange_requires_serial' => 'MatchQsoGroupJob::resolveExchangeMatch',
|
||||
'exchange_requires_report' => 'MatchQsoGroupJob::resolveExchangeMatch',
|
||||
'exchange_pattern' => 'MatchQsoGroupJob::resolveExchangeMatch',
|
||||
'ignore_slash_part' => 'MatchQsoGroupJob::normalizeCallsign',
|
||||
'ignore_third_part' => 'MatchQsoGroupJob::normalizeCallsign',
|
||||
'letters_in_rst' => 'MatchQsoGroupJob::normalizeRst',
|
||||
'rst_ignore_third_char' => 'MatchQsoGroupJob::reportsMatch',
|
||||
'discard_qso_rec_diff_call' => 'MatchQsoGroupJob (error_side=RX)',
|
||||
'discard_qso_sent_diff_call' => 'MatchQsoGroupJob (error_side=TX)',
|
||||
'discard_qso_rec_diff_rst' => 'MatchQsoGroupJob (error_side=RX)',
|
||||
'discard_qso_sent_diff_rst' => 'MatchQsoGroupJob (error_side=TX)',
|
||||
'discard_qso_rec_diff_code' => 'MatchQsoGroupJob (RX exchange mismatch)',
|
||||
'discard_qso_sent_diff_code' => 'MatchQsoGroupJob (TX exchange mismatch)',
|
||||
'discard_qso_rec_diff_serial' => 'MatchQsoGroupJob (RX serial mismatch)',
|
||||
'discard_qso_sent_diff_serial' => 'MatchQsoGroupJob (TX serial mismatch)',
|
||||
'discard_qso_rec_diff_wwl' => 'MatchQsoGroupJob (RX locator mismatch)',
|
||||
'discard_qso_sent_diff_wwl' => 'MatchQsoGroupJob (TX locator mismatch)',
|
||||
'busted_rst_policy' => 'ScoringService::penaltyPointsFor',
|
||||
'penalty_busted_rst_points' => 'ScoringService::penaltyPointsFor',
|
||||
'match_tiebreak_order' => 'MatchQsoGroupJob::rankDecision',
|
||||
'match_require_locator_match' => 'MatchQsoGroupJob::resolveExchangeMatch',
|
||||
'match_require_exchange_match' => 'MatchQsoGroupJob::resolveExchangeMatch',
|
||||
'multiplier_scope' => 'AggregateLogResultsJob::aggregateMultipliers',
|
||||
'multiplier_source' => 'AggregateLogResultsJob::aggregateMultipliers',
|
||||
'wwl_multiplier_level' => 'ScoreGroupJob::applyMultipliers',
|
||||
'checklog_matching' => 'MatchQsoGroupJob::groupLogsByKey',
|
||||
'out_of_window_dq_threshold' => 'AggregateLogResultsJob (DQ logu)',
|
||||
'time_diff_dq_threshold_percent' => 'AggregateLogResultsJob (DQ logu)',
|
||||
'time_diff_dq_threshold_sec' => 'AggregateLogResultsJob (DQ logu)',
|
||||
'bad_qso_dq_threshold_percent' => 'AggregateLogResultsJob (DQ logu)',
|
||||
'time_tolerance_sec' => 'MatchQsoGroupJob::findCandidates',
|
||||
'require_unique_qso' => 'UnpairedClassificationJob::UNIQUE',
|
||||
'allow_time_shift_one_hour' => 'MatchQsoGroupJob::matchWithTimeShift',
|
||||
'time_shift_seconds' => 'MatchQsoGroupJob::matchWithTimeShift',
|
||||
'time_mismatch_policy' => 'ScoreGroupJob (validita/bodování)',
|
||||
'callsign_suffix_max_len' => 'MatchingService::normalizeCallsign',
|
||||
'callsign_levenshtein_max' => 'MatchQsoGroupJob::rankDecision',
|
||||
'allow_time_mismatch_pairing' => 'MatchQsoGroupJob::findCandidates',
|
||||
'time_mismatch_max_sec' => 'MatchQsoGroupJob::findCandidates',
|
||||
'dup_resolution_strategy' => 'DuplicateResolutionJob::sort',
|
||||
'operating_window_mode' => 'AggregateLogResultsJob (operating window, 6H agregace)',
|
||||
'operating_window_hours' => 'AggregateLogResultsJob (operating window, 6H agregace)',
|
||||
'sixhr_ranking_mode' => 'RecalculateOfficialRanksJob (6H ranking mode)',
|
||||
'options' => 'fallback pro hodnoty bez sloupců (EvaluationRuleSet::getOption)',
|
||||
];
|
||||
|
||||
/**
|
||||
* Options fallback (používá se jen pokud není vyplněn odpovídající sloupec).
|
||||
*
|
||||
* Klíče, typy, defaulty a fáze použití:
|
||||
* - ignore_slash_part (bool, default=true) – matching (normalizace callsign)
|
||||
* - callsign_suffix_max_len (int, default=4) – matching (normalizace callsign)
|
||||
* - rst_ignore_third_char (bool, default=true) – matching (porovnání RST)
|
||||
* - letters_in_rst (bool, default=false) – matching (porovnání RST)
|
||||
* - callsign_levenshtein_max (int, default=0) – matching (fuzzy callsign)
|
||||
* - allow_time_shift_one_hour (bool, default=true) – matching (časový posun)
|
||||
* - time_shift_seconds (int, default=3600) – matching (časový posun)
|
||||
* - allow_time_mismatch_pairing (bool, default=false) – matching (TIME_MISMATCH pairing)
|
||||
* - time_mismatch_max_sec (int|null, default=null) – matching (max odchylka mimo toleranci)
|
||||
* - match_require_locator_match (bool, default=false) – matching (mismatch flagging)
|
||||
* - match_require_exchange_match (bool, default=false) – matching (mismatch flagging)
|
||||
* - unique_qso_enabled (bool, default=true) – unpaired klasifikace (UNIQUE)
|
||||
* - unique_qso_policy (string, default=FLAG_ONLY) – scoring (validita/bodování)
|
||||
* - no_counterpart_log_policy (string, default=FLAG_ONLY) – scoring
|
||||
* - not_in_counterpart_log_policy (string, default=ZERO_POINTS) – scoring
|
||||
* - duplicate_resolution_strategy (array, default=[paired_first, ok_first, earlier_time, lower_id]) – duplicity
|
||||
* - distance_rounding (string, default=CEIL) – scoring (vzdálenost)
|
||||
* - min_distance_km (int, default=1) – scoring (vzdálenost)
|
||||
*/
|
||||
|
||||
// ----- Vztahy -----
|
||||
|
||||
public function evaluationRuns(): HasMany
|
||||
{
|
||||
return $this->hasMany(EvaluationRun::class, 'rule_set_id');
|
||||
}
|
||||
|
||||
// ----- Pomocné metody pro logiku vyhodnocení -----
|
||||
|
||||
public function isDistanceScoring(): bool
|
||||
{
|
||||
return $this->scoring_mode === 'DISTANCE';
|
||||
}
|
||||
|
||||
public function isFixedPointsScoring(): bool
|
||||
{
|
||||
return $this->scoring_mode === 'FIXED_POINTS';
|
||||
}
|
||||
|
||||
public function usesMultipliers(): bool
|
||||
{
|
||||
return $this->use_multipliers && $this->multiplier_type !== 'NONE';
|
||||
}
|
||||
|
||||
public function multiplierIsWwl(): bool
|
||||
{
|
||||
return $this->multiplier_type === 'WWL';
|
||||
}
|
||||
|
||||
public function multiplierIsDxcc(): bool
|
||||
{
|
||||
return $this->multiplier_type === 'DXCC';
|
||||
}
|
||||
|
||||
public function multiplierIsSection(): bool
|
||||
{
|
||||
return $this->multiplier_type === 'SECTION';
|
||||
}
|
||||
|
||||
public function multiplierIsCountry(): bool
|
||||
{
|
||||
return $this->multiplier_type === 'COUNTRY';
|
||||
}
|
||||
|
||||
public function dupCountsAsPenalty(): bool
|
||||
{
|
||||
return $this->dup_qso_policy === 'PENALTY';
|
||||
}
|
||||
|
||||
// ----- Options fallback & typed accessors -----
|
||||
|
||||
protected function getOption(string $key, mixed $default = null): mixed
|
||||
{
|
||||
$options = $this->options ?? [];
|
||||
return array_key_exists($key, $options) ? $options[$key] : $default;
|
||||
}
|
||||
|
||||
public function getString(string $key, ?string $columnValue, ?string $default = null): ?string
|
||||
{
|
||||
if ($columnValue !== null) {
|
||||
return $columnValue;
|
||||
}
|
||||
$value = $this->getOption($key, $default);
|
||||
return is_string($value) ? $value : $default;
|
||||
}
|
||||
|
||||
public function getBool(string $key, ?bool $columnValue, bool $default = false): bool
|
||||
{
|
||||
if ($columnValue !== null) {
|
||||
return (bool) $columnValue;
|
||||
}
|
||||
return (bool) $this->getOption($key, $default);
|
||||
}
|
||||
|
||||
public function getInt(string $key, ?int $columnValue, ?int $default = null): ?int
|
||||
{
|
||||
if ($columnValue !== null) {
|
||||
return (int) $columnValue;
|
||||
}
|
||||
$value = $this->getOption($key, $default);
|
||||
return $value === null ? null : (int) $value;
|
||||
}
|
||||
|
||||
public function getArray(string $key, ?array $columnValue, array $default = []): array
|
||||
{
|
||||
if ($columnValue !== null) {
|
||||
return $columnValue;
|
||||
}
|
||||
$value = $this->getOption($key, $default);
|
||||
return is_array($value) ? $value : $default;
|
||||
}
|
||||
|
||||
public function ignoreSlashPart(): bool
|
||||
{
|
||||
return $this->getBool('ignore_slash_part', $this->ignore_slash_part);
|
||||
}
|
||||
|
||||
public function ignoreThirdPart(): bool
|
||||
{
|
||||
return $this->getBool('ignore_third_part', $this->ignore_third_part);
|
||||
}
|
||||
|
||||
public function lettersInRst(): bool
|
||||
{
|
||||
return $this->getBool('letters_in_rst', $this->letters_in_rst);
|
||||
}
|
||||
|
||||
public function callsignLevenshteinMax(): int
|
||||
{
|
||||
return (int) ($this->getInt('callsign_levenshtein_max', $this->callsign_levenshtein_max, 0) ?? 0);
|
||||
}
|
||||
|
||||
public function callsignSuffixMaxLen(): int
|
||||
{
|
||||
return (int) ($this->getInt('callsign_suffix_max_len', $this->callsign_suffix_max_len, 4) ?? 4);
|
||||
}
|
||||
|
||||
public function rstIgnoreThirdChar(): bool
|
||||
{
|
||||
return $this->getBool('rst_ignore_third_char', $this->rst_ignore_third_char, true);
|
||||
}
|
||||
|
||||
public function matchRequireLocatorMatch(): bool
|
||||
{
|
||||
return $this->getBool('match_require_locator_match', $this->match_require_locator_match, false);
|
||||
}
|
||||
|
||||
public function matchRequireExchangeMatch(): bool
|
||||
{
|
||||
return $this->getBool('match_require_exchange_match', $this->match_require_exchange_match, false);
|
||||
}
|
||||
|
||||
public function allowTimeShiftOneHour(): bool
|
||||
{
|
||||
return $this->getBool('allow_time_shift_one_hour', $this->allow_time_shift_one_hour, true);
|
||||
}
|
||||
|
||||
public function allowTimeMismatchPairing(): bool
|
||||
{
|
||||
return $this->getBool('allow_time_mismatch_pairing', $this->allow_time_mismatch_pairing, false);
|
||||
}
|
||||
|
||||
public function timeMismatchMaxSec(): ?int
|
||||
{
|
||||
return $this->getInt('time_mismatch_max_sec', $this->time_mismatch_max_sec, null);
|
||||
}
|
||||
|
||||
public function uniqueQsoEnabled(): bool
|
||||
{
|
||||
return $this->getBool('unique_qso_enabled', $this->require_unique_qso, true);
|
||||
}
|
||||
|
||||
public function dupResolutionStrategy(): array
|
||||
{
|
||||
$fallback = $this->getArray('duplicate_resolution_strategy', null, [
|
||||
'paired_first',
|
||||
'ok_first',
|
||||
'earlier_time',
|
||||
'lower_id',
|
||||
]);
|
||||
|
||||
$strategy = $this->getArray('duplicate_resolution_strategy', $this->dup_resolution_strategy, $fallback);
|
||||
if ($strategy !== $fallback) {
|
||||
return $strategy;
|
||||
}
|
||||
|
||||
return $this->getArray('dup_resolution_strategy', $this->dup_resolution_strategy, $fallback);
|
||||
}
|
||||
|
||||
public function distanceRounding(): string
|
||||
{
|
||||
return $this->getString('distance_rounding', $this->distance_rounding, 'CEIL') ?? 'CEIL';
|
||||
}
|
||||
|
||||
public function minDistanceKm(): ?int
|
||||
{
|
||||
return $this->getInt('min_distance_km', $this->min_distance_km, 1);
|
||||
}
|
||||
|
||||
public function nilCountsAsPenalty(): bool
|
||||
{
|
||||
return $this->nil_qso_policy === 'PENALTY';
|
||||
}
|
||||
|
||||
public function bustedCallCountsAsPenalty(): bool
|
||||
{
|
||||
return $this->busted_call_policy === 'PENALTY';
|
||||
}
|
||||
|
||||
public function bustedExchangeCountsAsPenalty(): bool
|
||||
{
|
||||
return $this->busted_exchange_policy === 'PENALTY';
|
||||
}
|
||||
|
||||
}
|
||||
95
app/Models/EvaluationRun.php
Normal file
95
app/Models/EvaluationRun.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use App\Models\User;
|
||||
use App\Models\EvaluationLock;
|
||||
use App\Models\EvaluationRunEvent;
|
||||
|
||||
class EvaluationRun extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'evaluation_runs';
|
||||
|
||||
protected $fillable = [
|
||||
'round_id',
|
||||
'rule_set_id',
|
||||
'name',
|
||||
'rules_version',
|
||||
'result_type',
|
||||
'is_official',
|
||||
'notes',
|
||||
'status',
|
||||
'batch_id',
|
||||
'current_step',
|
||||
'progress_total',
|
||||
'progress_done',
|
||||
'scope',
|
||||
'error',
|
||||
'started_at',
|
||||
'finished_at',
|
||||
'created_by_user_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'round_id' => 'integer',
|
||||
'rule_set_id' => 'integer',
|
||||
'is_official' => 'boolean',
|
||||
'result_type' => 'string',
|
||||
'progress_total' => 'integer',
|
||||
'progress_done' => 'integer',
|
||||
'scope' => 'array',
|
||||
'started_at' => 'datetime',
|
||||
'finished_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function round(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Round::class);
|
||||
}
|
||||
|
||||
public function logResults(): HasMany
|
||||
{
|
||||
return $this->hasMany(LogResult::class);
|
||||
}
|
||||
|
||||
public function qsoResults(): HasMany
|
||||
{
|
||||
return $this->hasMany(QsoResult::class);
|
||||
}
|
||||
|
||||
public function evaluationLocks(): HasMany
|
||||
{
|
||||
return $this->hasMany(EvaluationLock::class);
|
||||
}
|
||||
|
||||
public function events(): HasMany
|
||||
{
|
||||
return $this->hasMany(EvaluationRunEvent::class);
|
||||
}
|
||||
|
||||
public function ruleSet(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EvaluationRuleSet::class, 'rule_set_id');
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_user_id');
|
||||
}
|
||||
|
||||
public function isCanceled(): bool
|
||||
{
|
||||
return strtoupper((string) $this->status) === 'CANCELED';
|
||||
}
|
||||
|
||||
public static function isCanceledRun(int $runId): bool
|
||||
{
|
||||
return static::where('id', $runId)->value('status') === 'CANCELED';
|
||||
}
|
||||
}
|
||||
73
app/Models/EvaluationRunEvent.php
Normal file
73
app/Models/EvaluationRunEvent.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use App\Models\EvaluationRun;
|
||||
|
||||
/**
|
||||
* Model: EvaluationRunEvent
|
||||
*
|
||||
* Účel:
|
||||
* - Reprezentuje jednu auditní / diagnostickou událost vyhodnocovacího běhu
|
||||
* (EvaluationRun).
|
||||
* - Slouží k zaznamenávání průběhu vyhodnocovací pipeline pro účely:
|
||||
* - monitoringu v administraci
|
||||
* - diagnostiky chyb
|
||||
* - auditní stopy (co, kdy a v jakém kroku proběhlo)
|
||||
*
|
||||
* Kontext v architektuře:
|
||||
* - Události jsou vytvářeny během běhu background jobů
|
||||
* (PrepareRunJob, ParseLogJob, MatchQsoGroupJob, …, FinalizeRunJob).
|
||||
* - Jsou úzce svázány s jedním EvaluationRun a nikdy neexistují samostatně.
|
||||
*
|
||||
* Typické použití:
|
||||
* - Informování UI o aktuálním stavu a průběhu vyhodnocení.
|
||||
* - Záznam varování (např. nevalidní logy, ignorované QSO).
|
||||
* - Záznam chyb, které vedly k selhání kroku nebo celého běhu.
|
||||
*
|
||||
* Pole modelu:
|
||||
* - evaluation_run_id:
|
||||
* Reference na vyhodnocovací běh, ke kterému událost patří.
|
||||
* - level:
|
||||
* Úroveň události (např. info / warning / error / debug).
|
||||
* - message:
|
||||
* Lidsky čitelný popis události, vhodný pro zobrazení v UI.
|
||||
* - context:
|
||||
* Strukturovaná doplňující data (JSON), např.:
|
||||
* - identifikátory logů nebo QSO
|
||||
* - technické detaily chyby
|
||||
* - počty zpracovaných záznamů
|
||||
*
|
||||
* Co model NEDĚLÁ:
|
||||
* - neřídí stav EvaluationRun
|
||||
* - neobsahuje byznys logiku vyhodnocení
|
||||
* - neslouží jako systémový log (nahrazuje pouze audit pipeline)
|
||||
*
|
||||
* Zásady návrhu:
|
||||
* - Události mají být zapisovány sekvenčně během běhu pipeline.
|
||||
* - Neměly by se mazat ani přepisovat (append-only charakter).
|
||||
* - Slouží jako autoritativní zdroj informací o průběhu vyhodnocení.
|
||||
*/
|
||||
class EvaluationRunEvent extends Model
|
||||
{
|
||||
protected $table = 'evaluation_run_events';
|
||||
|
||||
protected $fillable = [
|
||||
'evaluation_run_id',
|
||||
'level',
|
||||
'message',
|
||||
'context',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'evaluation_run_id' => 'integer',
|
||||
'context' => 'array',
|
||||
];
|
||||
|
||||
public function evaluationRun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EvaluationRun::class);
|
||||
}
|
||||
}
|
||||
20
app/Models/File.php
Normal file
20
app/Models/File.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class File extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'path',
|
||||
'filename',
|
||||
'mimetype',
|
||||
'filesize',
|
||||
'hash',
|
||||
'uploaded_by'
|
||||
];
|
||||
}
|
||||
116
app/Models/Log.php
Normal file
116
app/Models/Log.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use App\Models\PowerCategory;
|
||||
|
||||
class Log extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'logs';
|
||||
|
||||
protected $fillable = [
|
||||
'round_id',
|
||||
'file_id',
|
||||
'accepted',
|
||||
'processed',
|
||||
'ip_address',
|
||||
|
||||
'tname',
|
||||
'tdate',
|
||||
'pcall',
|
||||
'pwwlo',
|
||||
'pexch',
|
||||
'psect',
|
||||
'pband',
|
||||
'pclub',
|
||||
'padr1',
|
||||
'padr2',
|
||||
|
||||
'rname',
|
||||
'rcall',
|
||||
'rcoun',
|
||||
'locator',
|
||||
'radr1',
|
||||
'radr2',
|
||||
'rpoco',
|
||||
'rcity',
|
||||
'rphon',
|
||||
'rhbbs',
|
||||
'mope1',
|
||||
'mope2',
|
||||
'stxeq',
|
||||
'srxeq',
|
||||
'sante',
|
||||
'santh',
|
||||
|
||||
'power_watt',
|
||||
'power_category',
|
||||
'power_category_id',
|
||||
'sixhr_category',
|
||||
|
||||
'claimed_qso_count',
|
||||
'claimed_score',
|
||||
'claimed_wwl',
|
||||
'claimed_dxcc',
|
||||
'cqsos',
|
||||
'cqsop',
|
||||
'cwwls',
|
||||
'cwwlb',
|
||||
'cexcs',
|
||||
'cexcb',
|
||||
'cdxcs',
|
||||
'cdxcb',
|
||||
'ctosc',
|
||||
'codxc',
|
||||
|
||||
'remarks',
|
||||
'remarks_eval',
|
||||
'raw_header',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'round_id' => 'integer',
|
||||
'file_id' => 'integer',
|
||||
|
||||
'accepted' => 'boolean',
|
||||
'processed' => 'boolean',
|
||||
|
||||
'power_watt' => 'float',
|
||||
'power_category_id' => 'integer',
|
||||
'sixhr_category' => 'boolean',
|
||||
|
||||
'claimed_qso_count' => 'integer',
|
||||
'claimed_score' => 'integer',
|
||||
];
|
||||
|
||||
public function round(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Round::class);
|
||||
}
|
||||
|
||||
public function file(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(File::class);
|
||||
}
|
||||
|
||||
public function qsos(): HasMany
|
||||
{
|
||||
return $this->hasMany(LogQso::class);
|
||||
}
|
||||
|
||||
public function logResults(): HasMany
|
||||
{
|
||||
return $this->hasMany(LogResult::class);
|
||||
}
|
||||
|
||||
public function powerCategory(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PowerCategory::class);
|
||||
}
|
||||
}
|
||||
70
app/Models/LogOverride.php
Normal file
70
app/Models/LogOverride.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class LogOverride extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'log_overrides';
|
||||
|
||||
protected $fillable = [
|
||||
'evaluation_run_id',
|
||||
'log_id',
|
||||
'forced_log_status',
|
||||
'forced_band_id',
|
||||
'forced_category_id',
|
||||
'forced_power_category_id',
|
||||
'forced_sixhr_category',
|
||||
'forced_power_w',
|
||||
'reason',
|
||||
'context',
|
||||
'created_by_user_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'evaluation_run_id' => 'integer',
|
||||
'log_id' => 'integer',
|
||||
'forced_band_id' => 'integer',
|
||||
'forced_category_id' => 'integer',
|
||||
'forced_power_category_id' => 'integer',
|
||||
'forced_sixhr_category' => 'boolean',
|
||||
'forced_power_w' => 'integer',
|
||||
'context' => 'array',
|
||||
'created_by_user_id' => 'integer',
|
||||
];
|
||||
|
||||
public function evaluationRun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EvaluationRun::class);
|
||||
}
|
||||
|
||||
public function log(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Log::class);
|
||||
}
|
||||
|
||||
public function forcedBand(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Band::class, 'forced_band_id');
|
||||
}
|
||||
|
||||
public function forcedCategory(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class, 'forced_category_id');
|
||||
}
|
||||
|
||||
public function forcedPowerCategory(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PowerCategory::class, 'forced_power_category_id');
|
||||
}
|
||||
|
||||
public function createdByUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_user_id');
|
||||
}
|
||||
}
|
||||
65
app/Models/LogQso.php
Normal file
65
app/Models/LogQso.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class LogQso extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'log_qsos';
|
||||
|
||||
protected $fillable = [
|
||||
'log_id',
|
||||
'qso_index',
|
||||
|
||||
'time_on',
|
||||
'band',
|
||||
'freq_khz',
|
||||
'mode',
|
||||
|
||||
'my_call',
|
||||
'my_rst',
|
||||
'my_serial',
|
||||
'my_locator',
|
||||
|
||||
'dx_call',
|
||||
'dx_rst',
|
||||
'dx_serial',
|
||||
'rx_wwl',
|
||||
'rx_exchange',
|
||||
'mode_code',
|
||||
|
||||
'points',
|
||||
'wwl',
|
||||
'dxcc',
|
||||
'new_exchange',
|
||||
'new_wwl',
|
||||
'new_dxcc',
|
||||
'duplicate_qso',
|
||||
|
||||
'raw_line',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'log_id' => 'integer',
|
||||
'qso_index' => 'integer',
|
||||
|
||||
'time_on' => 'datetime',
|
||||
'freq_khz' => 'integer',
|
||||
'points' => 'integer',
|
||||
|
||||
'new_exchange'=> 'boolean',
|
||||
'new_wwl' => 'boolean',
|
||||
'new_dxcc' => 'boolean',
|
||||
'duplicate_qso'=> 'boolean',
|
||||
];
|
||||
|
||||
public function log(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Log::class);
|
||||
}
|
||||
}
|
||||
125
app/Models/LogResult.php
Normal file
125
app/Models/LogResult.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class LogResult extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'log_results';
|
||||
|
||||
protected $fillable = [
|
||||
'evaluation_run_id',
|
||||
'log_id',
|
||||
|
||||
'band_id',
|
||||
'category_id',
|
||||
'power_category_id',
|
||||
'sixhr_category',
|
||||
'sixhr_ranking_bucket',
|
||||
'operating_window_start_utc',
|
||||
'operating_window_end_utc',
|
||||
'operating_window_2_start_utc',
|
||||
'operating_window_2_end_utc',
|
||||
'operating_window_hours',
|
||||
'operating_window_qso_count',
|
||||
|
||||
'claimed_qso_count',
|
||||
'claimed_score',
|
||||
|
||||
'valid_qso_count',
|
||||
'dupe_qso_count',
|
||||
'busted_qso_count',
|
||||
'other_error_qso_count',
|
||||
'total_qso_count',
|
||||
'discarded_qso_count',
|
||||
'discarded_points',
|
||||
'discarded_qso_percent',
|
||||
'unique_qso_count',
|
||||
|
||||
'official_score',
|
||||
'penalty_score',
|
||||
'base_score',
|
||||
'multiplier_count',
|
||||
'multiplier_score',
|
||||
'score_per_qso',
|
||||
|
||||
'rank_overall',
|
||||
'rank_in_category',
|
||||
'rank_overall_ok',
|
||||
'rank_in_category_ok',
|
||||
|
||||
'status',
|
||||
'status_reason',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'evaluation_run_id' => 'integer',
|
||||
'log_id' => 'integer',
|
||||
'band_id' => 'integer',
|
||||
'category_id' => 'integer',
|
||||
'power_category_id' => 'integer',
|
||||
'sixhr_category' => 'boolean',
|
||||
'sixhr_ranking_bucket' => 'string',
|
||||
'operating_window_start_utc' => 'datetime',
|
||||
'operating_window_end_utc' => 'datetime',
|
||||
'operating_window_2_start_utc' => 'datetime',
|
||||
'operating_window_2_end_utc' => 'datetime',
|
||||
'operating_window_hours' => 'integer',
|
||||
'operating_window_qso_count' => 'integer',
|
||||
|
||||
'claimed_qso_count' => 'integer',
|
||||
'claimed_score' => 'integer',
|
||||
|
||||
'valid_qso_count' => 'integer',
|
||||
'dupe_qso_count' => 'integer',
|
||||
'busted_qso_count' => 'integer',
|
||||
'other_error_qso_count' => 'integer',
|
||||
'total_qso_count' => 'integer',
|
||||
'discarded_qso_count' => 'integer',
|
||||
'discarded_points' => 'integer',
|
||||
'discarded_qso_percent' => 'float',
|
||||
'unique_qso_count' => 'integer',
|
||||
|
||||
'official_score' => 'integer',
|
||||
'penalty_score' => 'integer',
|
||||
'base_score' => 'integer',
|
||||
'multiplier_count' => 'integer',
|
||||
'multiplier_score' => 'integer',
|
||||
'score_per_qso' => 'float',
|
||||
|
||||
'rank_overall' => 'integer',
|
||||
'rank_in_category' => 'integer',
|
||||
'rank_overall_ok' => 'integer',
|
||||
'rank_in_category_ok' => 'integer',
|
||||
];
|
||||
|
||||
public function evaluationRun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EvaluationRun::class);
|
||||
}
|
||||
|
||||
public function log(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Log::class);
|
||||
}
|
||||
|
||||
public function band(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Band::class);
|
||||
}
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
|
||||
public function powerCategory(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PowerCategory::class);
|
||||
}
|
||||
}
|
||||
52
app/Models/NewsPost.php
Normal file
52
app/Models/NewsPost.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Spatie\Translatable\HasTranslations;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
//use App\Policies\NewsPostPolicy;
|
||||
//use Illuminate\Database\Eloquent\Attributes\UsePolicy;
|
||||
|
||||
//#[UsePolicy(NewsPostPolicy::class)]
|
||||
class NewsPost extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasTranslations;
|
||||
|
||||
protected $table = 'news_posts';
|
||||
|
||||
public array $translatable = [
|
||||
'title',
|
||||
'content',
|
||||
'excerpt'
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'title',
|
||||
'slug',
|
||||
'content',
|
||||
'excerpt',
|
||||
'is_published',
|
||||
'published_at',
|
||||
'author_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_published' => 'boolean',
|
||||
'published_at' => 'datetime',
|
||||
'author_id' => 'integer',
|
||||
];
|
||||
|
||||
public function author(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'author_id');
|
||||
}
|
||||
|
||||
// route model binding přes slug (pro /api/news/{slug})
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'slug';
|
||||
}
|
||||
}
|
||||
19
app/Models/PowerCategory.php
Normal file
19
app/Models/PowerCategory.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PowerCategory extends Model
|
||||
{
|
||||
protected $table = 'power_categories';
|
||||
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'order',
|
||||
'power_level'
|
||||
];
|
||||
}
|
||||
56
app/Models/QsoOverride.php
Normal file
56
app/Models/QsoOverride.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class QsoOverride extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'qso_overrides';
|
||||
|
||||
protected $fillable = [
|
||||
'evaluation_run_id',
|
||||
'log_qso_id',
|
||||
'forced_matched_log_qso_id',
|
||||
'forced_status',
|
||||
'forced_points',
|
||||
'forced_penalty',
|
||||
'reason',
|
||||
'context',
|
||||
'created_by_user_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'evaluation_run_id' => 'integer',
|
||||
'log_qso_id' => 'integer',
|
||||
'forced_matched_log_qso_id' => 'integer',
|
||||
'forced_points' => 'float',
|
||||
'forced_penalty' => 'float',
|
||||
'context' => 'array',
|
||||
'created_by_user_id' => 'integer',
|
||||
];
|
||||
|
||||
public function evaluationRun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EvaluationRun::class);
|
||||
}
|
||||
|
||||
public function logQso(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(LogQso::class);
|
||||
}
|
||||
|
||||
public function forcedMatchedLogQso(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(LogQso::class, 'forced_matched_log_qso_id');
|
||||
}
|
||||
|
||||
public function createdByUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_user_id');
|
||||
}
|
||||
}
|
||||
92
app/Models/QsoResult.php
Normal file
92
app/Models/QsoResult.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
class QsoResult extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'qso_results';
|
||||
|
||||
protected $fillable = [
|
||||
'evaluation_run_id',
|
||||
'log_qso_id',
|
||||
|
||||
'is_valid',
|
||||
'is_duplicate',
|
||||
'is_nil',
|
||||
'is_busted_call',
|
||||
'is_busted_rst',
|
||||
'is_busted_exchange',
|
||||
'is_time_out_of_window',
|
||||
'is_operating_window_excluded',
|
||||
|
||||
'points',
|
||||
'penalty_points',
|
||||
'distance_km',
|
||||
'time_diff_sec',
|
||||
|
||||
'wwl',
|
||||
'dxcc',
|
||||
'country',
|
||||
'section',
|
||||
|
||||
'matched_qso_id',
|
||||
'matched_log_qso_id',
|
||||
|
||||
'match_type',
|
||||
'match_confidence',
|
||||
'error_flags',
|
||||
'error_code',
|
||||
'error_side',
|
||||
'error_detail',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'evaluation_run_id' => 'integer',
|
||||
'log_qso_id' => 'integer',
|
||||
|
||||
'is_valid' => 'boolean',
|
||||
'is_duplicate' => 'boolean',
|
||||
'is_nil' => 'boolean',
|
||||
'is_busted_call' => 'boolean',
|
||||
'is_busted_rst' => 'boolean',
|
||||
'is_busted_exchange' => 'boolean',
|
||||
'is_time_out_of_window' => 'boolean',
|
||||
'is_operating_window_excluded' => 'boolean',
|
||||
|
||||
'points' => 'integer',
|
||||
'penalty_points' => 'integer',
|
||||
'distance_km' => 'float',
|
||||
'time_diff_sec' => 'integer',
|
||||
|
||||
'matched_qso_id' => 'integer',
|
||||
'matched_log_qso_id' => 'integer',
|
||||
'error_flags' => 'array',
|
||||
];
|
||||
|
||||
public function evaluationRun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EvaluationRun::class);
|
||||
}
|
||||
|
||||
public function logQso(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(LogQso::class);
|
||||
}
|
||||
|
||||
public function matchedQso(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(LogQso::class, 'matched_qso_id');
|
||||
}
|
||||
|
||||
public function workingQso(): HasOne
|
||||
{
|
||||
return $this->hasOne(WorkingQso::class, 'log_qso_id', 'log_qso_id');
|
||||
}
|
||||
}
|
||||
97
app/Models/Round.php
Normal file
97
app/Models/Round.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Spatie\Translatable\HasTranslations;
|
||||
use App\Models\EvaluationRuleSet;
|
||||
|
||||
class Round extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasTranslations;
|
||||
|
||||
public array $translatable = [
|
||||
'name',
|
||||
'description',
|
||||
];
|
||||
protected $table = 'rounds';
|
||||
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'contest_id',
|
||||
'rule_set_id',
|
||||
'preliminary_evaluation_run_id',
|
||||
'official_evaluation_run_id',
|
||||
'test_evaluation_run_id',
|
||||
'name',
|
||||
'description',
|
||||
'is_active',
|
||||
'is_test',
|
||||
'is_sixhr',
|
||||
'start_time',
|
||||
'end_time',
|
||||
'logs_deadline',
|
||||
'first_check',
|
||||
'second_check',
|
||||
'unique_qso_check',
|
||||
'third_check',
|
||||
'fourth_check',
|
||||
'prelimitary_results',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'contest_id' => 'integer',
|
||||
'rule_set_id' => 'integer',
|
||||
'preliminary_evaluation_run_id' => 'integer',
|
||||
'official_evaluation_run_id' => 'integer',
|
||||
'test_evaluation_run_id' => 'integer',
|
||||
|
||||
'name' => 'array',
|
||||
'description' => 'array',
|
||||
|
||||
'is_active' => 'boolean',
|
||||
'is_test' => 'boolean',
|
||||
'is_sixhr' => 'boolean',
|
||||
|
||||
'start_time' => 'datetime',
|
||||
'end_time' => 'datetime',
|
||||
'logs_deadline' => 'datetime',
|
||||
|
||||
'first_check' => 'datetime',
|
||||
'second_check' => 'datetime',
|
||||
'unique_qso_check' => 'datetime',
|
||||
'third_check' => 'datetime',
|
||||
'fourth_check' => 'datetime',
|
||||
'prelimitary_results'=> 'datetime',
|
||||
];
|
||||
|
||||
public function contest(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Contest::class);
|
||||
}
|
||||
|
||||
public function ruleSet(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EvaluationRuleSet::class, 'rule_set_id');
|
||||
}
|
||||
|
||||
public function bands(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Band::class, 'rounds_bands', 'round_id', 'band_id');
|
||||
}
|
||||
|
||||
public function categories(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Category::class, 'rounds_categories', 'round_id', 'category_id');
|
||||
}
|
||||
|
||||
public function powerCategories(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(PowerCategory::class, 'rounds_power_categories', 'round_id', 'power_category_id');
|
||||
}
|
||||
}
|
||||
53
app/Models/User.php
Normal file
53
app/Models/User.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasFactory, Notifiable, HasApiTokens;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'is_admin',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'is_admin' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
}
|
||||
56
app/Models/WorkingQso.php
Normal file
56
app/Models/WorkingQso.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class WorkingQso extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'working_qsos';
|
||||
|
||||
protected $fillable = [
|
||||
'evaluation_run_id',
|
||||
'log_qso_id',
|
||||
'log_id',
|
||||
'ts_utc',
|
||||
'call_norm',
|
||||
'rcall_norm',
|
||||
'loc_norm',
|
||||
'rloc_norm',
|
||||
'band_id',
|
||||
'mode',
|
||||
'match_key',
|
||||
'dupe_key',
|
||||
'out_of_window',
|
||||
'errors',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'evaluation_run_id' => 'integer',
|
||||
'log_qso_id' => 'integer',
|
||||
'log_id' => 'integer',
|
||||
'band_id' => 'integer',
|
||||
'out_of_window' => 'boolean',
|
||||
'errors' => 'array',
|
||||
'ts_utc' => 'datetime',
|
||||
];
|
||||
|
||||
public function evaluationRun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EvaluationRun::class);
|
||||
}
|
||||
|
||||
public function logQso(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(LogQso::class);
|
||||
}
|
||||
|
||||
public function log(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Log::class);
|
||||
}
|
||||
}
|
||||
25
app/Policies/BandPolicy.php
Normal file
25
app/Policies/BandPolicy.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Band;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class BandPolicy
|
||||
{
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function update(User $user, Band $band): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function delete(User $user, Band $band): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
}
|
||||
25
app/Policies/CategoryPolicy.php
Normal file
25
app/Policies/CategoryPolicy.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class CategoryPolicy
|
||||
{
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function update(User $user, Category $category): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function delete(User $user, Category $category): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
}
|
||||
25
app/Policies/ContestParameterPolicy.php
Normal file
25
app/Policies/ContestParameterPolicy.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\ContestParameter;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class ContestParameterPolicy
|
||||
{
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function update(User $user, ContestParameter $contestParameter): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function delete(User $user, ContestParameter $contestParameter): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
}
|
||||
25
app/Policies/ContestPolicy.php
Normal file
25
app/Policies/ContestPolicy.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Contest;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class ContestPolicy
|
||||
{
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function update(User $user, Contest $contest): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function delete(User $user, Contest $contest): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
}
|
||||
25
app/Policies/CountryWwlPolicy.php
Normal file
25
app/Policies/CountryWwlPolicy.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\CountryWwl;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class CountryWwlPolicy
|
||||
{
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function update(User $user, CountryWwl $countryWwl): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function delete(User $user, CountryWwl $countryWwl): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
}
|
||||
26
app/Policies/CtyPolicy.php
Normal file
26
app/Policies/CtyPolicy.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Cty;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class CtyPolicy
|
||||
{
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function update(User $user, Cty $cty): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function delete(User $user, Cty $cty): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
}
|
||||
26
app/Policies/EdiBandPolicy.php
Normal file
26
app/Policies/EdiBandPolicy.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\EdiBand;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class EdiBandPolicy
|
||||
{
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function update(User $user, EdiBand $edi_band): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function delete(User $user, EdiBand $edi_band): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
}
|
||||
25
app/Policies/EdiCategoryPolicy.php
Normal file
25
app/Policies/EdiCategoryPolicy.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\EdiCategory;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class EdiCategoryPolicy
|
||||
{
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function update(User $user, EdiCategory $edi_category): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function delete(User $user, EdiCategory $edi_category): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
}
|
||||
25
app/Policies/EvaluationRuleSetPolicy.php
Normal file
25
app/Policies/EvaluationRuleSetPolicy.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\EvaluationRuleSet;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class EvaluationRuleSetPolicy
|
||||
{
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function update(User $user, EvaluationRuleSet $evaluationRuleSet): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function delete(User $user, EvaluationRuleSet $evaluationRuleSet): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
}
|
||||
26
app/Policies/EvaluationRunPolicy.php
Normal file
26
app/Policies/EvaluationRunPolicy.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\EvaluationRun;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class EvaluationRunPolicy
|
||||
{
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function update(User $user, EvaluationRun $evaluationRun): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function delete(User $user, EvaluationRun $evaluationRun): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
}
|
||||
20
app/Policies/FilePolicy.php
Normal file
20
app/Policies/FilePolicy.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\File;
|
||||
use App\Models\User;
|
||||
|
||||
class FilePolicy
|
||||
{
|
||||
public function update(User $user, EvaluationRun $evaluationRun): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function delete(User $user, File $file): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
}
|
||||
24
app/Policies/LogOverridePolicy.php
Normal file
24
app/Policies/LogOverridePolicy.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\LogOverride;
|
||||
use App\Models\User;
|
||||
|
||||
class LogOverridePolicy
|
||||
{
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function update(User $user, LogOverride $logOverride): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function delete(User $user, LogOverride $logOverride): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
}
|
||||
25
app/Policies/LogPolicy.php
Normal file
25
app/Policies/LogPolicy.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Log;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class LogPolicy
|
||||
{
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function update(User $user, Log $log): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function delete(User $user, Log $log): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
}
|
||||
25
app/Policies/LogQsoPolicy.php
Normal file
25
app/Policies/LogQsoPolicy.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\LogQso;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class LogQsoPolicy
|
||||
{
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function update(User $user, LogQso $logQso): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function delete(User $user, LogQso $logQso): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
}
|
||||
26
app/Policies/LogResultPolicy.php
Normal file
26
app/Policies/LogResultPolicy.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\LogResult;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class LogResultPolicy
|
||||
{
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function update(User $user, LogResult $logResult): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function delete(User $user, LogResult $logResult): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
}
|
||||
25
app/Policies/NewsPostPolicy.php
Normal file
25
app/Policies/NewsPostPolicy.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\NewsPost;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class NewsPostPolicy
|
||||
{
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return (bool) $user->is_admin; // nebo kontrola role
|
||||
}
|
||||
|
||||
public function update(User $user, NewsPost $news): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function delete(User $user, NewsPost $news): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
}
|
||||
25
app/Policies/PowerCategoryPolicy.php
Normal file
25
app/Policies/PowerCategoryPolicy.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\PowerCategory;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class PowerCategoryPolicy
|
||||
{
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function update(User $user, PowerCategory $powerCategory): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function delete(User $user, PowerCategory $powerCategory): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
}
|
||||
24
app/Policies/QsoOverridePolicy.php
Normal file
24
app/Policies/QsoOverridePolicy.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\QsoOverride;
|
||||
use App\Models\User;
|
||||
|
||||
class QsoOverridePolicy
|
||||
{
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function update(User $user, QsoOverride $qsoOverride): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function delete(User $user, QsoOverride $qsoOverride): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
}
|
||||
25
app/Policies/QsoResultPolicy.php
Normal file
25
app/Policies/QsoResultPolicy.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\QsoResult;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class QsoResultPolicy
|
||||
{
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function update(User $user, QsoResult $qsoResult): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function delete(User $user, QsoResult $qsoResult): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
}
|
||||
25
app/Policies/RoundPolicy.php
Normal file
25
app/Policies/RoundPolicy.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Round;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class RoundPolicy
|
||||
{
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function update(User $user, Round $round): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function delete(User $user, Round $round): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
}
|
||||
33
app/Policies/UserPolicy.php
Normal file
33
app/Policies/UserPolicy.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
class UserPolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function view(User $user, User $model): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function update(User $user, User $model): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
|
||||
public function delete(User $user, User $model): bool
|
||||
{
|
||||
return (bool) $user->is_admin;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user