ifnex/04_Laravel/app/Services/PriceCalculatorService.php

311 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 │
* └──────────┴──────────────┴──────────────────┘
*
* ─── معماری ارزی (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),
]);
}
}