381 lines
13 KiB
PHP
381 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Evaluation;
|
|
|
|
use App\Models\Log;
|
|
use App\Models\LogQso;
|
|
use App\Models\PowerCategory;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
/**
|
|
* Service: EdiParserService
|
|
*
|
|
* Účel:
|
|
* - Zajišťuje parsing a syntaktickou validaci EDI souborů ve formátu REG1TEST.
|
|
* - Převádí surový textový EDI vstup do strukturovaných datových struktur
|
|
* použitelných pro další kroky vyhodnocovací pipeline.
|
|
*
|
|
* Kontext v architektuře:
|
|
* - Používá se výhradně v rámci vyhodnocovacího procesu (EvaluationRun).
|
|
* - Typicky je volán z jobu ParseLogJob nebo nepřímo přes EvaluationCoordinator.
|
|
* - Neobsahuje žádnou vyhodnocovací logiku (matching, scoring).
|
|
*
|
|
* Co služba dělá:
|
|
* - Načte obsah EDI souboru (již uloženého ve File storage).
|
|
* - Provede syntaktickou validaci podle specifikace REG1TEST:
|
|
* - sekce hlavičky
|
|
* - povinné klíče
|
|
* - struktura QSORecords
|
|
* - Parsuje hlavičku EDI souboru:
|
|
* - identifikace stanice (PCall, PWWLo)
|
|
* - metadata závodu (TName, TDate, PBand, PSect, SPowe, ...)
|
|
* - deklarované (claimed) hodnoty
|
|
* - Parsuje QSORecords do jednotlivých QSO položek se správným typováním:
|
|
* - datum / čas
|
|
* - volací znaky
|
|
* - reporty (RST)
|
|
* - pořadová čísla QSO
|
|
* - lokátory, kódy, flagy
|
|
* - Provádí základní normalizaci vstupních dat:
|
|
* - trimming, uppercase
|
|
* - základní kontrolu formátů (datum, čas, lokátor)
|
|
*
|
|
* Co služba NEDĚLÁ:
|
|
* - neřeší správnost QSO (NIL, busted, duplicity)
|
|
* - nepočítá vzdálenosti ani body
|
|
* - neaplikuje pravidla soutěže
|
|
* - neukládá finální výsledky vyhodnocení
|
|
*
|
|
* Výstupy služby:
|
|
* - Strukturovaná data vhodná pro uložení do entit Log a LogQso
|
|
* (nebo odpovídajících DTO / Value Objects).
|
|
* - Informace o syntaktických chybách EDI (pro logování a diagnostiku).
|
|
*
|
|
* Zásady návrhu:
|
|
* - Služba musí být deterministická: stejný EDI vstup => stejný výstup.
|
|
* - Musí být idempotentní z pohledu pipeline (opakované parsování
|
|
* nesmí vytvářet nekonzistentní data).
|
|
* - Implementace má striktně vycházet ze specifikace REG1TEST.
|
|
* - Veškeré heuristiky nebo odchylky od specifikace musí být explicitní
|
|
* a dobře zdokumentované.
|
|
*/
|
|
class EdiParserService
|
|
{
|
|
/**
|
|
* Služba je bezstavová (stateless).
|
|
*
|
|
* Všechny závislosti (např. helpery pro validaci, mapování pásma,
|
|
* případně přístup ke konfiguraci REG1TEST) mají být injektovány
|
|
* přes DI container.
|
|
*/
|
|
public function __construct()
|
|
{
|
|
//
|
|
}
|
|
|
|
/**
|
|
* Naparsuje EDI soubor a zapíše/aktualizuje data v Log a LogQso.
|
|
* Založeno na původní logice z LogController::parseUploadedFile.
|
|
*/
|
|
public function parseLogFile(Log $log, string $path): void
|
|
{
|
|
if (! Storage::exists($path)) {
|
|
return;
|
|
}
|
|
|
|
// Parser pracuje se souborem už uloženým ve storage.
|
|
$content = $this->sanitizeToUtf8(Storage::get($path));
|
|
$trimmed = ltrim($content);
|
|
|
|
// REG1TEST je jediný podporovaný formát v tomto parseru.
|
|
if (! str_starts_with($trimmed, '[REG1TEST')) {
|
|
return;
|
|
}
|
|
|
|
$lines = preg_split('/\r\n|\r|\n/', $content);
|
|
$headerLines = [];
|
|
$data = [];
|
|
$remarksLines = [];
|
|
$section = 'header';
|
|
|
|
// Rozdělení do sekcí: header / remarks / QSORecords.
|
|
foreach ($lines as $line) {
|
|
if (str_starts_with($line, '[QSORecords')) {
|
|
$section = 'qso';
|
|
continue;
|
|
}
|
|
if (str_starts_with($line, '[Remarks')) {
|
|
$section = 'remarks';
|
|
continue;
|
|
}
|
|
|
|
if ($section === 'header') {
|
|
$headerLines[] = $line;
|
|
$trimmedLine = trim($line);
|
|
if (preg_match('/^([A-Za-z0-9]+)=(.*)$/', $trimmedLine, $m)) {
|
|
$key = mb_strtoupper($m[1]);
|
|
$val = trim($m[2]);
|
|
$data[$key] = $val;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if ($section === 'remarks') {
|
|
$remarksLines[] = $line;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Mapování header klíčů na sloupce Log.
|
|
$update = [];
|
|
$map = [
|
|
'TNAME' => 'tname',
|
|
'TDATE' => 'tdate',
|
|
'PCALL' => 'pcall',
|
|
'PWWLO' => 'pwwlo',
|
|
'PEXCH' => 'pexch',
|
|
'PSECT' => 'psect',
|
|
'PBAND' => 'pband',
|
|
'PCLUB' => 'pclub',
|
|
'PADR1' => 'padr1',
|
|
'PADR2' => 'padr2',
|
|
'RNAME' => 'rname',
|
|
'RCALL' => 'rcall',
|
|
'RCOUN' => 'rcoun',
|
|
'LOCATOR' => 'locator',
|
|
'RADR1' => 'radr1',
|
|
'RADR2' => 'radr2',
|
|
'RPOCO' => 'rpoco',
|
|
'RCITY' => 'rcity',
|
|
'RPHON' => 'rphon',
|
|
'RHBBS' => 'rhbbs',
|
|
'MOPE1' => 'mope1',
|
|
'MOPE2' => 'mope2',
|
|
'STXEQ' => 'stxeq',
|
|
'SRXEQ' => 'srxeq',
|
|
'SANTE' => 'sante',
|
|
'SANTH' => 'santh',
|
|
'CQSOS' => 'cqsos',
|
|
'CQSOP' => 'cqsop',
|
|
'CWWLS' => 'cwwls',
|
|
'CWWLB' => 'cwwlb',
|
|
'CEXCS' => 'cexcs',
|
|
'CEXCB' => 'cexcb',
|
|
'CDXCS' => 'cdxcs',
|
|
'CDXCB' => 'cdxcb',
|
|
'CTOSC' => 'ctosc',
|
|
'CODXC' => 'codxc',
|
|
];
|
|
|
|
foreach ($map as $ediKey => $dbKey) {
|
|
if (isset($data[$ediKey]) && $data[$ediKey] !== '') {
|
|
$update[$dbKey] = $data[$ediKey];
|
|
}
|
|
}
|
|
|
|
// Detekce 6H kategorie z PSect (založeno na tokenu v hlavičce).
|
|
if (isset($data['PSECT'])
|
|
&& (stripos($data['PSECT'], '6H') !== false
|
|
|| stripos($data['PSECT'], '6') !== false)
|
|
) {
|
|
$update['sixhr_category'] = true;
|
|
}
|
|
|
|
$rawHeader = implode("\n", $headerLines);
|
|
if ($rawHeader !== '') {
|
|
$update['raw_header'] = $rawHeader;
|
|
}
|
|
|
|
if (! empty($update)) {
|
|
$log->fill($update);
|
|
$log->save();
|
|
}
|
|
|
|
// Claims / totals jsou deklarované hodnoty účastníka v hlavičce.
|
|
$claimedQsoRaw = $data['CQSOS'] ?? $log->cqsos ?? null;
|
|
if ($claimedQsoRaw !== null && $log->claimed_qso_count === null) {
|
|
$parts = explode(';', (string) $claimedQsoRaw);
|
|
$log->claimed_qso_count = isset($parts[0]) ? (int) $parts[0] : null;
|
|
}
|
|
$claimedScoreRaw = $data['CTOSC'] ?? $log->ctosc ?? null;
|
|
if ($claimedScoreRaw !== null && $log->claimed_score === null) {
|
|
$log->claimed_score = is_numeric($claimedScoreRaw) ? (int) $claimedScoreRaw : null;
|
|
}
|
|
$claimedWwlRaw = $data['CWWLS'] ?? $log->cwwls ?? null;
|
|
if ($claimedWwlRaw !== null && $log->claimed_wwl === null) {
|
|
$parts = explode(';', (string) $claimedWwlRaw);
|
|
$log->claimed_wwl = isset($parts[0]) ? $parts[0] : null;
|
|
}
|
|
$claimedDxccRaw = $data['CDXCS'] ?? $log->cdxcs ?? null;
|
|
if ($claimedDxccRaw !== null && $log->claimed_dxcc === null) {
|
|
$parts = explode(';', (string) $claimedDxccRaw);
|
|
$log->claimed_dxcc = isset($parts[0]) ? $parts[0] : null;
|
|
}
|
|
// SPowe se ukládá jako výkon ve wattech, akceptujeme i desetinný formát 0,5 / 0.5.
|
|
if (isset($data['SPOWE'])) {
|
|
$parsedPower = $this->parseSpoweValue($data['SPOWE']);
|
|
if ($parsedPower !== null) {
|
|
$log->power_watt = $parsedPower;
|
|
}
|
|
}
|
|
if (isset($data['PSECT'])) {
|
|
// Power kategorie se odvozuje z PSect (fallback z výkonu).
|
|
$powerName = $this->extractPowerFromPsect($data['PSECT'], $log->power_watt) ?? 'A';
|
|
$log->power_category = $powerName;
|
|
$log->power_category_id = PowerCategory::whereRaw('UPPER(name) = ?', [mb_strtoupper($powerName)])
|
|
->value('id');
|
|
}
|
|
$log->save();
|
|
|
|
if (! empty($remarksLines)) {
|
|
$log->remarks = implode("\n", $remarksLines);
|
|
$log->save();
|
|
}
|
|
|
|
// Smazat staré QSO a znovu naparsovat QSORecords (idempotence).
|
|
LogQso::where('log_id', $log->id)->delete();
|
|
|
|
$qsoIndex = 1;
|
|
$qsoStarted = false;
|
|
|
|
foreach ($lines as $line) {
|
|
if (str_starts_with($line, '[QSORecords')) {
|
|
$qsoStarted = true;
|
|
continue;
|
|
}
|
|
if (str_starts_with($line, '[END')) {
|
|
break;
|
|
}
|
|
if (! $qsoStarted) {
|
|
continue;
|
|
}
|
|
if (trim($line) === '' || str_starts_with($line, '[')) {
|
|
continue;
|
|
}
|
|
|
|
// QSO záznam je oddělený středníky podle REG1TEST.
|
|
$parts = explode(';', $line);
|
|
if (count($parts) < 4) {
|
|
continue;
|
|
}
|
|
|
|
$dateRaw = $parts[0] ?? '';
|
|
$timeRaw = $parts[1] ?? '';
|
|
|
|
$timeOn = null;
|
|
if (strlen($dateRaw) === 6 && strlen($timeRaw) >= 3) {
|
|
$year = substr($dateRaw, 0, 2);
|
|
$month = substr($dateRaw, 2, 2);
|
|
$day = substr($dateRaw, 4, 2);
|
|
$timeRaw = str_pad($timeRaw, 4, '0', STR_PAD_LEFT);
|
|
$hour = substr($timeRaw, 0, 2);
|
|
$minute = substr($timeRaw, 2, 2);
|
|
$yearFull = 2000 + (int) $year;
|
|
$validDate = checkdate((int) $month, (int) $day, $yearFull);
|
|
$validTime = (int) $hour >= 0 && (int) $hour <= 23 && (int) $minute >= 0 && (int) $minute <= 59;
|
|
if ($validDate && $validTime) {
|
|
$timeOn = sprintf('20%s-%s-%s %s:%s:00', $year, $month, $day, $hour, $minute);
|
|
}
|
|
}
|
|
|
|
$qsoData = [
|
|
'log_id' => $log->id,
|
|
'qso_index' => $qsoIndex++,
|
|
'time_on' => $timeOn,
|
|
'band' => $log->pband,
|
|
'freq_khz' => null,
|
|
'my_call' => $log->pcall,
|
|
'my_locator' => $log->pwwlo,
|
|
'mode_code' => $parts[3] ?? null,
|
|
'dx_call' => $parts[2] ?? null,
|
|
'my_rst' => $parts[4] ?? null,
|
|
'my_serial' => $parts[5] ?? null,
|
|
'dx_rst' => $parts[6] ?? null,
|
|
'dx_serial' => $parts[7] ?? null,
|
|
'rx_exchange' => $parts[8] ?? null,
|
|
'rx_wwl' => $parts[9] ?? null,
|
|
'points' => isset($parts[10]) ? (int) $parts[10] : null,
|
|
'new_exchange'=> (isset($parts[11]) && strtoupper($parts[11]) === 'N') ? true : null,
|
|
'new_wwl' => (isset($parts[12]) && strtoupper($parts[12]) === 'N') ? true : null,
|
|
'new_dxcc' => (isset($parts[13]) && strtoupper($parts[13]) === 'N') ? true : null,
|
|
'duplicate_qso'=> (isset($parts[14]) && strtoupper($parts[14]) === 'D') ? true : null,
|
|
'raw_line' => $line,
|
|
];
|
|
|
|
LogQso::create($qsoData);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Snaží se převést obsah do validního UTF-8, ignoruje nevalidní sekvence.
|
|
*/
|
|
protected function sanitizeToUtf8(string $content): string
|
|
{
|
|
if (str_starts_with($content, "\xEF\xBB\xBF")) {
|
|
$content = substr($content, 3);
|
|
}
|
|
|
|
$converted = @iconv('UTF-8', 'UTF-8//IGNORE', $content);
|
|
if ($converted === false) {
|
|
$converted = mb_convert_encoding($content, 'UTF-8', 'auto');
|
|
}
|
|
|
|
return $converted ?? '';
|
|
}
|
|
|
|
protected function extractPowerFromPsect(string $psect, ?float $powerWatt): ?string
|
|
{
|
|
$value = mb_strtoupper($psect);
|
|
if (preg_match('/\\bQRP\\b/', $value)) {
|
|
return 'QRP';
|
|
}
|
|
if (preg_match('/\\bLP\\b/', $value)) {
|
|
return 'LP';
|
|
}
|
|
if (preg_match('/\\bN\\b/', $value)) {
|
|
return 'N';
|
|
}
|
|
if (preg_match('/\\bA\\b/', $value)) {
|
|
return 'A';
|
|
}
|
|
if ($powerWatt !== null) {
|
|
$category = PowerCategory::whereNotNull('power_level')
|
|
->where('power_level', '>=', $powerWatt)
|
|
->orderBy('power_level')
|
|
->first();
|
|
if ($category && $category->name) {
|
|
return mb_strtoupper($category->name);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
protected function parseSpoweValue(?string $raw): ?float
|
|
{
|
|
$value = trim((string) $raw);
|
|
if ($value === '') {
|
|
return null;
|
|
}
|
|
|
|
$compact = strtolower(preg_replace('/\s+/', '', $value) ?? '');
|
|
$factor = 1.0;
|
|
if (str_ends_with($compact, 'kw')) {
|
|
$compact = substr($compact, 0, -2);
|
|
$factor = 1000.0;
|
|
} elseif (str_ends_with($compact, 'mw')) {
|
|
$compact = substr($compact, 0, -2);
|
|
$factor = 0.001;
|
|
} elseif (str_ends_with($compact, 'w')) {
|
|
$compact = substr($compact, 0, -1);
|
|
}
|
|
|
|
$compact = str_replace(',', '.', $compact);
|
|
if (! preg_match('/^[0-9]*\\.?[0-9]+$/', $compact)) {
|
|
return null;
|
|
}
|
|
|
|
$numeric = (float) $compact * $factor;
|
|
return is_finite($numeric) ? $numeric : null;
|
|
}
|
|
}
|