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) {

ریال
-
AED
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 @@
- +
@@ -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 () {