Files
vkv/app/Services/Evaluation/EdiParserService.php
Zdeněk Burda 41e3ce6f25 Initial commit
2026-01-09 21:26:40 +01:00

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