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