Implement a new pricing architecture based on a 4-quadrant model (Export/Import x Document/Non-Document) to replace the previous 2-zone model. This includes the ability to handle different zones for parcels and documents per country. - Add `RateEntry` and `CountryZone` models for managing complex rate matrices. - Implement `RateImportService` to parse Excel templates using PhpSpreadsheet. - Add `RateImportResource` in Filament for administrative rate uploads. - Refactor `PriceCalculatorService` to utilize the new 4-quadrant lookup logic. - Add database migrations to extend `rate_entries` and create `country_zones`. - Update `DEPLOYMENT.md` with new architectural details and deployment steps.
400 lines
15 KiB
PHP
400 lines
15 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Models\RateEntry;
|
||
use App\Models\CountryZone;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Log;
|
||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||
use PhpOffice\PhpSpreadsheet\Cell\Cell;
|
||
use Exception;
|
||
|
||
/**
|
||
* RateImportService
|
||
* ----------------
|
||
* Parses the customer's operational Excel format and upserts rates
|
||
* into the rate_entries table (4-quadrant model).
|
||
*
|
||
* Expected Excel structure (matches IFNEX_Rate_Template.xlsx):
|
||
*
|
||
* Sheet "Import Rate" (or "Export Rate"):
|
||
* Row N: "DOCUMENT" <- section marker in column E (merged)
|
||
* Row N+1: "Weight (kg)" | Zone 1 | Zone 2 | ... | Zone 10
|
||
* Row N+2: 0.5 | <rate> | <rate> | ...
|
||
* Row N+3: 1 | <rate> | <rate> | ...
|
||
* ... (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 | <rate> | ...
|
||
* ... 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();
|
||
}
|
||
}
|
||
}
|