ifnex/04_Laravel/app/Services/PriceCalculatorService.php
Kazem Alghasi 58493feca4 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.
2026-10-29 17:01:30 +03:30

206 lines
8.6 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
* ----------------------
* موتور قیمت‌گذاری ۴-رُبعی — همه نرخ‌ها به ریال (IRR).
*
* ─── معماری ارزی ───
* ارز مبنا = ریال ایران. نرخ‌ها در دیتابیس به ریال ذخیره می‌شوند
* (تبدیل در زمان ایمپورت توسط RateImportService انجام می‌شود).
* بنابراین موتور محاسبه نیازی به تبدیل ارز ندارد.
*/
class PriceCalculatorService
{
public function __construct(
protected ExchangeRateService $exchangeRate,
) {}
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',
];
}
public function calculateForShipment($shipment): array
{
$direction = $shipment->direction?->value ?? (string) $shipment->direction;
$type = $shipment->type?->value ?? (string) $shipment->type;
$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),
]);
}
}