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">
|
||||||
<div class="ifnex-od-price-total"><?php echo number_format((float)($data['total_fee'] ?? 0)); ?><br><small>ریال</small></div>
|
<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>
|
<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>
|
</div>
|
||||||
<div class="ifnex-od-card">
|
<div class="ifnex-od-card">
|
||||||
|
|||||||
@ -50,7 +50,7 @@ class CurrencyResource extends Resource
|
|||||||
->default(0)
|
->default(0)
|
||||||
->suffix('ریال')
|
->suffix('ریال')
|
||||||
->label('نرخ نسبت به ریال')
|
->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')
|
Forms\Components\Toggle::make('is_active')
|
||||||
->default(true)
|
->default(true)
|
||||||
->label('فعال'),
|
->label('فعال'),
|
||||||
|
|||||||
@ -16,6 +16,13 @@ use Filament\Resources\Pages\Page;
|
|||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
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
|
class ImportRates extends Page implements HasForms
|
||||||
{
|
{
|
||||||
use InteractsWithForms;
|
use InteractsWithForms;
|
||||||
@ -23,6 +30,7 @@ class ImportRates extends Page implements HasForms
|
|||||||
protected static string $resource = RateImportResource::class;
|
protected static string $resource = RateImportResource::class;
|
||||||
protected static string $view = 'filament.pages.rate-import';
|
protected static string $view = 'filament.pages.rate-import';
|
||||||
|
|
||||||
|
/** @var array<string, mixed> Form state */
|
||||||
public ?array $data = [];
|
public ?array $data = [];
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
@ -43,7 +51,7 @@ class ImportRates extends Page implements HasForms
|
|||||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
'application/vnd.ms-excel',
|
'application/vnd.ms-excel',
|
||||||
])
|
])
|
||||||
->maxSize(10240)
|
->maxSize(10240) // 10 MB
|
||||||
->disk('local')
|
->disk('local')
|
||||||
->directory('rate-imports')
|
->directory('rate-imports')
|
||||||
->required()
|
->required()
|
||||||
@ -74,12 +82,18 @@ class ImportRates extends Page implements HasForms
|
|||||||
$state = $this->form->getState();
|
$state = $this->form->getState();
|
||||||
|
|
||||||
if (empty($state['uploaded_file'])) {
|
if (empty($state['uploaded_file'])) {
|
||||||
Notification::make()->title('No file uploaded')->danger()->send();
|
Notification::make()
|
||||||
|
->title('No file uploaded')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$uploadedPath = $state['uploaded_file'];
|
$uploadedPath = $state['uploaded_file'];
|
||||||
|
|
||||||
|
// FileUpload روی دیسک 'local' ذخیره میشود که در config/filesystems.php
|
||||||
|
// به storage_path('app/private') اشاره دارد. مسیر واقعی را از خود دیسک
|
||||||
|
// میپرسیم تا با تغییر کانفیگ هم درست کار کند.
|
||||||
$filePath = null;
|
$filePath = null;
|
||||||
if (is_string($uploadedPath)) {
|
if (is_string($uploadedPath)) {
|
||||||
$filePath = Storage::disk('local')->path($uploadedPath);
|
$filePath = Storage::disk('local')->path($uploadedPath);
|
||||||
@ -101,6 +115,8 @@ class ImportRates extends Page implements HasForms
|
|||||||
$currency = $state['currency'] ?? 'AED';
|
$currency = $state['currency'] ?? 'AED';
|
||||||
$replaceExisting = (bool) ($state['replace_existing'] ?? false);
|
$replaceExisting = (bool) ($state['replace_existing'] ?? false);
|
||||||
|
|
||||||
|
// اگر گزینه جایگزینی کامل فعال باشد، تمام رکوردهای قبلی حذف میشوند
|
||||||
|
// تا از تداخل currency های مختلف (مثلاً AED و IRR) جلوگیری شود.
|
||||||
if ($replaceExisting) {
|
if ($replaceExisting) {
|
||||||
\App\Models\RateEntry::query()->delete();
|
\App\Models\RateEntry::query()->delete();
|
||||||
\App\Models\CountryZone::query()->delete();
|
\App\Models\CountryZone::query()->delete();
|
||||||
|
|||||||
@ -156,71 +156,66 @@ class ShipmentResource extends Resource
|
|||||||
->label('بستهها'),
|
->label('بستهها'),
|
||||||
])->collapsible(),
|
])->collapsible(),
|
||||||
|
|
||||||
Forms\Components\Section::make('Financial Info')
|
Forms\Components\Section::make('اطلاعات مالی (همگی به ریال)')
|
||||||
->schema([
|
->schema([
|
||||||
Forms\Components\TextInput::make('shipping_price')
|
Forms\Components\TextInput::make('shipping_price')
|
||||||
->numeric()
|
->numeric()
|
||||||
->nullable()
|
->nullable()
|
||||||
->prefix('درهم')
|
->prefix('ریال')
|
||||||
->label('Shipping Price'),
|
->label('قیمت پایه حمل'),
|
||||||
Forms\Components\TextInput::make('extra_service')
|
Forms\Components\TextInput::make('extra_service')
|
||||||
->numeric()
|
->numeric()
|
||||||
->default(0)
|
->default(0)
|
||||||
->prefix('درهم')
|
->prefix('ریال')
|
||||||
->label('Extra Service'),
|
->label('سرویس اضافی'),
|
||||||
Forms\Components\TextInput::make('packing_cost')
|
Forms\Components\TextInput::make('packing_cost')
|
||||||
->numeric()
|
->numeric()
|
||||||
->default(0)
|
->default(0)
|
||||||
->prefix('ریال')
|
->prefix('ریال')
|
||||||
->label('Packing Cost'),
|
->label('هزینه بستهبندی'),
|
||||||
Forms\Components\TextInput::make('domestic_pickup')
|
Forms\Components\TextInput::make('domestic_pickup')
|
||||||
->numeric()
|
->numeric()
|
||||||
->default(0)
|
->default(0)
|
||||||
->prefix('ریال')
|
->prefix('ریال')
|
||||||
->label('Domestic Pickup'),
|
->label('جمعآوری داخلی'),
|
||||||
Forms\Components\TextInput::make('domestic_delivery')
|
Forms\Components\TextInput::make('domestic_delivery')
|
||||||
->numeric()
|
->numeric()
|
||||||
->default(0)
|
->default(0)
|
||||||
->prefix('ریال')
|
->prefix('ریال')
|
||||||
->label('Domestic Delivery'),
|
->label('تحویل داخلی'),
|
||||||
Forms\Components\TextInput::make('warehousing_cost')
|
Forms\Components\TextInput::make('warehousing_cost')
|
||||||
->numeric()
|
->numeric()
|
||||||
->default(0)
|
->default(0)
|
||||||
->prefix('ریال')
|
->prefix('ریال')
|
||||||
->label('Warehousing Cost'),
|
->label('انبارداری'),
|
||||||
Forms\Components\TextInput::make('vat_amount')
|
Forms\Components\TextInput::make('vat_amount')
|
||||||
->numeric()
|
->numeric()
|
||||||
->default(0)
|
->default(0)
|
||||||
->prefix('ریال')
|
->prefix('ریال')
|
||||||
->label('VAT Amount'),
|
->label('مالیات بر ارزش افزوده'),
|
||||||
Forms\Components\TextInput::make('discount')
|
Forms\Components\TextInput::make('discount')
|
||||||
->numeric()
|
->numeric()
|
||||||
->default(0)
|
->default(0)
|
||||||
->prefix('ریال')
|
->prefix('ریال')
|
||||||
->label('Discount'),
|
->label('تخفیف'),
|
||||||
Forms\Components\TextInput::make('total_fee')
|
Forms\Components\TextInput::make('total_fee')
|
||||||
->numeric()
|
->numeric()
|
||||||
->nullable()
|
->nullable()
|
||||||
->prefix('ریال')
|
->prefix('ریال')
|
||||||
->label('Total Fee'),
|
->label('مبلغ نهایی'),
|
||||||
Forms\Components\TextInput::make('net_dirham')
|
|
||||||
->numeric()
|
|
||||||
->nullable()
|
|
||||||
->prefix('درهم')
|
|
||||||
->label('Net Dirham'),
|
|
||||||
Forms\Components\TextInput::make('net_rial')
|
Forms\Components\TextInput::make('net_rial')
|
||||||
->numeric()
|
->numeric()
|
||||||
->nullable()
|
->nullable()
|
||||||
->prefix('ریال')
|
->prefix('ریال')
|
||||||
->label('Net Rial'),
|
->label('ناخالص (ریال)'),
|
||||||
Forms\Components\TextInput::make('cod_amount')
|
Forms\Components\TextInput::make('cod_amount')
|
||||||
->numeric()
|
->numeric()
|
||||||
->default(0)
|
->default(0)
|
||||||
->prefix('AED')
|
->prefix('ریال')
|
||||||
->label('Cash on Delivery'),
|
->label('پرداخت در محل (COD)'),
|
||||||
])->columns(4),
|
])->columns(4),
|
||||||
|
|
||||||
Forms\Components\Section::make('Import Invoice Fields (Service Sales Invoice)')
|
Forms\Components\Section::make('فیلدهای فاکتور واردات (خدمات فروش)')
|
||||||
->schema([
|
->schema([
|
||||||
Forms\Components\TextInput::make('brand_fee')
|
Forms\Components\TextInput::make('brand_fee')
|
||||||
->numeric()
|
->numeric()
|
||||||
|
|||||||
@ -228,8 +228,8 @@ class CustomerOrderController extends Controller
|
|||||||
'from_country_id' => $validated['from_country_id'],
|
'from_country_id' => $validated['from_country_id'],
|
||||||
'to_country_id' => $validated['to_country_id'],
|
'to_country_id' => $validated['to_country_id'],
|
||||||
|
|
||||||
// اطلاعات مالی (از محاسبه)
|
// اطلاعات مالی (از محاسبه) — همگی به ریال (IRR)
|
||||||
'shipping_price' => $priceResult['net_dirham'],
|
'shipping_price' => $priceResult['base_price'], // قیمت پایه به ریال
|
||||||
'extra_service' => $priceResult['extra_service'],
|
'extra_service' => $priceResult['extra_service'],
|
||||||
'packing_cost' => $priceResult['packing_cost'],
|
'packing_cost' => $priceResult['packing_cost'],
|
||||||
'domestic_pickup' => $priceResult['domestic_pickup'],
|
'domestic_pickup' => $priceResult['domestic_pickup'],
|
||||||
@ -238,8 +238,8 @@ class CustomerOrderController extends Controller
|
|||||||
'vat_amount' => $priceResult['vat_amount'],
|
'vat_amount' => $priceResult['vat_amount'],
|
||||||
'discount' => $priceResult['discount_amount'],
|
'discount' => $priceResult['discount_amount'],
|
||||||
'total_fee' => $priceResult['total_fee'],
|
'total_fee' => $priceResult['total_fee'],
|
||||||
'net_dirham' => $priceResult['net_dirham'],
|
|
||||||
'net_rial' => $priceResult['net_rial'],
|
'net_rial' => $priceResult['net_rial'],
|
||||||
|
// NOTE: فیلد net_dirham دیگه استفاده نمیشه — سیستم بر مبنای ریال کار میکنه
|
||||||
|
|
||||||
// فرستنده
|
// فرستنده
|
||||||
'sender_name' => $validated['sender_name'],
|
'sender_name' => $validated['sender_name'],
|
||||||
@ -552,7 +552,7 @@ class CustomerOrderController extends Controller
|
|||||||
'from_country_id' => $validated['from_country_id'],
|
'from_country_id' => $validated['from_country_id'],
|
||||||
'to_country_id' => $validated['to_country_id'],
|
'to_country_id' => $validated['to_country_id'],
|
||||||
|
|
||||||
'shipping_price' => $priceResult['net_dirham'],
|
'shipping_price' => $priceResult['base_price'], // قیمت پایه به ریال
|
||||||
'extra_service' => $priceResult['extra_service'],
|
'extra_service' => $priceResult['extra_service'],
|
||||||
'packing_cost' => $priceResult['packing_cost'],
|
'packing_cost' => $priceResult['packing_cost'],
|
||||||
'domestic_pickup' => $priceResult['domestic_pickup'],
|
'domestic_pickup' => $priceResult['domestic_pickup'],
|
||||||
@ -561,8 +561,8 @@ class CustomerOrderController extends Controller
|
|||||||
'vat_amount' => $priceResult['vat_amount'],
|
'vat_amount' => $priceResult['vat_amount'],
|
||||||
'discount' => $priceResult['discount_amount'],
|
'discount' => $priceResult['discount_amount'],
|
||||||
'total_fee' => $priceResult['total_fee'],
|
'total_fee' => $priceResult['total_fee'],
|
||||||
'net_dirham' => $priceResult['net_dirham'],
|
|
||||||
'net_rial' => $priceResult['net_rial'],
|
'net_rial' => $priceResult['net_rial'],
|
||||||
|
// NOTE: فیلد net_dirham دیگه استفاده نمیشه — سیستم بر مبنای ریال کار میکنه
|
||||||
|
|
||||||
'sender_name' => $validated['sender_name'],
|
'sender_name' => $validated['sender_name'],
|
||||||
'sender_company' => $validated['sender_company'] ?? null,
|
'sender_company' => $validated['sender_company'] ?? null,
|
||||||
@ -896,7 +896,7 @@ class CustomerOrderController extends Controller
|
|||||||
'chargeable_weight' => $shipment->chargeable_weight,
|
'chargeable_weight' => $shipment->chargeable_weight,
|
||||||
'total_fee' => $shipment->total_fee,
|
'total_fee' => $shipment->total_fee,
|
||||||
'net_rial' => $shipment->net_rial,
|
'net_rial' => $shipment->net_rial,
|
||||||
'net_dirham' => $shipment->net_dirham,
|
'shipping_price' => $shipment->shipping_price, // قیمت پایه به ریال
|
||||||
'created_at' => $shipment->created_at->toIso8601String(),
|
'created_at' => $shipment->created_at->toIso8601String(),
|
||||||
'created_at_jalali' => $this->toJalali($shipment->created_at),
|
'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.
|
* Look up the applicable rate for a given weight/zone.
|
||||||
* Rounds UP to the next available weight tier if exact match not found.
|
* Rounds UP to the next available weight tier if exact match not found.
|
||||||
*
|
*
|
||||||
|
* نرخها همگی به ریال (IRR) در دیتابیس ذخیره میشوند.
|
||||||
|
*
|
||||||
* @return static|null
|
* @return static|null
|
||||||
*/
|
*/
|
||||||
public static function lookup(
|
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)
|
* @param string $currencyCode کد ارز (مثل AED, USD, EUR)
|
||||||
* @return float نرخ ارز نسبت به ریال
|
* @return float نرخ ارز نسبت به ریال
|
||||||
|
* @throws \RuntimeException اگر ارز در دیتابیس یافت نشود (fallback حذف شد تا سیستم نرخ غلط استفاده نکند)
|
||||||
*/
|
*/
|
||||||
public function getCurrentRate(string $currencyCode = 'AED'): float
|
public function getCurrentRate(string $currencyCode = 'AED'): float
|
||||||
{
|
{
|
||||||
$currency = Currency::where('code', strtoupper($currencyCode))->first();
|
$currency = Currency::where('code', strtoupper($currencyCode))->first();
|
||||||
|
|
||||||
if (!$currency) {
|
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
|
* PriceCalculatorService
|
||||||
* ----------------------
|
* ----------------------
|
||||||
* موتور قیمتگذاری ۴-رُبعی — همه نرخها به ریال (IRR).
|
* موتور قیمتگذاری ۴-رُبعی:
|
||||||
*
|
*
|
||||||
* ─── معماری ارزی ───
|
* ┌──────────┬──────────────┬──────────────────┐
|
||||||
* ارز مبنا = ریال ایران. نرخها در دیتابیس به ریال ذخیره میشوند
|
* │ │ DOCUMENT │ NON-DOCUMENT │
|
||||||
|
* ├──────────┼──────────────┼──────────────────┤
|
||||||
|
* │ Export │ Export×Doc │ Export×Parcel │
|
||||||
|
* │ Import │ Import×Doc │ Import×Parcel │
|
||||||
|
* └──────────┴──────────────┴──────────────────┘
|
||||||
|
*
|
||||||
|
* ─── معماری ارزی (Currency Architecture) ───
|
||||||
|
* ارز مبنا در کل سیستم IFNEX = ریال ایران (IRR).
|
||||||
|
* همه نرخها در جدول rate_entries بهصورت ریال ذخیره میشوند
|
||||||
* (تبدیل در زمان ایمپورت توسط RateImportService انجام میشود).
|
* (تبدیل در زمان ایمپورت توسط RateImportService انجام میشود).
|
||||||
* بنابراین موتور محاسبه نیازی به تبدیل ارز ندارد.
|
*
|
||||||
|
* بنابراین موتور محاسبه نیازی به تبدیل ارز ندارد — نرخ مستقیماً به ریال است.
|
||||||
|
*
|
||||||
|
* جریان محاسبه:
|
||||||
|
* 1. نرمالسازی ورودی (direction / type) و تعیین کشور مقصد با ISO.
|
||||||
|
* 2. جستجوی زون کشور از جدول country_zones.
|
||||||
|
* 3. جستجوی نرخ ریال از جدول rate_entries (نزدیکترین وزن بالاتر).
|
||||||
|
* 4. اعمال ضریب سود + هزینههای جانبی (همگی به ریال) → ناخالص ریال.
|
||||||
|
* 5. محاسبه مالیات بر ارزش افزوده (۹٪) و کسر تخفیف (در صورت وجود کد).
|
||||||
|
*
|
||||||
|
* امضای متد calculate(): آرایهای از ورودیها را میگیرد و آرایهای کامل
|
||||||
|
* از فیلدهای نمایشی برمیگرداند که وردپرس منتظرش است.
|
||||||
*/
|
*/
|
||||||
class PriceCalculatorService
|
class PriceCalculatorService
|
||||||
{
|
{
|
||||||
@ -25,9 +44,56 @@ class PriceCalculatorService
|
|||||||
protected ExchangeRateService $exchangeRate,
|
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
|
public function calculate(array $input): array
|
||||||
{
|
{
|
||||||
|
// ───────────────────────────────────────────────────────────────
|
||||||
// ۱. نرمالسازی direction
|
// ۱. نرمالسازی direction
|
||||||
|
// ───────────────────────────────────────────────────────────────
|
||||||
$directionRaw = strtolower((string) ($input['direction'] ?? ''));
|
$directionRaw = strtolower((string) ($input['direction'] ?? ''));
|
||||||
$direction = match ($directionRaw) {
|
$direction = match ($directionRaw) {
|
||||||
'outbound' => 'export',
|
'outbound' => 'export',
|
||||||
@ -41,7 +107,9 @@ class PriceCalculatorService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────────
|
||||||
// ۲. نرمالسازی type → service_type
|
// ۲. نرمالسازی type → service_type
|
||||||
|
// ───────────────────────────────────────────────────────────────
|
||||||
$typeRaw = strtoupper(trim((string) ($input['type'] ?? '')));
|
$typeRaw = strtoupper(trim((string) ($input['type'] ?? '')));
|
||||||
$serviceType = match ($typeRaw) {
|
$serviceType = match ($typeRaw) {
|
||||||
'DOC_NORMAL', 'DOC_ECONOMY', 'DOCUMENT', 'DOC', 'DOCNORMAL', 'DOCECONOMY' => 'document',
|
'DOC_NORMAL', 'DOC_ECONOMY', 'DOCUMENT', 'DOC', 'DOCNORMAL', 'DOCECONOMY' => 'document',
|
||||||
@ -54,7 +122,9 @@ class PriceCalculatorService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────────
|
||||||
// ۳. حل کشور با ISO → نام
|
// ۳. حل کشور با ISO → نام
|
||||||
|
// ───────────────────────────────────────────────────────────────
|
||||||
$countryIso = strtoupper(trim((string) ($input['country_iso'] ?? '')));
|
$countryIso = strtoupper(trim((string) ($input['country_iso'] ?? '')));
|
||||||
if (strlen($countryIso) !== 2) {
|
if (strlen($countryIso) !== 2) {
|
||||||
throw new \InvalidArgumentException(
|
throw new \InvalidArgumentException(
|
||||||
@ -67,12 +137,16 @@ class PriceCalculatorService
|
|||||||
}
|
}
|
||||||
$countryName = $country->name;
|
$countryName = $country->name;
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────────
|
||||||
// ۴. اوزان
|
// ۴. اوزان
|
||||||
|
// ───────────────────────────────────────────────────────────────
|
||||||
$weight = (float) ($input['weight'] ?? 0);
|
$weight = (float) ($input['weight'] ?? 0);
|
||||||
$volumetricWeight = (float) ($input['volumetric_weight'] ?? 0);
|
$volumetricWeight = (float) ($input['volumetric_weight'] ?? 0);
|
||||||
$chargeableWeight = max($weight, $volumetricWeight);
|
$chargeableWeight = max($weight, $volumetricWeight);
|
||||||
|
|
||||||
// ۵. جستجوی زون
|
// ───────────────────────────────────────────────────────────────
|
||||||
|
// ۵. جستجوی زون (برای کشور × نوع سرویس)
|
||||||
|
// ───────────────────────────────────────────────────────────────
|
||||||
$zone = CountryZone::lookupZone($countryName, $serviceType);
|
$zone = CountryZone::lookupZone($countryName, $serviceType);
|
||||||
if ($zone === null) {
|
if ($zone === null) {
|
||||||
throw new \RuntimeException(
|
throw new \RuntimeException(
|
||||||
@ -81,7 +155,9 @@ class PriceCalculatorService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────────
|
||||||
// ۶. جستجوی نرخ — مستقیماً به ریال (IRR)
|
// ۶. جستجوی نرخ — مستقیماً به ریال (IRR)
|
||||||
|
// ───────────────────────────────────────────────────────────────
|
||||||
$rateEntry = RateEntry::lookup($direction, $serviceType, $chargeableWeight, $zone, 'IRR');
|
$rateEntry = RateEntry::lookup($direction, $serviceType, $chargeableWeight, $zone, 'IRR');
|
||||||
if (! $rateEntry) {
|
if (! $rateEntry) {
|
||||||
throw new \RuntimeException(
|
throw new \RuntimeException(
|
||||||
@ -90,10 +166,14 @@ class PriceCalculatorService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ۷. قیمت پایه — مستقیماً به ریال
|
// ───────────────────────────────────────────────────────────────
|
||||||
|
// ۷. قیمت پایه — مستقیماً به ریال (نیازی به تبدیل نیست)
|
||||||
|
// ───────────────────────────────────────────────────────────────
|
||||||
$basePriceIrr = (float) $rateEntry->rate;
|
$basePriceIrr = (float) $rateEntry->rate;
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────────
|
||||||
// ۸. هزینههای جانبی — همه به ریال
|
// ۸. هزینههای جانبی — همه به ریال
|
||||||
|
// ───────────────────────────────────────────────────────────────
|
||||||
$extras = [
|
$extras = [
|
||||||
'extra_service' => (float) ($input['extra_service'] ?? 0),
|
'extra_service' => (float) ($input['extra_service'] ?? 0),
|
||||||
'packing_cost' => (float) ($input['packing_cost'] ?? 0),
|
'packing_cost' => (float) ($input['packing_cost'] ?? 0),
|
||||||
@ -103,7 +183,9 @@ class PriceCalculatorService
|
|||||||
];
|
];
|
||||||
$extrasTotalIrr = array_sum($extras);
|
$extrasTotalIrr = array_sum($extras);
|
||||||
|
|
||||||
// ۹. اعمال ضریب سود + هزینههای جانبی
|
// ───────────────────────────────────────────────────────────────
|
||||||
|
// ۹. اعمال ضریب سود (روی قیمت پایه، نه روی هزینههای جانبی)
|
||||||
|
// ───────────────────────────────────────────────────────────────
|
||||||
$profitMargin = (float) SystemSetting::get(
|
$profitMargin = (float) SystemSetting::get(
|
||||||
'profit_margin',
|
'profit_margin',
|
||||||
config('ifnex.financial_defaults.profit_margin', 1.25)
|
config('ifnex.financial_defaults.profit_margin', 1.25)
|
||||||
@ -112,16 +194,21 @@ class PriceCalculatorService
|
|||||||
$profitMargin = 1.0;
|
$profitMargin = 1.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ناخالص = (قیمت پایه × ضریب سود) + هزینههای جانبی — همه به ریال
|
||||||
$netRial = ($basePriceIrr * $profitMargin) + $extrasTotalIrr;
|
$netRial = ($basePriceIrr * $profitMargin) + $extrasTotalIrr;
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────────
|
||||||
// ۱۰. مالیات بر ارزش افزوده
|
// ۱۰. مالیات بر ارزش افزوده
|
||||||
|
// ───────────────────────────────────────────────────────────────
|
||||||
$vatRate = (float) SystemSetting::get(
|
$vatRate = (float) SystemSetting::get(
|
||||||
'vat_rate',
|
'vat_rate',
|
||||||
config('ifnex.financial_defaults.vat_rate', 0.09)
|
config('ifnex.financial_defaults.vat_rate', 0.09)
|
||||||
);
|
);
|
||||||
$vatAmount = $netRial * $vatRate;
|
$vatAmount = $netRial * $vatRate;
|
||||||
|
|
||||||
// ۱۱. تخفیف
|
// ───────────────────────────────────────────────────────────────
|
||||||
|
// ۱۱. تخفیف (در صورت وجود کد)
|
||||||
|
// ───────────────────────────────────────────────────────────────
|
||||||
$discountApplied = false;
|
$discountApplied = false;
|
||||||
$discountAmount = 0.0;
|
$discountAmount = 0.0;
|
||||||
$discountCode = trim((string) ($input['discount_code'] ?? ''));
|
$discountCode = trim((string) ($input['discount_code'] ?? ''));
|
||||||
@ -137,9 +224,10 @@ class PriceCalculatorService
|
|||||||
|
|
||||||
if ($discount) {
|
if ($discount) {
|
||||||
$subtotal = $netRial + $vatAmount;
|
$subtotal = $netRial + $vatAmount;
|
||||||
|
|
||||||
$minOrder = (float) ($discount->min_order_amount ?? 0);
|
$minOrder = (float) ($discount->min_order_amount ?? 0);
|
||||||
if ($minOrder > 0 && $subtotal < $minOrder) {
|
if ($minOrder > 0 && $subtotal < $minOrder) {
|
||||||
// زیر سقف
|
// زیر سقف — تخفیف اعمال نمیشود
|
||||||
} else {
|
} else {
|
||||||
$discountApplied = true;
|
$discountApplied = true;
|
||||||
if ($discount->type === 'percentage') {
|
if ($discount->type === 'percentage') {
|
||||||
@ -151,11 +239,14 @@ class PriceCalculatorService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ۱۲. مبلغ نهایی (گردش به ۱۰۰۰ ریال)
|
// ───────────────────────────────────────────────────────────────
|
||||||
|
// ۱۲. مبلغ نهایی (گردش به نزدیکترین ۱۰۰۰ ریال)
|
||||||
|
// ───────────────────────────────────────────────────────────────
|
||||||
$totalFee = $netRial + $vatAmount - $discountAmount;
|
$totalFee = $netRial + $vatAmount - $discountAmount;
|
||||||
$totalFee = (int) round($totalFee / 1000) * 1000;
|
$totalFee = (int) round($totalFee / 1000) * 1000;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
// فیلدهای نمایشی — همه به ریال
|
||||||
'base_price' => round($basePriceIrr, 2),
|
'base_price' => round($basePriceIrr, 2),
|
||||||
'net_rial' => (int) round($netRial),
|
'net_rial' => (int) round($netRial),
|
||||||
'packing_cost' => round($extras['packing_cost'], 2),
|
'packing_cost' => round($extras['packing_cost'], 2),
|
||||||
@ -163,13 +254,19 @@ class PriceCalculatorService
|
|||||||
'discount_applied' => $discountApplied,
|
'discount_applied' => $discountApplied,
|
||||||
'discount_amount' => (int) round($discountAmount),
|
'discount_amount' => (int) round($discountAmount),
|
||||||
'total_fee' => $totalFee,
|
'total_fee' => $totalFee,
|
||||||
|
|
||||||
|
// تفکیک هزینهها (ریال)
|
||||||
'extra_service' => round($extras['extra_service'], 2),
|
'extra_service' => round($extras['extra_service'], 2),
|
||||||
'domestic_pickup' => round($extras['domestic_pickup'], 2),
|
'domestic_pickup' => round($extras['domestic_pickup'], 2),
|
||||||
'domestic_delivery' => round($extras['domestic_delivery'], 2),
|
'domestic_delivery' => round($extras['domestic_delivery'], 2),
|
||||||
'warehousing_cost' => round($extras['warehousing_cost'], 2),
|
'warehousing_cost' => round($extras['warehousing_cost'], 2),
|
||||||
|
|
||||||
|
// اوزان
|
||||||
'weight' => round($weight, 3),
|
'weight' => round($weight, 3),
|
||||||
'volumetric_weight' => round($volumetricWeight, 3),
|
'volumetric_weight' => round($volumetricWeight, 3),
|
||||||
'chargeable_weight' => round($chargeableWeight, 3),
|
'chargeable_weight' => round($chargeableWeight, 3),
|
||||||
|
|
||||||
|
// متادیتای جستجو
|
||||||
'zone' => $zone,
|
'zone' => $zone,
|
||||||
'service_type' => $serviceType,
|
'service_type' => $serviceType,
|
||||||
'direction' => $direction,
|
'direction' => $direction,
|
||||||
@ -181,11 +278,18 @@ class PriceCalculatorService
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* راحتی: محاسبه قیمت از روی مدل Shipment.
|
||||||
|
*
|
||||||
|
* @param \App\Models\Shipment $shipment
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
public function calculateForShipment($shipment): array
|
public function calculateForShipment($shipment): array
|
||||||
{
|
{
|
||||||
$direction = $shipment->direction?->value ?? (string) $shipment->direction;
|
$direction = $shipment->direction?->value ?? (string) $shipment->direction;
|
||||||
$type = $shipment->type?->value ?? (string) $shipment->type;
|
$type = $shipment->type?->value ?? (string) $shipment->type;
|
||||||
|
|
||||||
|
// کشور مقصد: برای صادرات = toCountry، برای واردات = fromCountry
|
||||||
$country = ($direction === 'export')
|
$country = ($direction === 'export')
|
||||||
? $shipment->toCountry
|
? $shipment->toCountry
|
||||||
: $shipment->fromCountry;
|
: $shipment->fromCountry;
|
||||||
|
|||||||
@ -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
|
class RateImportService
|
||||||
{
|
{
|
||||||
@ -39,6 +55,7 @@ class RateImportService
|
|||||||
'NONDOC', 'NON DOC', 'PARCEL', 'NON-DOC', 'NON - DOC'],
|
'NONDOC', 'NON DOC', 'PARCEL', 'NON-DOC', 'NON - DOC'],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Result summary returned to caller for Filament notification */
|
||||||
public array $summary = [
|
public array $summary = [
|
||||||
'imported' => 0,
|
'imported' => 0,
|
||||||
'updated' => 0,
|
'updated' => 0,
|
||||||
@ -47,16 +64,28 @@ class RateImportService
|
|||||||
'zones_updated' => 0,
|
'zones_updated' => 0,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** ارز مبدأ فایل اکسل (نرخها در فایل به این ارز هستند) */
|
||||||
protected string $sourceCurrency = 'AED';
|
protected string $sourceCurrency = 'AED';
|
||||||
|
|
||||||
|
/** سرویس نرخ ارز برای تبدیل به ریال */
|
||||||
protected ExchangeRateService $exchangeRate;
|
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
|
public function import(string $filePath, string $sourceCurrency = 'AED'): array
|
||||||
{
|
{
|
||||||
|
// Reset summary
|
||||||
$this->summary = [
|
$this->summary = [
|
||||||
'imported' => 0, 'updated' => 0, 'skipped' => 0,
|
'imported' => 0, 'updated' => 0, 'skipped' => 0,
|
||||||
'errors' => [], 'zones_updated' => 0,
|
'errors' => [], 'zones_updated' => 0,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// تنظیم ارز مبدأ و سرویس تبدیل
|
||||||
$this->sourceCurrency = strtoupper($sourceCurrency);
|
$this->sourceCurrency = strtoupper($sourceCurrency);
|
||||||
$this->exchangeRate = app(ExchangeRateService::class);
|
$this->exchangeRate = app(ExchangeRateService::class);
|
||||||
|
|
||||||
@ -66,6 +95,7 @@ class RateImportService
|
|||||||
|
|
||||||
$spreadsheet = IOFactory::load($filePath);
|
$spreadsheet = IOFactory::load($filePath);
|
||||||
|
|
||||||
|
// 1. Parse Import Rate sheet (if present)
|
||||||
foreach (self::SHEET_NAMES['import'] as $name) {
|
foreach (self::SHEET_NAMES['import'] as $name) {
|
||||||
if ($spreadsheet->sheetNameExists($name)) {
|
if ($spreadsheet->sheetNameExists($name)) {
|
||||||
$this->importRateSheet($spreadsheet->getSheetByName($name), 'import');
|
$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) {
|
foreach (self::SHEET_NAMES['export'] as $name) {
|
||||||
if ($spreadsheet->sheetNameExists($name)) {
|
if ($spreadsheet->sheetNameExists($name)) {
|
||||||
$this->importRateSheet($spreadsheet->getSheetByName($name), 'export');
|
$this->importRateSheet($spreadsheet->getSheetByName($name), 'export');
|
||||||
@ -80,6 +111,7 @@ class RateImportService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Parse Zone sheet (if present)
|
||||||
if ($spreadsheet->sheetNameExists('Zone')) {
|
if ($spreadsheet->sheetNameExists('Zone')) {
|
||||||
$this->importZoneSheet($spreadsheet->getSheetByName('Zone'));
|
$this->importZoneSheet($spreadsheet->getSheetByName('Zone'));
|
||||||
} elseif ($spreadsheet->sheetNameExists('Zones')) {
|
} elseif ($spreadsheet->sheetNameExists('Zones')) {
|
||||||
@ -89,13 +121,18 @@ class RateImportService
|
|||||||
return $this->summary;
|
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
|
protected function importRateSheet(Worksheet $ws, string $direction): void
|
||||||
{
|
{
|
||||||
$maxRow = $ws->getHighestDataRow();
|
$maxRow = $ws->getHighestDataRow();
|
||||||
$maxCol = $ws->getHighestDataColumn();
|
$maxCol = $ws->getHighestDataColumn();
|
||||||
$maxColIdx = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::columnIndexFromString($maxCol);
|
$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;
|
$headerRowIdx = null;
|
||||||
|
|
||||||
for ($r = 1; $r <= $maxRow; $r++) {
|
for ($r = 1; $r <= $maxRow; $r++) {
|
||||||
@ -104,6 +141,7 @@ class RateImportService
|
|||||||
$rowValues[$c] = $ws->getCellByColumnAndRow($c, $r)->getCalculatedValue();
|
$rowValues[$c] = $ws->getCellByColumnAndRow($c, $r)->getCalculatedValue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect section markers
|
||||||
$matchedSection = $this->detectSection($rowValues);
|
$matchedSection = $this->detectSection($rowValues);
|
||||||
if ($matchedSection !== null) {
|
if ($matchedSection !== null) {
|
||||||
$currentSection = $matchedSection;
|
$currentSection = $matchedSection;
|
||||||
@ -111,6 +149,7 @@ class RateImportService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect header row ("Weight (kg)" + "Zone 1".."Zone N")
|
||||||
if ($currentSection !== null && $headerRowIdx === null) {
|
if ($currentSection !== null && $headerRowIdx === null) {
|
||||||
if ($this->isHeaderRow($rowValues)) {
|
if ($this->isHeaderRow($rowValues)) {
|
||||||
$headerRowIdx = $r;
|
$headerRowIdx = $r;
|
||||||
@ -118,9 +157,11 @@ class RateImportService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we have a section + header, parse data rows
|
||||||
if ($currentSection !== null && $headerRowIdx !== null) {
|
if ($currentSection !== null && $headerRowIdx !== null) {
|
||||||
$parsed = $this->parseRateRow($rowValues);
|
$parsed = $this->parseRateRow($rowValues);
|
||||||
if ($parsed === null) {
|
if ($parsed === null) {
|
||||||
|
// Either empty row or non-numeric weight — end of this section
|
||||||
if ($this->isEmptyRow($rowValues)) {
|
if ($this->isEmptyRow($rowValues)) {
|
||||||
$currentSection = null;
|
$currentSection = null;
|
||||||
$headerRowIdx = 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
|
protected function detectSection(array $rowValues): ?string
|
||||||
{
|
{
|
||||||
foreach ($rowValues as $val) {
|
foreach ($rowValues as $val) {
|
||||||
@ -149,6 +194,7 @@ class RateImportService
|
|||||||
}
|
}
|
||||||
$normalized = strtoupper(trim($val));
|
$normalized = strtoupper(trim($val));
|
||||||
|
|
||||||
|
// Check DOCUMENT first (because "NON-DOCUMENT" contains "DOCUMENT")
|
||||||
foreach (self::SECTION_MARKERS['parcel'] as $marker) {
|
foreach (self::SECTION_MARKERS['parcel'] as $marker) {
|
||||||
if ($normalized === strtoupper($marker)) {
|
if ($normalized === strtoupper($marker)) {
|
||||||
return 'parcel';
|
return 'parcel';
|
||||||
@ -163,6 +209,12 @@ class RateImportService
|
|||||||
return null;
|
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
|
protected function isHeaderRow(array $rowValues): bool
|
||||||
{
|
{
|
||||||
if (! isset($rowValues[1]) || ! is_string($rowValues[1])) {
|
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)'])) {
|
if (! in_array($first, ['weight (kg)', 'weight', 'weight(kg)', 'wt', 'wt (kg)'])) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// Count zone columns — either "Zone N" strings OR plain integers starting at 1
|
||||||
$zoneCount = 0;
|
$zoneCount = 0;
|
||||||
$expectedInt = 1;
|
$expectedInt = 1;
|
||||||
for ($c = 2; $c <= 11; $c++) {
|
for ($c = 2; $c <= 11; $c++) {
|
||||||
$val = $rowValues[$c] ?? null;
|
$val = $rowValues[$c] ?? null;
|
||||||
if ($val === null || $val === '') continue;
|
if ($val === null || $val === '') continue;
|
||||||
|
|
||||||
|
// Case A: "Zone 1" string format
|
||||||
if (is_string($val) && preg_match('/^zone\s*(\d+)$/i', trim($val), $m)) {
|
if (is_string($val) && preg_match('/^zone\s*(\d+)$/i', trim($val), $m)) {
|
||||||
$zoneCount++;
|
$zoneCount++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Case B: plain integer 1, 2, 3, ... (customer's operational format)
|
||||||
if (is_int($val) || (is_string($val) && preg_match('/^\d+$/', trim($val)))) {
|
if (is_int($val) || (is_string($val) && preg_match('/^\d+$/', trim($val)))) {
|
||||||
$intVal = (int) $val;
|
$intVal = (int) $val;
|
||||||
if ($intVal === $expectedInt) {
|
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
|
protected function parseRateRow(array $rowValues): ?array
|
||||||
{
|
{
|
||||||
if (! isset($rowValues[1]) || $rowValues[1] === null || $rowValues[1] === '') {
|
if (! isset($rowValues[1]) || $rowValues[1] === null || $rowValues[1] === '') {
|
||||||
@ -215,7 +274,7 @@ class RateImportService
|
|||||||
|
|
||||||
$rates = [];
|
$rates = [];
|
||||||
for ($c = 2; $c <= 11; $c++) {
|
for ($c = 2; $c <= 11; $c++) {
|
||||||
$zone = $c - 1;
|
$zone = $c - 1; // col 2 -> Zone 1, col 11 -> Zone 10
|
||||||
$val = $rowValues[$c] ?? null;
|
$val = $rowValues[$c] ?? null;
|
||||||
if ($val === null || $val === '' || (is_string($val) && trim($val) === '')) {
|
if ($val === null || $val === '' || (is_string($val) && trim($val) === '')) {
|
||||||
$rates[$zone] = null;
|
$rates[$zone] = null;
|
||||||
@ -224,6 +283,7 @@ class RateImportService
|
|||||||
if (is_numeric($val)) {
|
if (is_numeric($val)) {
|
||||||
$rates[$zone] = (float) $val;
|
$rates[$zone] = (float) $val;
|
||||||
} else {
|
} 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);
|
$this->summary['errors'][] = "Non-numeric rate at weight={$weightVal} zone={$zone}: " . substr((string)$val, 0, 40);
|
||||||
$rates[$zone] = null;
|
$rates[$zone] = null;
|
||||||
}
|
}
|
||||||
@ -243,9 +303,13 @@ class RateImportService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Upsert a rate row — همیشه به ریال ذخیره میشود.
|
* Upsert a rate row — همیشه به ریال ذخیره میشود.
|
||||||
|
*
|
||||||
|
* نرخ ورودی (به ارز مبدأ) با نرخ روزِ آن ارز به ریال تبدیل شده
|
||||||
|
* و سپس در جدول rate_entries با currency='IRR' ذخیره میشود.
|
||||||
*/
|
*/
|
||||||
protected function upsertRate(string $direction, string $serviceType, float $weight, int $zone, float $rate): void
|
protected function upsertRate(string $direction, string $serviceType, float $weight, int $zone, float $rate): void
|
||||||
{
|
{
|
||||||
|
// تبدیل نرخ از ارز مبدأ به ریال
|
||||||
$rateInIrr = $this->exchangeRate->convertToIrr($rate, $this->sourceCurrency);
|
$rateInIrr = $this->exchangeRate->convertToIrr($rate, $this->sourceCurrency);
|
||||||
|
|
||||||
try {
|
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
|
protected function importZoneSheet(Worksheet $ws): void
|
||||||
{
|
{
|
||||||
$maxRow = $ws->getHighestDataRow();
|
$maxRow = $ws->getHighestDataRow();
|
||||||
|
|
||||||
|
// Find the header row (contains "Country" or "Destination Country")
|
||||||
$headerRow = null;
|
$headerRow = null;
|
||||||
for ($r = 1; $r <= min(20, $maxRow); $r++) {
|
for ($r = 1; $r <= min(20, $maxRow); $r++) {
|
||||||
$b = $ws->getCellByColumnAndRow(2, $r)->getCalculatedValue();
|
$b = $ws->getCellByColumnAndRow(2, $r)->getCalculatedValue();
|
||||||
@ -294,7 +365,7 @@ class RateImportService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($headerRow === null) {
|
if ($headerRow === null) {
|
||||||
$headerRow = 3;
|
$headerRow = 3; // fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
for ($r = $headerRow + 1; $r <= $maxRow; $r++) {
|
for ($r = $headerRow + 1; $r <= $maxRow; $r++) {
|
||||||
@ -303,11 +374,13 @@ class RateImportService
|
|||||||
$documentCountry = $ws->getCellByColumnAndRow(6, $r)->getCalculatedValue();
|
$documentCountry = $ws->getCellByColumnAndRow(6, $r)->getCalculatedValue();
|
||||||
$documentZone = $ws->getCellByColumnAndRow(7, $r)->getCalculatedValue();
|
$documentZone = $ws->getCellByColumnAndRow(7, $r)->getCalculatedValue();
|
||||||
|
|
||||||
|
// PARCEL side
|
||||||
if (is_string($parcelCountry) && trim($parcelCountry) !== '' &&
|
if (is_string($parcelCountry) && trim($parcelCountry) !== '' &&
|
||||||
$parcelZone !== null && $parcelZone !== '' && (int)$parcelZone > 0) {
|
$parcelZone !== null && $parcelZone !== '' && (int)$parcelZone > 0) {
|
||||||
$this->upsertCountryZone(trim($parcelCountry), parcelZone: (int)$parcelZone);
|
$this->upsertCountryZone(trim($parcelCountry), parcelZone: (int)$parcelZone);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DOCUMENT side
|
||||||
if (is_string($documentCountry) && trim($documentCountry) !== '' &&
|
if (is_string($documentCountry) && trim($documentCountry) !== '' &&
|
||||||
$documentZone !== null && $documentZone !== '' && (int)$documentZone > 0) {
|
$documentZone !== null && $documentZone !== '' && (int)$documentZone > 0) {
|
||||||
$this->upsertCountryZone(trim($documentCountry), documentZone: (int)$documentZone);
|
$this->upsertCountryZone(trim($documentCountry), documentZone: (int)$documentZone);
|
||||||
|
|||||||
@ -124,7 +124,10 @@ return [
|
|||||||
'vat_rate' => 0.09, // ۹٪ مالیات بر ارزش افزوده
|
'vat_rate' => 0.09, // ۹٪ مالیات بر ارزش افزوده
|
||||||
'profit_margin' => 1.25, // ۲۵٪ حاشیه سود
|
'profit_margin' => 1.25, // ۲۵٪ حاشیه سود
|
||||||
'packing_cost' => 100000, // ۱۰۰,۰۰۰ ریال هزینه بستهبندی پیشفرض
|
'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
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration: تغییر پیشفرض ستون currency در rate_entries به IRR
|
||||||
|
*
|
||||||
|
* با معماری جدید، همه نرخها بهصورت ریال در دیتابیس ذخیره میشوند.
|
||||||
|
* این مایگریشن فقط پیشفرض ستون را از AED به IRR تغییر میدهد.
|
||||||
|
*
|
||||||
|
* توجه: رکوردهای قدیمی با currency=AED دستنخورده باقی میمانند.
|
||||||
|
* برای پاک کردن آنها از پنل ادمین → Rate Import → گزینه «جایگزینی کامل» را فعال کنید.
|
||||||
|
*/
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
use Illuminate\Database\Migrations\Migration;
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|||||||
@ -4,8 +4,11 @@
|
|||||||
* Migration: بزرگکردن ستون rate برای پشتیبانی از مبالغ بزرگ ریالی
|
* Migration: بزرگکردن ستون rate برای پشتیبانی از مبالغ بزرگ ریالی
|
||||||
*
|
*
|
||||||
* ستون rate قبلاً decimal(12,4) بود که حداکثر ~100 میلیون را نگه میداشت.
|
* ستون rate قبلاً decimal(12,4) بود که حداکثر ~100 میلیون را نگه میداشت.
|
||||||
* با تبدیل نرخهای درهم به ریال، ستون را به decimal(20,4) افزایش میدهیم
|
* با تبدیل نرخهای ارزی به ریال (مثلاً 149 AED × 7,419,700 IRR/AED = ~1.1 میلیارد ریال)
|
||||||
* که حداکثر ~100 تریلیون را نگه میدارد.
|
* ستون باید بزرگتر شود. به decimal(20,4) افزایش میدهیم که حداکثر ~100 تریلیون را نگه میدارد.
|
||||||
|
*
|
||||||
|
* ⚠️ نکته: ارز مبنا در کل سیستم = ریال ایران (IRR). هیچ واحد دیگری در محاسبات و
|
||||||
|
* ذخیرهسازی استفاده نمیشود.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
use Illuminate\Database\Migrations\Migration;
|
||||||
@ -17,7 +20,7 @@ return new class extends Migration
|
|||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
Schema::table('rate_entries', function (Blueprint $table) {
|
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();
|
$table->decimal('rate', 20, 4)->comment('in IRR (Iranian Rial)')->change();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -29,3 +32,5 @@ return new class extends Migration
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
use App\Models\SystemSetting;
|
use App\Models\SystemSetting;
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
class SystemSettingSeeder extends Seeder
|
class SystemSettingSeeder extends Seeder
|
||||||
{
|
{
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
$settings = [
|
$settings = [
|
||||||
['key' => 'vat_rate', 'value' => 0.0900, 'description' => 'VAT rate — 9%'],
|
// تنظیمات مالی — همه نرخها به ریال (IRR)
|
||||||
['key' => 'packing_cost_default', 'value' => 100000.0000, 'description' => 'Default packing cost in IRR'],
|
['key' => 'vat_rate', 'value' => 0.0900, 'description' => 'نرخ مالیات بر ارزش افزوده — ۹٪'],
|
||||||
['key' => 'profit_margin', 'value' => 1.2500, 'description' => 'Profit margin — set per company'],
|
['key' => 'packing_cost_default', 'value' => 100000.0, 'description' => 'هزینه بستهبندی پیشفرض (ریال)'],
|
||||||
['key' => 'aed_to_irr', 'value' => 455000.0000, 'description' => 'AED to IRR exchange rate'],
|
['key' => 'profit_margin', 'value' => 1.2500, 'description' => 'ضریب سود (۱.۲۵ = ۲۵٪ سود)'],
|
||||||
['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'],
|
// ⚠️ نرخهای ارز در جدول currencies ذخیره میشوند، نه در SystemSettings.
|
||||||
['key' => 'eur_to_irr', 'value' => 0.0000, 'description' => 'EUR to IRR exchange rate'],
|
// این مقادیر فقط برای 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) {
|
foreach ($settings as $setting) {
|
||||||
|
|||||||
@ -13,38 +13,50 @@
|
|||||||
<div class="mt-8 p-6 bg-gray-100 dark:bg-gray-800 rounded-lg border">
|
<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>
|
<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 class="text-red-500 font-bold">{{ $result['message'] }}</p>
|
||||||
<p>وزن قابل پرداخت: {{ $result['chargeable_weight'] }} کیلوگرم</p>
|
<p>وزن قابل پرداخت: {{ $result['chargeable_weight'] }} کیلوگرم</p>
|
||||||
@else
|
@else
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-gray-500 text-sm">وزن حجمی</p>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-gray-500 text-sm">وزن قابل پرداخت (ماکزیمم)</p>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-gray-500 text-sm">زون مقصد</p>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-gray-500 text-sm">قیمت پایه (درهم)</p>
|
<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-lg font-bold">{{ number_format($result['base_price'] ?? 0) }} ریال</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr class="col-span-2 border-dashed my-2">
|
<hr class="col-span-2 border-dashed my-2">
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p class="text-gray-500 text-sm">قیمت نهایی (درهم - شامل ضریب سود)</p>
|
<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-2xl font-bold text-blue-600">{{ number_format($result['net_rial'] ?? 0) }} ریال</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-gray-500 text-sm">قیمت نهایی (ریال)</p>
|
<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-2xl font-bold text-green-600">{{ number_format($result['total_fee'] ?? 0) }} ریال</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -20,14 +20,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filament-card p-4 bg-amber-50 border border-amber-200">
|
<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">
|
<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>نام شیتها باید دقیقاً <code>Import Rate</code> و <code>Export Rate</code> باشد.</li>
|
||||||
<li>Section markers must appear in column E (merged): <code>DOCUMENT</code> / <code>NON - DOCUMENT</code>.</li>
|
<li>مارکرهای بخش باید در ستون E باشند: <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>سرستونها: <code>Weight (kg)</code> و <code>Zone 1</code>..<code>Zone 10</code> (یا اعداد ۱..۱۰).</li>
|
||||||
<li>Weights must be decimal numbers in column A (0.5, 1, 1.5, ...).</li>
|
<li>وزنها باید عدد اعشاری در ستون A باشند (0.5, 1, 1.5, ...).</li>
|
||||||
<li>Empty cells are skipped (no rate stored for that weight × zone).</li>
|
<li>سلولهای خالی نادیده گرفته میشوند.</li>
|
||||||
<li>Currency defaults to AED. System converts to IRR using current exchange rate.</li>
|
<li><strong>ارز مبدأ:</strong> ارزی که نرخهای فایل اکسل به آن هستند را انتخاب کنید. سیستم با نرخ روز آن ارز → ریال تبدیل کرده و در دیتابیس ذخیره میکند.</li>
|
||||||
|
<li><strong>نکته:</strong> قبل از ایمپورت، مطمئن شوید نرخ روز ارز مبدأ در پنل «ارزها» تنظیم شده است.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -369,7 +369,7 @@ async function calculatePrice() {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (response.ok) {
|
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('totalFee').textContent = Number(data.total_fee).toLocaleString('fa-IR') + ' ریال';
|
||||||
document.getElementById('zone').textContent = data.zone;
|
document.getElementById('zone').textContent = data.zone;
|
||||||
document.getElementById('priceResult').classList.remove('hidden');
|
document.getElementById('priceResult').classList.remove('hidden');
|
||||||
|
|||||||
@ -48,7 +48,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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="اختیاری">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
@ -65,21 +65,17 @@
|
|||||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">نتیجه محاسبه</h3>
|
<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 class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<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 class="text-xl font-bold" id="basePrice">—</div>
|
||||||
</div>
|
</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 class="text-xl font-bold text-orange-600" id="totalFee">—</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="text-sm text-gray-600">زون</span>
|
<span class="text-sm text-gray-600">زون</span>
|
||||||
<div class="text-xl font-bold" id="zone">—</div>
|
<div class="text-xl font-bold" id="zone">—</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<span class="text-sm text-gray-600">نرخ تبدیل</span>
|
|
||||||
<div class="text-xl font-bold" id="rate">—</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500 mt-4">* این قیمت تقریبی است و نهایی بودن آن منوط تأیید کارشناسان خواهد بود.</p>
|
<p class="text-xs text-gray-500 mt-4">* این قیمت تقریبی است و نهایی بودن آن منوط تأیید کارشناسان خواهد بود.</p>
|
||||||
</div>
|
</div>
|
||||||
@ -125,10 +121,9 @@ document.getElementById('pricingForm').addEventListener('submit', async function
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (response.ok) {
|
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('totalFee').textContent = Number(data.total_fee).toLocaleString('fa-IR') + ' ریال';
|
||||||
document.getElementById('zone').textContent = data.zone;
|
document.getElementById('zone').textContent = data.zone;
|
||||||
document.getElementById('rate').textContent = '1 درهم = {{ number_format($settings['aed_to_irr'], 0) }} ریال';
|
|
||||||
document.getElementById('result').classList.remove('hidden');
|
document.getElementById('result').classList.remove('hidden');
|
||||||
} else {
|
} else {
|
||||||
alert('خطا در محاسبه قیمت. لطفاً دوباره تلاش کنید.');
|
alert('خطا در محاسبه قیمت. لطفاً دوباره تلاش کنید.');
|
||||||
|
|||||||
@ -48,7 +48,6 @@ Route::get('/payment-result', function (Illuminate\Http\Request $request) {
|
|||||||
'message' => $message,
|
'message' => $message,
|
||||||
]);
|
]);
|
||||||
})->name('payment.result');
|
})->name('payment.result');
|
||||||
|
|
||||||
// دانلود قالب خالی نرخها (IFNEX_Rate_Template.xlsx — فرمت ۴-رُبعی جدید)
|
// دانلود قالب خالی نرخها (IFNEX_Rate_Template.xlsx — فرمت ۴-رُبعی جدید)
|
||||||
// فایل واقعی در public/templates قرار دارد تا مستقیم از طریق وب هم قابل دسترس باشد.
|
// فایل واقعی در public/templates قرار دارد تا مستقیم از طریق وب هم قابل دسترس باشد.
|
||||||
Route::get('/download-rate-template', function () {
|
Route::get('/download-rate-template', function () {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user