From 848f95f32e735bf602eae9da30aa8fe0dea32fa2 Mon Sep 17 00:00:00 2001
From: Kazem Alghasi
Date: Thu, 29 Oct 2026 22:31:26 +0330
Subject: [PATCH] refactor(currency): enforce IRR-only across all UIs and
storage
---
01_Documents/IFNEX_Pro_Backlog.md | 103 ++++++++++++++
.../ifnex-bridge/includes/shortcodes.php | 1 -
.../Filament/Resources/CurrencyResource.php | 2 +-
.../RateImportResource/Pages/ImportRates.php | 22 ++-
.../Filament/Resources/ShipmentResource.php | 37 +++--
.../Resources/views/rate-import.blade.php | 2 +-
.../Api/Customer/CustomerOrderController.php | 12 +-
04_Laravel/app/Models/RateEntry.php | 2 +
.../app/Services/ExchangeRateService.php | 26 +++-
.../app/Services/PriceCalculatorService.php | 126 ++++++++++++++++--
04_Laravel/app/Services/RateImportService.php | 83 +++++++++++-
04_Laravel/config/ifnex.php | 5 +-
...e_rate_entries_currency_default_to_irr.php | 12 +-
...30000_enlarge_rate_entries_rate_column.php | 13 +-
...00_enlarge_shipments_financial_columns.php | 56 ++++++++
.../database/seeders/SystemSettingSeeder.php | 21 +--
.../filament/pages/price-test-page.blade.php | 34 +++--
.../filament/pages/rate-import.blade.php | 17 +--
.../resources/views/orders/create.blade.php | 2 +-
.../resources/views/pricing/index.blade.php | 13 +-
04_Laravel/routes/web.php | 1 -
21 files changed, 493 insertions(+), 97 deletions(-)
create mode 100644 01_Documents/IFNEX_Pro_Backlog.md
create mode 100644 04_Laravel/database/migrations/2026_10_29_140000_enlarge_shipments_financial_columns.php
diff --git a/01_Documents/IFNEX_Pro_Backlog.md b/01_Documents/IFNEX_Pro_Backlog.md
new file mode 100644
index 0000000..1fde4af
--- /dev/null
+++ b/01_Documents/IFNEX_Pro_Backlog.md
@@ -0,0 +1,103 @@
+# IFNEX Pro Version — Feature Backlog
+
+> این فایل شامل فیچرهایی است که برای نسخه Pro (پولی) برنامهریزی شدهاند.
+> هر مورد با برچسب نسخه و اولویت مشخص شده است.
+> تاریخ ایجاد: ۲۰۲۶-۱۰-۲۹
+
+---
+
+## 🔧 فیچر ۱: ارز مرجع قابل تنظیم (Configurable Base Currency)
+
+### وضعیت: 📋 مطرحشده — برای نسخه Pro
+
+### توضیح:
+
+در نسخه فعلی، ارز مبنا (Base Currency) بهصورت hard-code شده به **ریال ایران (IRR)**. این برای مشتریان ایرانی مناسب است، ولی اگر مشتری خارجی (مثلاً اروپایی) بخواهد از IFNEX استفاده کند، باید محاسبات بر مبنای یورو انجام شود.
+
+### پیشنهاد پیادهسازی:
+
+#### ۱. تنظیم در پنل ادمین:
+
+در `System Settings` یک فیلد جدید اضافه شود:
+- `base_currency` — کد ارز مرجع (مثلاً IRR, EUR, USD, AED)
+- پیشفرض: IRR
+
+#### ۲. تغییر در دیتابیس:
+
+جدول `currencies`:
+- ستون `rate_to_rial` → تغییر نام به `rate_to_base`
+- هر ارز نرخ نسبت به ارز مرجع ذخیره میشود
+
+جدول `rate_entries`:
+- ستون `currency` به ارز مرجع تغییر میکند
+- مثلاً اگه base=EUR، همه نرخها به EUR ذخیره میشوند
+
+#### ۳. تغییر در `ExchangeRateService`:
+
+```php
+public function getBaseCurrency(): string
+{
+ return SystemSetting::get('base_currency', 'IRR');
+}
+
+public function convertToBase(float $amount, string $fromCurrency): float
+{
+ $baseCurrency = $this->getBaseCurrency();
+ if ($fromCurrency === $baseCurrency) return $amount;
+
+ $rate = Currency::where('code', $fromCurrency)->value('rate_to_base');
+ return $amount * $rate;
+}
+```
+
+#### ۴. تغییر در `PriceCalculatorService`:
+
+- همه ارجاعات به IRR با ارز مرجع جایگزین شود
+- نمایش در پورتال مشتری هم با ارز مرجع
+
+#### ۵. تأثیر روی فاکتورها و گزارشها:
+
+- PDF فاکتورها باید ارز مرجع رو نشون بدن
+- گزارشهای مالی بر مبنای ارز مرجع
+- امکان نمایش معادل ارز دیگر (مثلاً معادل یورو و دلار)
+
+### مزایا:
+- ✅ پشتیبانی از مشتریان بینالمللی
+- ✅ انعطاف در انتخاب ارز محاسباتی
+- ✅ White-label friendly
+
+### چالشها:
+- ⚠️ تغییر اسکیمای دیتابیس (نیازمند migration)
+- ⚠️ بازنویسی بخشهای مالی سیستم
+- ⚠️ تست مجدد همه فلوی پرداخت
+
+### اولویت: 🔴 بالا (نسخه Pro)
+
+---
+
+## 📝 یادآوریهای دیگر (در این چت مطرح شد)
+
+### فیچر ۲: دفترچه آدرس (Address Book)
+- ذخیره فرستنده/گیرندههای قبلی
+- انتخاب از لیست در فرم سفارش
+- مدیریت در پورتال مشتری
+
+### فیچر ۳: پلاگین SMS بهصورت ماژول
+- فعالسازی بهصورت add-on
+- پشتیبانی از چندین سرویسدهنده (Kavenegar, MeliPayamak, ...)
+- پلنهای جداگانه در مدل تجاری
+
+### فیچر ۴: سیستم لایسنس (Domain Lock + Feature Flags)
+- مدل: License Key + Domain Lock
+- Feature Flags برای فعالسازی پلنهای Starter/Business/Enterprise
+- سرور لایسنس مرکزی
+
+### فیچر ۵: بروزرسانی خودکار از گیت
+- نسخهبندی سمنتیک (SemVer)
+- آپدیت از گیت با تأیید مدیر
+- بکاپ قبل از آپدیت
+
+---
+
+> این فایل توسط Z.ai در تاریخ ۲۰۲۶-۱۰-۲۹ ایجاد شد.
+> در جلسات آینده با کارفرما باید هر فیچر بهصورت جداگانه طراحی شود.
diff --git a/03_WordPress/wp-content/plugins/ifnex-bridge/includes/shortcodes.php b/03_WordPress/wp-content/plugins/ifnex-bridge/includes/shortcodes.php
index bf5f617..d57484b 100644
--- a/03_WordPress/wp-content/plugins/ifnex-bridge/includes/shortcodes.php
+++ b/03_WordPress/wp-content/plugins/ifnex-bridge/includes/shortcodes.php
@@ -1330,7 +1330,6 @@ function ifnex_order_detail_shortcode($atts) {
diff --git a/04_Laravel/app/Filament/Resources/CurrencyResource.php b/04_Laravel/app/Filament/Resources/CurrencyResource.php
index c1394ba..6740e8a 100644
--- a/04_Laravel/app/Filament/Resources/CurrencyResource.php
+++ b/04_Laravel/app/Filament/Resources/CurrencyResource.php
@@ -50,7 +50,7 @@ class CurrencyResource extends Resource
->default(0)
->suffix('ریال')
->label('نرخ نسبت به ریال')
-->helperText('⚠️ مقدار به ریال (نه تومان) وارد کنید. مثالهای واقعی: AED ≈ 14,000 ریال (1,400 تومان) | USD ≈ 53,000 ریال (5,300 تومان) | EUR ≈ 58,000 ریال (5,800 تومان). با تغییر این فیلد، نرخ جدید در تاریخچه ثبت میشود.'),
+ ->helperText('نرخ روز این ارز به ریال ایران. مثال واقعی (اکتبر ۲۰۲۶): 1 AED ≈ 7,419,700 ریال | 1 USD ≈ 27,150,000 ریال | 1 EUR ≈ 3,013,000 ریال | 1 CNY ≈ 4,046,000 ریال. با تغییر این فیلد، نرخ جدید در تاریخچه ثبت میشود. توجه: نرخ باید به ریال باشد — واحد دیگری در سیستم پذیرفته نمیشود.'),
Forms\Components\Toggle::make('is_active')
->default(true)
->label('فعال'),
diff --git a/04_Laravel/app/Filament/Resources/RateImportResource/Pages/ImportRates.php b/04_Laravel/app/Filament/Resources/RateImportResource/Pages/ImportRates.php
index 8090aa7..2a47632 100644
--- a/04_Laravel/app/Filament/Resources/RateImportResource/Pages/ImportRates.php
+++ b/04_Laravel/app/Filament/Resources/RateImportResource/Pages/ImportRates.php
@@ -16,6 +16,13 @@ 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;
@@ -23,6 +30,7 @@ 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
@@ -43,7 +51,7 @@ class ImportRates extends Page implements HasForms
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel',
])
- ->maxSize(10240)
+ ->maxSize(10240) // 10 MB
->disk('local')
->directory('rate-imports')
->required()
@@ -74,12 +82,18 @@ 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)) {
$filePath = Storage::disk('local')->path($uploadedPath);
@@ -101,6 +115,8 @@ class ImportRates extends Page implements HasForms
$currency = $state['currency'] ?? 'AED';
$replaceExisting = (bool) ($state['replace_existing'] ?? false);
+ // اگر گزینه جایگزینی کامل فعال باشد، تمام رکوردهای قبلی حذف میشوند
+ // تا از تداخل currency های مختلف (مثلاً AED و IRR) جلوگیری شود.
if ($replaceExisting) {
\App\Models\RateEntry::query()->delete();
\App\Models\CountryZone::query()->delete();
@@ -146,4 +162,4 @@ class ImportRates extends Page implements HasForms
->send();
}
}
-}
\ No newline at end of file
+}
diff --git a/04_Laravel/app/Filament/Resources/ShipmentResource.php b/04_Laravel/app/Filament/Resources/ShipmentResource.php
index f7a6dd5..47e0bca 100644
--- a/04_Laravel/app/Filament/Resources/ShipmentResource.php
+++ b/04_Laravel/app/Filament/Resources/ShipmentResource.php
@@ -156,71 +156,66 @@ class ShipmentResource extends Resource
->label('بستهها'),
])->collapsible(),
- Forms\Components\Section::make('Financial Info')
+ Forms\Components\Section::make('اطلاعات مالی (همگی به ریال)')
->schema([
Forms\Components\TextInput::make('shipping_price')
->numeric()
->nullable()
- ->prefix('درهم')
- ->label('Shipping Price'),
+ ->prefix('ریال')
+ ->label('قیمت پایه حمل'),
Forms\Components\TextInput::make('extra_service')
->numeric()
->default(0)
- ->prefix('درهم')
- ->label('Extra Service'),
+ ->prefix('ریال')
+ ->label('سرویس اضافی'),
Forms\Components\TextInput::make('packing_cost')
->numeric()
->default(0)
->prefix('ریال')
- ->label('Packing Cost'),
+ ->label('هزینه بستهبندی'),
Forms\Components\TextInput::make('domestic_pickup')
->numeric()
->default(0)
->prefix('ریال')
- ->label('Domestic Pickup'),
+ ->label('جمعآوری داخلی'),
Forms\Components\TextInput::make('domestic_delivery')
->numeric()
->default(0)
->prefix('ریال')
- ->label('Domestic Delivery'),
+ ->label('تحویل داخلی'),
Forms\Components\TextInput::make('warehousing_cost')
->numeric()
->default(0)
->prefix('ریال')
- ->label('Warehousing Cost'),
+ ->label('انبارداری'),
Forms\Components\TextInput::make('vat_amount')
->numeric()
->default(0)
->prefix('ریال')
- ->label('VAT Amount'),
+ ->label('مالیات بر ارزش افزوده'),
Forms\Components\TextInput::make('discount')
->numeric()
->default(0)
->prefix('ریال')
- ->label('Discount'),
+ ->label('تخفیف'),
Forms\Components\TextInput::make('total_fee')
->numeric()
->nullable()
->prefix('ریال')
- ->label('Total Fee'),
- Forms\Components\TextInput::make('net_dirham')
- ->numeric()
- ->nullable()
- ->prefix('درهم')
- ->label('Net Dirham'),
+ ->label('مبلغ نهایی'),
Forms\Components\TextInput::make('net_rial')
->numeric()
->nullable()
->prefix('ریال')
- ->label('Net Rial'),
+ ->label('ناخالص (ریال)'),
Forms\Components\TextInput::make('cod_amount')
->numeric()
->default(0)
- ->prefix('AED')
- ->label('Cash on Delivery'),
+ ->prefix('ریال')
+ ->label('پرداخت در محل (COD)'),
])->columns(4),
- Forms\Components\Section::make('Import Invoice Fields (Service Sales Invoice)')
+ Forms\Components\Section::make('فیلدهای فاکتور واردات (خدمات فروش)')
->schema([
Forms\Components\TextInput::make('brand_fee')
->numeric()
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 9a62e77..6501d79 100644
--- a/04_Laravel/app/Filament/Resources/views/rate-import.blade.php
+++ b/04_Laravel/app/Filament/Resources/views/rate-import.blade.php
@@ -44,4 +44,4 @@
-
\ No newline at end of file
+
diff --git a/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php b/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php
index 1b07a8f..2cfb245 100644
--- a/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php
+++ b/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php
@@ -228,8 +228,8 @@ class CustomerOrderController extends Controller
'from_country_id' => $validated['from_country_id'],
'to_country_id' => $validated['to_country_id'],
- // اطلاعات مالی (از محاسبه)
- 'shipping_price' => $priceResult['net_dirham'],
+ // اطلاعات مالی (از محاسبه) — همگی به ریال (IRR)
+ 'shipping_price' => $priceResult['base_price'], // قیمت پایه به ریال
'extra_service' => $priceResult['extra_service'],
'packing_cost' => $priceResult['packing_cost'],
'domestic_pickup' => $priceResult['domestic_pickup'],
@@ -238,8 +238,8 @@ class CustomerOrderController extends Controller
'vat_amount' => $priceResult['vat_amount'],
'discount' => $priceResult['discount_amount'],
'total_fee' => $priceResult['total_fee'],
- 'net_dirham' => $priceResult['net_dirham'],
'net_rial' => $priceResult['net_rial'],
+ // NOTE: فیلد net_dirham دیگه استفاده نمیشه — سیستم بر مبنای ریال کار میکنه
// فرستنده
'sender_name' => $validated['sender_name'],
@@ -552,7 +552,7 @@ class CustomerOrderController extends Controller
'from_country_id' => $validated['from_country_id'],
'to_country_id' => $validated['to_country_id'],
- 'shipping_price' => $priceResult['net_dirham'],
+ 'shipping_price' => $priceResult['base_price'], // قیمت پایه به ریال
'extra_service' => $priceResult['extra_service'],
'packing_cost' => $priceResult['packing_cost'],
'domestic_pickup' => $priceResult['domestic_pickup'],
@@ -561,8 +561,8 @@ class CustomerOrderController extends Controller
'vat_amount' => $priceResult['vat_amount'],
'discount' => $priceResult['discount_amount'],
'total_fee' => $priceResult['total_fee'],
- 'net_dirham' => $priceResult['net_dirham'],
'net_rial' => $priceResult['net_rial'],
+ // NOTE: فیلد net_dirham دیگه استفاده نمیشه — سیستم بر مبنای ریال کار میکنه
'sender_name' => $validated['sender_name'],
'sender_company' => $validated['sender_company'] ?? null,
@@ -896,7 +896,7 @@ class CustomerOrderController extends Controller
'chargeable_weight' => $shipment->chargeable_weight,
'total_fee' => $shipment->total_fee,
'net_rial' => $shipment->net_rial,
- 'net_dirham' => $shipment->net_dirham,
+ 'shipping_price' => $shipment->shipping_price, // قیمت پایه به ریال
'created_at' => $shipment->created_at->toIso8601String(),
'created_at_jalali' => $this->toJalali($shipment->created_at),
];
diff --git a/04_Laravel/app/Models/RateEntry.php b/04_Laravel/app/Models/RateEntry.php
index 29aa72a..8792c78 100644
--- a/04_Laravel/app/Models/RateEntry.php
+++ b/04_Laravel/app/Models/RateEntry.php
@@ -44,6 +44,8 @@ class RateEntry extends Model
* Look up the applicable rate for a given weight/zone.
* Rounds UP to the next available weight tier if exact match not found.
*
+ * نرخها همگی به ریال (IRR) در دیتابیس ذخیره میشوند.
+ *
* @return static|null
*/
public static function lookup(
diff --git a/04_Laravel/app/Services/ExchangeRateService.php b/04_Laravel/app/Services/ExchangeRateService.php
index 03c1c0a..6897922 100644
--- a/04_Laravel/app/Services/ExchangeRateService.php
+++ b/04_Laravel/app/Services/ExchangeRateService.php
@@ -11,20 +11,40 @@ class ExchangeRateService
{
/**
* دریافت نرخ فعلی ارز نسبت به ریال.
- * ریال ارز مبناست و نرخ تمام ارزها فقط نسبت به ریال ذخیره میشود.
+ *
+ * ارز مبنا در سیستم IFNEX = ریال ایران (IRR).
+ * همه نرخها فقط نسبت به ریال در جدول currencies ذخیره میشوند.
+ *
+ * نرخها از دو مسیر بهروز میشوند:
+ * 1. مدیر بهصورت دستی در پنل (Currencies) وارد میکند
+ * 2. API نرخ ارز (مثلاً Exir.io) نرخ را دریافت و با وضعیت 'pending' ذخیره میکند
+ * و مدیر باید آن را تأیید کند تا بهعنوان نرخ فعال اعمال شود
+ * (در حال حاضر دکمه API موقتاً غیرفعال است).
*
* @param string $currencyCode کد ارز (مثل AED, USD, EUR)
* @return float نرخ ارز نسبت به ریال
+ * @throws \RuntimeException اگر ارز در دیتابیس یافت نشود (fallback حذف شد تا سیستم نرخ غلط استفاده نکند)
*/
public function getCurrentRate(string $currencyCode = 'AED'): float
{
$currency = Currency::where('code', strtoupper($currencyCode))->first();
if (!$currency) {
- return config('ifnex.financial_defaults.aed_to_irr', 455000);
+ throw new \RuntimeException(
+ "نرخ ارز برای '{$currencyCode}' در دیتابیس تنظیم نشده است. "
+ . "لطفاً در پنل ادمین → ارزی → این ارز را اضافه و نرخ آن را وارد کنید."
+ );
}
- return (float) $currency->rate_to_rial;
+ $rate = (float) $currency->rate_to_rial;
+ if ($rate <= 0) {
+ throw new \RuntimeException(
+ "نرخ ارز برای '{$currencyCode}' صفر یا منفی است. "
+ . "لطفاً در پنل ادمین → ارزی → نرخ معتبر وارد کنید."
+ );
+ }
+
+ return $rate;
}
/**
diff --git a/04_Laravel/app/Services/PriceCalculatorService.php b/04_Laravel/app/Services/PriceCalculatorService.php
index 7c1d225..64d6833 100644
--- a/04_Laravel/app/Services/PriceCalculatorService.php
+++ b/04_Laravel/app/Services/PriceCalculatorService.php
@@ -12,12 +12,31 @@ use App\Enums\ShipmentDirection;
/**
* PriceCalculatorService
* ----------------------
- * موتور قیمتگذاری ۴-رُبعی — همه نرخها به ریال (IRR).
+ * موتور قیمتگذاری ۴-رُبعی:
*
- * ─── معماری ارزی ───
- * ارز مبنا = ریال ایران. نرخها در دیتابیس به ریال ذخیره میشوند
+ * ┌──────────┬──────────────┬──────────────────┐
+ * │ │ DOCUMENT │ NON-DOCUMENT │
+ * ├──────────┼──────────────┼──────────────────┤
+ * │ Export │ Export×Doc │ Export×Parcel │
+ * │ Import │ Import×Doc │ Import×Parcel │
+ * └──────────┴──────────────┴──────────────────┘
+ *
+ * ─── معماری ارزی (Currency Architecture) ───
+ * ارز مبنا در کل سیستم IFNEX = ریال ایران (IRR).
+ * همه نرخها در جدول rate_entries بهصورت ریال ذخیره میشوند
* (تبدیل در زمان ایمپورت توسط RateImportService انجام میشود).
- * بنابراین موتور محاسبه نیازی به تبدیل ارز ندارد.
+ *
+ * بنابراین موتور محاسبه نیازی به تبدیل ارز ندارد — نرخ مستقیماً به ریال است.
+ *
+ * جریان محاسبه:
+ * 1. نرمالسازی ورودی (direction / type) و تعیین کشور مقصد با ISO.
+ * 2. جستجوی زون کشور از جدول country_zones.
+ * 3. جستجوی نرخ ریال از جدول rate_entries (نزدیکترین وزن بالاتر).
+ * 4. اعمال ضریب سود + هزینههای جانبی (همگی به ریال) → ناخالص ریال.
+ * 5. محاسبه مالیات بر ارزش افزوده (۹٪) و کسر تخفیف (در صورت وجود کد).
+ *
+ * امضای متد calculate(): آرایهای از ورودیها را میگیرد و آرایهای کامل
+ * از فیلدهای نمایشی برمیگرداند که وردپرس منتظرش است.
*/
class PriceCalculatorService
{
@@ -25,9 +44,56 @@ class PriceCalculatorService
protected ExchangeRateService $exchangeRate,
) {}
+ /**
+ * محاسبه قیمت نهایی محموله — همه مبالغ به ریال.
+ *
+ * @param array $input {
+ * @var string $direction 'export' | 'import' (یا 'Outbound' | 'Inbound')
+ * @var string $type 'DOC_NORMAL' | 'DOC_ECONOMY' | 'PARCEL'
+ * @var string $country_iso کد ISO دو حرفی کشور مقصد
+ * @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 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 $extra_service هزینه سرویس اضافی (ریال)
+ * @var float $domestic_pickup هزینه جمعآوری (ریال)
+ * @var float $domestic_delivery هزینه تحویل (ریال)
+ * @var float $warehousing_cost هزینه انبارداری (ریال)
+ * @var float $weight وزن واقعی
+ * @var float $volumetric_weight وزن حجمی
+ * @var float $chargeable_weight وزن قابل محاسبه
+ * @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',
@@ -41,7 +107,9 @@ class PriceCalculatorService
);
}
+ // ───────────────────────────────────────────────────────────────
// ۲. نرمالسازی type → service_type
+ // ───────────────────────────────────────────────────────────────
$typeRaw = strtoupper(trim((string) ($input['type'] ?? '')));
$serviceType = match ($typeRaw) {
'DOC_NORMAL', 'DOC_ECONOMY', 'DOCUMENT', 'DOC', 'DOCNORMAL', 'DOCECONOMY' => 'document',
@@ -54,7 +122,9 @@ class PriceCalculatorService
);
}
+ // ───────────────────────────────────────────────────────────────
// ۳. حل کشور با ISO → نام
+ // ───────────────────────────────────────────────────────────────
$countryIso = strtoupper(trim((string) ($input['country_iso'] ?? '')));
if (strlen($countryIso) !== 2) {
throw new \InvalidArgumentException(
@@ -67,12 +137,16 @@ 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(
@@ -81,7 +155,9 @@ class PriceCalculatorService
);
}
+ // ───────────────────────────────────────────────────────────────
// ۶. جستجوی نرخ — مستقیماً به ریال (IRR)
+ // ───────────────────────────────────────────────────────────────
$rateEntry = RateEntry::lookup($direction, $serviceType, $chargeableWeight, $zone, 'IRR');
if (! $rateEntry) {
throw new \RuntimeException(
@@ -90,10 +166,14 @@ class PriceCalculatorService
);
}
- // ۷. قیمت پایه — مستقیماً به ریال
+ // ───────────────────────────────────────────────────────────────
+ // ۷. قیمت پایه — مستقیماً به ریال (نیازی به تبدیل نیست)
+ // ───────────────────────────────────────────────────────────────
$basePriceIrr = (float) $rateEntry->rate;
+ // ───────────────────────────────────────────────────────────────
// ۸. هزینههای جانبی — همه به ریال
+ // ───────────────────────────────────────────────────────────────
$extras = [
'extra_service' => (float) ($input['extra_service'] ?? 0),
'packing_cost' => (float) ($input['packing_cost'] ?? 0),
@@ -103,7 +183,9 @@ class PriceCalculatorService
];
$extrasTotalIrr = array_sum($extras);
- // ۹. اعمال ضریب سود + هزینههای جانبی
+ // ───────────────────────────────────────────────────────────────
+ // ۹. اعمال ضریب سود (روی قیمت پایه، نه روی هزینههای جانبی)
+ // ───────────────────────────────────────────────────────────────
$profitMargin = (float) SystemSetting::get(
'profit_margin',
config('ifnex.financial_defaults.profit_margin', 1.25)
@@ -112,16 +194,21 @@ class PriceCalculatorService
$profitMargin = 1.0;
}
+ // ناخالص = (قیمت پایه × ضریب سود) + هزینههای جانبی — همه به ریال
$netRial = ($basePriceIrr * $profitMargin) + $extrasTotalIrr;
+ // ───────────────────────────────────────────────────────────────
// ۱۰. مالیات بر ارزش افزوده
+ // ───────────────────────────────────────────────────────────────
$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'] ?? ''));
@@ -137,9 +224,10 @@ 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') {
@@ -151,11 +239,14 @@ class PriceCalculatorService
}
}
- // ۱۲. مبلغ نهایی (گردش به ۱۰۰۰ ریال)
+ // ───────────────────────────────────────────────────────────────
+ // ۱۲. مبلغ نهایی (گردش به نزدیکترین ۱۰۰۰ ریال)
+ // ───────────────────────────────────────────────────────────────
$totalFee = $netRial + $vatAmount - $discountAmount;
$totalFee = (int) round($totalFee / 1000) * 1000;
return [
+ // فیلدهای نمایشی — همه به ریال
'base_price' => round($basePriceIrr, 2),
'net_rial' => (int) round($netRial),
'packing_cost' => round($extras['packing_cost'], 2),
@@ -163,13 +254,19 @@ class PriceCalculatorService
'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,
@@ -181,11 +278,18 @@ 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;
+ // کشور مقصد: برای صادرات = toCountry، برای واردات = fromCountry
$country = ($direction === 'export')
? $shipment->toCountry
: $shipment->fromCountry;
@@ -203,4 +307,4 @@ class PriceCalculatorService
'warehousing_cost' => (float) ($shipment->warehousing_cost ?? 0),
]);
}
-}
\ No newline at end of file
+}
diff --git a/04_Laravel/app/Services/RateImportService.php b/04_Laravel/app/Services/RateImportService.php
index 0c03af9..d595d8f 100644
--- a/04_Laravel/app/Services/RateImportService.php
+++ b/04_Laravel/app/Services/RateImportService.php
@@ -25,6 +25,22 @@ use Exception;
* سرویس هر نرخ را با نرخ روزِ آن ارز به ریال تبدیل کرده و سپس ذخیره میکند.
* بدین ترتیب نرخها در زمان ایمپورت «ثبت» میشوند و تغییرات نرخ ارز روزانه
* روی نرخهای حمل ذخیرهشده اثر نمیگذارد — فقط ایمپورتهای بعدی از نرخ جدید استفاده میکنند.
+ *
+ * Expected Excel structure (matches IFNEX_Rate_Template.xlsx):
+ *
+ * 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 M: "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
*/
class RateImportService
{
@@ -39,6 +55,7 @@ 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,
@@ -47,16 +64,28 @@ class RateImportService
'zones_updated' => 0,
];
+ /** ارز مبدأ فایل اکسل (نرخها در فایل به این ارز هستند) */
protected string $sourceCurrency = 'AED';
+
+ /** سرویس نرخ ارز برای تبدیل به ریال */
protected ExchangeRateService $exchangeRate;
+ /**
+ * Main entry point.
+ *
+ * @param string $filePath Absolute path to the uploaded .xlsx
+ * @param string $sourceCurrency ارز مبدأ فایل اکسل (AED, USD, EUR, IRR)
+ * @return array Summary array
+ */
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);
@@ -66,6 +95,7 @@ class RateImportService
$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');
@@ -73,6 +103,7 @@ class RateImportService
}
}
+ // 2. Parse Export Rate sheet (if present)
foreach (self::SHEET_NAMES['export'] as $name) {
if ($spreadsheet->sheetNameExists($name)) {
$this->importRateSheet($spreadsheet->getSheetByName($name), 'export');
@@ -80,6 +111,7 @@ class RateImportService
}
}
+ // 3. Parse Zone sheet (if present)
if ($spreadsheet->sheetNameExists('Zone')) {
$this->importZoneSheet($spreadsheet->getSheetByName('Zone'));
} elseif ($spreadsheet->sheetNameExists('Zones')) {
@@ -89,13 +121,18 @@ 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): void
{
$maxRow = $ws->getHighestDataRow();
$maxCol = $ws->getHighestDataColumn();
$maxColIdx = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::columnIndexFromString($maxCol);
- $currentSection = null;
+ // Walk every row, detect section markers, then parse the matrix that follows.
+ $currentSection = null; // 'document' | 'parcel'
$headerRowIdx = null;
for ($r = 1; $r <= $maxRow; $r++) {
@@ -104,6 +141,7 @@ class RateImportService
$rowValues[$c] = $ws->getCellByColumnAndRow($c, $r)->getCalculatedValue();
}
+ // Detect section markers
$matchedSection = $this->detectSection($rowValues);
if ($matchedSection !== null) {
$currentSection = $matchedSection;
@@ -111,6 +149,7 @@ class RateImportService
continue;
}
+ // Detect header row ("Weight (kg)" + "Zone 1".."Zone N")
if ($currentSection !== null && $headerRowIdx === null) {
if ($this->isHeaderRow($rowValues)) {
$headerRowIdx = $r;
@@ -118,9 +157,11 @@ 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;
@@ -141,6 +182,10 @@ class RateImportService
}
}
+ /**
+ * 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) {
@@ -149,6 +194,7 @@ 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';
@@ -163,6 +209,12 @@ 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])) {
@@ -172,16 +224,19 @@ 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) {
@@ -191,9 +246,13 @@ class RateImportService
}
}
}
- return $zoneCount >= 3;
+ return $zoneCount >= 3; // require at least 3 zone columns to consider this a header
}
+ /**
+ * 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] === '') {
@@ -215,7 +274,7 @@ class RateImportService
$rates = [];
for ($c = 2; $c <= 11; $c++) {
- $zone = $c - 1;
+ $zone = $c - 1; // col 2 -> Zone 1, col 11 -> Zone 10
$val = $rowValues[$c] ?? null;
if ($val === null || $val === '' || (is_string($val) && trim($val) === '')) {
$rates[$zone] = null;
@@ -224,6 +283,7 @@ 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;
}
@@ -243,9 +303,13 @@ class RateImportService
/**
* Upsert a rate row — همیشه به ریال ذخیره میشود.
+ *
+ * نرخ ورودی (به ارز مبدأ) با نرخ روزِ آن ارز به ریال تبدیل شده
+ * و سپس در جدول rate_entries با currency='IRR' ذخیره میشود.
*/
protected function upsertRate(string $direction, string $serviceType, float $weight, int $zone, float $rate): void
{
+ // تبدیل نرخ از ارز مبدأ به ریال
$rateInIrr = $this->exchangeRate->convertToIrr($rate, $this->sourceCurrency);
try {
@@ -279,10 +343,17 @@ 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();
@@ -294,7 +365,7 @@ class RateImportService
}
}
if ($headerRow === null) {
- $headerRow = 3;
+ $headerRow = 3; // fallback
}
for ($r = $headerRow + 1; $r <= $maxRow; $r++) {
@@ -303,11 +374,13 @@ 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);
@@ -339,4 +412,4 @@ class RateImportService
$this->summary['errors'][] = "Country zone error ({$country}): " . $e->getMessage();
}
}
-}
\ No newline at end of file
+}
diff --git a/04_Laravel/config/ifnex.php b/04_Laravel/config/ifnex.php
index 64ba0b3..0bcfb99 100644
--- a/04_Laravel/config/ifnex.php
+++ b/04_Laravel/config/ifnex.php
@@ -124,7 +124,10 @@ return [
'vat_rate' => 0.09, // ۹٪ مالیات بر ارزش افزوده
'profit_margin' => 1.25, // ۲۵٪ حاشیه سود
'packing_cost' => 100000, // ۱۰۰,۰۰۰ ریال هزینه بستهبندی پیشفرض
- 'aed_to_irr' => 14000, // نرخ درهم به ریال (fallback)
+ // ⚠️ نرخهای ارز فقط از جدول currencies خوانده میشوند (هیچ fallback عددی نداریم).
+ // اگه ارز در دیتابیس نباشد، ExchangeRateService خطا میدهد و مدیر باید آن را تنظیم کند.
+ // این عدد فقط برای جلوگیری از Fatal Error در تستهای قدیمی نگه داشته شده — نباید به آن اعتماد کرد.
+ 'aed_to_irr' => 7419700, // [Deprecated — فقط تست] 7,419,700 ریال/درهم (اکتبر ۲۰۲۶)
],
/*
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
index d41efcc..4197d9f 100644
--- 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
@@ -1,5 +1,15 @@
char('currency', 3)->default('AED')->change();
});
}
-};
\ No newline at end of file
+};
diff --git a/04_Laravel/database/migrations/2026_10_29_130000_enlarge_rate_entries_rate_column.php b/04_Laravel/database/migrations/2026_10_29_130000_enlarge_rate_entries_rate_column.php
index d5beab4..96e3fdd 100644
--- a/04_Laravel/database/migrations/2026_10_29_130000_enlarge_rate_entries_rate_column.php
+++ b/04_Laravel/database/migrations/2026_10_29_130000_enlarge_rate_entries_rate_column.php
@@ -4,8 +4,11 @@
* Migration: بزرگکردن ستون rate برای پشتیبانی از مبالغ بزرگ ریالی
*
* ستون rate قبلاً decimal(12,4) بود که حداکثر ~100 میلیون را نگه میداشت.
- * با تبدیل نرخهای درهم به ریال، ستون را به decimal(20,4) افزایش میدهیم
- * که حداکثر ~100 تریلیون را نگه میدارد.
+ * با تبدیل نرخهای ارزی به ریال (مثلاً 149 AED × 7,419,700 IRR/AED = ~1.1 میلیارد ریال)
+ * ستون باید بزرگتر شود. به decimal(20,4) افزایش میدهیم که حداکثر ~100 تریلیون را نگه میدارد.
+ *
+ * ⚠️ نکته: ارز مبنا در کل سیستم = ریال ایران (IRR). هیچ واحد دیگری در محاسبات و
+ * ذخیرهسازی استفاده نمیشود.
*/
use Illuminate\Database\Migrations\Migration;
@@ -17,7 +20,7 @@ return new class extends Migration
public function up(): void
{
Schema::table('rate_entries', function (Blueprint $table) {
- // decimal(20,4): 16 رقم قبل اعشار + 4 رقم بعد اعشار
+ // decimal(20,4): 16 رقم قبل اعشار + 4 رقم بعد اعشار = حداکثر 99,999,999,999,999.9999
$table->decimal('rate', 20, 4)->comment('in IRR (Iranian Rial)')->change();
});
}
@@ -28,4 +31,6 @@ return new class extends Migration
$table->decimal('rate', 12, 4)->change();
});
}
-};
\ No newline at end of file
+};
+
+
diff --git a/04_Laravel/database/migrations/2026_10_29_140000_enlarge_shipments_financial_columns.php b/04_Laravel/database/migrations/2026_10_29_140000_enlarge_shipments_financial_columns.php
new file mode 100644
index 0000000..2711921
--- /dev/null
+++ b/04_Laravel/database/migrations/2026_10_29_140000_enlarge_shipments_financial_columns.php
@@ -0,0 +1,56 @@
+decimal('shipping_price', 20, 2)->nullable()->unsigned()->change();
+ $table->decimal('extra_service', 20, 2)->default(0)->unsigned()->change();
+ $table->decimal('packing_cost', 20, 2)->default(0)->unsigned()->change();
+ $table->decimal('domestic_pickup', 20, 2)->default(0)->unsigned()->change();
+ $table->decimal('domestic_delivery', 20, 2)->default(0)->unsigned()->change();
+ $table->decimal('warehousing_cost', 20, 2)->default(0)->unsigned()->change();
+ $table->decimal('vat_amount', 20, 2)->default(0)->unsigned()->change();
+ $table->decimal('discount', 20, 2)->default(0)->unsigned()->change();
+ $table->decimal('total_fee', 20, 2)->nullable()->unsigned()->change();
+ $table->decimal('net_dirham', 20, 2)->nullable()->unsigned()->change();
+ $table->decimal('net_rial', 20, 2)->nullable()->unsigned()->change();
+ $table->decimal('cod_amount', 20, 2)->default(0)->unsigned()->change();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('shipments', function (Blueprint $table) {
+ $table->decimal('shipping_price', 12, 2)->nullable()->unsigned()->change();
+ $table->decimal('extra_service', 12, 2)->default(0)->unsigned()->change();
+ $table->decimal('packing_cost', 12, 2)->default(0)->unsigned()->change();
+ $table->decimal('domestic_pickup', 12, 2)->default(0)->unsigned()->change();
+ $table->decimal('domestic_delivery', 12, 2)->default(0)->unsigned()->change();
+ $table->decimal('warehousing_cost', 12, 2)->default(0)->unsigned()->change();
+ $table->decimal('vat_amount', 12, 2)->default(0)->unsigned()->change();
+ $table->decimal('discount', 12, 2)->default(0)->unsigned()->change();
+ $table->decimal('total_fee', 12, 2)->nullable()->unsigned()->change();
+ $table->decimal('net_dirham', 12, 2)->nullable()->unsigned()->change();
+ $table->decimal('net_rial', 12, 2)->nullable()->unsigned()->change();
+ $table->decimal('cod_amount', 12, 2)->default(0)->unsigned()->change();
+ });
+ }
+};
diff --git a/04_Laravel/database/seeders/SystemSettingSeeder.php b/04_Laravel/database/seeders/SystemSettingSeeder.php
index 04db095..da9164e 100644
--- a/04_Laravel/database/seeders/SystemSettingSeeder.php
+++ b/04_Laravel/database/seeders/SystemSettingSeeder.php
@@ -3,22 +3,25 @@
namespace Database\Seeders;
use App\Models\SystemSetting;
-use App\Models\User;
use Illuminate\Database\Seeder;
-use Illuminate\Support\Facades\DB;
class SystemSettingSeeder extends Seeder
{
public function run(): void
{
$settings = [
- ['key' => 'vat_rate', 'value' => 0.0900, 'description' => 'VAT rate — 9%'],
- ['key' => 'packing_cost_default', 'value' => 100000.0000, 'description' => 'Default packing cost in IRR'],
- ['key' => 'profit_margin', 'value' => 1.2500, 'description' => 'Profit margin — set per company'],
- ['key' => 'aed_to_irr', 'value' => 455000.0000, 'description' => 'AED to IRR exchange rate'],
- ['key' => 'usd_to_irr', 'value' => 0.0000, 'description' => 'USD to IRR exchange rate'],
- ['key' => 'cny_to_irr', 'value' => 0.0000, 'description' => 'CNY to IRR exchange rate'],
- ['key' => 'eur_to_irr', 'value' => 0.0000, 'description' => 'EUR to IRR exchange rate'],
+ // تنظیمات مالی — همه نرخها به ریال (IRR)
+ ['key' => 'vat_rate', 'value' => 0.0900, 'description' => 'نرخ مالیات بر ارزش افزوده — ۹٪'],
+ ['key' => 'packing_cost_default', 'value' => 100000.0, 'description' => 'هزینه بستهبندی پیشفرض (ریال)'],
+ ['key' => 'profit_margin', 'value' => 1.2500, 'description' => 'ضریب سود (۱.۲۵ = ۲۵٪ سود)'],
+
+ // ⚠️ نرخهای ارز در جدول currencies ذخیره میشوند، نه در SystemSettings.
+ // این مقادیر فقط برای backward-compatibility نگه داشته شدهاند و نباید استفاده شوند.
+ // در نسخه Pro این کلیدها حذف خواهند شد و فقط از جدول currencies خوانده میشود.
+ ['key' => 'aed_to_irr', 'value' => 7419700.0, 'description' => '[Deprecated] نرخ درهم به ریال — نرخ واقعی از جدول currencies خوانده شود'],
+ ['key' => 'usd_to_irr', 'value' => 27150000.0, 'description' => '[Deprecated] نرخ دلار به ریال'],
+ ['key' => 'cny_to_irr', 'value' => 4046000.0, 'description' => '[Deprecated] نرخ یوان به ریال'],
+ ['key' => 'eur_to_irr', 'value' => 3013000.0, 'description' => '[Deprecated] نرخ یورو به ریال'],
];
foreach ($settings as $setting) {
diff --git a/04_Laravel/resources/views/filament/pages/price-test-page.blade.php b/04_Laravel/resources/views/filament/pages/price-test-page.blade.php
index 4c3eadd..f2d043d 100644
--- a/04_Laravel/resources/views/filament/pages/price-test-page.blade.php
+++ b/04_Laravel/resources/views/filament/pages/price-test-page.blade.php
@@ -13,40 +13,52 @@
نتیجه محاسبه:
- @if($result['status'] === 'error')
+ @if(isset($result['status']) && $result['status'] === 'error')
{{ $result['message'] }}
وزن قابل پرداخت: {{ $result['chargeable_weight'] }} کیلوگرم
@else
وزن حجمی
-
{{ number_format($result['volumetric_weight'], 3) }} kg
+
{{ number_format($result['volumetric_weight'] ?? 0, 3) }} kg
وزن قابل پرداخت (ماکزیمم)
-
{{ number_format($result['chargeable_weight'], 2) }} kg
+
{{ number_format($result['chargeable_weight'] ?? 0, 2) }} kg
زون مقصد
-
{{ $result['zone'] }}
+
{{ $result['zone'] ?? '-' }}
-
قیمت پایه (درهم)
-
{{ number_format($result['base_price_dirham']) }} AED
+
قیمت پایه (ریال)
+
{{ number_format($result['base_price'] ?? 0) }} ریال
-
قیمت نهایی (درهم - شامل ضریب سود)
-
{{ number_format($result['net_dirham']) }} AED
+
ناخالص (شامل ضریب سود — ریال)
+
{{ number_format($result['net_rial'] ?? 0) }} ریال
-
قیمت نهایی (ریال)
-
{{ number_format($result['net_rial']) }} ریال
+
مبلغ نهایی (شامل مالیات — ریال)
+
{{ number_format($result['total_fee'] ?? 0) }} ریال
+ @if(isset($result['vat_amount']) && $result['vat_amount'] > 0)
+
+
مالیات بر ارزش افزوده (۹٪)
+
{{ number_format($result['vat_amount']) }} ریال
+
+ @endif
+ @if(isset($result['profit_margin']))
+
+
ضریب سود اعمالشده
+
{{ $result['profit_margin'] }}×
+
+ @endif
@endif
@endif
-
\ No newline at end of file
+
diff --git a/04_Laravel/resources/views/filament/pages/rate-import.blade.php b/04_Laravel/resources/views/filament/pages/rate-import.blade.php
index b0f02a2..6501d79 100644
--- a/04_Laravel/resources/views/filament/pages/rate-import.blade.php
+++ b/04_Laravel/resources/views/filament/pages/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, ...).
+ سلولهای خالی نادیده گرفته میشوند.
+ ارز مبدأ: ارزی که نرخهای فایل اکسل به آن هستند را انتخاب کنید. سیستم با نرخ روز آن ارز → ریال تبدیل کرده و در دیتابیس ذخیره میکند.
+ نکته: قبل از ایمپورت، مطمئن شوید نرخ روز ارز مبدأ در پنل «ارزها» تنظیم شده است.
@@ -43,4 +44,4 @@
-
\ No newline at end of file
+
diff --git a/04_Laravel/resources/views/orders/create.blade.php b/04_Laravel/resources/views/orders/create.blade.php
index 8637866..134df8e 100644
--- a/04_Laravel/resources/views/orders/create.blade.php
+++ b/04_Laravel/resources/views/orders/create.blade.php
@@ -369,7 +369,7 @@ async function calculatePrice() {
const data = await response.json();
if (response.ok) {
- document.getElementById('basePrice').textContent = data.base_price?.toFixed(2) + ' درهم';
+ document.getElementById('basePrice').textContent = Number(data.base_price).toLocaleString('fa-IR') + ' ریال';
document.getElementById('totalFee').textContent = Number(data.total_fee).toLocaleString('fa-IR') + ' ریال';
document.getElementById('zone').textContent = data.zone;
document.getElementById('priceResult').classList.remove('hidden');
diff --git a/04_Laravel/resources/views/pricing/index.blade.php b/04_Laravel/resources/views/pricing/index.blade.php
index 3a0fe5d..f591175 100644
--- a/04_Laravel/resources/views/pricing/index.blade.php
+++ b/04_Laravel/resources/views/pricing/index.blade.php
@@ -48,7 +48,7 @@
- سرویس اضافی (AED)
+ سرویس اضافی (ریال)
@@ -65,21 +65,17 @@
نتیجه محاسبه
-
قیمت پایه (AED)
+
قیمت پایه (ریال)
—
-
قیمت نهایی (IRR)
+
مبلغ نهایی قابل پرداخت (ریال)
—
-
* این قیمت تقریبی است و نهایی بودن آن منوط تأیید کارشناسان خواهد بود.
@@ -125,10 +121,9 @@ document.getElementById('pricingForm').addEventListener('submit', async function
const data = await response.json();
if (response.ok) {
- document.getElementById('basePrice').textContent = Number(data.base_price).toFixed(2) + ' درهم';
+ document.getElementById('basePrice').textContent = Number(data.base_price).toLocaleString('fa-IR') + ' ریال';
document.getElementById('totalFee').textContent = Number(data.total_fee).toLocaleString('fa-IR') + ' ریال';
document.getElementById('zone').textContent = data.zone;
- document.getElementById('rate').textContent = '1 درهم = {{ number_format($settings['aed_to_irr'], 0) }} ریال';
document.getElementById('result').classList.remove('hidden');
} else {
alert('خطا در محاسبه قیمت. لطفاً دوباره تلاش کنید.');
diff --git a/04_Laravel/routes/web.php b/04_Laravel/routes/web.php
index abd7df0..df81da7 100644
--- a/04_Laravel/routes/web.php
+++ b/04_Laravel/routes/web.php
@@ -48,7 +48,6 @@ Route::get('/payment-result', function (Illuminate\Http\Request $request) {
'message' => $message,
]);
})->name('payment.result');
-
// دانلود قالب خالی نرخها (IFNEX_Rate_Template.xlsx — فرمت ۴-رُبعی جدید)
// فایل واقعی در public/templates قرار دارد تا مستقیم از طریق وب هم قابل دسترس باشد.
Route::get('/download-rate-template', function () {