refactor(currency): IRR as single base currency for all shipping rates
Make Iranian Rial (IRR) the sole base currency for shipping rates: - RateImportService now converts all imported rates to IRR at import time using the current daily exchange rate (source currency AED/USD/ EUR/IRR). Rates are stored in rate_entries with currency='IRR'. - PriceCalculatorService looks up IRR rates directly — no conversion at calc time. Also fixes a pre-existing bug where extras (in IRR) were incorrectly added to the AED base before conversion. - RateEntry::lookup() default currency changed from 'AED' to 'IRR'. - Migration changes the column default to 'IRR'. - Filament import form: relabel 'Rate currency' to 'Source currency (in Excel file)' with helper text explaining the conversion. - WordPress JS: update price summary labels from 'درهم' to 'ریال' and drop the deprecated net_dirham field. Architectural benefit: rates are 'frozen' in IRR at import time — daily exchange rate changes only affect NEW imports, not already- stored rates. This is more stable and predictable for customers.
This commit is contained in:
parent
4a38cf4fce
commit
58493feca4
@ -968,13 +968,12 @@ jQuery(document).ready(function($) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
html += '<hr style="margin: 10px 0;">';
|
html += '<hr style="margin: 10px 0;">';
|
||||||
html += '<div class="ifnex-price-row"><span>قیمت پایه:</span><strong>' + (data.base_price || '0') + ' درهم</strong></div>';
|
html += '<div class="ifnex-price-row"><span>قیمت پایه:</span><strong>' + numberFormat(data.base_price || 0) + ' ریال</strong></div>';
|
||||||
html += '<div class="ifnex-price-row"><span>ناخالص:</span><strong>' + (data.net_dirham || '0') + ' درهم</strong></div>';
|
html += '<div class="ifnex-price-row"><span>ناخالص:</span><strong>' + numberFormat(data.net_rial || 0) + ' ریال</strong></div>';
|
||||||
html += '<div class="ifnex-price-row"><span>ریال:</span><strong>' + numberFormat(data.net_rial) + ' ریال</strong></div>';
|
if (data.packing_cost > 0) html += '<div class="ifnex-price-row"><span>بستهبندی:</span><strong>' + numberFormat(data.packing_cost) + ' ریال</strong></div>';
|
||||||
if (data.packing_cost > 0) html += '<div class="ifnex-price-row"><span>بستهبندی:</span><strong>' + numberFormat(data.packing_cost) + '</strong></div>';
|
if (data.vat_amount > 0) html += '<div class="ifnex-price-row"><span>مالیات (۹٪):</span><strong>' + numberFormat(data.vat_amount) + ' ریال</strong></div>';
|
||||||
if (data.vat_amount > 0) html += '<div class="ifnex-price-row"><span>مالیات (۹٪):</span><strong>' + numberFormat(data.vat_amount) + '</strong></div>';
|
|
||||||
if (data.discount_applied) {
|
if (data.discount_applied) {
|
||||||
html += '<div class="ifnex-price-row ifnex-discount"><span>تخفیف:</span><strong>-' + numberFormat(data.discount_amount) + '</strong></div>';
|
html += '<div class="ifnex-price-row ifnex-discount"><span>تخفیف:</span><strong>-' + numberFormat(data.discount_amount) + ' ریال</strong></div>';
|
||||||
}
|
}
|
||||||
html += '<div class="ifnex-price-row ifnex-total"><span>مبلغ قابل پرداخت:</span><strong>' + numberFormat(data.total_fee) + ' ریال</strong></div>';
|
html += '<div class="ifnex-price-row ifnex-total"><span>مبلغ قابل پرداخت:</span><strong>' + numberFormat(data.total_fee) + ' ریال</strong></div>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
|
|||||||
@ -16,13 +16,6 @@ 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;
|
||||||
@ -30,7 +23,6 @@ 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
|
||||||
@ -51,22 +43,27 @@ 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) // 10 MB
|
->maxSize(10240)
|
||||||
->disk('local')
|
->disk('local')
|
||||||
->directory('rate-imports')
|
->directory('rate-imports')
|
||||||
->required()
|
->required()
|
||||||
->hint('Sheets expected: "Import Rate", "Export Rate", "Zone"'),
|
->hint('Sheets expected: "Import Rate", "Export Rate", "Zone"'),
|
||||||
Select::make('currency')
|
Select::make('currency')
|
||||||
->label('Rate currency')
|
->label('ارز مبدأ فایل اکسل (Source currency)')
|
||||||
->options([
|
->options([
|
||||||
'AED' => 'AED (UAE Dirham)',
|
'AED' => 'AED (درهم امارات)',
|
||||||
'USD' => 'USD (US Dollar)',
|
'USD' => 'USD (دلار آمریکا)',
|
||||||
'EUR' => 'EUR (Euro)',
|
'EUR' => 'EUR (یورو)',
|
||||||
'IRR' => 'IRR (Iranian Rial — direct, no conversion)',
|
'IRR' => 'IRR (ریال ایران — بدون تبدیل)',
|
||||||
])
|
])
|
||||||
->default('AED')
|
->default('AED')
|
||||||
->required()
|
->required()
|
||||||
->helperText('Defaults to AED. The system converts to IRR using the current exchange rate.'),
|
->helperText('⚠️ ارز مبنا سیستم = ریال ایران. نرخهای فایل اکسل با نرخ روز این ارز به ریال تبدیل شده و سپس در دیتابیس ذخیره میشوند. مثلاً اگر فایل به درهم است، AED را انتخاب کنید — سیستم با نرخ روز درهم→ریال تبدیل میکند.'),
|
||||||
|
\Filament\Forms\Components\Toggle::make('replace_existing')
|
||||||
|
->label('جایگزینی کامل (حذف همه نرخها و زونهای قبلی قبل از ایمپورت)')
|
||||||
|
->default(false)
|
||||||
|
->helperText('⚠️ اگر فعال باشد، تمام رکوردهای جدول rate_entries و country_zones حذف شده و سپس ایمپورت جدید انجام میشود. برای re-import با currency متفاوت این گزینه را فعال کنید.')
|
||||||
|
->reactive(),
|
||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
->statePath('data');
|
->statePath('data');
|
||||||
@ -77,21 +74,14 @@ 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()
|
Notification::make()->title('No file uploaded')->danger()->send();
|
||||||
->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)) {
|
||||||
// مسیر نسبی روی دیسک 'local' — Storage مسیر کامل را برمیگرداند
|
|
||||||
$filePath = Storage::disk('local')->path($uploadedPath);
|
$filePath = Storage::disk('local')->path($uploadedPath);
|
||||||
} elseif ($uploadedPath instanceof TemporaryUploadedFile) {
|
} elseif ($uploadedPath instanceof TemporaryUploadedFile) {
|
||||||
$filePath = $uploadedPath->getRealPath();
|
$filePath = $uploadedPath->getRealPath();
|
||||||
@ -109,6 +99,12 @@ class ImportRates extends Page implements HasForms
|
|||||||
}
|
}
|
||||||
|
|
||||||
$currency = $state['currency'] ?? 'AED';
|
$currency = $state['currency'] ?? 'AED';
|
||||||
|
$replaceExisting = (bool) ($state['replace_existing'] ?? false);
|
||||||
|
|
||||||
|
if ($replaceExisting) {
|
||||||
|
\App\Models\RateEntry::query()->delete();
|
||||||
|
\App\Models\CountryZone::query()->delete();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$service = app(RateImportService::class);
|
$service = app(RateImportService::class);
|
||||||
@ -122,6 +118,10 @@ class ImportRates extends Page implements HasForms
|
|||||||
$summary['zones_updated'] ?? 0,
|
$summary['zones_updated'] ?? 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if ($replaceExisting) {
|
||||||
|
$msg = "🔄 جایگزینی کامل انجام شد.\n" . $msg;
|
||||||
|
}
|
||||||
|
|
||||||
if (! empty($summary['errors'])) {
|
if (! empty($summary['errors'])) {
|
||||||
$msg .= "\n⚠ Errors: " . count($summary['errors']) . " (see log)";
|
$msg .= "\n⚠ Errors: " . count($summary['errors']) . " (see log)";
|
||||||
foreach (array_slice($summary['errors'], 0, 5) as $err) {
|
foreach (array_slice($summary['errors'], 0, 5) as $err) {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -51,7 +51,7 @@ class RateEntry extends Model
|
|||||||
string $serviceType,
|
string $serviceType,
|
||||||
float $weight,
|
float $weight,
|
||||||
int $zone,
|
int $zone,
|
||||||
string $currency = 'AED',
|
string $currency = 'IRR',
|
||||||
?\DateTimeInterface $at = null
|
?\DateTimeInterface $at = null
|
||||||
): ?self {
|
): ?self {
|
||||||
$at ??= now();
|
$at ??= now();
|
||||||
|
|||||||
@ -12,26 +12,12 @@ use App\Enums\ShipmentDirection;
|
|||||||
/**
|
/**
|
||||||
* PriceCalculatorService
|
* PriceCalculatorService
|
||||||
* ----------------------
|
* ----------------------
|
||||||
* موتور قیمتگذاری ۴-رُبعی:
|
* موتور قیمتگذاری ۴-رُبعی — همه نرخها به ریال (IRR).
|
||||||
*
|
*
|
||||||
* ┌──────────┬──────────────┬──────────────────┐
|
* ─── معماری ارزی ───
|
||||||
* │ │ DOCUMENT │ NON-DOCUMENT │
|
* ارز مبنا = ریال ایران. نرخها در دیتابیس به ریال ذخیره میشوند
|
||||||
* ├──────────┼──────────────┼──────────────────┤
|
* (تبدیل در زمان ایمپورت توسط RateImportService انجام میشود).
|
||||||
* │ Export │ Export×Doc │ Export×Parcel │
|
* بنابراین موتور محاسبه نیازی به تبدیل ارز ندارد.
|
||||||
* │ Import │ Import×Doc │ Import×Parcel │
|
|
||||||
* └──────────┴──────────────┴──────────────────┘
|
|
||||||
*
|
|
||||||
* جریان محاسبه:
|
|
||||||
* 1. نرمالسازی ورودی (direction / type) و تعیین کشور مقصد با ISO.
|
|
||||||
* 2. جستجوی زون کشور از جدول country_zones (زون Doc و Parcel ممکن است متفاوت باشد).
|
|
||||||
* 3. جستجوی نرخ AED از جدول rate_entries (نزدیکترین وزن بالاتر).
|
|
||||||
* 4. اعمال ضریب سود + هزینههای جانبی → ناخالص درهم.
|
|
||||||
* 5. تبدیل درهم → ریال با نرخ روز از ExchangeRateService.
|
|
||||||
* 6. محاسبه مالیات بر ارزش افزوده (۹٪) و کسر تخفیف (در صورت وجود کد).
|
|
||||||
*
|
|
||||||
* امضای متد calculate(): آرایهای از ورودیها را میگیرد (برای سازگاری با
|
|
||||||
* PricingController، CustomerOrderController و PriceTestPage) و آرایهای کامل
|
|
||||||
* از فیلدهای نمایشی برمیگرداند که وردپرس منتظرش است.
|
|
||||||
*/
|
*/
|
||||||
class PriceCalculatorService
|
class PriceCalculatorService
|
||||||
{
|
{
|
||||||
@ -39,54 +25,9 @@ class PriceCalculatorService
|
|||||||
protected ExchangeRateService $exchangeRate,
|
protected ExchangeRateService $exchangeRate,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
|
||||||
* محاسبه قیمت نهایی محموله.
|
|
||||||
*
|
|
||||||
* @param array $input {
|
|
||||||
* @var string $direction 'export' | 'import' (یا 'Outbound' | 'Inbound' برای backward-compat)
|
|
||||||
* @var string $type 'DOC_NORMAL' | 'DOC_ECONOMY' | 'PARCEL'
|
|
||||||
* (یا 'DOCUMENT' | 'NON DOC' | 'DOC' از فرم تست ادمین)
|
|
||||||
* @var string $country_iso کد ISO دو حرفی کشور مقصد (مثلاً 'US', 'AE')
|
|
||||||
* @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 float $net_dirham ناخالص شامل سود + هزینهها (درهم)
|
|
||||||
* @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 $weight وزن واقعی
|
|
||||||
* @var float $volumetric_weight وزن حجمی
|
|
||||||
* @var float $chargeable_weight وزن قابل محاسبه (max واقعی، حجمی)
|
|
||||||
* @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',
|
||||||
@ -100,12 +41,7 @@ class PriceCalculatorService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───────────────────────────────────────────────────────────────
|
|
||||||
// ۲. نرمالسازی type → service_type
|
// ۲. نرمالسازی type → service_type
|
||||||
// جدول rate_entries فقط 'document' | 'parcel' را ذخیره میکند.
|
|
||||||
// DOC_ECONOMY فعلاً به همان نرخ DOC_NORMAL محاسبه میشود
|
|
||||||
// (تفکیک کامل نیازمند گسترش 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',
|
||||||
@ -118,9 +54,7 @@ class PriceCalculatorService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───────────────────────────────────────────────────────────────
|
// ۳. حل کشور با ISO → نام
|
||||||
// ۳. حل کشور با ISO → نام (CountryZone با نام کشور جستجو میشود)
|
|
||||||
// ───────────────────────────────────────────────────────────────
|
|
||||||
$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(
|
||||||
@ -133,16 +67,12 @@ 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(
|
||||||
@ -151,10 +81,8 @@ class PriceCalculatorService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───────────────────────────────────────────────────────────────
|
// ۶. جستجوی نرخ — مستقیماً به ریال (IRR)
|
||||||
// ۶. جستجوی نرخ (۴-رُبعی)
|
$rateEntry = RateEntry::lookup($direction, $serviceType, $chargeableWeight, $zone, 'IRR');
|
||||||
// ───────────────────────────────────────────────────────────────
|
|
||||||
$rateEntry = RateEntry::lookup($direction, $serviceType, $chargeableWeight, $zone, 'AED');
|
|
||||||
if (! $rateEntry) {
|
if (! $rateEntry) {
|
||||||
throw new \RuntimeException(
|
throw new \RuntimeException(
|
||||||
"No rate found for {$direction}×{$serviceType} weight={$chargeableWeight}kg zone={$zone}. "
|
"No rate found for {$direction}×{$serviceType} weight={$chargeableWeight}kg zone={$zone}. "
|
||||||
@ -162,50 +90,38 @@ class PriceCalculatorService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───────────────────────────────────────────────────────────────
|
// ۷. قیمت پایه — مستقیماً به ریال
|
||||||
// ۷. محاسبه هزینهها در درهم
|
$basePriceIrr = (float) $rateEntry->rate;
|
||||||
// ───────────────────────────────────────────────────────────────
|
|
||||||
$basePriceAed = (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),
|
||||||
'domestic_pickup' => (float) ($input['domestic_pickup'] ?? 0),
|
'domestic_pickup' => (float) ($input['domestic_pickup'] ?? 0),
|
||||||
'domestic_delivery' => (float) ($input['domestic_delivery'] ?? 0),
|
'domestic_delivery' => (float) ($input['domestic_delivery'] ?? 0),
|
||||||
'warehousing_cost' => (float) ($input['warehousing_cost'] ?? 0),
|
'warehousing_cost' => (float) ($input['warehousing_cost'] ?? 0),
|
||||||
];
|
];
|
||||||
$extrasTotalAed = 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)
|
||||||
);
|
);
|
||||||
if ($profitMargin <= 0) {
|
if ($profitMargin <= 0) {
|
||||||
$profitMargin = 1.0; // محافظت در برابر صفر یا منفی
|
$profitMargin = 1.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$netDirham = ($basePriceAed * $profitMargin) + $extrasTotalAed;
|
$netRial = ($basePriceIrr * $profitMargin) + $extrasTotalIrr;
|
||||||
|
|
||||||
// ───────────────────────────────────────────────────────────────
|
|
||||||
// ۹. تبدیل به ریال
|
|
||||||
// ───────────────────────────────────────────────────────────────
|
|
||||||
$netRial = $this->exchangeRate->convertToIrr($netDirham, 'AED');
|
|
||||||
|
|
||||||
// ───────────────────────────────────────────────────────────────
|
|
||||||
// ۱۰. مالیات بر ارزش افزوده
|
// ۱۰. مالیات بر ارزش افزوده
|
||||||
// ───────────────────────────────────────────────────────────────
|
|
||||||
$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'] ?? ''));
|
||||||
@ -221,51 +137,39 @@ 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') {
|
||||||
$discountAmount = $subtotal * ((float) $discount->value / 100);
|
$discountAmount = $subtotal * ((float) $discount->value / 100);
|
||||||
} else { // fixed
|
} else {
|
||||||
$discountAmount = min((float) $discount->value, $subtotal);
|
$discountAmount = min((float) $discount->value, $subtotal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───────────────────────────────────────────────────────────────
|
// ۱۲. مبلغ نهایی (گردش به ۱۰۰۰ ریال)
|
||||||
// ۱۲. مبلغ نهایی (گردش به نزدیکترین ۱۰۰۰ ریال)
|
|
||||||
// ───────────────────────────────────────────────────────────────
|
|
||||||
$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($basePriceAed, 2),
|
|
||||||
'net_dirham' => round($netDirham, 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),
|
||||||
'vat_amount' => (int) round($vatAmount),
|
'vat_amount' => (int) round($vatAmount),
|
||||||
'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,
|
||||||
@ -277,19 +181,11 @@ 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;
|
||||||
$weight = (float) ($shipment->chargeable_weight ?? $shipment->weight ?? 0);
|
|
||||||
|
|
||||||
// کشور مقصد: برای صادرات = toCountry، برای واردات = fromCountry
|
|
||||||
$country = ($direction === 'export')
|
$country = ($direction === 'export')
|
||||||
? $shipment->toCountry
|
? $shipment->toCountry
|
||||||
: $shipment->fromCountry;
|
: $shipment->fromCountry;
|
||||||
|
|||||||
@ -14,33 +14,17 @@ use Exception;
|
|||||||
/**
|
/**
|
||||||
* RateImportService
|
* RateImportService
|
||||||
* ----------------
|
* ----------------
|
||||||
* Parses the customer's operational Excel format and upserts rates
|
* Parses the customer's operational Excel format and stores rates
|
||||||
* into the rate_entries table (4-quadrant model).
|
* into the rate_entries table (4-quadrant model).
|
||||||
*
|
*
|
||||||
* Expected Excel structure (matches IFNEX_Rate_Template.xlsx):
|
* ─── معماری ارزی (Currency Architecture) ───
|
||||||
|
* ارز مبنا در کل سیستم IFNEX = ریال ایران (IRR).
|
||||||
|
* همه نرخها در دیتابیس بهصورت ریال ذخیره میشوند.
|
||||||
*
|
*
|
||||||
* Sheet "Import Rate" (or "Export Rate"):
|
* هنگام ایمپورت، کاربر «ارز مبدأ» فایل اکسل را انتخاب میکند (مثلاً AED، USD، EUR، IRR).
|
||||||
* 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 N+3: 1 | <rate> | <rate> | ...
|
|
||||||
* ... (only 4 rows for documents — weights 0.5/1/1.5/2)
|
|
||||||
*
|
|
||||||
* Row M: "NON - DOCUMENT" (or "NON-DOCUMENT" or "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
|
|
||||||
*
|
|
||||||
* The importer is tolerant of:
|
|
||||||
* - Whitespace variations in section markers
|
|
||||||
* - Empty cells (skipped, not stored)
|
|
||||||
* - Weights in any order (parsed as decimals)
|
|
||||||
* - Either "Import Rate" / "Export Rate" sheet present (other one optional)
|
|
||||||
* - Currency defaults to 'AED' but can be overridden via second param
|
|
||||||
*/
|
*/
|
||||||
class RateImportService
|
class RateImportService
|
||||||
{
|
{
|
||||||
@ -55,7 +39,6 @@ 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,
|
||||||
@ -64,44 +47,39 @@ class RateImportService
|
|||||||
'zones_updated' => 0,
|
'zones_updated' => 0,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
protected string $sourceCurrency = 'AED';
|
||||||
* Main entry point.
|
protected ExchangeRateService $exchangeRate;
|
||||||
*
|
|
||||||
* @param string $filePath Absolute path to the uploaded .xlsx
|
public function import(string $filePath, string $sourceCurrency = 'AED'): array
|
||||||
* @param string $currency ISO 4217 code, default 'AED'
|
|
||||||
* @return array Summary array
|
|
||||||
*/
|
|
||||||
public function import(string $filePath, string $currency = '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->exchangeRate = app(ExchangeRateService::class);
|
||||||
|
|
||||||
if (! file_exists($filePath)) {
|
if (! file_exists($filePath)) {
|
||||||
throw new Exception("File not found: {$filePath}");
|
throw new Exception("File not found: {$filePath}");
|
||||||
}
|
}
|
||||||
|
|
||||||
$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', $currency);
|
$this->importRateSheet($spreadsheet->getSheetByName($name), 'import');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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', $currency);
|
$this->importRateSheet($spreadsheet->getSheetByName($name), 'export');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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')) {
|
||||||
@ -111,18 +89,13 @@ class RateImportService
|
|||||||
return $this->summary;
|
return $this->summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
protected function importRateSheet(Worksheet $ws, string $direction): void
|
||||||
* 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, string $currency): 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);
|
||||||
|
|
||||||
// Walk every row, detect section markers, then parse the matrix that follows.
|
$currentSection = null;
|
||||||
$currentSection = null; // 'document' | 'parcel'
|
|
||||||
$headerRowIdx = null;
|
$headerRowIdx = null;
|
||||||
|
|
||||||
for ($r = 1; $r <= $maxRow; $r++) {
|
for ($r = 1; $r <= $maxRow; $r++) {
|
||||||
@ -131,7 +104,6 @@ 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;
|
||||||
@ -139,7 +111,6 @@ 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;
|
||||||
@ -147,11 +118,9 @@ 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;
|
||||||
@ -166,16 +135,12 @@ class RateImportService
|
|||||||
$this->summary['skipped']++;
|
$this->summary['skipped']++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$this->upsertRate($direction, $currentSection, $weight, $zone, (float) $rate, $currency);
|
$this->upsertRate($direction, $currentSection, $weight, $zone, (float) $rate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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) {
|
||||||
@ -184,7 +149,6 @@ 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';
|
||||||
@ -199,12 +163,6 @@ 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])) {
|
||||||
@ -214,19 +172,16 @@ 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) {
|
||||||
@ -236,13 +191,9 @@ class RateImportService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return $zoneCount >= 3; // require at least 3 zone columns to consider this a header
|
return $zoneCount >= 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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] === '') {
|
||||||
@ -264,7 +215,7 @@ class RateImportService
|
|||||||
|
|
||||||
$rates = [];
|
$rates = [];
|
||||||
for ($c = 2; $c <= 11; $c++) {
|
for ($c = 2; $c <= 11; $c++) {
|
||||||
$zone = $c - 1; // col 2 -> Zone 1, col 11 -> Zone 10
|
$zone = $c - 1;
|
||||||
$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;
|
||||||
@ -273,7 +224,6 @@ 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;
|
||||||
}
|
}
|
||||||
@ -292,22 +242,24 @@ class RateImportService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upsert a rate row. Within a transaction.
|
* Upsert a rate row — همیشه به ریال ذخیره میشود.
|
||||||
*/
|
*/
|
||||||
protected function upsertRate(string $direction, string $serviceType, float $weight, int $zone, float $rate, string $currency): void
|
protected function upsertRate(string $direction, string $serviceType, float $weight, int $zone, float $rate): void
|
||||||
{
|
{
|
||||||
|
$rateInIrr = $this->exchangeRate->convertToIrr($rate, $this->sourceCurrency);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$existing = RateEntry::where([
|
$existing = RateEntry::where([
|
||||||
'direction' => $direction,
|
'direction' => $direction,
|
||||||
'service_type' => $serviceType,
|
'service_type' => $serviceType,
|
||||||
'weight' => $weight,
|
'weight' => $weight,
|
||||||
'zone' => $zone,
|
'zone' => $zone,
|
||||||
'currency' => $currency,
|
'currency' => 'IRR',
|
||||||
])->where('effective_at', '>=', now()->startOfDay())
|
])->where('effective_at', '>=', now()->startOfDay())
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($existing) {
|
if ($existing) {
|
||||||
$existing->update(['rate' => $rate]);
|
$existing->update(['rate' => $rateInIrr]);
|
||||||
$this->summary['updated']++;
|
$this->summary['updated']++;
|
||||||
} else {
|
} else {
|
||||||
RateEntry::create([
|
RateEntry::create([
|
||||||
@ -315,8 +267,8 @@ class RateImportService
|
|||||||
'service_type' => $serviceType,
|
'service_type' => $serviceType,
|
||||||
'weight' => $weight,
|
'weight' => $weight,
|
||||||
'zone' => $zone,
|
'zone' => $zone,
|
||||||
'rate' => $rate,
|
'rate' => $rateInIrr,
|
||||||
'currency' => $currency,
|
'currency' => 'IRR',
|
||||||
'effective_at' => now(),
|
'effective_at' => now(),
|
||||||
]);
|
]);
|
||||||
$this->summary['imported']++;
|
$this->summary['imported']++;
|
||||||
@ -327,17 +279,10 @@ 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();
|
||||||
@ -349,7 +294,7 @@ class RateImportService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($headerRow === null) {
|
if ($headerRow === null) {
|
||||||
$headerRow = 3; // fallback
|
$headerRow = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
for ($r = $headerRow + 1; $r <= $maxRow; $r++) {
|
for ($r = $headerRow + 1; $r <= $maxRow; $r++) {
|
||||||
@ -358,13 +303,11 @@ 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);
|
||||||
@ -396,4 +339,4 @@ class RateImportService
|
|||||||
$this->summary['errors'][] = "Country zone error ({$country}): " . $e->getMessage();
|
$this->summary['errors'][] = "Country zone error ({$country}): " . $e->getMessage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
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('rate_entries', function (Blueprint $table) {
|
||||||
|
$table->char('currency', 3)->default('IRR')->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('rate_entries', function (Blueprint $table) {
|
||||||
|
$table->char('currency', 3)->default('AED')->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user