ifnex/04_Laravel/app/Services/PriceCalculatorService.php
Kazem Alghasi 4fac1dae01 refactor(currency): simplify exchange rate logic to use Rial as base
Refactor the currency management system to eliminate the redundant
`rate_to_aed` field and transition to a single-base model where all
rates are stored relative to the Iranian Rial (IRR).

Key changes:
- Removed `rate_to_aed` from `currencies` table via new migration.
- Updated `ExchangeRateService` to handle rates using a single
  `currency_code` parameter instead of currency pairs.
- Refactored `UpdateExchangeRates` command to fetch rates from the
  Exir.io API and support multiple currencies (AED, USD, EUR, CNY).
- Updated Filament resources (`CurrencyResource`,
  `ExchangeRateHistoryResource`) and widgets to reflect the new
  single-base architecture.
- Improved `PriceCalculatorService` to use the updated service
  methodology for fetching the AED/IRR conversion rate.
- Localized console command output to Persian.
2026-10-29 03:54:02 +03:30

296 lines
11 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\Enums\ShipmentType;
use App\Models\Country;
use App\Models\DiscountCode;
use App\Models\SystemSetting;
use InvalidArgumentException;
class PriceCalculatorService
{
/**
* محاسبه قیمت نهایی حمل و نقل بر اساس پارامترهای ورودی.
*
* این متد هیچ side effectای روی دیتابیس ندارد.
*/
public function calculate(array $data): array
{
// 1. استخراج و نرمال‌سازی داده‌های ورودی
$type = ShipmentType::from($data['type']);
$direction = $data['direction'] === 'Outbound' ? 'export' : 'import';
$chargeableWeight = max($data['weight'], $data['volumetric_weight']);
// 2. یافتن کشور و zone مربوطه
$country = Country::where('iso_code', $data['country_iso'])->firstOrFail();
$zone = match (true) {
$type === ShipmentType::Parcel && $direction === 'export' => $country->export_zone_parcel,
$type === ShipmentType::Parcel && $direction === 'import' => $country->import_zone_parcel,
$type !== ShipmentType::Parcel && $direction === 'export' => $country->export_zone_doc,
$type !== ShipmentType::Parcel && $direction === 'import' => $country->import_zone_doc,
};
// 3. یافتن نرخ پایه
$baseRate = $this->lookupRate($direction, $type, $chargeableWeight, $zone);
// 4. محاسبات مالی
$financials = $this->calculateFinancials($baseRate);
// 5. هزینه‌های جانبی
$extraCosts = $this->calculateExtraCosts($data);
// 6. قیمت قبل از تخفیف
$subtotal = $financials['net_rial'] + array_sum($extraCosts);
// 7. اعمال تخفیف
// مهم: اینجا دیگر used_count تغییر نمی‌کند.
$discountResult = $this->applyDiscount(
$subtotal,
$data['discount_code'] ?? null
);
// 8. محاسبات مالی نهایی
$vatRate = (float) SystemSetting::get('vat_rate', 0.09);
$finalPrice = $discountResult['final_price'];
$vatAmount = $finalPrice * $vatRate;
$totalFee = $finalPrice + $vatAmount;
// 9. خروجی
return [
'base_price' => $baseRate,
'net_dirham' => $financials['net_dirham'],
'net_rial' => $financials['net_rial'],
'extra_service' => $extraCosts['extra_service'],
'packing_cost' => $extraCosts['packing_cost'],
'domestic_pickup' => $extraCosts['domestic_pickup'],
'domestic_delivery' => $extraCosts['domestic_delivery'],
'warehousing_cost' => $extraCosts['warehousing_cost'],
'discount_applied' => $discountResult['success'],
'discount_code' => $discountResult['discount_code'] ?? null,
'discount_amount' => $discountResult['discount_amount'],
'discount_message' => $discountResult['message'],
'vat_amount' => $vatAmount,
'total_fee' => $totalFee,
'zone' => $zone,
'chargeable_weight' => $chargeableWeight,
];
}
/**
* ثبت مصرف واقعی یک کد تخفیف.
*
* این متد باید فقط بعد از محاسبه موفق و داخل transaction
* ثبت سفارش فراخوانی شود.
*/
public function consumeDiscountCode(?string $code): void
{
if (empty($code)) {
return;
}
$discount = DiscountCode::query()
->where('code', $code)
->lockForUpdate()
->first();
if (!$discount) {
throw new InvalidArgumentException('کد تخفیف معتبر نیست.');
}
if (!$discount->is_active) {
throw new InvalidArgumentException('کد تخفیف غیرفعال است.');
}
if ($discount->expires_at && $discount->expires_at->isPast()) {
throw new InvalidArgumentException('کد تخفیف منقضی شده است.');
}
if (
$discount->usage_limit !== null &&
$discount->used_count >= $discount->usage_limit
) {
throw new InvalidArgumentException('ظرفیت استفاده از این کد تخفیف به پایان رسیده است.');
}
$discount->increment('used_count');
}
/**
* محاسبه ارزش مالی نرخ پایه.
*/
private function calculateFinancials(float $baseRate): array
{
$profitMargin = (float) SystemSetting::get('profit_margin', 1.25);
// نرخ ارز فقط از ExchangeRateService خوانده می‌شود (منبع واحد — منوی نرخ ارز)
$aedToIrr = app(ExchangeRateService::class)->getCurrentRate('AED');
$netDirham = $baseRate * $profitMargin;
$netRial = $netDirham * $aedToIrr;
return [
'net_dirham' => $netDirham,
'net_rial' => $netRial,
];
}
/**
* محاسبه مجموع هزینه‌های جانبی.
*/
private function calculateExtraCosts(array $data): array
{
$packingCostDefault = (float) SystemSetting::get(
'packing_cost_default',
100000
);
return [
'extra_service' => (float) ($data['extra_service'] ?? 0),
'packing_cost' => (float) ($data['packing_cost'] ?? $packingCostDefault),
'domestic_pickup' => (float) ($data['domestic_pickup'] ?? 0),
'domestic_delivery' => (float) ($data['domestic_delivery'] ?? 0),
'warehousing_cost' => (float) ($data['warehousing_cost'] ?? 0),
];
}
/**
* اعمال کد تخفیف روی قیمت.
*
* این متد pure است و used_count را تغییر نمی‌دهد.
*/
public function applyDiscount(float $totalPrice, ?string $code): array
{
if (empty($code)) {
return [
'success' => false,
'message' => 'کد تخفیف وارد نشده است.',
'discount_amount' => 0,
'final_price' => $totalPrice,
];
}
$discount = DiscountCode::where('code', $code)->first();
// 1. بررسی وجود
if (!$discount) {
return [
'success' => false,
'message' => 'کد تخفیف نامعتبر است.',
'discount_amount' => 0,
'final_price' => $totalPrice,
];
}
// 2. بررسی فعال بودن
if (!$discount->is_active) {
return [
'success' => false,
'message' => 'این کد تخفیف غیرفعال شده است.',
'discount_amount' => 0,
'final_price' => $totalPrice,
];
}
// 3. بررسی تاریخ انقضا
if ($discount->expires_at && $discount->expires_at->isPast()) {
return [
'success' => false,
'message' => 'این کد تخفیف منقضی شده است.',
'discount_amount' => 0,
'final_price' => $totalPrice,
];
}
// 4. حداقل مبلغ سفارش
if (
$discount->min_order_amount > 0 &&
$totalPrice < $discount->min_order_amount
) {
return [
'success' => false,
'message' => 'حداقل مبلغ سفارش برای استفاده از این کد '
. number_format($discount->min_order_amount)
. ' ریال است.',
'discount_amount' => 0,
'final_price' => $totalPrice,
];
}
// 5. محدودیت تعداد استفاده
if (
$discount->usage_limit &&
$discount->used_count >= $discount->usage_limit
) {
return [
'success' => false,
'message' => 'ظرفیت استفاده از این کد تخفیف به پایان رسیده است.',
'discount_amount' => 0,
'final_price' => $totalPrice,
];
}
// محاسبه تخفیف
$discountAmount = 0;
if ($discount->type === 'percentage') {
$discountAmount = ($totalPrice * $discount->value) / 100;
} elseif ($discount->type === 'fixed') {
$discountAmount = $discount->value;
}
// جلوگیری از تخفیف بیشتر از مبلغ سفارش
$discountAmount = min($discountAmount, $totalPrice);
$finalPrice = $totalPrice - $discountAmount;
return [
'success' => true,
'message' => 'کد تخفیف با موفقیت اعمال شد.',
'discount_amount' => $discountAmount,
'final_price' => $finalPrice,
'discount_code' => $discount->code,
];
}
/**
* جستجوی نرخ حمل و نقل در دیتابیس.
*/
private function lookupRate(
string $direction,
ShipmentType $type,
float $weight,
int $zone
): float {
$zoneColumn = 'zone_' . $zone;
// نرخی را پیدا کن که وزنش بزرگتر مساوی وزن واردشده باشد
// مثلاً اگه وزن 0.3 است، نرخ 0.5 را برمی‌گرداند
$rate = \App\Models\ShippingRate::query()
->where('direction', $direction)
->where('type', $type->value)
->where('weight', '>=', $weight)
->where($zoneColumn, '>', 0)
->orderBy('weight') // صعودی - اولین نرخ بزرگتر
->value($zoneColumn);
// اگه نرخی با وزن بزرگتر پیدا نشد،
// آخرین نرخ موجود (بزرگترین وزن) رو بگیر
// این برای مواقعی هست که وزن واردشده از بزرگترین وزن در جدول بیشتره
if ($rate === null) {
$rate = \App\Models\ShippingRate::query()
->where('direction', $direction)
->where('type', $type->value)
->where($zoneColumn, '>', 0)
->orderByDesc('weight') // نزولی - بزرگترین وزن
->value($zoneColumn);
}
if ($rate === null) {
throw new InvalidArgumentException(
'برای این مسیر و وزن، نرخی تعریف نشده است. لطفاً با پشتیبانی تماس بگیرید.'
);
}
return (float) $rate;
}
}