From 58493feca4eea8436d376afc8ba43944e4e25a90 Mon Sep 17 00:00:00 2001 From: Kazem Alghasi Date: Thu, 29 Oct 2026 17:01:30 +0330 Subject: [PATCH] refactor(currency): IRR as single base currency for all shipping rates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../assets/js/ifnex-order-form.js | 11 +- .../RateImportResource/Pages/ImportRates.php | 46 +++--- .../Resources/views/rate-import.blade.php | 15 +- 04_Laravel/app/Models/RateEntry.php | 2 +- .../app/Services/PriceCalculatorService.php | 156 +++--------------- 04_Laravel/app/Services/RateImportService.php | 121 ++++---------- ...e_rate_entries_currency_default_to_irr.php | 22 +++ 7 files changed, 117 insertions(+), 256 deletions(-) create mode 100644 04_Laravel/database/migrations/2026_10_29_120000_change_rate_entries_currency_default_to_irr.php diff --git a/03_WordPress/wp-content/plugins/ifnex-bridge/assets/js/ifnex-order-form.js b/03_WordPress/wp-content/plugins/ifnex-bridge/assets/js/ifnex-order-form.js index d1fa1dc..6998dcd 100644 --- a/03_WordPress/wp-content/plugins/ifnex-bridge/assets/js/ifnex-order-form.js +++ b/03_WordPress/wp-content/plugins/ifnex-bridge/assets/js/ifnex-order-form.js @@ -968,13 +968,12 @@ jQuery(document).ready(function($) { } html += '
'; - html += '
قیمت پایه:' + (data.base_price || '0') + ' درهم
'; - html += '
ناخالص:' + (data.net_dirham || '0') + ' درهم
'; - html += '
ریال:' + numberFormat(data.net_rial) + ' ریال
'; - if (data.packing_cost > 0) html += '
بسته‌بندی:' + numberFormat(data.packing_cost) + '
'; - if (data.vat_amount > 0) html += '
مالیات (۹٪):' + numberFormat(data.vat_amount) + '
'; + html += '
قیمت پایه:' + numberFormat(data.base_price || 0) + ' ریال
'; + html += '
ناخالص:' + numberFormat(data.net_rial || 0) + ' ریال
'; + if (data.packing_cost > 0) html += '
بسته‌بندی:' + numberFormat(data.packing_cost) + ' ریال
'; + if (data.vat_amount > 0) html += '
مالیات (۹٪):' + numberFormat(data.vat_amount) + ' ریال
'; if (data.discount_applied) { - html += '
تخفیف:-' + numberFormat(data.discount_amount) + '
'; + html += '
تخفیف:-' + numberFormat(data.discount_amount) + ' ریال
'; } html += '
مبلغ قابل پرداخت:' + numberFormat(data.total_fee) + ' ریال
'; html += ''; diff --git a/04_Laravel/app/Filament/Resources/RateImportResource/Pages/ImportRates.php b/04_Laravel/app/Filament/Resources/RateImportResource/Pages/ImportRates.php index 11e716f..8090aa7 100644 --- a/04_Laravel/app/Filament/Resources/RateImportResource/Pages/ImportRates.php +++ b/04_Laravel/app/Filament/Resources/RateImportResource/Pages/ImportRates.php @@ -16,13 +16,6 @@ use Filament\Resources\Pages\Page; use Illuminate\Support\Facades\Storage; use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; -/** - * Rate Import page for Filament v3. - * - * URL: /admin/rate-import - * - * Provides a file upload form that hands off to RateImportService. - */ class ImportRates extends Page implements HasForms { use InteractsWithForms; @@ -30,7 +23,6 @@ class ImportRates extends Page implements HasForms protected static string $resource = RateImportResource::class; protected static string $view = 'filament.pages.rate-import'; - /** @var array Form state */ public ?array $data = []; public function mount(): void @@ -51,22 +43,27 @@ class ImportRates extends Page implements HasForms 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-excel', ]) - ->maxSize(10240) // 10 MB + ->maxSize(10240) ->disk('local') ->directory('rate-imports') ->required() ->hint('Sheets expected: "Import Rate", "Export Rate", "Zone"'), Select::make('currency') - ->label('Rate currency') + ->label('ارز مبدأ فایل اکسل (Source currency)') ->options([ - 'AED' => 'AED (UAE Dirham)', - 'USD' => 'USD (US Dollar)', - 'EUR' => 'EUR (Euro)', - 'IRR' => 'IRR (Iranian Rial — direct, no conversion)', + 'AED' => 'AED (درهم امارات)', + 'USD' => 'USD (دلار آمریکا)', + 'EUR' => 'EUR (یورو)', + 'IRR' => 'IRR (ریال ایران — بدون تبدیل)', ]) ->default('AED') ->required() - ->helperText('Defaults to AED. The system converts to IRR using the current exchange rate.'), + ->helperText('⚠️ ارز مبنا سیستم = ریال ایران. نرخ‌های فایل اکسل با نرخ روز این ارز به ریال تبدیل شده و سپس در دیتابیس ذخیره می‌شوند. مثلاً اگر فایل به درهم است، AED را انتخاب کنید — سیستم با نرخ روز درهم→ریال تبدیل می‌کند.'), + \Filament\Forms\Components\Toggle::make('replace_existing') + ->label('جایگزینی کامل (حذف همه نرخ‌ها و زون‌های قبلی قبل از ایمپورت)') + ->default(false) + ->helperText('⚠️ اگر فعال باشد، تمام رکوردهای جدول rate_entries و country_zones حذف شده و سپس ایمپورت جدید انجام می‌شود. برای re-import با currency متفاوت این گزینه را فعال کنید.') + ->reactive(), ]), ]) ->statePath('data'); @@ -77,21 +74,14 @@ class ImportRates extends Page implements HasForms $state = $this->form->getState(); if (empty($state['uploaded_file'])) { - Notification::make() - ->title('No file uploaded') - ->danger() - ->send(); + Notification::make()->title('No file uploaded')->danger()->send(); return; } $uploadedPath = $state['uploaded_file']; - // FileUpload روی دیسک 'local' ذخیره می‌شود که در config/filesystems.php - // به storage_path('app/private') اشاره دارد. مسیر واقعی را از خود دیسک - // می‌پرسیم تا با تغییر کانفیگ هم درست کار کند. $filePath = null; if (is_string($uploadedPath)) { - // مسیر نسبی روی دیسک 'local' — Storage مسیر کامل را برمی‌گرداند $filePath = Storage::disk('local')->path($uploadedPath); } elseif ($uploadedPath instanceof TemporaryUploadedFile) { $filePath = $uploadedPath->getRealPath(); @@ -109,6 +99,12 @@ class ImportRates extends Page implements HasForms } $currency = $state['currency'] ?? 'AED'; + $replaceExisting = (bool) ($state['replace_existing'] ?? false); + + if ($replaceExisting) { + \App\Models\RateEntry::query()->delete(); + \App\Models\CountryZone::query()->delete(); + } try { $service = app(RateImportService::class); @@ -122,6 +118,10 @@ class ImportRates extends Page implements HasForms $summary['zones_updated'] ?? 0, ); + if ($replaceExisting) { + $msg = "🔄 جایگزینی کامل انجام شد.\n" . $msg; + } + if (! empty($summary['errors'])) { $msg .= "\n⚠ Errors: " . count($summary['errors']) . " (see log)"; foreach (array_slice($summary['errors'], 0, 5) as $err) { diff --git a/04_Laravel/app/Filament/Resources/views/rate-import.blade.php b/04_Laravel/app/Filament/Resources/views/rate-import.blade.php index b0f02a2..9a62e77 100644 --- a/04_Laravel/app/Filament/Resources/views/rate-import.blade.php +++ b/04_Laravel/app/Filament/Resources/views/rate-import.blade.php @@ -20,14 +20,15 @@
-

