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