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`
This commit is contained in:
Kazem Alghasi 2026-08-07 04:39:14 +03:30
parent 1ea9486ca1
commit abae820070
10 changed files with 323 additions and 55 deletions

View File

@ -0,0 +1 @@
{"version":2,"defects":{"Tests\\Feature\\Api\\PricingControllerTest::it_can_calculate_pricing_with_valid_data":5,"Tests\\Feature\\Api\\PricingControllerTest::it_returns_validation_errors_for_invalid_data":7,"Tests\\Feature\\Services\\PriceCalculatorServiceTest::it_calculates_price_correctly_for_standard_package":8},"times":{"Tests\\Feature\\Api\\PricingControllerTest::it_can_calculate_pricing_with_valid_data":0.107,"Tests\\Feature\\Api\\PricingControllerTest::it_returns_validation_errors_for_invalid_data":0.052,"Tests\\Feature\\Services\\PriceCalculatorServiceTest::it_calculates_price_correctly_for_standard_package":0.055}}

View File

@ -16,11 +16,11 @@ class ShippingRatesImport implements WithMultipleSheets
* تعریف شیت‌های مختلف فایل اکسل و کلاس‌های پردازش‌گر مربوطه
*/
public function sheets(): array
{
{
return [
'Export Rate' => new RateSheetImport('export'),
'Import Rate' => new RateSheetImport('import'),
'DocEco' => new SimpleRateSheetImport(ShipmentType::DocEconomy, 'export'),
'Export Rate' => new RateSheetImport('outbound'), // تغییر به حروف کوچک
'Import Rate' => new RateSheetImport('inbound'), // تغییر به حروف کوچک
'DocEco' => new SimpleRateSheetImport(ShipmentType::DocEconomy, 'outbound'), // تغییر به حروف کوچک
];
}
}
@ -72,7 +72,7 @@ class RateSheetImport implements OnEachRow, WithStartRow
// ثبت یا به‌روزرسانی نرخ در دیتابیس
ShippingRate::updateOrCreate(
[
'direction' => $this->direction,
'direction' => $this->direction, // اکنون مقدار صحیح (Outbound یا Inbound) است
'type' => $type->value,
'weight' => (float) $cells[0],
],
@ -129,9 +129,10 @@ class SimpleRateSheetImport implements OnEachRow, WithStartRow
public function __construct(
protected ShipmentType $type,
protected string $direction = 'export'
protected string $direction = 'outbound' // تغییر پیش‌فرض به حروف کوچک
) {}
/**
* شروع خواندن از ردیف ۲ (۱ ردیف اول هدر است)
*/
@ -180,7 +181,7 @@ class SimpleRateSheetImport implements OnEachRow, WithStartRow
// یافتن یا ایجاد رکورد و به‌روزرسانی قیمت زون
$record = ShippingRate::firstOrCreate(
[
'direction' => $this->direction,
'direction' => $this->direction, // اکنون مقدار صحیح (Outbound یا Inbound) است
'type' => $this->type->value,
'weight' => $weight,
],

View File

@ -5,9 +5,15 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Factories\HasFactory; // این خط را اضافه کنید
class Country extends Model
{
use HasFactory; // این خط را اضافه کنید
protected $fillable = [
'name',
'iso_code',

View File

@ -2,23 +2,40 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class ShippingRate extends Model
{
use HasFactory;
protected $fillable = [
'direction',
'type',
'weight',
'zone_1', 'zone_2', 'zone_3', 'zone_4', 'zone_5',
'zone_6', 'zone_7', 'zone_8', 'zone_9', 'zone_10',
'zone_1',
'zone_2',
'zone_3',
'zone_4',
'zone_5',
'zone_6',
'zone_7',
'zone_8',
'zone_9',
'zone_10',
];
protected function casts(): array
{
return [
'direction' => \App\Enums\ShipmentDirection::class,
'type' => \App\Enums\ShipmentType::class,
];
}
protected $casts = [
'weight' => 'float',
'zone_1' => 'float',
'zone_2' => 'float',
'zone_3' => 'float',
'zone_4' => 'float',
'zone_5' => 'float',
'zone_6' => 'float',
'zone_7' => 'float',
'zone_8' => 'float',
'zone_9' => 'float',
'zone_10' => 'float',
];
}

View File

@ -10,12 +10,35 @@ 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) {
@ -25,56 +48,42 @@ class PriceCalculatorService
$type !== ShipmentType::Parcel && $direction === 'import' => $country->import_zone_doc,
};
$rate = $this->lookupRate($direction, $type, $chargeableWeight, $zone);
// 3. یافتن نرخ پایه
$baseRate = $this->lookupRate($direction, $type, $chargeableWeight, $zone);
$profitMargin = (float) SystemSetting::get('profit_margin', 1.25);
$aedToIrr = (float) SystemSetting::get('aed_to_irr', 455000);
$vatRate = (float) SystemSetting::get('vat_rate', 0.09);
$packingCostDefault = (float) SystemSetting::get('packing_cost_default', 100000);
// 4. محاسبات مالی (تبدیل ارز، سود، مالیات)
$financials = $this->calculateFinancials($baseRate);
$netDirham = $rate * $profitMargin;
$netRial = $netDirham * $aedToIrr;
// 5. جمع‌آوری هزینه‌های جانبی
$extraCosts = $this->calculateExtraCosts($data);
$extraService = (float) ($data['extra_service'] ?? 0);
$packingCost = (float) ($data['packing_cost'] ?? $packingCostDefault);
$domesticPickup = (float) ($data['domestic_pickup'] ?? 0);
$domesticDelivery = (float) ($data['domestic_delivery'] ?? 0);
$warehousingCost = (float) ($data['warehousing_cost'] ?? 0);
// متغیر $discount را از آرایه داده‌ها دریافت می‌کنیم (اگر وجود داشته باشد)
// اما در اینجا ما از کد تخفیف استفاده می‌کنیم، پس این خط را حذف یا اصلاح می‌کنیم
// $discount = (float) ($data['discount'] ?? 0);
// 6. محاسبه قیمت قبل از تخفیف
$subtotal = $financials['net_rial'] + array_sum($extraCosts);
// محاسبه قیمت اولیه قبل از تخفیف کد
$subtotal = $netRial + $extraService + $packingCost + $domesticPickup + $domesticDelivery + $warehousingCost;
// اعمال کد تخفیف (در صورت وجود)
$discountCode = $data['discount_code'] ?? null;
$discountResult = $this->applyDiscount($subtotal, $discountCode);
// 7. اعمال کد تخفیف
$discountResult = $this->applyDiscount($subtotal, $data['discount_code'] ?? null);
if ($discountResult['success']) {
$finalPrice = $discountResult['final_price'];
// افزایش شمارنده استفاده از کد تخفیف
$discount = DiscountCode::where('code', $discountResult['discount_code'])->first();
if ($discount) {
$discount->increment('used_count');
}
} else {
$finalPrice = $subtotal;
DiscountCode::where('code', $discountResult['discount_code'])->increment('used_count');
}
$totalFee = $finalPrice * (1 + $vatRate);
$vatAmount = $totalFee - $finalPrice;
// 8. محاسبه مالیات و قیمت نهایی
$vatRate = (float) SystemSetting::get('vat_rate', 0.09);
$finalPrice = $discountResult['final_price'];
$vatAmount = $finalPrice * $vatRate;
$totalFee = $finalPrice + $vatAmount;
// 9. بازگرداندن نتیجه نهایی
return [
'base_price' => $rate,
'net_dirham' => $netDirham,
'net_rial' => $netRial,
'extra_service' => $extraService,
'packing_cost' => $packingCost,
'domestic_pickup' => $domesticPickup,
'domestic_delivery' => $domesticDelivery,
'warehousing_cost' => $warehousingCost,
'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'],
@ -85,6 +94,45 @@ class PriceCalculatorService
];
}
/**
* محاسبه ارزش مالی نرخ پایه (با اعمال حاشیه سود و تبدیل ارز)
*
* @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),
];
}
/**
* اعمال کد تخفیف روی قیمت نهایی
*
@ -177,6 +225,17 @@ class PriceCalculatorService
];
}
/**
* جستجوی نرخ حمل و نقل در دیتابیس
*
* @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;

View File

@ -0,0 +1,24 @@
<?php
namespace Database\Factories;
use App\Models\Country;
use Illuminate\Database\Eloquent\Factories\Factory;
class CountryFactory extends Factory
{
protected $model = Country::class;
public function definition(): array
{
return [
'iso_code' => $this->faker->countryCode(),
'name' => $this->faker->country(),
'export_zone_parcel' => $this->faker->numberBetween(1, 10),
'export_zone_doc' => $this->faker->numberBetween(1, 10),
'import_zone_parcel' => $this->faker->numberBetween(1, 10),
'import_zone_doc' => $this->faker->numberBetween(1, 10),
'is_active' => true,
];
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace Database\Factories;
use App\Models\ShippingRate;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ShippingRate>
*/
class ShippingRateFactory extends Factory
{
protected $model = ShippingRate::class;
public function definition(): array
{
return [
// مقادیر باید دقیقاً با enum در migration یکسان باشند
'direction' => $this->faker->randomElement(['import', 'export']),
'type' => $this->faker->randomElement(['DOC_NORMAL', 'DOC_ECONOMY', 'PARCEL']),
'weight' => $this->faker->randomFloat(2, 0.1, 30),
'zone_1' => $this->faker->randomFloat(2, 10, 200),
'zone_2' => $this->faker->randomFloat(2, 10, 200),
'zone_3' => $this->faker->randomFloat(2, 10, 200),
'zone_4' => $this->faker->randomFloat(2, 10, 200),
'zone_5' => $this->faker->randomFloat(2, 10, 200),
'zone_6' => $this->faker->randomFloat(2, 10, 200),
'zone_7' => $this->faker->randomFloat(2, 10, 200),
'zone_8' => $this->faker->randomFloat(2, 10, 200),
'zone_9' => $this->faker->randomFloat(2, 10, 200),
'zone_10' => $this->faker->randomFloat(2, 10, 200),
];
}
}

View File

@ -21,3 +21,4 @@ Route::post('/v1/calculate', [PricingController::class, 'calculate']);
Route::get('/v1/discount-codes', [DiscountCodeController::class, 'index']);
Route::post('/v1/discount-codes/validate', [DiscountCodeController::class, 'validate']);
Route::post('/calculate', [PricingController::class, 'calculate']);

View File

@ -0,0 +1,57 @@
<?php
namespace Tests\Feature\Api;
use App\Services\PriceCalculatorService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class PricingControllerTest extends TestCase
{
#[Test]
public function it_can_calculate_pricing_with_valid_data()
{
// Mock کردن سرویس محاسبه قیمت
$this->mock(PriceCalculatorService::class, function ($mock) {
$mock->shouldReceive('calculate')
->once()
->andReturn([
'total_cost' => 150.00,
'currency' => 'USD',
]);
});
// ارسال درخواست معتبر (اصلاح آدرس به /api/calculate)
$response = $this->postJson('/api/calculate', [
'direction' => 'Outbound',
'type' => 'DOC_NORMAL',
'country_iso' => 'US',
'weight' => 2.5,
'volumetric_weight' => 3.0,
'extra_service' => 10.00,
]);
// بررسی وضعیت پاسخ و ساختار داده‌ها
$response->assertStatus(200)
->assertJson([
'total_cost' => 150.00,
'currency' => 'USD',
]);
}
#[Test]
public function it_returns_validation_errors_for_invalid_data()
{
// ارسال درخواست نامعتبر (اصلاح آدرس به /api/calculate)
$response = $this->postJson('/api/calculate', [
'direction' => 'InvalidDirection',
'type' => 'PARCEL',
]);
// بررسی دریافت خطای اعتبارسنجی
$response->assertStatus(422)
->assertJsonValidationErrors(['country_iso', 'weight', 'volumetric_weight', 'direction']);
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace Tests\Feature\Services;
use App\Models\Country;
use App\Models\ShippingRate;
use App\Services\PriceCalculatorService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class PriceCalculatorServiceTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function it_calculates_price_correctly_for_standard_package()
{
// ایجاد رکورد کشور مورد نیاز برای تست در دیتابیس
$country = Country::factory()->create([
'iso_code' => 'US',
'name' => 'United States',
'export_zone_parcel' => 1,
'export_zone_doc' => 1,
'import_zone_parcel' => 1,
'import_zone_doc' => 1,
'is_active' => true,
]);
// ایجاد نرخ‌های حمل‌ونقل مورد نیاز برای تست
ShippingRate::factory()->create([
'direction' => 'export', // اصلاح شد: تطبیق با مقادیر مجاز دیتابیس
'type' => 'DOC_NORMAL',
'weight' => 1.0,
'zone_1' => 20.00,
]);
ShippingRate::factory()->create([
'direction' => 'export', // اصلاح شد: تطبیق با مقادیر مجاز دیتابیس
'type' => 'DOC_NORMAL',
'weight' => 3.0,
'zone_1' => 40.00,
]);
$service = app(PriceCalculatorService::class);
$data = [
'direction' => 'export',
'type' => 'DOC_NORMAL', // اصلاح شد: تطبیق با مقادیر مجاز ShipmentType Enum
'country_iso' => 'US',
'weight' => 2.5,
'volumetric_weight' => 3.0,
'extra_service' => 10.00,
];
$result = $service->calculate($data);
$this->assertIsArray($result);
$this->assertArrayHasKey('total_cost', $result);
$this->assertArrayHasKey('currency', $result);
// بررسی مقدار محاسبه شده
// وزن حجمی (3.0) بیشتر از وزن واقعی (2.5) است، بنابراین باید از وزن حجمی استفاده شود
// نزدیک‌ترین نرخ برای وزن 3.0، نرخ 40.00 است
// هزینه کل = نرخ حمل (40.00) + خدمات اضافی (10.00) = 50.00
$this->assertEquals(50.00, $result['total_cost']);
}
}