From abae8200707498f1199fb3a9c3e9b508ebe71055 Mon Sep 17 00:00:00 2001 From: Kazem Alghasi Date: Fri, 7 Aug 2026 04:39:14 +0330 Subject: [PATCH] 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` --- 04_Laravel/.phpunit.result.cache | 1 + .../app/Imports/ShippingRatesImport.php | 15 +- 04_Laravel/app/Models/Country.php | 6 + 04_Laravel/app/Models/ShippingRate.php | 35 +++-- .../app/Services/PriceCalculatorService.php | 137 +++++++++++++----- .../database/factories/CountryFactory.php | 24 +++ .../factories/ShippingRateFactory.php | 34 +++++ 04_Laravel/routes/api.php | 1 + .../Feature/Api/PricingControllerTest.php | 57 ++++++++ .../Services/PriceCalculatorServiceTest.php | 68 +++++++++ 10 files changed, 323 insertions(+), 55 deletions(-) create mode 100644 04_Laravel/.phpunit.result.cache create mode 100644 04_Laravel/database/factories/CountryFactory.php create mode 100644 04_Laravel/database/factories/ShippingRateFactory.php create mode 100644 04_Laravel/tests/Feature/Api/PricingControllerTest.php create mode 100644 04_Laravel/tests/Feature/Services/PriceCalculatorServiceTest.php diff --git a/04_Laravel/.phpunit.result.cache b/04_Laravel/.phpunit.result.cache new file mode 100644 index 0000000..91db643 --- /dev/null +++ b/04_Laravel/.phpunit.result.cache @@ -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}} \ No newline at end of file diff --git a/04_Laravel/app/Imports/ShippingRatesImport.php b/04_Laravel/app/Imports/ShippingRatesImport.php index 124d5ac..a2110c9 100644 --- a/04_Laravel/app/Imports/ShippingRatesImport.php +++ b/04_Laravel/app/Imports/ShippingRatesImport.php @@ -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, ], diff --git a/04_Laravel/app/Models/Country.php b/04_Laravel/app/Models/Country.php index 13d0f2d..134c968 100644 --- a/04_Laravel/app/Models/Country.php +++ b/04_Laravel/app/Models/Country.php @@ -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', diff --git a/04_Laravel/app/Models/ShippingRate.php b/04_Laravel/app/Models/ShippingRate.php index d0de7d7..468440e 100644 --- a/04_Laravel/app/Models/ShippingRate.php +++ b/04_Laravel/app/Models/ShippingRate.php @@ -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', + ]; } diff --git a/04_Laravel/app/Services/PriceCalculatorService.php b/04_Laravel/app/Services/PriceCalculatorService.php index 2300492..459d52b 100644 --- a/04_Laravel/app/Services/PriceCalculatorService.php +++ b/04_Laravel/app/Services/PriceCalculatorService.php @@ -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; diff --git a/04_Laravel/database/factories/CountryFactory.php b/04_Laravel/database/factories/CountryFactory.php new file mode 100644 index 0000000..c94b946 --- /dev/null +++ b/04_Laravel/database/factories/CountryFactory.php @@ -0,0 +1,24 @@ + $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, + ]; + } +} diff --git a/04_Laravel/database/factories/ShippingRateFactory.php b/04_Laravel/database/factories/ShippingRateFactory.php new file mode 100644 index 0000000..6468629 --- /dev/null +++ b/04_Laravel/database/factories/ShippingRateFactory.php @@ -0,0 +1,34 @@ + + */ +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), + ]; + } +} diff --git a/04_Laravel/routes/api.php b/04_Laravel/routes/api.php index 3bf0360..f0fd2e3 100644 --- a/04_Laravel/routes/api.php +++ b/04_Laravel/routes/api.php @@ -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']); diff --git a/04_Laravel/tests/Feature/Api/PricingControllerTest.php b/04_Laravel/tests/Feature/Api/PricingControllerTest.php new file mode 100644 index 0000000..939769c --- /dev/null +++ b/04_Laravel/tests/Feature/Api/PricingControllerTest.php @@ -0,0 +1,57 @@ +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']); + } +} diff --git a/04_Laravel/tests/Feature/Services/PriceCalculatorServiceTest.php b/04_Laravel/tests/Feature/Services/PriceCalculatorServiceTest.php new file mode 100644 index 0000000..558a4d2 --- /dev/null +++ b/04_Laravel/tests/Feature/Services/PriceCalculatorServiceTest.php @@ -0,0 +1,68 @@ +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']); + } +}