| | ... * Row N+3: 1 | | | ... * ... (only 4 rows for documents — weights 0.5/1/1.5/2) * * Row M: "NON - DOCUMENT" (or "NON-DOCUMENT" or "NON DOCUMENT") * Row M+1: "Weight (kg)" | Zone 1 | ... | Zone 10 * Row M+2: 0.5 | | ... * ... up to 30 kg * * Sheet "Zone": * Columns A-C: No. | Destination Country | Parcel Zone * Columns E-G: No. | Destination Country | Document Zone * * The importer is tolerant of: * - Whitespace variations in section markers * - Empty cells (skipped, not stored) * - Weights in any order (parsed as decimals) * - Either "Import Rate" / "Export Rate" sheet present (other one optional) * - Currency defaults to 'AED' but can be overridden via second param */ class RateImportService { public const SHEET_NAMES = [ 'import' => ['Import Rate', 'ImportRate', 'Import'], 'export' => ['Export Rate', 'ExportRate', 'Export'], ]; public const SECTION_MARKERS = [ 'document' => ['DOCUMENT', 'DOC', 'DOC NORMAL', 'DOC_NORMAL', 'DOCNORMAL'], 'parcel' => ['NON - DOCUMENT', 'NON-DOCUMENT', 'NON DOCUMENT', 'NONDOC', 'NON DOC', 'PARCEL', 'NON-DOC', 'NON - DOC'], ]; /** Result summary returned to caller for Filament notification */ public array $summary = [ 'imported' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => [], 'zones_updated' => 0, ]; /** * Main entry point. * * @param string $filePath Absolute path to the uploaded .xlsx * @param string $currency ISO 4217 code, default 'AED' * @return array Summary array */ public function import(string $filePath, string $currency = 'AED'): array { // Reset summary $this->summary = [ 'imported' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => [], 'zones_updated' => 0, ]; if (! file_exists($filePath)) { throw new Exception("File not found: {$filePath}"); } $spreadsheet = IOFactory::load($filePath); // 1. Parse Import Rate sheet (if present) foreach (self::SHEET_NAMES['import'] as $name) { if ($spreadsheet->sheetNameExists($name)) { $this->importRateSheet($spreadsheet->getSheetByName($name), 'import', $currency); break; } } // 2. Parse Export Rate sheet (if present) foreach (self::SHEET_NAMES['export'] as $name) { if ($spreadsheet->sheetNameExists($name)) { $this->importRateSheet($spreadsheet->getSheetByName($name), 'export', $currency); break; } } // 3. Parse Zone sheet (if present) if ($spreadsheet->sheetNameExists('Zone')) { $this->importZoneSheet($spreadsheet->getSheetByName('Zone')); } elseif ($spreadsheet->sheetNameExists('Zones')) { $this->importZoneSheet($spreadsheet->getSheetByName('Zones')); } return $this->summary; } /** * Parse one rate sheet (Import Rate or Export Rate). * The sheet has 2 sections (DOCUMENT + NON-DOCUMENT), each with a header row. */ protected function importRateSheet(Worksheet $ws, string $direction, string $currency): void { $maxRow = $ws->getHighestDataRow(); $maxCol = $ws->getHighestDataColumn(); $maxColIdx = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::columnIndexFromString($maxCol); // Walk every row, detect section markers, then parse the matrix that follows. $currentSection = null; // 'document' | 'parcel' $headerRowIdx = null; for ($r = 1; $r <= $maxRow; $r++) { $rowValues = []; for ($c = 1; $c <= min($maxColIdx, 11); $c++) { $rowValues[$c] = $ws->getCellByColumnAndRow($c, $r)->getCalculatedValue(); } // Detect section markers $matchedSection = $this->detectSection($rowValues); if ($matchedSection !== null) { $currentSection = $matchedSection; $headerRowIdx = null; continue; } // Detect header row ("Weight (kg)" + "Zone 1".."Zone N") if ($currentSection !== null && $headerRowIdx === null) { if ($this->isHeaderRow($rowValues)) { $headerRowIdx = $r; continue; } } // If we have a section + header, parse data rows if ($currentSection !== null && $headerRowIdx !== null) { $parsed = $this->parseRateRow($rowValues); if ($parsed === null) { // Either empty row or non-numeric weight — end of this section if ($this->isEmptyRow($rowValues)) { $currentSection = null; $headerRowIdx = null; } continue; } ['weight' => $weight, 'rates' => $rates] = $parsed; foreach ($rates as $zone => $rate) { if ($rate === null) { $this->summary['skipped']++; continue; } $this->upsertRate($direction, $currentSection, $weight, $zone, (float) $rate, $currency); } } } } /** * Detect whether a row is a section marker (DOCUMENT / NON-DOCUMENT). * Returns 'document' | 'parcel' | null. */ protected function detectSection(array $rowValues): ?string { foreach ($rowValues as $val) { if (! is_string($val) || trim($val) === '') { continue; } $normalized = strtoupper(trim($val)); // Check DOCUMENT first (because "NON-DOCUMENT" contains "DOCUMENT") foreach (self::SECTION_MARKERS['parcel'] as $marker) { if ($normalized === strtoupper($marker)) { return 'parcel'; } } foreach (self::SECTION_MARKERS['document'] as $marker) { if ($normalized === strtoupper($marker)) { return 'document'; } } } return null; } /** * Is this row a column header? * Detects two formats: * ["Weight (kg)", "Zone 1", "Zone 2", ..., "Zone N"] ← template format * ["Weight (kg)", 1, 2, 3, ..., 10] ← customer's operational format */ protected function isHeaderRow(array $rowValues): bool { if (! isset($rowValues[1]) || ! is_string($rowValues[1])) { return false; } $first = strtolower(trim($rowValues[1])); if (! in_array($first, ['weight (kg)', 'weight', 'weight(kg)', 'wt', 'wt (kg)'])) { return false; } // Count zone columns — either "Zone N" strings OR plain integers starting at 1 $zoneCount = 0; $expectedInt = 1; for ($c = 2; $c <= 11; $c++) { $val = $rowValues[$c] ?? null; if ($val === null || $val === '') continue; // Case A: "Zone 1" string format if (is_string($val) && preg_match('/^zone\s*(\d+)$/i', trim($val), $m)) { $zoneCount++; continue; } // Case B: plain integer 1, 2, 3, ... (customer's operational format) if (is_int($val) || (is_string($val) && preg_match('/^\d+$/', trim($val)))) { $intVal = (int) $val; if ($intVal === $expectedInt) { $zoneCount++; $expectedInt++; continue; } } } return $zoneCount >= 3; // require at least 3 zone columns to consider this a header } /** * Parse a data row: weight in col 1, rates in cols 2..11 (Zone 1..10). * Returns ['weight' => float, 'rates' => [zone => rate|null, ...]] or null. */ protected function parseRateRow(array $rowValues): ?array { if (! isset($rowValues[1]) || $rowValues[1] === null || $rowValues[1] === '') { return null; } $weightVal = $rowValues[1]; if (is_string($weightVal)) { $weightVal = trim($weightVal); if ($weightVal === '' || ! is_numeric($weightVal)) { return null; } $weightVal = (float) $weightVal; } elseif (! is_numeric($weightVal)) { return null; } else { $weightVal = (float) $weightVal; } $weightVal = round($weightVal, 3); $rates = []; for ($c = 2; $c <= 11; $c++) { $zone = $c - 1; // col 2 -> Zone 1, col 11 -> Zone 10 $val = $rowValues[$c] ?? null; if ($val === null || $val === '' || (is_string($val) && trim($val) === '')) { $rates[$zone] = null; continue; } if (is_numeric($val)) { $rates[$zone] = (float) $val; } else { // Non-numeric content in rate cell — log and skip $this->summary['errors'][] = "Non-numeric rate at weight={$weightVal} zone={$zone}: " . substr((string)$val, 0, 40); $rates[$zone] = null; } } return ['weight' => $weightVal, 'rates' => $rates]; } protected function isEmptyRow(array $rowValues): bool { foreach ($rowValues as $v) { if ($v !== null && $v !== '' && !(is_string($v) && trim($v) === '')) { return false; } } return true; } /** * Upsert a rate row. Within a transaction. */ protected function upsertRate(string $direction, string $serviceType, float $weight, int $zone, float $rate, string $currency): void { try { $existing = RateEntry::where([ 'direction' => $direction, 'service_type' => $serviceType, 'weight' => $weight, 'zone' => $zone, 'currency' => $currency, ])->where('effective_at', '>=', now()->startOfDay()) ->first(); if ($existing) { $existing->update(['rate' => $rate]); $this->summary['updated']++; } else { RateEntry::create([ 'direction' => $direction, 'service_type' => $serviceType, 'weight' => $weight, 'zone' => $zone, 'rate' => $rate, 'currency' => $currency, 'effective_at' => now(), ]); $this->summary['imported']++; } } catch (Exception $e) { $this->summary['errors'][] = "DB error: {$direction}×{$serviceType} w={$weight} z={$zone}: " . $e->getMessage(); Log::warning('RateImport: ' . $e->getMessage()); } } /** * Parse the Zone sheet for country → parcel_zone / document_zone mapping. * Layout: * A: No. B: Country (PARCEL) C: Parcel Zone * E: No. F: Country (DOCUMENT) G: Document Zone */ protected function importZoneSheet(Worksheet $ws): void { $maxRow = $ws->getHighestDataRow(); // Find the header row (contains "Country" or "Destination Country") $headerRow = null; for ($r = 1; $r <= min(20, $maxRow); $r++) { $b = $ws->getCellByColumnAndRow(2, $r)->getCalculatedValue(); $f = $ws->getCellByColumnAndRow(6, $r)->getCalculatedValue(); if ((is_string($b) && stripos($b, 'country') !== false) || (is_string($f) && stripos($f, 'country') !== false)) { $headerRow = $r; break; } } if ($headerRow === null) { $headerRow = 3; // fallback } for ($r = $headerRow + 1; $r <= $maxRow; $r++) { $parcelCountry = $ws->getCellByColumnAndRow(2, $r)->getCalculatedValue(); $parcelZone = $ws->getCellByColumnAndRow(3, $r)->getCalculatedValue(); $documentCountry = $ws->getCellByColumnAndRow(6, $r)->getCalculatedValue(); $documentZone = $ws->getCellByColumnAndRow(7, $r)->getCalculatedValue(); // PARCEL side if (is_string($parcelCountry) && trim($parcelCountry) !== '' && $parcelZone !== null && $parcelZone !== '' && (int)$parcelZone > 0) { $this->upsertCountryZone(trim($parcelCountry), parcelZone: (int)$parcelZone); } // DOCUMENT side if (is_string($documentCountry) && trim($documentCountry) !== '' && $documentZone !== null && $documentZone !== '' && (int)$documentZone > 0) { $this->upsertCountryZone(trim($documentCountry), documentZone: (int)$documentZone); } } } protected function upsertCountryZone(string $country, ?int $parcelZone = null, ?int $documentZone = null): void { try { $row = CountryZone::where('country_name', $country)->first(); if ($row) { $update = []; if ($parcelZone !== null) $update['parcel_zone'] = $parcelZone; if ($documentZone !== null) $update['document_zone'] = $documentZone; if (! empty($update)) { $row->update($update); $this->summary['zones_updated']++; } } else { CountryZone::create([ 'country_name' => $country, 'parcel_zone' => $parcelZone, 'document_zone' => $documentZone, ]); $this->summary['zones_updated']++; } } catch (Exception $e) { $this->summary['errors'][] = "Country zone error ({$country}): " . $e->getMessage(); } } }