diff --git a/04_Laravel/app/Filament/Pages/PriceTestPage.php b/04_Laravel/app/Filament/Pages/PriceTestPage.php index a4bffeb..e237d7f 100644 --- a/04_Laravel/app/Filament/Pages/PriceTestPage.php +++ b/04_Laravel/app/Filament/Pages/PriceTestPage.php @@ -81,8 +81,45 @@ class PriceTestPage extends Page public function calculate() { $data = $this->form->getState(); - - $service = new PriceCalculatorService(); - $this->result = $service->calculate($data); + + // تعیین کشور مقصد (برای صادرات = مقصد، برای واردات = مبدا) + $destinationId = ($data['direction'] === 'export') + ? ($data['destination_country_id'] ?? null) + : ($data['origin_country_id'] ?? null); + + $country = Country::find($destinationId); + if (! $country) { + \Filament\Notifications\Notification::make() + ->title('خطا') + ->body('کشور مقصد یافت نشد.') + ->danger() + ->send(); + return; + } + + // محاسبه وزن حجمی از ابعاد (فرمول IATA: L×W×H/5000) + $length = (float) ($data['length'] ?? 0); + $width = (float) ($data['width'] ?? 0); + $height = (float) ($data['height'] ?? 0); + $volumetricWeight = ($length * $width * $height) / 5000; + + $service = app(PriceCalculatorService::class); + + try { + $this->result = $service->calculate([ + 'direction' => $data['direction'], // 'export' | 'import' + 'type' => $data['type'], // 'DOCUMENT' | 'NON DOC' + 'country_iso' => $country->iso_code, + 'weight' => (float) $data['weight'], + 'volumetric_weight' => $volumetricWeight, + ]); + } catch (\Exception $e) { + \Filament\Notifications\Notification::make() + ->title('خطا در محاسبه قیمت') + ->body($e->getMessage()) + ->danger() + ->send(); + $this->result = null; + } } } \ No newline at end of file diff --git a/04_Laravel/app/Http/Controllers/Api/PricingController.php b/04_Laravel/app/Http/Controllers/Api/PricingController.php index 7aa777f..0a34552 100644 --- a/04_Laravel/app/Http/Controllers/Api/PricingController.php +++ b/04_Laravel/app/Http/Controllers/Api/PricingController.php @@ -15,8 +15,8 @@ class PricingController { try { $validated = $request->validate([ - 'direction' => ['required', 'string', 'in:Outbound,Inbound'], - 'type' => ['required', 'string', 'in:DOC_NORMAL,DOC_ECONOMY,PARCEL'], + 'direction' => ['required', 'string', 'in:Outbound,Inbound,export,import'], + 'type' => ['required', 'string', 'in:DOC_NORMAL,DOC_ECONOMY,PARCEL,DOCUMENT,NON DOC'], 'country_iso' => ['required', 'string', 'size:2'], 'weight' => ['required', 'numeric', 'min:0'], 'volumetric_weight' => ['required', 'numeric', 'min:0'], @@ -25,17 +25,36 @@ class PricingController 'domestic_pickup' => ['nullable', 'numeric', 'min:0'], 'domestic_delivery' => ['nullable', 'numeric', 'min:0'], 'warehousing_cost' => ['nullable', 'numeric', 'min:0'], - 'discount' => ['nullable', 'numeric', 'min:0'], + 'discount_code' => ['nullable', 'string', 'max:50'], ]); } catch (ValidationException $e) { return response()->json([ + 'success' => false, 'message' => 'Validation failed', 'errors' => $e->errors(), ], 422); } - $result = $this->calculator->calculate($validated); - - return response()->json($result); + try { + $result = $this->calculator->calculate($validated); + return response()->json($result); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 422); + } catch (\RuntimeException $e) { + // خطاهای «زون یافت نشد» یا «نرخ یافت نشد» — خطای کسب‌وکار، نه سرور + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 400); + } catch (\Throwable $e) { + report($e); + return response()->json([ + 'success' => false, + 'message' => 'خطای داخلی سرور هنگام محاسبه قیمت.', + ], 500); + } } -} +} \ No newline at end of file diff --git a/04_Laravel/app/Services/ExchangeRateService.php b/04_Laravel/app/Services/ExchangeRateService.php index 650418d..03c1c0a 100644 --- a/04_Laravel/app/Services/ExchangeRateService.php +++ b/04_Laravel/app/Services/ExchangeRateService.php @@ -27,6 +27,28 @@ class ExchangeRateService return (float) $currency->rate_to_rial; } + /** + * تبدیل مبلغ یک ارز به ریال ایران با نرخ روز. + * ریال ارز مبناست و نرخ تمام ارزها فقط نسبت به ریال ذخیره می‌شود. + * + * @param float $amount مبلغ به ارز داده‌شده + * @param string $currencyCode کد ارز مبدأ (مثل AED, USD, EUR) + * @return float مبلغ معادل به ریال + */ + public function convertToIrr(float $amount, string $currencyCode = 'AED'): float + { + if ($amount == 0) { + return 0.0; + } + + // ریال → ریال: تبدیل لازم نیست + if (strtoupper($currencyCode) === 'IRR') { + return $amount; + } + + return $amount * $this->getCurrentRate($currencyCode); + } + /** * تنظیم نرخ جدید برای یک ارز (نسبت به ریال). * diff --git a/04_Laravel/app/Services/PriceCalculatorService.php b/04_Laravel/app/Services/PriceCalculatorService.php index 4e1b68b..ad01021 100644 --- a/04_Laravel/app/Services/PriceCalculatorService.php +++ b/04_Laravel/app/Services/PriceCalculatorService.php @@ -2,27 +2,36 @@ namespace App\Services; -use App\Models\RateEntry; +use App\Models\Country; use App\Models\CountryZone; +use App\Models\DiscountCode; +use App\Models\RateEntry; +use App\Models\SystemSetting; use App\Enums\ShipmentDirection; -use App\Enums\ShipmentType; /** * PriceCalculatorService * ---------------------- - * 4-quadrant price calculator. + * موتور قیمت‌گذاری ۴-رُبعی: * - * ┌──────────┬──────────────┬──────────────────┐ - * │ │ DOCUMENT │ NON-DOCUMENT │ - * ├──────────┼──────────────┼──────────────────┤ - * │ Export │ Export×Doc │ Export×Parcel │ - * │ Import │ Import×Doc │ Import×Parcel │ - * └──────────┴──────────────┴──────────────────┘ + * ┌──────────┬──────────────┬──────────────────┐ + * │ │ DOCUMENT │ NON-DOCUMENT │ + * ├──────────┼──────────────┼──────────────────┤ + * │ Export │ Export×Doc │ Export×Parcel │ + * │ Import │ Import×Doc │ Import×Parcel │ + * └──────────┴──────────────┴──────────────────┘ * - * Currency flow: - * 1. Look up AED rate for (direction, serviceType, weight, zone). - * 2. Convert AED → IRR using ExchangeRateService at the current rate. - * 3. Return IRR amount (plus optional AED reference). + * جریان محاسبه: + * 1. نرمال‌سازی ورودی (direction / type) و تعیین کشور مقصد با ISO. + * 2. جستجوی زون کشور از جدول country_zones (زون Doc و Parcel ممکن است متفاوت باشد). + * 3. جستجوی نرخ AED از جدول rate_entries (نزدیک‌ترین وزن بالاتر). + * 4. اعمال ضریب سود + هزینه‌های جانبی → ناخالص درهم. + * 5. تبدیل درهم → ریال با نرخ روز از ExchangeRateService. + * 6. محاسبه مالیات بر ارزش افزوده (۹٪) و کسر تخفیف (در صورت وجود کد). + * + * امضای متد calculate(): آرایه‌ای از ورودی‌ها را می‌گیرد (برای سازگاری با + * PricingController، CustomerOrderController و PriceTestPage) و آرایه‌ای کامل + * از فیلدهای نمایشی برمی‌گرداند که وردپرس منتظرش است. */ class PriceCalculatorService { @@ -31,69 +40,271 @@ class PriceCalculatorService ) {} /** - * Calculate the price for a shipment. + * محاسبه قیمت نهایی محموله. * - * @param string $direction 'import' | 'export' - * @param string $serviceType 'document' | 'parcel' - * @param float $weightKg actual shipment weight - * @param string $countryName destination country name (matched via country_zones) - * @return array { - * @var float $aed AED amount - * @var float $irr IRR amount (rounded to nearest 1000) - * @var string $currency Final currency code ('IRR') - * @var RateEntry|null $rate the rate row that was used + * @param array $input { + * @var string $direction 'export' | 'import' (یا 'Outbound' | 'Inbound' برای backward-compat) + * @var string $type 'DOC_NORMAL' | 'DOC_ECONOMY' | 'PARCEL' + * (یا 'DOCUMENT' | 'NON DOC' | 'DOC' از فرم تست ادمین) + * @var string $country_iso کد ISO دو حرفی کشور مقصد (مثلاً 'US', 'AE') + * @var float $weight وزن واقعی (کیلوگرم) + * @var float $volumetric_weight وزن حجمی (کیلوگرم) + * @var float|null $extra_service هزینه سرویس اضافی (درهم) + * @var float|null $packing_cost هزینه بسته‌بندی (درهم) + * @var float|null $domestic_pickup هزینه جمع‌آوری داخلی (درهم) + * @var float|null $domestic_delivery هزینه تحویل داخلی (درهم) + * @var float|null $warehousing_cost هزینه انبارداری (درهم) + * @var string|null $discount_code کد تخفیف اختیاری * } - * @throws \RuntimeException when no rate is found + * + * @return array { + * @var float $base_price قیمت پایه (درهم) + * @var float $net_dirham ناخالص شامل سود + هزینه‌ها (درهم) + * @var int $net_rial ناخالص به ریال + * @var float $packing_cost هزینه بسته‌بندی (درهم) + * @var int $vat_amount مبلغ مالیات (ریال) + * @var bool $discount_applied آیا تخفیف اعمال شد؟ + * @var int $discount_amount مبلغ تخفیف (ریال) + * @var int $total_fee مبلغ نهایی قابل پرداخت (ریال) + * @var float $weight وزن واقعی + * @var float $volumetric_weight وزن حجمی + * @var float $chargeable_weight وزن قابل محاسبه (max واقعی، حجمی) + * @var int $zone زون محاسبه‌شده + * @var string $service_type 'document' | 'parcel' + * @var string $direction 'export' | 'import' + * @var string $country نام کشور + * @var string $country_iso ISO کشور + * @var int $rate_entry_id آیدی رکورد نرخ استفاده‌شده + * @var float $profit_margin ضریب سود اعمال‌شده + * @var string $currency همیشه 'IRR' + * } + * + * @throws \InvalidArgumentException ورودی نامعتبر + * @throws \RuntimeException زون یا نرخ یافت نشد */ - public function calculate( - string $direction, - string $serviceType, - float $weightKg, - string $countryName, - ): array { - // 1. Look up zone for this country × service type + public function calculate(array $input): array + { + // ─────────────────────────────────────────────────────────────── + // ۱. نرمال‌سازی direction + // ─────────────────────────────────────────────────────────────── + $directionRaw = strtolower((string) ($input['direction'] ?? '')); + $direction = match ($directionRaw) { + 'outbound' => 'export', + 'inbound' => 'import', + 'export', 'import' => $directionRaw, + default => null, + }; + if ($direction === null) { + throw new \InvalidArgumentException( + "Invalid direction: " . ($input['direction'] ?? '(empty)') . " — expected 'export'|'import'|'Outbound'|'Inbound'" + ); + } + + // ─────────────────────────────────────────────────────────────── + // ۲. نرمال‌سازی type → service_type + // جدول rate_entries فقط 'document' | 'parcel' را ذخیره می‌کند. + // DOC_ECONOMY فعلاً به همان نرخ DOC_NORMAL محاسبه می‌شود + // (تفکیک کامل نیازمند گسترش service_type در دیتابیس است). + // ─────────────────────────────────────────────────────────────── + $typeRaw = strtoupper(trim((string) ($input['type'] ?? ''))); + $serviceType = match ($typeRaw) { + 'DOC_NORMAL', 'DOC_ECONOMY', 'DOCUMENT', 'DOC', 'DOCNORMAL', 'DOCECONOMY' => 'document', + 'PARCEL', 'NON DOC', 'NON-DOC', 'NONDOC', 'NON_DOCUMENT' => 'parcel', + default => null, + }; + if ($serviceType === null) { + throw new \InvalidArgumentException( + "Invalid type: " . ($input['type'] ?? '(empty)') . " — expected DOC_NORMAL|DOC_ECONOMY|PARCEL" + ); + } + + // ─────────────────────────────────────────────────────────────── + // ۳. حل کشور با ISO → نام (CountryZone با نام کشور جستجو می‌شود) + // ─────────────────────────────────────────────────────────────── + $countryIso = strtoupper(trim((string) ($input['country_iso'] ?? ''))); + if (strlen($countryIso) !== 2) { + throw new \InvalidArgumentException( + "Invalid country_iso: must be exactly 2 letters, got: " . ($input['country_iso'] ?? '(empty)') + ); + } + $country = Country::where('iso_code', $countryIso)->first(); + if (! $country) { + throw new \RuntimeException("Country not found for ISO: {$countryIso}"); + } + $countryName = $country->name; + + // ─────────────────────────────────────────────────────────────── + // ۴. اوزان + // ─────────────────────────────────────────────────────────────── + $weight = (float) ($input['weight'] ?? 0); + $volumetricWeight = (float) ($input['volumetric_weight'] ?? 0); + $chargeableWeight = max($weight, $volumetricWeight); + + // ─────────────────────────────────────────────────────────────── + // ۵. جستجوی زون (برای کشور × نوع سرویس) + // ─────────────────────────────────────────────────────────────── $zone = CountryZone::lookupZone($countryName, $serviceType); if ($zone === null) { throw new \RuntimeException( - "No zone mapping for country '{$countryName}' service type '{$serviceType}'" + "No zone mapping for country '{$countryName}' service type '{$serviceType}'. " + . "Please import the Zone sheet via admin → Rate Management." ); } - // 2. Look up the AED rate for this weight/zone - $rateEntry = RateEntry::lookup($direction, $serviceType, $weightKg, $zone, 'AED'); + // ─────────────────────────────────────────────────────────────── + // ۶. جستجوی نرخ (۴-رُبعی) + // ─────────────────────────────────────────────────────────────── + $rateEntry = RateEntry::lookup($direction, $serviceType, $chargeableWeight, $zone, 'AED'); if (! $rateEntry) { throw new \RuntimeException( - "No rate found for {$direction}×{$serviceType} weight={$weightKg}kg zone={$zone}" + "No rate found for {$direction}×{$serviceType} weight={$chargeableWeight}kg zone={$zone}. " + . "Please import rates via admin → Rate Management." ); } - // 3. Convert AED → IRR using current exchange rate - $aedAmount = (float) $rateEntry->rate; - $irrAmount = $this->exchangeRate->convertToIrr($aedAmount, 'AED'); - $irrAmount = (int) round($irrAmount / 1000) * 1000; // round to nearest 1000 IRR + // ─────────────────────────────────────────────────────────────── + // ۷. محاسبه هزینه‌ها در درهم + // ─────────────────────────────────────────────────────────────── + $basePriceAed = (float) $rateEntry->rate; + + $extras = [ + 'extra_service' => (float) ($input['extra_service'] ?? 0), + 'packing_cost' => (float) ($input['packing_cost'] ?? 0), + 'domestic_pickup' => (float) ($input['domestic_pickup'] ?? 0), + 'domestic_delivery' => (float) ($input['domestic_delivery'] ?? 0), + 'warehousing_cost' => (float) ($input['warehousing_cost'] ?? 0), + ]; + $extrasTotalAed = array_sum($extras); + + // ─────────────────────────────────────────────────────────────── + // ۸. اعمال ضریب سود (روی قیمت پایه، نه روی هزینه‌های جانبی) + // ─────────────────────────────────────────────────────────────── + $profitMargin = (float) SystemSetting::get( + 'profit_margin', + config('ifnex.financial_defaults.profit_margin', 1.25) + ); + if ($profitMargin <= 0) { + $profitMargin = 1.0; // محافظت در برابر صفر یا منفی + } + + $netDirham = ($basePriceAed * $profitMargin) + $extrasTotalAed; + + // ─────────────────────────────────────────────────────────────── + // ۹. تبدیل به ریال + // ─────────────────────────────────────────────────────────────── + $netRial = $this->exchangeRate->convertToIrr($netDirham, 'AED'); + + // ─────────────────────────────────────────────────────────────── + // ۱۰. مالیات بر ارزش افزوده + // ─────────────────────────────────────────────────────────────── + $vatRate = (float) SystemSetting::get( + 'vat_rate', + config('ifnex.financial_defaults.vat_rate', 0.09) + ); + $vatAmount = $netRial * $vatRate; + + // ─────────────────────────────────────────────────────────────── + // ۱۱. تخفیف (در صورت وجود کد) + // ─────────────────────────────────────────────────────────────── + $discountApplied = false; + $discountAmount = 0.0; + $discountCode = trim((string) ($input['discount_code'] ?? '')); + + if ($discountCode !== '') { + $discount = DiscountCode::where('code', $discountCode) + ->where('is_active', true) + ->where(function ($q) { + $q->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }) + ->first(); + + if ($discount) { + $subtotal = $netRial + $vatAmount; + + // بررسی حداقل مبلغ سفارش + $minOrder = (float) ($discount->min_order_amount ?? 0); + if ($minOrder > 0 && $subtotal < $minOrder) { + // زیر سقف — تخفیف اعمال نمی‌شود + } else { + $discountApplied = true; + if ($discount->type === 'percentage') { + $discountAmount = $subtotal * ((float) $discount->value / 100); + } else { // fixed + $discountAmount = min((float) $discount->value, $subtotal); + } + } + } + } + + // ─────────────────────────────────────────────────────────────── + // ۱۲. مبلغ نهایی (گردش به نزدیک‌ترین ۱۰۰۰ ریال) + // ─────────────────────────────────────────────────────────────── + $totalFee = $netRial + $vatAmount - $discountAmount; + $totalFee = (int) round($totalFee / 1000) * 1000; return [ - 'aed' => $aedAmount, - 'irr' => $irrAmount, - 'currency' => 'IRR', - 'rate' => $rateEntry, + // فیلدهای نمایشی که وردپرس از آن‌ها استفاده می‌کند + 'base_price' => round($basePriceAed, 2), + 'net_dirham' => round($netDirham, 2), + 'net_rial' => (int) round($netRial), + 'packing_cost' => round($extras['packing_cost'], 2), + 'vat_amount' => (int) round($vatAmount), + 'discount_applied' => $discountApplied, + 'discount_amount' => (int) round($discountAmount), + 'total_fee' => $totalFee, + + // تفکیک هزینه‌ها + 'extra_service' => round($extras['extra_service'], 2), + 'domestic_pickup' => round($extras['domestic_pickup'], 2), + 'domestic_delivery' => round($extras['domestic_delivery'], 2), + 'warehousing_cost' => round($extras['warehousing_cost'], 2), + + // اوزان + 'weight' => round($weight, 3), + 'volumetric_weight' => round($volumetricWeight, 3), + 'chargeable_weight' => round($chargeableWeight, 3), + + // متادیتای جستجو + 'zone' => $zone, + 'service_type' => $serviceType, + 'direction' => $direction, + 'country' => $countryName, + 'country_iso' => $countryIso, + 'rate_entry_id' => $rateEntry->id, + 'profit_margin' => $profitMargin, + 'currency' => 'IRR', ]; } /** - * Convenience: calculate from a Shipment model. + * راحتی: محاسبه قیمت از روی مدل Shipment. + * + * @param \App\Models\Shipment $shipment + * @return array */ public function calculateForShipment($shipment): array { - $direction = $shipment->direction === ShipmentDirection::Export->value - || $shipment->direction === 'export' ? 'export' : 'import'; - $serviceType = $shipment->type === ShipmentType::Document->value - || $shipment->type === 'document' ? 'document' : 'parcel'; - $weight = (float) ($shipment->total_weight ?? $shipment->weight ?? 0); - $country = $shipment->toCountry?->name - ?? $shipment->receiver_country - ?? $shipment->destination_country; + $direction = $shipment->direction?->value ?? (string) $shipment->direction; + $type = $shipment->type?->value ?? (string) $shipment->type; + $weight = (float) ($shipment->chargeable_weight ?? $shipment->weight ?? 0); - return $this->calculate($direction, $serviceType, $weight, $country); + // کشور مقصد: برای صادرات = toCountry، برای واردات = fromCountry + $country = ($direction === 'export') + ? $shipment->toCountry + : $shipment->fromCountry; + + return $this->calculate([ + 'direction' => $direction, + 'type' => $type, + 'country_iso' => $country?->iso_code, + 'weight' => (float) ($shipment->weight ?? 0), + 'volumetric_weight' => (float) ($shipment->volumetric_weight ?? 0), + 'extra_service' => (float) ($shipment->extra_service ?? 0), + 'packing_cost' => (float) ($shipment->packing_cost ?? 0), + 'domestic_pickup' => (float) ($shipment->domestic_pickup ?? 0), + 'domestic_delivery' => (float) ($shipment->domestic_delivery ?? 0), + 'warehousing_cost' => (float) ($shipment->warehousing_cost ?? 0), + ]); } -} +} \ No newline at end of file