refactor(currency): enforce IRR-only across all UIs and storage

This commit is contained in:
Kazem Alghasi 2026-10-29 22:31:26 +03:30
parent 477418b349
commit 848f95f32e
21 changed files with 493 additions and 97 deletions

View File

@ -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 در تاریخ ۲۰۲۶-۱۰-۲۹ ایجاد شد.
> در جلسات آینده با کارفرما باید هر فیچر به‌صورت جداگانه طراحی شود.

View File

@ -1330,7 +1330,6 @@ function ifnex_order_detail_shortcode($atts) {
<div class="ifnex-od-price">
<div class="ifnex-od-price-total"><?php echo number_format((float)($data['total_fee'] ?? 0)); ?><br><small>ریال</small></div>
<div class="ifnex-od-price-status <?php echo $status['is_paid'] ? 'paid' : 'unpaid'; ?>"><?php echo $status['is_paid'] ? '✅ پرداخت شده' : '⏳ در انتظار پرداخت'; ?></div>
<?php if (!empty($data['net_dirham'])): ?><div style="font-size:12px;color:#94a3b8;margin-top:4px"><?php echo number_format((float)$data['net_dirham']); ?> AED</div><?php endif; ?>
</div>
</div>
<div class="ifnex-od-card">

View File

@ -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('فعال'),

View File

@ -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<string, mixed> 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();
}
}
}
}

View File

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

View File

@ -44,4 +44,4 @@
</p>
</div>
</div>
</x-filament-panels::page>
</x-filament-panels::page>

View File

@ -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),
];

View File

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

View File

@ -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;
}
/**

View File

@ -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),
]);
}
}
}

View File

@ -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 | <rate> | <rate> | ...
*
* Row M: "NON - DOCUMENT"
* Row M+1: "Weight (kg)" | Zone 1 | ... | Zone 10
* Row M+2: 0.5 | <rate> | ...
* ... up to 30 kg
*
* Sheet "Zone":
* Columns A-C: No. | Destination Country | Parcel Zone
* Columns E-G: No. | Destination Country | Document Zone
*/
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();
}
}
}
}

View File

