ifnex/04_Laravel/app/Services/PriceCalculatorService.php
Kazem Alghasi abae820070 refactor(api): improve pricing logic and add test coverage
Refactor the pricing calculation service and shipping rate import logic to improve consistency and reliability. This includes updating direction naming conventions, enhancing model casting, and adding comprehensive test suites.

- Update `ShippingRatesImport` to use 'outbound' and 'inbound' instead of 'export' and 'import'
- Refactor `PriceCalculatorService` to use a more modular calculation structure
- Update `ShippingRate` model to use explicit property casting for zones
- Add `HasFactory` trait to `Country` and `ShippingRate` models
- Add new database factories for `Country` and `ShippingRate`
- Implement new feature tests for API endpoints and service logic
- Add new service tests for `PriceCalculatorService`
2026-08-07 04:39:14 +03:30

258 lines
10 KiB
PHP

<?php
namespace App\Services;
use App\Enums\ShipmentType;
use App\Models\Country;
use App\Models\DiscountCode;
use App\Models\SystemSetting;
use InvalidArgumentException;
class PriceCalculatorService
{
/**
* محاسبه قیمت نهایی حمل و نقل بر اساس پارامترهای ورودی
*
* @param array $data آرایه‌ای شامل اطلاعات محاسبه قیمت:
* - 'type' (string): نوع سرویس (مقدار Enum ShipmentType)
* - 'direction' (string): جهت ارسال ('Outbound' یا 'Inbound')
* - 'weight' (float): وزن واقعی بسته
* - 'volumetric_weight' (float): وزن حجمی بسته
* - 'country_iso' (string): کد ISO کشور مقصد
* - 'extra_service' (float, optional): هزینه خدمات اضافی
* - 'packing_cost' (float, optional): هزینه بسته‌بندی
* - 'domestic_pickup' (float, optional): هزینه دریافت داخلی
* - 'domestic_delivery' (float, optional): هزینه تحویل داخلی
* - 'warehousing_cost' (float, optional): هزینه انبارداری
* - 'discount_code' (string, optional): کد تخفیف
*
* @return array آرایه‌ای شامل جزئیات قیمت نهایی
*
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException اگر کشور یافت نشود
* @throws InvalidArgumentException اگر نرخی برای پارامترهای داده شده یافت نشود
*/
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. یافتن کشور و زون مربوطه
$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. اعمال کد تخفیف
$discountResult = $this->applyDiscount($subtotal, $data['discount_code'] ?? null);
if ($discountResult['success']) {
// افزایش شمارنده استفاده از کد تخفیف
DiscountCode::where('code', $discountResult['discount_code'])->increment('used_count');
}
// 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_amount' => $discountResult['discount_amount'],
'discount_message' => $discountResult['message'],
'vat_amount' => $vatAmount,
'total_fee' => $totalFee,
'zone' => $zone,
'chargeable_weight' => $chargeableWeight,
];
}
/**
* محاسبه ارزش مالی نرخ پایه (با اعمال حاشیه سود و تبدیل ارز)
*
* @param float $baseRate نرخ پایه به درهم
* @return array ['net_dirham' => float, 'net_rial' => float]
*/
private function calculateFinancials(float $baseRate): array
{
$profitMargin = (float) SystemSetting::get('profit_margin', 1.25);
$aedToIrr = (float) SystemSetting::get('aed_to_irr', 455000);
$netDirham = $baseRate * $profitMargin;
$netRial = $netDirham * $aedToIrr;
return [
'net_dirham' => $netDirham,
'net_rial' => $netRial,
];
}
/**
* محاسبه مجموع هزینه‌های جانبی
*
* @param array $data داده‌های ورودی
* @return array آرایه‌ای شامل هزینه‌های جانبی
*/
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),
];
}
/**
* اعمال کد تخفیف روی قیمت نهایی
*
* @param float $totalPrice قیمت کل قبل از تخفیف
* @param string|null $code کد تخفیف وارد شده توسط کاربر
* @return array ['success' => bool, 'message' => string, 'discount_amount' => float, 'final_price' => float]
*/
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, // برای ذخیره در سفارش
];
}
/**
* جستجوی نرخ حمل و نقل در دیتابیس
*
* @param string $direction جهت ارسال (export/import)
* @param ShipmentType $type نوع سرویس
* @param float $weight وزن قابل پرداخت
* @param int $zone شماره زون
* @return float نرخ پیدا شده
*
* @throws InvalidArgumentException اگر نرخی یافت نشود
*/
private function lookupRate(string $direction, ShipmentType $type, float $weight, int $zone): float
{
$zoneColumn = 'zone_' . $zone;
$rate = \App\Models\ShippingRate::query()
->where('direction', $direction)
->where('type', $type->value)
->where('weight', '<=', $weight)
->where($zoneColumn, '>', 0)
->orderByDesc('weight')
->value($zoneColumn);
if ($rate === null) {
throw new InvalidArgumentException('No rate found for the given parameters.');
}
return (float) $rate;
}
}