📋 Quick checklist

+

📋 نکات مهم

    -
  • Sheet names must be exactly Import Rate and Export Rate.
  • -
  • Section markers must appear in column E (merged): DOCUMENT / NON - DOCUMENT.
  • -
  • Column headers must be Weight (kg) and Zone 1..Zone 10 (or just integers 1..10).
  • -
  • Weights must be decimal numbers in column A (0.5, 1, 1.5, ...).
  • -
  • Empty cells are skipped (no rate stored for that weight × zone).
  • -
  • Currency defaults to AED. System converts to IRR using current exchange rate.
  • +
  • نام شیت‌ها باید دقیقاً Import Rate و Export Rate باشد.
  • +
  • مارکرهای بخش باید در ستون E باشند: DOCUMENT / NON - DOCUMENT.
  • +
  • سرستون‌ها: Weight (kg) و Zone 1..Zone 10 (یا اعداد ۱..۱۰).
  • +
  • وزن‌ها باید عدد اعشاری در ستون A باشند (0.5, 1, 1.5, ...).
  • +
  • سلول‌های خالی نادیده گرفته می‌شوند.
  • +
  • ارز مبدأ: ارزی که نرخ‌های فایل اکسل به آن هستند را انتخاب کنید. سیستم با نرخ روز آن ارز → ریال تبدیل کرده و در دیتابیس ذخیره می‌کند.
  • +
  • نکته: قبل از ایمپورت، مطمئن شوید نرخ روز ارز مبدأ در پنل «ارزها» تنظیم شده است.