@ -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 ریال/درهم (اکتبر ۲۰۲۶)
],
/*

View File

@ -1,5 +1,15 @@
<?php
/**
* Migration: تغییر پیش‌فرض ستون currency در rate_entries به IRR
*
* با معماری جدید، همه نرخ‌ها به‌صورت ریال در دیتابیس ذخیره می‌شوند.
* این مایگریشن فقط پیش‌فرض ستون را از AED به IRR تغییر می‌دهد.
*
* توجه: رکوردهای قدیمی با currency=AED دست‌نخورده باقی می‌مانند.
* برای پاک کردن آن‌ها از پنل ادمین → Rate Import → گزینه «جایگزینی کامل» را فعال کنید.
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
@ -19,4 +29,4 @@ return new class extends Migration
$table->char('currency', 3)->default('AED')->change();
});
}
};
};

View File

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

View File

@ -0,0 +1,56 @@
<?php
/**
* Migration: بزرگ‌کردن ستون‌های مالی جدول shipments برای پشتیبانی از مبالغ بزرگ ریالی
*
* ستون‌های قبلی decimal(12,2) بودند که حداکثر ~10 میلیارد را نگه می‌داشتند.
* با محاسبات ریالی ممکن است مقادیر بزرگ‌تر شوند، پس به decimal(20,2) افزایش می‌دهیم
* که حداکثر ~100 تریلیون را نگه می‌دارد.
*
* ستون net_dirham هم در این مایگریشن nullable می‌شود چون سیستم به IRR-only تغییر کرده
* و این فیلد دیگه در سفارش‌های جدید پر نمی‌شه.
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('shipments', function (Blueprint $table) {
// بزرگ‌کردن ستون‌های مالی برای مبالغ ریالی بزرگ
$table->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();
});
}
};

View File

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

View File

@ -13,40 +13,52 @@
<div class="mt-8 p-6 bg-gray-100 dark:bg-gray-800 rounded-lg border">
<h2 class="text-xl font-bold mb-4">نتیجه محاسبه:</h2>
@if($result['status'] === 'error')
@if(isset($result['status']) && $result['status'] === 'error')
<p class="text-red-500 font-bold">{{ $result['message'] }}</p>
<p>وزن قابل پرداخت: {{ $result['chargeable_weight'] }} کیلوگرم</p>
@else
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-gray-500 text-sm">وزن حجمی</p>
<p class="text-lg font-bold">{{ number_format($result['volumetric_weight'], 3) }} kg</p>
<p class="text-lg font-bold">{{ number_format($result['volumetric_weight'] ?? 0, 3) }} kg</p>
</div>
<div>
<p class="text-gray-500 text-sm">وزن قابل پرداخت (ماکزیمم)</p>
<p class="text-lg font-bold">{{ number_format($result['chargeable_weight'], 2) }} kg</p>
<p class="text-lg font-bold">{{ number_format($result['chargeable_weight'] ?? 0, 2) }} kg</p>
</div>
<div>
<p class="text-gray-500 text-sm">زون مقصد</p>
<p class="text-lg font-bold">{{ $result['zone'] }}</p>
<p class="text-lg font-bold">{{ $result['zone'] ?? '-' }}</p>
</div>
<div>
<p class="text-gray-500 text-sm">قیمت پایه (درهم)</p>
<p class="text-lg font-bold">{{ number_format($result['base_price_dirham']) }} AED</p>
<p class="text-gray-500 text-sm">قیمت پایه (ریال)</p>
<p class="text-lg font-bold">{{ number_format($result['base_price'] ?? 0) }} ریال</p>
</div>
<hr class="col-span-2 border-dashed my-2">
<div>
<p class="text-gray-500 text-sm">قیمت نهایی (درهم - شامل ضریب سود)</p>
<p class="text-2xl font-bold text-blue-600">{{ number_format($result['net_dirham']) }} AED</p>
<p class="text-gray-500 text-sm">ناخالص (شامل ضریب سود — ریال)</p>
<p class="text-2xl font-bold text-blue-600">{{ number_format($result['net_rial'] ?? 0) }} ریال</p>
</div>
<div>
<p class="text-gray-500 text-sm">قیمت نهایی (ریال)</p>
<p class="text-2xl font-bold text-green-600">{{ number_format($result['net_rial']) }} ریال</p>
<p class="text-gray-500 text-sm">مبلغ نهایی (شامل مالیات — ریال)</p>
<p class="text-2xl font-bold text-green-600">{{ number_format($result['total_fee'] ?? 0) }} ریال</p>
</div>
@if(isset($result['vat_amount']) && $result['vat_amount'] > 0)
<div>
<p class="text-gray-500 text-sm">مالیات بر ارزش افزوده (۹٪)</p>
<p class="text-lg font-bold">{{ number_format($result['vat_amount']) }} ریال</p>
</div>
@endif
@if(isset($result['profit_margin']))
<div>
<p class="text-gray-500 text-sm">ضریب سود اعمال‌شده</p>
<p class="text-lg font-bold">{{ $result['profit_margin'] }}×</p>
</div>
@endif
</div>
@endif
</div>
@endif
</x-filament-panels::page>
</x-filament-panels::page>

View File

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

View File

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

View File

@ -48,7 +48,7 @@
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">سرویس اضافی (AED)</label>
<label class="block text-sm font-medium text-gray-700 mb-1">سرویس اضافی (ریال)</label>
<input type="number" step="0.01" id="extraService" class="w-full rounded-md border-gray-300 shadow-sm focus:border-orange-500 focus:ring-orange-500" placeholder="اختیاری">
</div>
</div>
@ -65,21 +65,17 @@
<h3 class="text-lg font-semibold text-gray-900 mb-4">نتیجه محاسبه</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<span class="text-sm text-gray-600">قیمت پایه (AED)</span>
<span class="text-sm text-gray-600">قیمت پایه (ریال)</span>
<div class="text-xl font-bold" id="basePrice">—</div>
</div>
<div>
<span class="text-sm text-gray-600">قیمت نهایی (IRR)</span>
<span class="text-sm text-gray-600">مبلغ نهایی قابل پرداخت (ریال)</span>
<div class="text-xl font-bold text-orange-600" id="totalFee">—</div>
</div>
<div>
<span class="text-sm text-gray-600">زون</span>
<div class="text-xl font-bold" id="zone">—</div>
</div>
<div>
<span class="text-sm text-gray-600">نرخ تبدیل</span>
<div class="text-xl font-bold" id="rate">—</div>
</div>
</div>
<p class="text-xs text-gray-500 mt-4">* این قیمت تقریبی است و نهایی بودن آن منوط تأیید کارشناسان خواهد بود.</p>
</div>
@ -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('خطا در محاسبه قیمت. لطفاً دوباره تلاش کنید.');

View File

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