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.
This commit is contained in:
Kazem Alghasi 2026-10-29 17:01:30 +03:30
parent 4a38cf4fce
commit 58493feca4
7 changed files with 117 additions and 256 deletions

View File

@ -968,13 +968,12 @@ jQuery(document).ready(function($) {
}
html += '<hr style="margin: 10px 0;">';
html += '<div class="ifnex-price-row"><span>قیمت پایه:</span><strong>' + (data.base_price || '0') + ' درهم</strong></div>';
html += '<div class="ifnex-price-row"><span>ناخالص:</span><strong>' + (data.net_dirham || '0') + ' درهم</strong></div>';
html += '<div class="ifnex-price-row"><span>ریال:</span><strong>' + numberFormat(data.net_rial) + ' ریال</strong></div>';
if (data.packing_cost > 0) html += '<div class="ifnex-price-row"><span>بسته‌بندی:</span><strong>' + numberFormat(data.packing_cost) + '</strong></div>';
if (data.vat_amount > 0) html += '<div class="ifnex-price-row"><span>مالیات (۹٪):</span><strong>' + numberFormat(data.vat_amount) + '</strong></div>';
html += '<div class="ifnex-price-row"><span>قیمت پایه:</span><strong>' + numberFormat(data.base_price || 0) + ' ریال</strong></div>';
html += '<div class="ifnex-price-row"><span>ناخالص:</span><strong>' + numberFormat(data.net_rial || 0) + ' ریال</strong></div>';
if (data.packing_cost > 0) html += '<div class="ifnex-price-row"><span>بسته‌بندی:</span><strong>' + numberFormat(data.packing_cost) + ' ریال</strong></div>';
if (data.vat_amount > 0) html += '<div class="ifnex-price-row"><span>مالیات (۹٪):</span><strong>' + numberFormat(data.vat_amount) + ' ریال</strong></div>';
if (data.discount_applied) {
html += '<div class="ifnex-price-row ifnex-discount"><span>تخفیف:</span><strong>-' + numberFormat(data.discount_amount) + '</strong></div>';
html += '<div class="ifnex-price-row ifnex-discount"><span>تخفیف:</span><strong>-' + numberFormat(data.discount_amount) + ' ریال</strong></div>';
}
html += '<div class="ifnex-price-row ifnex-total"><span>مبلغ قابل پرداخت:</span><strong>' + numberFormat(data.total_fee) + ' ریال</strong></div>';
html += '</div>';

View File

@ -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<string, mixed> 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) {

View File

@ -20,14 +20,15 @@
</div>
<div class="filament-card p-4 bg-amber-50 border border-amber-200">
<h3 class="text-sm font-semibold text-amber-800 mb-2">📋 Quick checklist</h3>
<h3 class="text-sm font-semibold text-amber-800 mb-2">📋 نکات مهم</h3>
<ul class="text-xs text-amber-700 list-disc ml-5 space-y-1">
<li>Sheet names must be exactly <code>Import Rate</code> and <code>Export Rate</code>.</li>
<li>Section markers must appear in column E (merged): <code>DOCUMENT</code> / <code>NON - DOCUMENT</code>.</li>
<li>Column headers must be <code>Weight (kg)</code> and <code>Zone 1</code>..<code>Zone 10</code> (or just integers 1..10).</li>
<li>Weights must be decimal numbers in column A (0.5, 1, 1.5, ...).</li>
<li>Empty cells are skipped (no rate stored for that weight × zone).</li>
<li>Currency defaults to AED. System converts to IRR using current exchange rate.</li>
<li>نام شیت‌ها باید دقیقاً <code>Import Rate</code> و <code>Export Rate</code> باشد.</li>
<li>مارکرهای بخش باید در ستون E باشند: <code>DOCUMENT</code> / <code>NON - DOCUMENT</code>.</li>
<li>سرستون‌ها: <code>Weight (kg)</code> و <code>Zone 1</code>..<code>Zone 10</code> (یا اعداد ۱..۱۰).</li>
<li>وزن‌ها باید عدد اعشاری در ستون A باشند (0.5, 1, 1.5, ...).</li>
<li>سلول‌های خالی نادیده گرفته می‌شوند.</li>
<li><strong>ارز مبدأ:</strong> ارزی که نرخ‌های فایل اکسل به آن هستند را انتخاب کنید. سیستم با نرخ روز آن ارز → ریال تبدیل کرده و در دیتابیس ذخیره می‌کند.</li>
<li><strong>نکته:</strong> قبل از ایمپورت، مطمئن شوید نرخ روز ارز مبدأ در پنل «ارزها» تنظیم شده است.</li>
</ul>
</div>

View File

@ -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();

View File

@ -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;

View File

@ -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 | <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
* هنگام ایمپورت، کاربر «ارز مبدأ» فایل اکسل را انتخاب می‌کند (مثلاً 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();
}
}
}
}

View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('rate_entries', function (Blueprint $table) {
$table->char('currency', 3)->default('IRR')->change();
});
}
public function down(): void
{
Schema::table('rate_entries', function (Blueprint $table) {
$table->char('currency', 3)->default('AED')->change();
});
}
};