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']); + } +}