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