ifnex/04_Laravel/app/Services/RateImportService.php
Kazem Alghasi 513e8d8e7e feat(api): implement 4-quadrant rate import and pricing model
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.
2026-10-29 07:18:52 +03:30

400 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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();
}
}
}