ifnex/04_Laravel/app/Services/PriceCalculatorService.php
Kazem Alghasi 563f89495c fix(pricing): restore array-based calculate() and harden pricing API
Restore the array-based signature of PriceCalculatorService::calculate()
to fix the TypeError thrown when PricingController and CustomerOrderController
called it with an array (the previous commit had switched to 4 separate
string params without updating the callers).

- PriceCalculatorService: support multiple input formats for direction
  (export|import|Outbound|Inbound) and type (DOC_NORMAL|DOC_ECONOMY|PARCEL|
  DOCUMENT|NON DOC) for backward compatibility with admin and customer flows.
- ExchangeRateService: add convertToIrr() for explicit AED→IRR conversion
  (was missing but referenced by the calculator).
- PricingController: expand validation rules to accept new shipment types
  and a discount_code string (was 'discount' numeric). Add 3-layer exception
  handling (422 validation / 400 business / 500 server).
- PriceTestPage: compute volumetric weight from dimensions (IATA: L×W×H/5000),
  resolve destination country by direction, and show errors as Filament
  notifications instead of crashing.
2026-10-29 14:40:58 +03:30

310 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services;
use App\Models\Country;
use App\Models\CountryZone;
use App\Models\DiscountCode;
use App\Models\RateEntry;
use App\Models\SystemSetting;
use App\Enums\ShipmentDirection;
/**
* PriceCalculatorService
* ----------------------
* موتور قیمت‌گذاری ۴-رُبعی:
*
* ┌──────────┬──────────────┬──────────────────┐
* │ │ DOCUMENT │ NON-DOCUMENT │
* ├──────────┼──────────────┼──────────────────┤
* │ 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
{
public function __construct(
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
{
// ───────────────────────────────────────────────────────────────
// ۱. نرمال‌سازی direction
// ───────────────────────────────────────────────────────────────
$directionRaw = strtolower((string) ($input['direction'] ?? ''));
$direction = match ($directionRaw) {
'outbound' => 'export',
'inbound' => 'import',
'export', 'import' => $directionRaw,
default => null,
};
if ($direction === null) {
throw new \InvalidArgumentException(
"Invalid direction: " . ($input['direction'] ?? '(empty)') . " — expected 'export'|'import'|'Outbound'|'Inbound'"
);
}
// ───────────────────────────────────────────────────────────────
// ۲. نرمال‌سازی type → service_type
// جدول rate_entries فقط 'document' | 'parcel' را ذخیره می‌کند.
// DOC_ECONOMY فعلاً به همان نرخ DOC_NORMAL محاسبه می‌شود
// (تفکیک کامل نیازمند گسترش service_type در دیتابیس است).
// ───────────────────────────────────────────────────────────────
$typeRaw = strtoupper(trim((string) ($input['type'] ?? '')));
$serviceType = match ($typeRaw) {
'DOC_NORMAL', 'DOC_ECONOMY', 'DOCUMENT', 'DOC', 'DOCNORMAL', 'DOCECONOMY' => 'document',
'PARCEL', 'NON DOC', 'NON-DOC', 'NONDOC', 'NON_DOCUMENT' => 'parcel',
default => null,
};
if ($serviceType === null) {
throw new \InvalidArgumentException(
"Invalid type: " . ($input['type'] ?? '(empty)') . " — expected DOC_NORMAL|DOC_ECONOMY|PARCEL"
);
}
// ───────────────────────────────────────────────────────────────
// ۳. حل کشور با ISO → نام (CountryZone با نام کشور جستجو می‌شود)
// ───────────────────────────────────────────────────────────────
$countryIso = strtoupper(trim((string) ($input['country_iso'] ?? '')));
if (strlen($countryIso) !== 2) {
throw new \InvalidArgumentException(
"Invalid country_iso: must be exactly 2 letters, got: " . ($input['country_iso'] ?? '(empty)')
);
}
$country = Country::where('iso_code', $countryIso)->first();
if (! $country) {
throw new \RuntimeException("Country not found for ISO: {$countryIso}");
}
$countryName = $country->name;
// ───────────────────────────────────────────────────────────────
// ۴. اوزان
// ───────────────────────────────────────────────────────────────
$weight = (float) ($input['weight'] ?? 0);
$volumetricWeight = (float) ($input['volumetric_weight'] ?? 0);
$chargeableWeight = max($weight, $volumetricWeight);
// ───────────────────────────────────────────────────────────────
// ۵. جستجوی زون (برای کشور × نوع سرویس)
// ───────────────────────────────────────────────────────────────
$zone = CountryZone::lookupZone($countryName, $serviceType);
if ($zone === null) {
throw new \RuntimeException(
"No zone mapping for country '{$countryName}' service type '{$serviceType}'. "
. "Please import the Zone sheet via admin → Rate Management."
);
}
// ───────────────────────────────────────────────────────────────
// ۶. جستجوی نرخ (۴-رُبعی)
// ───────────────────────────────────────────────────────────────
$rateEntry = RateEntry::lookup($direction, $serviceType, $chargeableWeight, $zone, 'AED');
if (! $rateEntry) {
throw new \RuntimeException(
"No rate found for {$direction}×{$serviceType} weight={$chargeableWeight}kg zone={$zone}. "
. "Please import rates via admin → Rate Management."
);
}
// ───────────────────────────────────────────────────────────────
// ۷. محاسبه هزینه‌ها در درهم
// ───────────────────────────────────────────────────────────────
$basePriceAed = (float) $rateEntry->rate;
$extras = [
'extra_service' => (float) ($input['extra_service'] ?? 0),
'packing_cost' => (float) ($input['packing_cost'] ?? 0),
'domestic_pickup' => (float) ($input['domestic_pickup'] ?? 0),
'domestic_delivery' => (float) ($input['domestic_delivery'] ?? 0),
'warehousing_cost' => (float) ($input['warehousing_cost'] ?? 0),
];
$extrasTotalAed = array_sum($extras);
// ───────────────────────────────────────────────────────────────
// ۸. اعمال ضریب سود (روی قیمت پایه، نه روی هزینه‌های جانبی)
// ───────────────────────────────────────────────────────────────
$profitMargin = (float) SystemSetting::get(
'profit_margin',
config('ifnex.financial_defaults.profit_margin', 1.25)
);
if ($profitMargin <= 0) {
$profitMargin = 1.0; // محافظت در برابر صفر یا منفی
}
$netDirham = ($basePriceAed * $profitMargin) + $extrasTotalAed;
// ───────────────────────────────────────────────────────────────
// ۹. تبدیل به ریال
// ───────────────────────────────────────────────────────────────
$netRial = $this->exchangeRate->convertToIrr($netDirham, 'AED');
// ───────────────────────────────────────────────────────────────
// ۱۰. مالیات بر ارزش افزوده
// ───────────────────────────────────────────────────────────────
$vatRate = (float) SystemSetting::get(
'vat_rate',
config('ifnex.financial_defaults.vat_rate', 0.09)
);
$vatAmount = $netRial * $vatRate;
// ───────────────────────────────────────────────────────────────
// ۱۱. تخفیف (در صورت وجود کد)
// ───────────────────────────────────────────────────────────────
$discountApplied = false;
$discountAmount = 0.0;
$discountCode = trim((string) ($input['discount_code'] ?? ''));
if ($discountCode !== '') {
$discount = DiscountCode::where('code', $discountCode)
->where('is_active', true)
->where(function ($q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->first();
if ($discount) {
$subtotal = $netRial + $vatAmount;
// بررسی حداقل مبلغ سفارش
$minOrder = (float) ($discount->min_order_amount ?? 0);
if ($minOrder > 0 && $subtotal < $minOrder) {
// زیر سقف — تخفیف اعمال نمی‌شود
} else {
$discountApplied = true;
if ($discount->type === 'percentage') {
$discountAmount = $subtotal * ((float) $discount->value / 100);
} else { // fixed
$discountAmount = min((float) $discount->value, $subtotal);
}
}
}
}
// ───────────────────────────────────────────────────────────────
// ۱۲. مبلغ نهایی (گردش به نزدیک‌ترین ۱۰۰۰ ریال)
// ───────────────────────────────────────────────────────────────
$totalFee = $netRial + $vatAmount - $discountAmount;
$totalFee = (int) round($totalFee / 1000) * 1000;
return [
// فیلدهای نمایشی که وردپرس از آن‌ها استفاده می‌کند
'base_price' => round($basePriceAed, 2),
'net_dirham' => round($netDirham, 2),
'net_rial' => (int) round($netRial),
'packing_cost' => round($extras['packing_cost'], 2),
'vat_amount' => (int) round($vatAmount),
'discount_applied' => $discountApplied,
'discount_amount' => (int) round($discountAmount),
'total_fee' => $totalFee,
// تفکیک هزینه‌ها
'extra_service' => round($extras['extra_service'], 2),
'domestic_pickup' => round($extras['domestic_pickup'], 2),
'domestic_delivery' => round($extras['domestic_delivery'], 2),
'warehousing_cost' => round($extras['warehousing_cost'], 2),
// اوزان
'weight' => round($weight, 3),
'volumetric_weight' => round($volumetricWeight, 3),
'chargeable_weight' => round($chargeableWeight, 3),
// متادیتای جستجو
'zone' => $zone,
'service_type' => $serviceType,
'direction' => $direction,
'country' => $countryName,
'country_iso' => $countryIso,
'rate_entry_id' => $rateEntry->id,
'profit_margin' => $profitMargin,
'currency' => 'IRR',
];
}
/**
* راحتی: محاسبه قیمت از روی مدل Shipment.
*
* @param \App\Models\Shipment $shipment
* @return array
*/
public function calculateForShipment($shipment): array
{
$direction = $shipment->direction?->value ?? (string) $shipment->direction;
$type = $shipment->type?->value ?? (string) $shipment->type;
$weight = (float) ($shipment->chargeable_weight ?? $shipment->weight ?? 0);
// کشور مقصد: برای صادرات = toCountry، برای واردات = fromCountry
$country = ($direction === 'export')
? $shipment->toCountry
: $shipment->fromCountry;
return $this->calculate([
'direction' => $direction,
'type' => $type,
'country_iso' => $country?->iso_code,
'weight' => (float) ($shipment->weight ?? 0),
'volumetric_weight' => (float) ($shipment->volumetric_weight ?? 0),
'extra_service' => (float) ($shipment->extra_service ?? 0),
'packing_cost' => (float) ($shipment->packing_cost ?? 0),
'domestic_pickup' => (float) ($shipment->domestic_pickup ?? 0),
'domestic_delivery' => (float) ($shipment->domestic_delivery ?? 0),
'warehousing_cost' => (float) ($shipment->warehousing_cost ?? 0),
]);
}
}