311 lines
18 KiB
PHP
311 lines
18 KiB
PHP
<?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 │
|
||
* └──────────┴──────────────┴──────────────────┘
|
||
*
|
||
* ─── معماری ارزی (Currency Architecture) ───
|
||
* ارز مبنا در کل سیستم IFNEX = ریال ایران (IRR).
|
||
* همه نرخها در جدول rate_entries بهصورت ریال ذخیره میشوند
|
||
* (تبدیل در زمان ایمپورت توسط RateImportService انجام میشود).
|
||
*
|
||
* بنابراین موتور محاسبه نیازی به تبدیل ارز ندارد — نرخ مستقیماً به ریال است.
|
||
*
|
||
* جریان محاسبه:
|
||
* 1. نرمالسازی ورودی (direction / type) و تعیین کشور مقصد با ISO.
|
||
* 2. جستجوی زون کشور از جدول country_zones.
|
||
* 3. جستجوی نرخ ریال از جدول rate_entries (نزدیکترین وزن بالاتر).
|
||
* 4. اعمال ضریب سود + هزینههای جانبی (همگی به ریال) → ناخالص ریال.
|
||
* 5. محاسبه مالیات بر ارزش افزوده (۹٪) و کسر تخفیف (در صورت وجود کد).
|
||
*
|
||
* امضای متد calculate(): آرایهای از ورودیها را میگیرد و آرایهای کامل
|
||
* از فیلدهای نمایشی برمیگرداند که وردپرس منتظرش است.
|
||
*/
|
||
class PriceCalculatorService
|
||
{
|
||
public function __construct(
|
||
protected ExchangeRateService $exchangeRate,
|
||
) {}
|
||
|
||
/**
|
||
* محاسبه قیمت نهایی محموله — همه مبالغ به ریال.
|
||
*
|
||
* @param array $input {
|
||
* @var string $direction 'export' | 'import' (یا 'Outbound' | 'Inbound')
|
||
* @var string $type 'DOC_NORMAL' | 'DOC_ECONOMY' | 'PARCEL'
|
||
* @var string $country_iso کد ISO دو حرفی کشور مقصد
|
||
* @var float $weight وزن واقعی (کیلوگرم)
|
||
* @var float $volumetric_weight وزن حجمی (کیلوگرم)
|
||
* @var float|null $extra_service هزینه سرویس اضافی (ریال)
|
||
* @var float|null $packing_cost هزینه بستهبندی (ریال)
|
||
* @var float|null $domestic_pickup هزینه جمعآوری داخلی (ریال)
|
||
* @var float|null $domestic_delivery هزینه تحویل داخلی (ریال)
|
||
* @var float|null $warehousing_cost هزینه انبارداری (ریال)
|
||
* @var string|null $discount_code کد تخفیف اختیاری
|
||
* }
|
||
*
|
||
* @return array {
|
||
* @var float $base_price قیمت پایه (ریال)
|
||
* @var int $net_rial ناخالص شامل سود + هزینهها (ریال)
|
||
* @var float $packing_cost هزینه بستهبندی (ریال)
|
||
* @var int $vat_amount مبلغ مالیات (ریال)
|
||
* @var bool $discount_applied آیا تخفیف اعمال شد؟
|
||
* @var int $discount_amount مبلغ تخفیف (ریال)
|
||
* @var int $total_fee مبلغ نهایی قابل پرداخت (ریال)
|
||
* @var float $extra_service هزینه سرویس اضافی (ریال)
|
||
* @var float $domestic_pickup هزینه جمعآوری (ریال)
|
||
* @var float $domestic_delivery هزینه تحویل (ریال)
|
||
* @var float $warehousing_cost هزینه انبارداری (ریال)
|
||
* @var float $weight وزن واقعی
|
||
* @var float $volumetric_weight وزن حجمی
|
||
* @var float $chargeable_weight وزن قابل محاسبه
|
||
* @var int $zone زون محاسبهشده
|
||
* @var string $service_type 'document' | 'parcel'
|
||
* @var string $direction 'export' | 'import'
|
||
* @var string $country نام کشور
|
||
* @var string $country_iso ISO کشور
|
||
* @var int $rate_entry_id آیدی رکورد نرخ استفادهشده
|
||
* @var float $profit_margin ضریب سود اعمالشده
|
||
* @var string $currency همیشه 'IRR'
|
||
* }
|
||
*
|
||
* @throws \InvalidArgumentException ورودی نامعتبر
|
||
* @throws \RuntimeException زون یا نرخ یافت نشد
|
||
*/
|
||
public function calculate(array $input): array
|
||
{
|
||
// ───────────────────────────────────────────────────────────────
|
||
// ۱. نرمالسازی direction
|
||
// ───────────────────────────────────────────────────────────────
|
||
$directionRaw = strtolower((string) ($input['direction'] ?? ''));
|
||
$direction = match ($directionRaw) {
|
||
'outbound' => 'export',
|
||
'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
|
||
// ───────────────────────────────────────────────────────────────
|
||
$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 → نام
|
||
// ───────────────────────────────────────────────────────────────
|
||
$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."
|
||
);
|
||
}
|
||
|
||
// ───────────────────────────────────────────────────────────────
|
||
// ۶. جستجوی نرخ — مستقیماً به ریال (IRR)
|
||
// ───────────────────────────────────────────────────────────────
|
||
$rateEntry = RateEntry::lookup($direction, $serviceType, $chargeableWeight, $zone, 'IRR');
|
||
if (! $rateEntry) {
|
||
throw new \RuntimeException(
|
||
"No rate found for {$direction}×{$serviceType} weight={$chargeableWeight}kg zone={$zone}. "
|
||
. "Please import rates via admin → Rate Management."
|
||
);
|
||
}
|
||
|
||
// ───────────────────────────────────────────────────────────────
|
||
// ۷. قیمت پایه — مستقیماً به ریال (نیازی به تبدیل نیست)
|
||
// ───────────────────────────────────────────────────────────────
|
||
$basePriceIrr = (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),
|
||
];
|
||
$extrasTotalIrr = array_sum($extras);
|
||
|
||
// ───────────────────────────────────────────────────────────────
|
||
// ۹. اعمال ضریب سود (روی قیمت پایه، نه روی هزینههای جانبی)
|
||
// ───────────────────────────────────────────────────────────────
|
||
$profitMargin = (float) SystemSetting::get(
|
||
'profit_margin',
|
||
config('ifnex.financial_defaults.profit_margin', 1.25)
|
||
);
|
||
if ($profitMargin <= 0) {
|
||
$profitMargin = 1.0;
|
||
}
|
||
|
||
// ناخالص = (قیمت پایه × ضریب سود) + هزینههای جانبی — همه به ریال
|
||
$netRial = ($basePriceIrr * $profitMargin) + $extrasTotalIrr;
|
||
|
||
// ───────────────────────────────────────────────────────────────
|
||
// ۱۰. مالیات بر ارزش افزوده
|
||
// ───────────────────────────────────────────────────────────────
|
||
$vatRate = (float) SystemSetting::get(
|
||
'vat_rate',
|
||
config('ifnex.financial_defaults.vat_rate', 0.09)
|
||
);
|
||
$vatAmount = $netRial * $vatRate;
|
||
|
||
// ───────────────────────────────────────────────────────────────
|
||
// ۱۱. تخفیف (در صورت وجود کد)
|
||
// ───────────────────────────────────────────────────────────────
|
||
$discountApplied = false;
|
||
$discountAmount = 0.0;
|
||
$discountCode = trim((string) ($input['discount_code'] ?? ''));
|
||
|
||
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 {
|
||
$discountAmount = min((float) $discount->value, $subtotal);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ───────────────────────────────────────────────────────────────
|
||
// ۱۲. مبلغ نهایی (گردش به نزدیکترین ۱۰۰۰ ریال)
|
||
// ───────────────────────────────────────────────────────────────
|
||
$totalFee = $netRial + $vatAmount - $discountAmount;
|
||
$totalFee = (int) round($totalFee / 1000) * 1000;
|
||
|
||
return [
|
||
// فیلدهای نمایشی — همه به ریال
|
||
'base_price' => round($basePriceIrr, 2),
|
||
'net_rial' => (int) round($netRial),
|
||
'packing_cost' => round($extras['packing_cost'], 2),
|
||
'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;
|
||
|
||
// کشور مقصد: برای صادرات = 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),
|
||
]);
|
||
}
|
||
}
|