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.
342 lines
12 KiB
PHP
342 lines
12 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 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();
|
||
}
|
||
}
|
||
} |