refactor(currency): enforce IRR-only across all UIs and storage
This commit is contained in:
parent
477418b349
commit
848f95f32e
103
01_Documents/IFNEX_Pro_Backlog.md
Normal file
103
01_Documents/IFNEX_Pro_Backlog.md
Normal 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 در تاریخ ۲۰۲۶-۱۰-۲۹ ایجاد شد.
|
||||
> در جلسات آینده با کارفرما باید هر فیچر بهصورت جداگانه طراحی شود.
|
||||
@ -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">
|
||||
|
||||
@ -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('فعال'),
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -44,4 +44,4 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
</x-filament-panels::page>
|
||||
|
||||
@ -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),
|
||||
];
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 ریال/درهم (اکتبر ۲۰۲۶)
|
||||
],
|
||||
|
||||
/*
|
||||
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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('خطا در محاسبه قیمت. لطفاً دوباره تلاش کنید.');
|
||||
|
||||
@ -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 () {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user