ifnex/04_Laravel/app/Services/RateImportService.php

416 lines
16 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 stores rates
* into the rate_entries table (4-quadrant model).
*
* ─── معماری ارزی (Currency Architecture) ───
* ارز مبنا در کل سیستم IFNEX = ریال ایران (IRR).
* همه نرخ‌ها در دیتابیس به‌صورت ریال ذخیره می‌شوند.
*
* هنگام ایمپورت، کاربر «ارز مبدأ» فایل اکسل را انتخاب می‌کند (مثلاً AED، USD، EUR، IRR).
* سرویس هر نرخ را با نرخ روزِ آن ارز به ریال تبدیل کرده و سپس ذخیره می‌کند.
* بدین ترتیب نرخ‌ها در زمان ایمپورت «ثبت» می‌شوند و تغییرات نرخ ارز روزانه
* روی نرخ‌های حمل ذخیره‌شده اثر نمی‌گذارد — فقط ایمپورت‌های بعدی از نرخ جدید استفاده می‌کنند.
*
* 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 M: "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
*/
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,
];
/** ارز مبدأ فایل اکسل (نرخ‌ها در فایل به این ارز هستند) */
protected string $sourceCurrency = 'AED';
/** سرویس نرخ ارز برای تبدیل به ریال */
protected ExchangeRateService $exchangeRate;
/**
* Main entry point.
*
* @param string $filePath Absolute path to the uploaded .xlsx
* @param string $sourceCurrency ارز مبدأ فایل اکسل (AED, USD, EUR, IRR)
* @return array Summary array
*/
public function import(string $filePath, string $sourceCurrency = 'AED'): array
{
// Reset summary
$this->summary = [
'imported' => 0, 'updated' => 0, 'skipped' => 0,
'errors' => [], 'zones_updated' => 0,
];
// تنظیم ارز مبدأ و سرویس تبدیل
$this->sourceCurrency = strtoupper($sourceCurrency);
$this->exchangeRate = app(ExchangeRateService::class);
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');
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');
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): 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);
}
}
}
}
/**
* 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 — همیشه به ریال ذخیره می‌شود.
*
* نرخ ورودی (به ارز مبدأ) با نرخ روزِ آن ارز به ریال تبدیل شده
* و سپس در جدول rate_entries با currency='IRR' ذخیره می‌شود.
*/
protected function upsertRate(string $direction, string $serviceType, float $weight, int $zone, float $rate): void
{
// تبدیل نرخ از ارز مبدأ به ریال
$rateInIrr = $this->exchangeRate->convertToIrr($rate, $this->sourceCurrency);
try {
$existing = RateEntry::where([
'direction' => $direction,
'service_type' => $serviceType,
'weight' => $weight,
'zone' => $zone,
'currency' => 'IRR',
])->where('effective_at', '>=', now()->startOfDay())
->first();
if ($existing) {
$existing->update(['rate' => $rateInIrr]);
$this->summary['updated']++;
} else {
RateEntry::create([
'direction' => $direction,
'service_type' => $serviceType,
'weight' => $weight,
'zone' => $zone,
'rate' => $rateInIrr,
'currency' => 'IRR',
'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();
}
}
}