Initial commit

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

18
.editorconfig Normal file
View File

@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[compose.yaml]
indent_size = 4

65
.env.example Normal file
View File

@@ -0,0 +1,65 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=mariadb
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=vkv
DB_USERNAME=vkv
DB_PASSWORD=vkv
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"

11
.gitattributes vendored Normal file
View File

@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore

48
.gitignore vendored
View File

@@ -1,32 +1,24 @@
# ---> Laravel *.log
/vendor/ .DS_Store
node_modules/
npm-debug.log
yarn-error.log
# Laravel 4 specific
bootstrap/compiled.php
app/storage/
# Laravel 5 & Lumen specific
public/storage
public/hot
# Laravel 5 & Lumen specific with changed public path
public_html/storage
public_html/hot
storage/*.key
.env .env
Homestead.yaml
Homestead.json
/.vagrant
.phpunit.result.cache
/public/build
/storage/pail
.env.backup .env.backup
.env.production .env.production
.phpactor.json .phpactor.json
auth.json .phpunit.result.cache
/.fleet
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
Homestead.json
Homestead.yaml
Thumbs.db

95
Dockerfile Normal file
View File

@@ -0,0 +1,95 @@
# Dockerfile
# Multi-stage build for Laravel 12 + React SPA (Vite)
# Targets:
# - app: php-fpm runtime with vendor + built assets
# - nginx: separate container (uses official nginx image, see docker-compose + nginx.conf)
############################
# 1) Node build (Vite)
############################
FROM node:20-alpine AS node-build
WORKDIR /app
# Install deps
COPY package.json package-lock.json* pnpm-lock.yaml* yarn.lock* ./
RUN \
if [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable && pnpm i --frozen-lockfile; \
elif [ -f yarn.lock ]; then yarn install --frozen-lockfile; \
else npm i; fi
# Build
COPY resources ./resources
COPY public ./public
COPY vite.config.* hero.ts tailwind.config.* postcss.config.* tsconfig.* ./
RUN \
if [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then pnpm build; \
elif [ -f yarn.lock ]; then yarn build; \
else npm run build; fi
############################
# 2) Composer deps
############################
FROM composer:2 AS composer-build
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --prefer-dist --no-interaction --no-progress --optimize-autoloader --no-scripts
############################
# 3) PHP runtime (php-fpm)
############################
FROM php:8.3-fpm-alpine AS app
WORKDIR /var/www/html
# System deps + PHP extensions
RUN apk add --no-cache \
bash icu-dev libzip-dev oniguruma-dev zlib-dev \
freetype-dev libjpeg-turbo-dev libpng-dev \
git curl \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) \
pdo_mysql mbstring intl zip bcmath exif gd opcache \
&& rm -rf /var/cache/apk/*
# PHP configuration (opcache tuned for prod)
COPY docker/php/opcache.ini /usr/local/etc/php/conf.d/opcache.ini
COPY docker/php/php.ini /usr/local/etc/php/conf.d/php.ini
# Copy application code
COPY . .
# Copy vendor from composer stage
COPY --from=composer-build /app/vendor ./vendor
# Copy built assets from node stage (Vite -> public/build by default)
COPY --from=node-build /app/public/build ./public/build
# Ensure writable dirs
RUN mkdir -p storage bootstrap/cache \
&& chown -R www-data:www-data storage bootstrap/cache
# Entrypoint: caches/migrations optional; starts php-fpm
COPY docker/app/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
USER www-data
EXPOSE 9000
ENTRYPOINT ["/entrypoint.sh"]
CMD ["php-fpm", "-F"]
############################
# 4) Nginx runtime (static + proxy to php-fpm)
############################
FROM nginx:1.27-alpine AS nginx
WORKDIR /var/www/html
# Nginx config
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
# Public files (incl. built Vite assets)
COPY public ./public
COPY --from=node-build /app/public/build ./public/build

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

View 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'];
}
}

View 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();
}
}

View 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();
}
}

View 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();
}
}

View 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,
];
}
}

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

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

View 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']);
}
}
}

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

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

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

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

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

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

View 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;
}
}

View File

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

View 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';
}
}

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

View 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,
];
}
}

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

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

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

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

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

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

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

View File

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

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

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

View 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 zůstat jednoduchý a čitelný;
* komplexní logika patří do controlleru nebo service layer.
*/
class StartEvaluationRunRequest extends FormRequest
{
/**
* Určuje, zda 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 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 [
//
];
}
}

View 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]);
}
}

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

View 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;
}
}

View 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;
}
}
}

View 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;
}
}
}

View 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;
}
}
}

View 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;
}
}
}

View 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;
}
}
}

View 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;
}
}
}

View 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 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
* 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
View 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);
}
}
}

View 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;
}
}
}

File diff suppressed because it is too large Load Diff

120
app/Jobs/ParseLogJob.php Normal file
View 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);
}
}
}
}

View 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
View 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ít minimální vedlejší efekty mimo svůj scope.
* - Pokud příprava selže, 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;
}
}

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

View 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';
}
}

View 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
View 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 být výkonnostně bezpečný (chunking, minimalizace N+1).
* - Pokud scoring jedné skupiny selže, 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 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);
}
}

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

View 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;
}
}
}

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

View File

@@ -0,0 +1,329 @@
<?php
namespace App\Jobs;
use App\Models\EdiBand;
use App\Models\EdiCategory;
use App\Models\Log;
use App\Models\LogResult;
use App\Models\Round;
use App\Services\Evaluation\ClaimedRunResolver;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/**
* Job: UpsertClaimedLogResultJob
*
* Projekce deklarovaných výsledků (claimed) do log_results
* pro CLAIMED evaluation run.
*/
class UpsertClaimedLogResultJob implements ShouldQueue
{
use Queueable;
public function __construct(
protected int $logId
) {
}
public function handle(): void
{
$log = Log::find($this->logId);
if (! $log) {
return;
}
$round = Round::with(['bands', 'categories', 'powerCategories'])->find($log->round_id);
$remarksEval = $this->decodeRemarksEval($log->remarks_eval);
// TDate validace hlídá, že log odpovídá termínu kola.
$tDateInvalid = ! $this->isTDateWithinRound($log->tdate, $round);
if ($tDateInvalid) {
$this->addRemark($remarksEval, 'Datum v TDate neodpovídá termínu závodu.');
$log->remarks_eval = $this->encodeRemarksEval($remarksEval);
$log->save();
}
$categoryId = $this->resolveCategoryId($log, $round, $remarksEval);
$bandId = $this->resolveBandId($log, $round, $remarksEval);
[$powerCategoryId, $powerMismatch] = $this->resolvePowerCategoryId($log, $round, $remarksEval);
// 6H kategorie je povolená jen pro vybraná pásma.
if ($log->sixhr_category && ! $this->isSixHourBand($bandId)) {
$remarksEval[] = '6H kategorie je povolena jen pro pásma 145 MHz a 435 MHz.';
}
$missingClaimedQso = $log->claimed_qso_count === null;
$missingClaimedScore = $log->claimed_score === null;
if ($missingClaimedQso) {
$remarksEval[] = 'Nebyl načten CQSOs.';
}
if ($missingClaimedScore) {
$remarksEval[] = 'Nebyl načten CToSc.';
}
$log->remarks_eval = $this->encodeRemarksEval($remarksEval);
$log->save();
$claimedRun = ClaimedRunResolver::forRound($log->round_id);
$claimedQsoCount = $log->claimed_qso_count ?? 0;
$claimedScore = $log->claimed_score ?? 0;
$scorePerQso = $claimedQsoCount > 0 ? round($claimedScore / $claimedQsoCount, 2) : null;
$status = 'OK';
$statusReason = null;
if ($this->isCheckLog($log)) {
$status = 'CHECK';
}
// IGNORED = log nelze bezpečně zařadit do claimed scoreboardu.
if ($tDateInvalid || $categoryId === null || $bandId === null || $missingClaimedQso || $missingClaimedScore || $powerMismatch) {
$status = 'IGNORED';
$reasons = [];
if ($tDateInvalid) {
$reasons[] = 'TDate mimo termín závodu.';
}
if ($categoryId === null) {
$reasons[] = 'Kategorie nebyla rozpoznána.';
}
if ($bandId === null) {
$reasons[] = 'Pásmo nebylo rozpoznáno.';
}
if ($missingClaimedQso) {
$reasons[] = 'Chybí CQSOs.';
}
if ($missingClaimedScore) {
$reasons[] = 'Chybí CToSc.';
}
if ($powerMismatch) {
$reasons[] = 'Výkon neodpovídá zvolené kategorii.';
}
$statusReason = implode(' ', $reasons);
}
LogResult::updateOrCreate(
['log_id' => $log->id, 'evaluation_run_id' => $claimedRun->id],
[
'evaluation_run_id' => $claimedRun->id,
'category_id' => $categoryId,
'band_id' => $bandId,
'power_category_id' => $powerCategoryId,
'claimed_qso_count' => $log->claimed_qso_count,
'claimed_score' => $log->claimed_score,
'total_qso_count' => $claimedQsoCount,
'discarded_qso_count' => 0,
'discarded_points' => 0,
'discarded_qso_percent' => 0,
'unique_qso_count' => 0,
'score_per_qso' => $scorePerQso,
'official_score' => 0,
'penalty_score' => 0,
'status' => $status,
'status_reason' => $statusReason,
]
);
}
protected function resolveCategoryId(Log $log, ?Round $round, array &$remarksEval): ?int
{
$resolveCategory = function (?string $value) use ($round): ?int {
if (! $value) {
return null;
}
$ediCat = EdiCategory::whereRaw('LOWER(value) = ?', [mb_strtolower(trim($value))])->first();
if (! $ediCat) {
$ediCat = $this->matchEdiCategoryByRegex($value);
if (! $ediCat) {
return null;
}
}
$mappedCategoryId = $ediCat->categories()->value('categories.id');
if (! $mappedCategoryId) {
return null;
}
if ($round && $round->categories()->where('categories.id', $mappedCategoryId)->exists()) {
return $mappedCategoryId;
}
if (! $round || $round->categories()->count() === 0) {
return $mappedCategoryId;
}
return null;
};
// V PSect může být více tokenů zkoušíme je postupně.
$categoryId = null;
if ($log->psect) {
$categoryId = $resolveCategory($log->psect);
if ($categoryId === null) {
$parts = preg_split('/\\s+/', trim((string) $log->psect)) ?: [];
if (count($parts) > 1) {
foreach ($parts as $part) {
$categoryId = $resolveCategory($part);
if ($categoryId !== null) {
break;
}
}
}
}
}
if ($categoryId === null) {
$remarksEval[] = 'Kategorie nebyla rozpoznána.';
}
return $categoryId;
}
protected function matchEdiCategoryByRegex(string $value): ?EdiCategory
{
$candidates = EdiCategory::whereNotNull('regex_pattern')->get();
foreach ($candidates as $candidate) {
$pattern = $candidate->regex_pattern;
if (! $pattern) {
continue;
}
$delimited = preg_match('/^([#\\/]).+\\1[imsxuADSUXJ]*$/', $pattern) ? $pattern : '/' . $pattern . '/i';
set_error_handler(function () {
});
$matched = @preg_match($delimited, $value) === 1;
restore_error_handler();
if ($matched) {
return $candidate;
}
}
return null;
}
protected function resolveBandId(Log $log, ?Round $round, array &$remarksEval): ?int
{
$bandId = null;
if ($log->pband) {
// Nejprve přímá mapa přes EDI bandy, fallback je interval v MHz.
$pbandVal = mb_strtolower(trim($log->pband));
$ediBand = EdiBand::whereRaw('LOWER(value) = ?', [$pbandVal])->first();
if ($ediBand) {
$mappedBandId = $ediBand->bands()->value('bands.id');
if ($mappedBandId) {
if ($round && $round->bands()->where('bands.id', $mappedBandId)->exists()) {
$bandId = $mappedBandId;
} elseif (! $round || $round->bands()->count() === 0) {
$bandId = $mappedBandId;
}
}
} else {
$num = is_numeric($pbandVal) ? (float) $pbandVal : null;
if ($num === null && $log->pband) {
if (preg_match('/([0-9]+(?:[\\.,][0-9]+)?)/', $log->pband, $m)) {
$num = (float) str_replace(',', '.', $m[1]);
}
}
if ($num !== null) {
$bandMatch = \App\Models\Band::where('edi_band_begin', '<=', $num)
->where('edi_band_end', '>=', $num)
->first();
if ($bandMatch) {
if ($round && $round->bands()->where('bands.id', $bandMatch->id)->exists()) {
$bandId = $bandMatch->id;
} elseif (! $round || $round->bands()->count() === 0) {
$bandId = $bandMatch->id;
}
}
}
}
}
if ($bandId === null) {
$remarksEval[] = 'Pásmo nebylo rozpoznáno.';
}
return $bandId;
}
protected function resolvePowerCategoryId(Log $log, ?Round $round, array &$remarksEval): array
{
$powerCategoryId = null;
$powerMismatch = false;
if ($log->power_category_id) {
$powerCategoryId = $log->power_category_id;
}
if ($round && $round->powerCategories()->count() > 0) {
$exists = $round->powerCategories()->where('power_categories.id', $powerCategoryId)->exists();
if (! $exists) {
$powerMismatch = true;
}
}
return [$powerCategoryId, $powerMismatch];
}
protected function isSixHourBand(?int $bandId): bool
{
if (! $bandId) {
return false;
}
return in_array($bandId, [1, 2], true);
}
protected function isCheckLog(Log $log): bool
{
$psect = trim((string) $log->psect);
return $psect !== '' && preg_match('/\\bcheck\\b/i', $psect) === 1;
}
protected function decodeRemarksEval(?string $value): array
{
if (! $value) {
return [];
}
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : [];
}
protected function encodeRemarksEval(array $value): ?string
{
$filtered = array_values(array_filter($value, fn ($item) => is_string($item) && trim($item) !== ''));
$filtered = array_values(array_unique($filtered));
if (count($filtered) === 0) {
return null;
}
return json_encode($filtered, JSON_UNESCAPED_UNICODE);
}
protected function addRemark(array &$remarksEval, string $message): void
{
if (! in_array($message, $remarksEval, true)) {
$remarksEval[] = $message;
}
}
protected function isTDateWithinRound(?string $tdate, ?Round $round): bool
{
if (! $tdate || ! $round || ! $round->start_time || ! $round->end_time) {
return true;
}
$parts = explode(';', $tdate);
if (count($parts) !== 2) {
return false;
}
if (!preg_match('/^\\d{8}$/', $parts[0]) || !preg_match('/^\\d{8}$/', $parts[1])) {
return false;
}
$start = \Carbon\Carbon::createFromFormat('Ymd', $parts[0])->startOfDay();
$end = \Carbon\Carbon::createFromFormat('Ymd', $parts[1])->endOfDay();
return $start->lte($round->end_time) && $end->gte($round->start_time);
}
}

32
app/Models/Band.php Normal file
View 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
View 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
View 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');
}
}

View 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
View 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
View 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
View 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');
}
}

View 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');
}
}

View 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 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();
}
}

View 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';
}
}

View 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';
}
}

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

View 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
View 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
View 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
View 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';
}
}

View 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'
];
}

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

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

Some files were not shown because too many files have changed in this diff Show More