ifnex/04_Laravel/app/Services/RateImportService.php
Kazem Alghasi 58493feca4 refactor(currency): IRR as single base currency for all shipping rates
Make Iranian Rial (IRR) the sole base currency for shipping rates:
- RateImportService now converts all imported rates to IRR at import
  time using the current daily exchange rate (source currency AED/USD/
  EUR/IRR). Rates are stored in rate_entries with currency='IRR'.
- PriceCalculatorService looks up IRR rates directly — no conversion
  at calc time. Also fixes a pre-existing bug where extras (in IRR)
  were incorrectly added to the AED base before conversion.
- RateEntry::lookup() default currency changed from 'AED' to 'IRR'.
- Migration changes the column default to 'IRR'.
- Filament import form: relabel 'Rate currency' to 'Source currency
  (in Excel file)' with helper text explaining the conversion.
- WordPress JS: update price summary labels from 'درهم' to 'ریال'
  and drop the deprecated net_dirham field.

Architectural benefit: rates are 'frozen' in IRR at import time —
daily exchange rate changes only affect NEW imports, not already-
stored rates. This is more stable and predictable for customers.
2026-10-29 17:01:30 +03:30

342 lines
12 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).
* سرویس هر نرخ را با نرخ روزِ آن ارز به ریال تبدیل کرده و سپس ذخیره می‌کند.
* بدین ترتیب نرخ‌ها در زمان ایمپورت «ثبت» می‌شوند و تغییرات نرخ ارز روزانه
* روی نرخ‌های حمل ذخیره‌شده اثر نمی‌گذارد — فقط ایمپورت‌های بعدی از نرخ جدید استفاده می‌کنند.
*/
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'],
];
public array $summary = [
'imported' => 0,
'updated' => 0,
'skipped' => 0,
'errors' => [],
'zones_updated' => 0,
];
protected string $sourceCurrency = 'AED';
protected ExchangeRateService $exchangeRate;
public function import(string $filePath, string $sourceCurrency = 'AED'): array
{
$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);
foreach (self::SHEET_NAMES['import'] as $name) {
if ($spreadsheet->sheetNameExists($name)) {
$this->importRateSheet($spreadsheet->getSheetByName($name), 'import');
break;
}
}
foreach (self::SHEET_NAMES['export'] as $name) {
if ($spreadsheet->sheetNameExists($name)) {
$this->importRateSheet($spreadsheet->getSheetByName($name), 'export');
break;
}
}
if ($spreadsheet->sheetNameExists('Zone')) {
$this->importZoneSheet($spreadsheet->getSheetByName('Zone'));
} elseif ($spreadsheet->sheetNameExists('Zones')) {
$this->importZoneSheet($spreadsheet->getSheetByName('Zones'));
}
return $this->summary;
}
protected function importRateSheet(Worksheet $ws, string $direction): void
{
$maxRow = $ws->getHighestDataRow();
$maxCol = $ws->getHighestDataColumn();
$maxColIdx = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::columnIndexFromString($maxCol);
$currentSection = null;
$headerRowIdx = null;
for ($r = 1; $r <= $maxRow; $r++) {
$rowValues = [];
for ($c = 1; $c <= min($maxColIdx, 11); $c++) {
$rowValues[$c] = $ws->getCellByColumnAndRow($c, $r)->getCalculatedValue();
}
$matchedSection = $this->detectSection($rowValues);
if ($matchedSection !== null) {
$currentSection = $matchedSection;
$headerRowIdx = null;
continue;
}
if ($currentSection !== null && $headerRowIdx === null) {
if ($this->isHeaderRow($rowValues)) {
$headerRowIdx = $r;
continue;
}
}
if ($currentSection !== null && $headerRowIdx !== null) {
$parsed = $this->parseRateRow($rowValues);
if ($parsed === null) {
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);
}
}
}
}
protected function detectSection(array $rowValues): ?string
{
foreach ($rowValues as $val) {
if (! is_string($val) || trim($val) === '') {
continue;
}
$normalized = strtoupper(trim($val));
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;
}
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;
}
$zoneCount = 0;
$expectedInt = 1;
for ($c = 2; $c <= 11; $c++) {
$val = $rowValues[$c] ?? null;
if ($val === null || $val === '') continue;
if (is_string($val) && preg_match('/^zone\s*(\d+)$/i', trim($val), $m)) {
$zoneCount++;
continue;
}
if (is_int($val) || (is_string($val) && preg_match('/^\d+$/', trim($val)))) {
$intVal = (int) $val;
if ($intVal === $expectedInt) {
$zoneCount++;
$expectedInt++;
continue;
}
}
}
return $zoneCount >= 3;
}
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;
$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 {
$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 — همیشه به ریال ذخیره می‌شود.
*/
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());
}
}
protected function importZoneSheet(Worksheet $ws): void
{
$maxRow = $ws->getHighestDataRow();
$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;
}
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();
if (is_string($parcelCountry) && trim($parcelCountry) !== '' &&
$parcelZone !== null && $parcelZone !== '' && (int)$parcelZone > 0) {
$this->upsertCountryZone(trim($parcelCountry), parcelZone: (int)$parcelZone);
}
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();
}
}
}