diff --git a/04_Laravel/app/Models/RateEntry.php b/04_Laravel/app/Models/RateEntry.php index 6e1c84c..29aa72a 100644 --- a/04_Laravel/app/Models/RateEntry.php +++ b/04_Laravel/app/Models/RateEntry.php @@ -51,7 +51,7 @@ class RateEntry extends Model string $serviceType, float $weight, int $zone, - string $currency = 'AED', + string $currency = 'IRR', ?\DateTimeInterface $at = null ): ?self { $at ??= now(); diff --git a/04_Laravel/app/Services/PriceCalculatorService.php b/04_Laravel/app/Services/PriceCalculatorService.php index ad01021..7c1d225 100644 --- a/04_Laravel/app/Services/PriceCalculatorService.php +++ b/04_Laravel/app/Services/PriceCalculatorService.php @@ -12,26 +12,12 @@ use App\Enums\ShipmentDirection; /** * PriceCalculatorService * ---------------------- - * موتور قیمت‌گذاری ۴-رُبعی: + * موتور قیمت‌گذاری ۴-رُبعی — همه نرخ‌ها به ریال (IRR). * - * ┌──────────┬──────────────┬──────────────────┐ - * │ │ DOCUMENT │ NON-DOCUMENT │ - * ├──────────┼──────────────┼──────────────────┤ - * │ Export │ Export×Doc │ Export×Parcel │ - * │ Import │ Import×Doc │ Import×Parcel │ - * └──────────┴──────────────┴──────────────────┘ - * - * جریان محاسبه: - * 1. نرمال‌سازی ورودی (direction / type) و تعیین کشور مقصد با ISO. - * 2. جستجوی زون کشور از جدول country_zones (زون Doc و Parcel ممکن است متفاوت باشد). - * 3. جستجوی نرخ AED از جدول rate_entries (نزدیک‌ترین وزن بالاتر). - * 4. اعمال ضریب سود + هزینه‌های جانبی → ناخالص درهم. - * 5. تبدیل درهم → ریال با نرخ روز از ExchangeRateService. - * 6. محاسبه مالیات بر ارزش افزوده (۹٪) و کسر تخفیف (در صورت وجود کد). - * - * امضای متد calculate(): آرایه‌ای از ورودی‌ها را می‌گیرد (برای سازگاری با - * PricingController، CustomerOrderController و PriceTestPage) و آرایه‌ای کامل - * از فیلدهای نمایشی برمی‌گرداند که وردپرس منتظرش است. + * ─── معماری ارزی ─── + * ارز مبنا = ریال ایران. نرخ‌ها در دیتابیس به ریال ذخیره می‌شوند + * (تبدیل در زمان ایمپورت توسط RateImportService انجام می‌شود). + * بنابراین موتور محاسبه نیازی به تبدیل ارز ندارد. */ class PriceCalculatorService { @@ -39,54 +25,9 @@ class PriceCalculatorService protected ExchangeRateService $exchangeRate, ) {} - /** - * محاسبه قیمت نهایی محموله. - * - * @param array $input { - * @var string $direction 'export' | 'import' (یا 'Outbound' | 'Inbound' برای backward-compat) - * @var string $type 'DOC_NORMAL' | 'DOC_ECONOMY' | 'PARCEL' - * (یا 'DOCUMENT' | 'NON DOC' | 'DOC' از فرم تست ادمین) - * @var string $country_iso کد ISO دو حرفی کشور مقصد (مثلاً 'US', 'AE') - * @var float $weight وزن واقعی (کیلوگرم) - * @var float $volumetric_weight وزن حجمی (کیلوگرم) - * @var float|null $extra_service هزینه سرویس اضافی (درهم) - * @var float|null $packing_cost هزینه بسته‌بندی (درهم) - * @var float|null $domestic_pickup هزینه جمع‌آوری داخلی (درهم) - * @var float|null $domestic_delivery هزینه تحویل داخلی (درهم) - * @var float|null $warehousing_cost هزینه انبارداری (درهم) - * @var string|null $discount_code کد تخفیف اختیاری - * } - * - * @return array { - * @var float $base_price قیمت پایه (درهم) - * @var float $net_dirham ناخالص شامل سود + هزینه‌ها (درهم) - * @var int $net_rial ناخالص به ریال - * @var float $packing_cost هزینه بسته‌بندی (درهم) - * @var int $vat_amount مبلغ مالیات (ریال) - * @var bool $discount_applied آیا تخفیف اعمال شد؟ - * @var int $discount_amount مبلغ تخفیف (ریال) - * @var int $total_fee مبلغ نهایی قابل پرداخت (ریال) - * @var float $weight وزن واقعی - * @var float $volumetric_weight وزن حجمی - * @var float $chargeable_weight وزن قابل محاسبه (max واقعی، حجمی) - * @var int $zone زون محاسبه‌شده - * @var string $service_type 'document' | 'parcel' - * @var string $direction 'export' | 'import' - * @var string $country نام کشور - * @var string $country_iso ISO کشور - * @var int $rate_entry_id آیدی رکورد نرخ استفاده‌شده - * @var float $profit_margin ضریب سود اعمال‌شده - * @var string $currency همیشه 'IRR' - * } - * - * @throws \InvalidArgumentException ورودی نامعتبر - * @throws \RuntimeException زون یا نرخ یافت نشد - */ public function calculate(array $input): array { - // ─────────────────────────────────────────────────────────────── // ۱. نرمال‌سازی direction - // ─────────────────────────────────────────────────────────────── $directionRaw = strtolower((string) ($input['direction'] ?? '')); $direction = match ($directionRaw) { 'outbound' => 'export', @@ -100,12 +41,7 @@ class PriceCalculatorService ); } - // ─────────────────────────────────────────────────────────────── // ۲. نرمال‌سازی type → service_type - // جدول rate_entries فقط 'document' | 'parcel' را ذخیره می‌کند. - // DOC_ECONOMY فعلاً به همان نرخ DOC_NORMAL محاسبه می‌شود - // (تفکیک کامل نیازمند گسترش service_type در دیتابیس است). - // ─────────────────────────────────────────────────────────────── $typeRaw = strtoupper(trim((string) ($input['type'] ?? ''))); $serviceType = match ($typeRaw) { 'DOC_NORMAL', 'DOC_ECONOMY', 'DOCUMENT', 'DOC', 'DOCNORMAL', 'DOCECONOMY' => 'document', @@ -118,9 +54,7 @@ class PriceCalculatorService ); } - // ─────────────────────────────────────────────────────────────── - // ۳. حل کشور با ISO → نام (CountryZone با نام کشور جستجو می‌شود) - // ─────────────────────────────────────────────────────────────── + // ۳. حل کشور با ISO → نام $countryIso = strtoupper(trim((string) ($input['country_iso'] ?? ''))); if (strlen($countryIso) !== 2) { throw new \InvalidArgumentException( @@ -133,16 +67,12 @@ class PriceCalculatorService } $countryName = $country->name; - // ─────────────────────────────────────────────────────────────── // ۴. اوزان - // ─────────────────────────────────────────────────────────────── $weight = (float) ($input['weight'] ?? 0); $volumetricWeight = (float) ($input['volumetric_weight'] ?? 0); $chargeableWeight = max($weight, $volumetricWeight); - // ─────────────────────────────────────────────────────────────── - // ۵. جستجوی زون (برای کشور × نوع سرویس) - // ─────────────────────────────────────────────────────────────── + // ۵. جستجوی زون $zone = CountryZone::lookupZone($countryName, $serviceType); if ($zone === null) { throw new \RuntimeException( @@ -151,10 +81,8 @@ class PriceCalculatorService ); } - // ─────────────────────────────────────────────────────────────── - // ۶. جستجوی نرخ (۴-رُبعی) - // ─────────────────────────────────────────────────────────────── - $rateEntry = RateEntry::lookup($direction, $serviceType, $chargeableWeight, $zone, 'AED'); + // ۶. جستجوی نرخ — مستقیماً به ریال (IRR) + $rateEntry = RateEntry::lookup($direction, $serviceType, $chargeableWeight, $zone, 'IRR'); if (! $rateEntry) { throw new \RuntimeException( "No rate found for {$direction}×{$serviceType} weight={$chargeableWeight}kg zone={$zone}. " @@ -162,50 +90,38 @@ class PriceCalculatorService ); } - // ─────────────────────────────────────────────────────────────── - // ۷. محاسبه هزینه‌ها در درهم - // ─────────────────────────────────────────────────────────────── - $basePriceAed = (float) $rateEntry->rate; + // ۷. قیمت پایه — مستقیماً به ریال + $basePriceIrr = (float) $rateEntry->rate; + // ۸. هزینه‌های جانبی — همه به ریال $extras = [ - 'extra_service' => (float) ($input['extra_service'] ?? 0), - 'packing_cost' => (float) ($input['packing_cost'] ?? 0), - 'domestic_pickup' => (float) ($input['domestic_pickup'] ?? 0), + 'extra_service' => (float) ($input['extra_service'] ?? 0), + 'packing_cost' => (float) ($input['packing_cost'] ?? 0), + 'domestic_pickup' => (float) ($input['domestic_pickup'] ?? 0), 'domestic_delivery' => (float) ($input['domestic_delivery'] ?? 0), - 'warehousing_cost' => (float) ($input['warehousing_cost'] ?? 0), + 'warehousing_cost' => (float) ($input['warehousing_cost'] ?? 0), ]; - $extrasTotalAed = array_sum($extras); + $extrasTotalIrr = array_sum($extras); - // ─────────────────────────────────────────────────────────────── - // ۸. اعمال ضریب سود (روی قیمت پایه، نه روی هزینه‌های جانبی) - // ─────────────────────────────────────────────────────────────── + // ۹. اعمال ضریب سود + هزینه‌های جانبی $profitMargin = (float) SystemSetting::get( 'profit_margin', config('ifnex.financial_defaults.profit_margin', 1.25) ); if ($profitMargin <= 0) { - $profitMargin = 1.0; // محافظت در برابر صفر یا منفی + $profitMargin = 1.0; } - $netDirham = ($basePriceAed * $profitMargin) + $extrasTotalAed; + $netRial = ($basePriceIrr * $profitMargin) + $extrasTotalIrr; - // ─────────────────────────────────────────────────────────────── - // ۹. تبدیل به ریال - // ─────────────────────────────────────────────────────────────── - $netRial = $this->exchangeRate->convertToIrr($netDirham, 'AED'); - - // ─────────────────────────────────────────────────────────────── // ۱۰. مالیات بر ارزش افزوده - // ─────────────────────────────────────────────────────────────── $vatRate = (float) SystemSetting::get( 'vat_rate', config('ifnex.financial_defaults.vat_rate', 0.09) ); $vatAmount = $netRial * $vatRate; - // ─────────────────────────────────────────────────────────────── - // ۱۱. تخفیف (در صورت وجود کد) - // ─────────────────────────────────────────────────────────────── + // ۱۱. تخفیف $discountApplied = false; $discountAmount = 0.0; $discountCode = trim((string) ($input['discount_code'] ?? '')); @@ -221,51 +137,39 @@ class PriceCalculatorService if ($discount) { $subtotal = $netRial + $vatAmount; - - // بررسی حداقل مبلغ سفارش $minOrder = (float) ($discount->min_order_amount ?? 0); if ($minOrder > 0 && $subtotal < $minOrder) { - // زیر سقف — تخفیف اعمال نمی‌شود + // زیر سقف } else { $discountApplied = true; if ($discount->type === 'percentage') { $discountAmount = $subtotal * ((float) $discount->value / 100); - } else { // fixed + } else { $discountAmount = min((float) $discount->value, $subtotal); } } } } - // ─────────────────────────────────────────────────────────────── - // ۱۲. مبلغ نهایی (گردش به نزدیک‌ترین ۱۰۰۰ ریال) - // ─────────────────────────────────────────────────────────────── + // ۱۲. مبلغ نهایی (گردش به ۱۰۰۰ ریال) $totalFee = $netRial + $vatAmount - $discountAmount; - $totalFee = (int) round($totalFee / 1000) * 1000; + $totalFee = (int) round($totalFee / 1000) * 1000; return [ - // فیلدهای نمایشی که وردپرس از آن‌ها استفاده می‌کند - 'base_price' => round($basePriceAed, 2), - 'net_dirham' => round($netDirham, 2), + 'base_price' => round($basePriceIrr, 2), 'net_rial' => (int) round($netRial), 'packing_cost' => round($extras['packing_cost'], 2), 'vat_amount' => (int) round($vatAmount), 'discount_applied' => $discountApplied, 'discount_amount' => (int) round($discountAmount), 'total_fee' => $totalFee, - - // تفکیک هزینه‌ها 'extra_service' => round($extras['extra_service'], 2), 'domestic_pickup' => round($extras['domestic_pickup'], 2), 'domestic_delivery' => round($extras['domestic_delivery'], 2), 'warehousing_cost' => round($extras['warehousing_cost'], 2), - - // اوزان 'weight' => round($weight, 3), 'volumetric_weight' => round($volumetricWeight, 3), 'chargeable_weight' => round($chargeableWeight, 3), - - // متادیتای جستجو 'zone' => $zone, 'service_type' => $serviceType, 'direction' => $direction, @@ -277,19 +181,11 @@ class PriceCalculatorService ]; } - /** - * راحتی: محاسبه قیمت از روی مدل Shipment. - * - * @param \App\Models\Shipment $shipment - * @return array - */ public function calculateForShipment($shipment): array { $direction = $shipment->direction?->value ?? (string) $shipment->direction; $type = $shipment->type?->value ?? (string) $shipment->type; - $weight = (float) ($shipment->chargeable_weight ?? $shipment->weight ?? 0); - // کشور مقصد: برای صادرات = toCountry، برای واردات = fromCountry $country = ($direction === 'export') ? $shipment->toCountry : $shipment->fromCountry; diff --git a/04_Laravel/app/Services/RateImportService.php b/04_Laravel/app/Services/RateImportService.php index e932a7a..0c03af9 100644 --- a/04_Laravel/app/Services/RateImportService.php +++ b/04_Laravel/app/Services/RateImportService.php @@ -14,33 +14,17 @@ use Exception; /** * RateImportService * ---------------- - * Parses the customer's operational Excel format and upserts rates + * Parses the customer's operational Excel format and stores rates * into the rate_entries table (4-quadrant model). * - * Expected Excel structure (matches IFNEX_Rate_Template.xlsx): + * ─── معماری ارزی (Currency Architecture) ─── + * ارز مبنا در کل سیستم IFNEX = ریال ایران (IRR). + * همه نرخ‌ها در دیتابیس به‌صورت ریال ذخیره می‌شوند. * - * 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 | | | ... - * Row N+3: 1 | | | ... - * ... (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 | | ... - * ... 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 + * هنگام ایمپورت، کاربر «ارز مبدأ» فایل اکسل را انتخاب می‌کند (مثلاً AED، USD، EUR، IRR). + * سرویس هر نرخ را با نرخ روزِ آن ارز به ریال تبدیل کرده و سپس ذخیره می‌کند. + * بدین ترتیب نرخ‌ها در زمان ایمپورت «ثبت» می‌شوند و تغییرات نرخ ارز روزانه + * روی نرخ‌های حمل ذخیره‌شده اثر نمی‌گذارد — فقط ایمپورت‌های بعدی از نرخ جدید استفاده می‌کنند. */ class RateImportService { @@ -55,7 +39,6 @@ class RateImportService 'NONDOC', 'NON DOC', 'PARCEL', 'NON-DOC', 'NON - DOC'], ]; - /** Result summary returned to caller for Filament notification */ public array $summary = [ 'imported' => 0, 'updated' => 0, @@ -64,44 +47,39 @@ class RateImportService '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 + protected string $sourceCurrency = 'AED'; + protected ExchangeRateService $exchangeRate; + + 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', $currency); + $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', $currency); + $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')) { @@ -111,18 +89,13 @@ class RateImportService 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 + 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' + $currentSection = null; $headerRowIdx = null; for ($r = 1; $r <= $maxRow; $r++) { @@ -131,7 +104,6 @@ class RateImportService $rowValues[$c] = $ws->getCellByColumnAndRow($c, $r)->getCalculatedValue(); } - // Detect section markers $matchedSection = $this->detectSection($rowValues); if ($matchedSection !== null) { $currentSection = $matchedSection; @@ -139,7 +111,6 @@ class RateImportService continue; } - // Detect header row ("Weight (kg)" + "Zone 1".."Zone N") if ($currentSection !== null && $headerRowIdx === null) { if ($this->isHeaderRow($rowValues)) { $headerRowIdx = $r; @@ -147,11 +118,9 @@ class RateImportService } } - // 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; @@ -166,16 +135,12 @@ class RateImportService $this->summary['skipped']++; continue; } - $this->upsertRate($direction, $currentSection, $weight, $zone, (float) $rate, $currency); + $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) { @@ -184,7 +149,6 @@ class RateImportService } $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'; @@ -199,12 +163,6 @@ class RateImportService 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])) { @@ -214,19 +172,16 @@ class RateImportService 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) { @@ -236,13 +191,9 @@ class RateImportService } } } - return $zoneCount >= 3; // require at least 3 zone columns to consider this a header + return $zoneCount >= 3; } - /** - * 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] === '') { @@ -264,7 +215,7 @@ class RateImportService $rates = []; for ($c = 2; $c <= 11; $c++) { - $zone = $c - 1; // col 2 -> Zone 1, col 11 -> Zone 10 + $zone = $c - 1; $val = $rowValues[$c] ?? null; if ($val === null || $val === '' || (is_string($val) && trim($val) === '')) { $rates[$zone] = null; @@ -273,7 +224,6 @@ class RateImportService 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; } @@ -292,22 +242,24 @@ class RateImportService } /** - * Upsert a rate row. Within a transaction. + * Upsert a rate row — همیشه به ریال ذخیره می‌شود. */ - protected function upsertRate(string $direction, string $serviceType, float $weight, int $zone, float $rate, string $currency): void + 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' => $currency, + 'currency' => 'IRR', ])->where('effective_at', '>=', now()->startOfDay()) ->first(); if ($existing) { - $existing->update(['rate' => $rate]); + $existing->update(['rate' => $rateInIrr]); $this->summary['updated']++; } else { RateEntry::create([ @@ -315,8 +267,8 @@ class RateImportService 'service_type' => $serviceType, 'weight' => $weight, 'zone' => $zone, - 'rate' => $rate, - 'currency' => $currency, + 'rate' => $rateInIrr, + 'currency' => 'IRR', 'effective_at' => now(), ]); $this->summary['imported']++; @@ -327,17 +279,10 @@ class RateImportService } } - /** - * 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(); @@ -349,7 +294,7 @@ class RateImportService } } if ($headerRow === null) { - $headerRow = 3; // fallback + $headerRow = 3; } for ($r = $headerRow + 1; $r <= $maxRow; $r++) { @@ -358,13 +303,11 @@ class RateImportService $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); @@ -396,4 +339,4 @@ class RateImportService $this->summary['errors'][] = "Country zone error ({$country}): " . $e->getMessage(); } } -} +} \ No newline at end of file diff --git a/04_Laravel/database/migrations/2026_10_29_120000_change_rate_entries_currency_default_to_irr.php b/04_Laravel/database/migrations/2026_10_29_120000_change_rate_entries_currency_default_to_irr.php new file mode 100644 index 0000000..d41efcc --- /dev/null +++ b/04_Laravel/database/migrations/2026_10_29_120000_change_rate_entries_currency_default_to_irr.php @@ -0,0 +1,22 @@ +char('currency', 3)->default('IRR')->change(); + }); + } + + public function down(): void + { + Schema::table('rate_entries', function (Blueprint $table) { + $table->char('currency', 3)->default('AED')->change(); + }); + } +}; \ No newline at end of file