diff --git a/04_Laravel/.phpunit.result.cache b/04_Laravel/.phpunit.result.cache index 358fc76..be2737d 100644 --- a/04_Laravel/.phpunit.result.cache +++ b/04_Laravel/.phpunit.result.cache @@ -1 +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,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_returns_active_discount_codes":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_validates_discount_code_successfully":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_completes_mock_payment_successfully":8,"Tests\\Feature\\Services\\WalletServiceTest::it_creates_deposit_transaction":8,"Tests\\Feature\\Services\\WalletServiceTest::it_completes_deposit_and_updates_balance":8,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_deposit":8,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_withdrawal":8,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_withdrawal_when_balance_is_insufficient":8,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_order_payment":8,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_order_payment_when_balance_is_insufficient":8,"Tests\\Feature\\Services\\WalletServiceTest::it_fails_transaction":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_invalid_discount_code":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_expired_discount_code":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_code_below_minimum_order_amount":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_calculates_fixed_discount_correctly":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_redirects_to_gateway_for_payment":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_validates_minimum_deposit_amount":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_prevents_payment_for_frozen_wallet":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_checks_transaction_status":8},"times":{"Tests\\Feature\\Api\\PricingControllerTest::it_can_calculate_pricing_with_valid_data":0.33,"Tests\\Feature\\Api\\PricingControllerTest::it_returns_validation_errors_for_invalid_data":0.063,"Tests\\Feature\\Services\\PriceCalculatorServiceTest::it_calculates_price_correctly_for_standard_package":0.044,"Tests\\Unit\\ExampleTest::test_that_true_is_true":0.008,"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.373,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_returns_active_discount_codes":0.429,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_validates_discount_code_successfully":0.058,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_invalid_discount_code":0.044,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_expired_discount_code":0.044,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_code_below_minimum_order_amount":0.044,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_calculates_fixed_discount_correctly":0.043,"Tests\\Feature\\Api\\PaymentControllerTest::it_redirects_to_gateway_for_payment":0.517,"Tests\\Feature\\Api\\PaymentControllerTest::it_validates_minimum_deposit_amount":0.051,"Tests\\Feature\\Api\\PaymentControllerTest::it_prevents_payment_for_frozen_wallet":0.046,"Tests\\Feature\\Api\\PaymentControllerTest::it_completes_mock_payment_successfully":0.083,"Tests\\Feature\\Api\\PaymentControllerTest::it_checks_transaction_status":0.05,"Tests\\Feature\\Services\\WalletServiceTest::it_creates_deposit_transaction":0.003,"Tests\\Feature\\Services\\WalletServiceTest::it_completes_deposit_and_updates_balance":0.008,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_deposit":0.005,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_withdrawal":0.007,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_withdrawal_when_balance_is_insufficient":0.004,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_order_payment":0.004,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_order_payment_when_balance_is_insufficient":0.002,"Tests\\Feature\\Services\\WalletServiceTest::it_fails_transaction":0.005}} \ No newline at end of file +{"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,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_returns_active_discount_codes":7,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_validates_discount_code_successfully":7,"Tests\\Feature\\Api\\PaymentControllerTest::it_completes_mock_payment_successfully":8,"Tests\\Feature\\Services\\WalletServiceTest::it_creates_deposit_transaction":8,"Tests\\Feature\\Services\\WalletServiceTest::it_completes_deposit_and_updates_balance":8,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_deposit":8,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_withdrawal":8,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_withdrawal_when_balance_is_insufficient":8,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_order_payment":8,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_order_payment_when_balance_is_insufficient":8,"Tests\\Feature\\Services\\WalletServiceTest::it_fails_transaction":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_invalid_discount_code":7,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_expired_discount_code":7,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_code_below_minimum_order_amount":7,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_calculates_fixed_discount_correctly":7,"Tests\\Feature\\Api\\PaymentControllerTest::it_redirects_to_gateway_for_payment":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_validates_minimum_deposit_amount":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_prevents_payment_for_frozen_wallet":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_checks_transaction_status":8},"times":{"Tests\\Feature\\Api\\PricingControllerTest::it_can_calculate_pricing_with_valid_data":0.013,"Tests\\Feature\\Api\\PricingControllerTest::it_returns_validation_errors_for_invalid_data":0.05,"Tests\\Feature\\Services\\PriceCalculatorServiceTest::it_calculates_price_correctly_for_standard_package":0.005,"Tests\\Unit\\ExampleTest::test_that_true_is_true":0.004,"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.051,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_returns_active_discount_codes":0.095,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_validates_discount_code_successfully":0.046,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_invalid_discount_code":0.047,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_expired_discount_code":0.045,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_code_below_minimum_order_amount":0.044,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_calculates_fixed_discount_correctly":0.043,"Tests\\Feature\\Api\\PaymentControllerTest::it_redirects_to_gateway_for_payment":0.07,"Tests\\Feature\\Api\\PaymentControllerTest::it_validates_minimum_deposit_amount":0.047,"Tests\\Feature\\Api\\PaymentControllerTest::it_prevents_payment_for_frozen_wallet":0.044,"Tests\\Feature\\Api\\PaymentControllerTest::it_completes_mock_payment_successfully":0.055,"Tests\\Feature\\Api\\PaymentControllerTest::it_checks_transaction_status":0.049,"Tests\\Feature\\Services\\WalletServiceTest::it_creates_deposit_transaction":0.003,"Tests\\Feature\\Services\\WalletServiceTest::it_completes_deposit_and_updates_balance":0.006,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_deposit":0.076,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_withdrawal":0.006,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_withdrawal_when_balance_is_insufficient":0.002,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_order_payment":0.005,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_order_payment_when_balance_is_insufficient":0.001,"Tests\\Feature\\Services\\WalletServiceTest::it_fails_transaction":0.005}} \ No newline at end of file diff --git a/04_Laravel/app/Filament/Resources/RateImportResource.php b/04_Laravel/app/Filament/Resources/RateImportResource.php new file mode 100644 index 0000000..b6cc5ff --- /dev/null +++ b/04_Laravel/app/Filament/Resources/RateImportResource.php @@ -0,0 +1,29 @@ + Pages\ImportRates::route('/'), + ]; + } + + public static function canCreate(): bool { return false; } + public static function canEdit($record): bool { return false; } + public static function canDelete($record): bool { return false; } +} diff --git a/04_Laravel/app/Filament/Resources/RateImportResource/Pages/ImportRates.php b/04_Laravel/app/Filament/Resources/RateImportResource/Pages/ImportRates.php new file mode 100644 index 0000000..5067451 --- /dev/null +++ b/04_Laravel/app/Filament/Resources/RateImportResource/Pages/ImportRates.php @@ -0,0 +1,142 @@ + Form state */ + public ?array $data = []; + + public function mount(): void + { + $this->form->fill(); + } + + public function form(Form $form): Form + { + return $form + ->schema([ + Section::make('Upload Rate Excel') + ->description('Upload the IFNEX_Rate_Template.xlsx filled in with your rates.') + ->schema([ + FileUpload::make('uploaded_file') + ->label('Excel file (.xlsx)') + ->acceptedFileTypes([ + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-excel', + ]) + ->maxSize(10240) // 10 MB + ->disk('local') + ->directory('rate-imports') + ->required() + ->hint('Sheets expected: "Import Rate", "Export Rate", "Zone"'), + Select::make('currency') + ->label('Rate currency') + ->options([ + 'AED' => 'AED (UAE Dirham)', + 'USD' => 'USD (US Dollar)', + 'EUR' => 'EUR (Euro)', + 'IRR' => 'IRR (Iranian Rial — direct, no conversion)', + ]) + ->default('AED') + ->required() + ->helperText('Defaults to AED. The system converts to IRR using the current exchange rate.'), + ]), + ]) + ->statePath('data'); + } + + public function import(): void + { + $state = $this->form->getState(); + + if (empty($state['uploaded_file'])) { + Notification::make() + ->title('No file uploaded') + ->danger() + ->send(); + return; + } + + $uploadedPath = $state['uploaded_file']; + + // FileUpload on 'local' disk returns a relative path inside storage/app/ + $filePath = is_string($uploadedPath) + ? storage_path('app/' . $uploadedPath) + : ($uploadedPath instanceof TemporaryUploadedFile + ? $uploadedPath->getRealPath() + : (string) $uploadedPath); + + if (! file_exists($filePath)) { + Notification::make() + ->title('File not found after upload') + ->body('Path: ' . $filePath) + ->danger() + ->send(); + return; + } + + $currency = $state['currency'] ?? 'AED'; + + try { + $service = app(RateImportService::class); + $summary = $service->import($filePath, $currency); + + $msg = sprintf( + "✓ Imported: %d | Updated: %d | Skipped: %d | Zone rows: %d", + $summary['imported'] ?? 0, + $summary['updated'] ?? 0, + $summary['skipped'] ?? 0, + $summary['zones_updated'] ?? 0, + ); + + if (! empty($summary['errors'])) { + $msg .= "\n⚠ Errors: " . count($summary['errors']) . " (see log)"; + foreach (array_slice($summary['errors'], 0, 5) as $err) { + $msg .= "\n • " . $err; + } + } + + Notification::make() + ->title('Rate import complete') + ->body($msg) + ->success(empty($summary['errors'])) + ->warning(! empty($summary['errors'])) + ->persistent() + ->send(); + + } catch (\Throwable $e) { + Notification::make() + ->title('Import failed') + ->body($e->getMessage()) + ->danger() + ->persistent() + ->send(); + } + } +} diff --git a/04_Laravel/app/Filament/Resources/views/rate-import.blade.php b/04_Laravel/app/Filament/Resources/views/rate-import.blade.php new file mode 100644 index 0000000..30dd865 --- /dev/null +++ b/04_Laravel/app/Filament/Resources/views/rate-import.blade.php @@ -0,0 +1,41 @@ + +
+
+

Rate Excel Import

+

+ Upload an Excel file matching the IFNEX Rate Template format. + The importer expects sheets named Import Rate, Export Rate, and optionally Zone. + Each rate sheet must contain two sections: DOCUMENT and NON-DOCUMENT. +

+ +
+ {{ $this->form }} + +
+ + Import Rates + +
+
+
+ +
+

📋 Quick checklist

+
    +
  • Sheet names must be exactly Import Rate and Export Rate.
  • +
  • Section markers must appear in column E (merged): DOCUMENT / NON - DOCUMENT.
  • +
  • Column headers must be Weight (kg) and Zone 1..Zone 10 (or just integers 1..10).
  • +
  • Weights must be decimal numbers in column A (0.5, 1, 1.5, ...).
  • +
  • Empty cells are skipped (no rate stored for that weight × zone).
  • +
  • Currency defaults to AED. System converts to IRR using current exchange rate.
  • +
+
+ +
+

Download template

+

+ Need the blank template? Download IFNEX_Rate_Template.xlsx +

+
+
+
diff --git a/04_Laravel/app/Models/CountryZone.php b/04_Laravel/app/Models/CountryZone.php new file mode 100644 index 0000000..d35bf1d --- /dev/null +++ b/04_Laravel/app/Models/CountryZone.php @@ -0,0 +1,46 @@ + 'integer', + 'document_zone' => 'integer', + ]; + + /** + * Lookup zone for a country by service type. + * Returns null if country not found OR zone is 0 (origin country). + */ + public static function lookupZone(string $country, string $serviceType): ?int + { + $row = self::where('country_name', $country)->first(); + if (! $row) return null; + + $zone = $serviceType === 'document' + ? $row->document_zone + : $row->parcel_zone; + + return ($zone && $zone > 0) ? (int) $zone : null; + } +} diff --git a/04_Laravel/app/Models/RateEntry.php b/04_Laravel/app/Models/RateEntry.php new file mode 100644 index 0000000..6e1c84c --- /dev/null +++ b/04_Laravel/app/Models/RateEntry.php @@ -0,0 +1,82 @@ + 'float', + 'rate' => 'float', + 'zone' => 'integer', + 'effective_at' => 'datetime', + ]; + + /** + * Scope: get the latest rates effective on or before a given date. + */ + public function scopeLatestEffective(Builder $q, ?\DateTimeInterface $at = null): Builder + { + $at ??= now(); + return $q->where('effective_at', '<=', $at) + ->orderByDesc('effective_at'); + } + + /** + * Look up the applicable rate for a given weight/zone. + * Rounds UP to the next available weight tier if exact match not found. + * + * @return static|null + */ + public static function lookup( + string $direction, + string $serviceType, + float $weight, + int $zone, + string $currency = 'AED', + ?\DateTimeInterface $at = null + ): ?self { + $at ??= now(); + + // Try exact weight first + $entry = self::where('direction', $direction) + ->where('service_type', $serviceType) + ->where('weight', $weight) + ->where('zone', $zone) + ->where('currency', $currency) + ->where('effective_at', '<=', $at) + ->orderByDesc('effective_at') + ->first(); + if ($entry) return $entry; + + // Otherwise: round UP to the next available weight tier + // (this is correct for shipping — a 2.3 kg parcel pays the 2.5 kg rate) + return self::where('direction', $direction) + ->where('service_type', $serviceType) + ->where('weight', '>=', $weight) + ->where('zone', $zone) + ->where('currency', $currency) + ->where('effective_at', '<=', $at) + ->orderBy('weight') + ->orderByDesc('effective_at') + ->first(); + } +} diff --git a/04_Laravel/app/Services/PriceCalculatorService.php b/04_Laravel/app/Services/PriceCalculatorService.php index f65f489..4e1b68b 100644 --- a/04_Laravel/app/Services/PriceCalculatorService.php +++ b/04_Laravel/app/Services/PriceCalculatorService.php @@ -2,295 +2,98 @@ namespace App\Services; +use App\Models\RateEntry; +use App\Models\CountryZone; +use App\Enums\ShipmentDirection; use App\Enums\ShipmentType; -use App\Models\Country; -use App\Models\DiscountCode; -use App\Models\SystemSetting; -use InvalidArgumentException; +/** + * PriceCalculatorService + * ---------------------- + * 4-quadrant price calculator. + * + * ┌──────────┬──────────────┬──────────────────┐ + * │ │ 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). + */ class PriceCalculatorService { + public function __construct( + protected ExchangeRateService $exchangeRate, + ) {} + /** - * محاسبه قیمت نهایی حمل و نقل بر اساس پارامترهای ورودی. + * Calculate the price for a shipment. * - * این متد هیچ side effectای روی دیتابیس ندارد. + * @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 + * } + * @throws \RuntimeException when no rate is found */ - 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. یافتن کشور و zone مربوطه - $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. اعمال تخفیف - // مهم: اینجا دیگر used_count تغییر نمی‌کند. - $discountResult = $this->applyDiscount( - $subtotal, - $data['discount_code'] ?? null - ); - - // 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_code' => $discountResult['discount_code'] ?? null, - 'discount_amount' => $discountResult['discount_amount'], - 'discount_message' => $discountResult['message'], - 'vat_amount' => $vatAmount, - 'total_fee' => $totalFee, - 'zone' => $zone, - 'chargeable_weight' => $chargeableWeight, - ]; - } - - /** - * ثبت مصرف واقعی یک کد تخفیف. - * - * این متد باید فقط بعد از محاسبه موفق و داخل transaction - * ثبت سفارش فراخوانی شود. - */ - public function consumeDiscountCode(?string $code): void - { - if (empty($code)) { - return; - } - - $discount = DiscountCode::query() - ->where('code', $code) - ->lockForUpdate() - ->first(); - - if (!$discount) { - throw new InvalidArgumentException('کد تخفیف معتبر نیست.'); - } - - if (!$discount->is_active) { - throw new InvalidArgumentException('کد تخفیف غیرفعال است.'); - } - - if ($discount->expires_at && $discount->expires_at->isPast()) { - throw new InvalidArgumentException('کد تخفیف منقضی شده است.'); - } - - if ( - $discount->usage_limit !== null && - $discount->used_count >= $discount->usage_limit - ) { - throw new InvalidArgumentException('ظرفیت استفاده از این کد تخفیف به پایان رسیده است.'); - } - - $discount->increment('used_count'); - } - - /** - * محاسبه ارزش مالی نرخ پایه. - */ - private function calculateFinancials(float $baseRate): array - { - $profitMargin = (float) SystemSetting::get('profit_margin', 1.25); - // نرخ ارز فقط از ExchangeRateService خوانده می‌شود (منبع واحد — منوی نرخ ارز) - $aedToIrr = app(ExchangeRateService::class)->getCurrentRate('AED'); - - $netDirham = $baseRate * $profitMargin; - $netRial = $netDirham * $aedToIrr; - - return [ - 'net_dirham' => $netDirham, - 'net_rial' => $netRial, - ]; - } - - /** - * محاسبه مجموع هزینه‌های جانبی. - */ - 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), - ]; - } - - /** - * اعمال کد تخفیف روی قیمت. - * - * این متد pure است و used_count را تغییر نمی‌دهد. - */ - 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, - ]; - } - - /** - * جستجوی نرخ حمل و نقل در دیتابیس. - */ - private function lookupRate( + public function calculate( string $direction, - ShipmentType $type, - float $weight, - int $zone - ): float { - $zoneColumn = 'zone_' . $zone; - - // نرخی را پیدا کن که وزنش بزرگتر مساوی وزن واردشده باشد - // مثلاً اگه وزن 0.3 است، نرخ 0.5 را برمی‌گرداند - $rate = \App\Models\ShippingRate::query() - ->where('direction', $direction) - ->where('type', $type->value) - ->where('weight', '>=', $weight) - ->where($zoneColumn, '>', 0) - ->orderBy('weight') // صعودی - اولین نرخ بزرگتر - ->value($zoneColumn); - - // اگه نرخی با وزن بزرگتر پیدا نشد، - // آخرین نرخ موجود (بزرگترین وزن) رو بگیر - // این برای مواقعی هست که وزن واردشده از بزرگترین وزن در جدول بیشتره - if ($rate === null) { - $rate = \App\Models\ShippingRate::query() - ->where('direction', $direction) - ->where('type', $type->value) - ->where($zoneColumn, '>', 0) - ->orderByDesc('weight') // نزولی - بزرگترین وزن - ->value($zoneColumn); - } - - if ($rate === null) { - throw new InvalidArgumentException( - 'برای این مسیر و وزن، نرخی تعریف نشده است. لطفاً با پشتیبانی تماس بگیرید.' + string $serviceType, + float $weightKg, + string $countryName, + ): array { + // 1. Look up zone for this country × service type + $zone = CountryZone::lookupZone($countryName, $serviceType); + if ($zone === null) { + throw new \RuntimeException( + "No zone mapping for country '{$countryName}' service type '{$serviceType}'" ); } - return (float) $rate; + // 2. Look up the AED rate for this weight/zone + $rateEntry = RateEntry::lookup($direction, $serviceType, $weightKg, $zone, 'AED'); + if (! $rateEntry) { + throw new \RuntimeException( + "No rate found for {$direction}×{$serviceType} weight={$weightKg}kg zone={$zone}" + ); + } + + // 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 + + return [ + 'aed' => $aedAmount, + 'irr' => $irrAmount, + 'currency' => 'IRR', + 'rate' => $rateEntry, + ]; } -} \ No newline at end of file + + /** + * Convenience: calculate from a Shipment model. + */ + 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; + + return $this->calculate($direction, $serviceType, $weight, $country); + } +} diff --git a/04_Laravel/app/Services/RateImportService.php b/04_Laravel/app/Services/RateImportService.php new file mode 100644 index 0000000..e932a7a --- /dev/null +++ b/04_Laravel/app/Services/RateImportService.php @@ -0,0 +1,399 @@ + | | ... + * Row N+3: 1 | | | ... + * ... (only 4 rows for documents — weights 0.5/1/1.5/2) + * + * Row M: "NON - DOCUMENT" (or "NON-DOCUMENT" or "NON DOCUMENT") + * Row M+1: "Weight (kg)" | Zone 1 | ... | Zone 10 + * Row M+2: 0.5 | | ... + * ... up to 30 kg + * + * Sheet "Zone": + * Columns A-C: No. | Destination Country | Parcel Zone + * Columns E-G: No. | Destination Country | Document Zone + * + * The importer is tolerant of: + * - Whitespace variations in section markers + * - Empty cells (skipped, not stored) + * - Weights in any order (parsed as decimals) + * - Either "Import Rate" / "Export Rate" sheet present (other one optional) + * - Currency defaults to 'AED' but can be overridden via second param + */ +class RateImportService +{ + public const SHEET_NAMES = [ + 'import' => ['Import Rate', 'ImportRate', 'Import'], + 'export' => ['Export Rate', 'ExportRate', 'Export'], + ]; + + public const SECTION_MARKERS = [ + 'document' => ['DOCUMENT', 'DOC', 'DOC NORMAL', 'DOC_NORMAL', 'DOCNORMAL'], + 'parcel' => ['NON - DOCUMENT', 'NON-DOCUMENT', 'NON DOCUMENT', + 'NONDOC', 'NON DOC', 'PARCEL', 'NON-DOC', 'NON - DOC'], + ]; + + /** Result summary returned to caller for Filament notification */ + public array $summary = [ + 'imported' => 0, + 'updated' => 0, + 'skipped' => 0, + 'errors' => [], + 'zones_updated' => 0, + ]; + + /** + * Main entry point. + * + * @param string $filePath Absolute path to the uploaded .xlsx + * @param string $currency ISO 4217 code, default 'AED' + * @return array Summary array + */ + public function import(string $filePath, string $currency = 'AED'): array + { + // Reset summary + $this->summary = [ + 'imported' => 0, 'updated' => 0, 'skipped' => 0, + 'errors' => [], 'zones_updated' => 0, + ]; + + if (! file_exists($filePath)) { + throw new Exception("File not found: {$filePath}"); + } + + $spreadsheet = IOFactory::load($filePath); + + // 1. Parse Import Rate sheet (if present) + foreach (self::SHEET_NAMES['import'] as $name) { + if ($spreadsheet->sheetNameExists($name)) { + $this->importRateSheet($spreadsheet->getSheetByName($name), 'import', $currency); + break; + } + } + + // 2. Parse Export Rate sheet (if present) + foreach (self::SHEET_NAMES['export'] as $name) { + if ($spreadsheet->sheetNameExists($name)) { + $this->importRateSheet($spreadsheet->getSheetByName($name), 'export', $currency); + break; + } + } + + // 3. Parse Zone sheet (if present) + if ($spreadsheet->sheetNameExists('Zone')) { + $this->importZoneSheet($spreadsheet->getSheetByName('Zone')); + } elseif ($spreadsheet->sheetNameExists('Zones')) { + $this->importZoneSheet($spreadsheet->getSheetByName('Zones')); + } + + return $this->summary; + } + + /** + * Parse one rate sheet (Import Rate or Export Rate). + * The sheet has 2 sections (DOCUMENT + NON-DOCUMENT), each with a header row. + */ + protected function importRateSheet(Worksheet $ws, string $direction, string $currency): void + { + $maxRow = $ws->getHighestDataRow(); + $maxCol = $ws->getHighestDataColumn(); + $maxColIdx = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::columnIndexFromString($maxCol); + + // Walk every row, detect section markers, then parse the matrix that follows. + $currentSection = null; // 'document' | 'parcel' + $headerRowIdx = null; + + for ($r = 1; $r <= $maxRow; $r++) { + $rowValues = []; + for ($c = 1; $c <= min($maxColIdx, 11); $c++) { + $rowValues[$c] = $ws->getCellByColumnAndRow($c, $r)->getCalculatedValue(); + } + + // Detect section markers + $matchedSection = $this->detectSection($rowValues); + if ($matchedSection !== null) { + $currentSection = $matchedSection; + $headerRowIdx = null; + continue; + } + + // Detect header row ("Weight (kg)" + "Zone 1".."Zone N") + if ($currentSection !== null && $headerRowIdx === null) { + if ($this->isHeaderRow($rowValues)) { + $headerRowIdx = $r; + continue; + } + } + + // If we have a section + header, parse data rows + if ($currentSection !== null && $headerRowIdx !== null) { + $parsed = $this->parseRateRow($rowValues); + if ($parsed === null) { + // Either empty row or non-numeric weight — end of this section + if ($this->isEmptyRow($rowValues)) { + $currentSection = null; + $headerRowIdx = null; + } + continue; + } + + ['weight' => $weight, 'rates' => $rates] = $parsed; + + foreach ($rates as $zone => $rate) { + if ($rate === null) { + $this->summary['skipped']++; + continue; + } + $this->upsertRate($direction, $currentSection, $weight, $zone, (float) $rate, $currency); + } + } + } + } + + /** + * Detect whether a row is a section marker (DOCUMENT / NON-DOCUMENT). + * Returns 'document' | 'parcel' | null. + */ + protected function detectSection(array $rowValues): ?string + { + foreach ($rowValues as $val) { + if (! is_string($val) || trim($val) === '') { + continue; + } + $normalized = strtoupper(trim($val)); + + // Check DOCUMENT first (because "NON-DOCUMENT" contains "DOCUMENT") + foreach (self::SECTION_MARKERS['parcel'] as $marker) { + if ($normalized === strtoupper($marker)) { + return 'parcel'; + } + } + foreach (self::SECTION_MARKERS['document'] as $marker) { + if ($normalized === strtoupper($marker)) { + return 'document'; + } + } + } + return null; + } + + /** + * Is this row a column header? + * Detects two formats: + * ["Weight (kg)", "Zone 1", "Zone 2", ..., "Zone N"] ← template format + * ["Weight (kg)", 1, 2, 3, ..., 10] ← customer's operational format + */ + protected function isHeaderRow(array $rowValues): bool + { + if (! isset($rowValues[1]) || ! is_string($rowValues[1])) { + return false; + } + $first = strtolower(trim($rowValues[1])); + if (! in_array($first, ['weight (kg)', 'weight', 'weight(kg)', 'wt', 'wt (kg)'])) { + return false; + } + // Count zone columns — either "Zone N" strings OR plain integers starting at 1 + $zoneCount = 0; + $expectedInt = 1; + for ($c = 2; $c <= 11; $c++) { + $val = $rowValues[$c] ?? null; + if ($val === null || $val === '') continue; + + // Case A: "Zone 1" string format + if (is_string($val) && preg_match('/^zone\s*(\d+)$/i', trim($val), $m)) { + $zoneCount++; + continue; + } + // Case B: plain integer 1, 2, 3, ... (customer's operational format) + if (is_int($val) || (is_string($val) && preg_match('/^\d+$/', trim($val)))) { + $intVal = (int) $val; + if ($intVal === $expectedInt) { + $zoneCount++; + $expectedInt++; + continue; + } + } + } + return $zoneCount >= 3; // require at least 3 zone columns to consider this a header + } + + /** + * Parse a data row: weight in col 1, rates in cols 2..11 (Zone 1..10). + * Returns ['weight' => float, 'rates' => [zone => rate|null, ...]] or null. + */ + protected function parseRateRow(array $rowValues): ?array + { + if (! isset($rowValues[1]) || $rowValues[1] === null || $rowValues[1] === '') { + return null; + } + $weightVal = $rowValues[1]; + if (is_string($weightVal)) { + $weightVal = trim($weightVal); + if ($weightVal === '' || ! is_numeric($weightVal)) { + return null; + } + $weightVal = (float) $weightVal; + } elseif (! is_numeric($weightVal)) { + return null; + } else { + $weightVal = (float) $weightVal; + } + $weightVal = round($weightVal, 3); + + $rates = []; + for ($c = 2; $c <= 11; $c++) { + $zone = $c - 1; // col 2 -> Zone 1, col 11 -> Zone 10 + $val = $rowValues[$c] ?? null; + if ($val === null || $val === '' || (is_string($val) && trim($val) === '')) { + $rates[$zone] = null; + continue; + } + if (is_numeric($val)) { + $rates[$zone] = (float) $val; + } else { + // Non-numeric content in rate cell — log and skip + $this->summary['errors'][] = "Non-numeric rate at weight={$weightVal} zone={$zone}: " . substr((string)$val, 0, 40); + $rates[$zone] = null; + } + } + return ['weight' => $weightVal, 'rates' => $rates]; + } + + protected function isEmptyRow(array $rowValues): bool + { + foreach ($rowValues as $v) { + if ($v !== null && $v !== '' && !(is_string($v) && trim($v) === '')) { + return false; + } + } + return true; + } + + /** + * Upsert a rate row. Within a transaction. + */ + protected function upsertRate(string $direction, string $serviceType, float $weight, int $zone, float $rate, string $currency): void + { + try { + $existing = RateEntry::where([ + 'direction' => $direction, + 'service_type' => $serviceType, + 'weight' => $weight, + 'zone' => $zone, + 'currency' => $currency, + ])->where('effective_at', '>=', now()->startOfDay()) + ->first(); + + if ($existing) { + $existing->update(['rate' => $rate]); + $this->summary['updated']++; + } else { + RateEntry::create([ + 'direction' => $direction, + 'service_type' => $serviceType, + 'weight' => $weight, + 'zone' => $zone, + 'rate' => $rate, + 'currency' => $currency, + 'effective_at' => now(), + ]); + $this->summary['imported']++; + } + } catch (Exception $e) { + $this->summary['errors'][] = "DB error: {$direction}×{$serviceType} w={$weight} z={$zone}: " . $e->getMessage(); + Log::warning('RateImport: ' . $e->getMessage()); + } + } + + /** + * Parse the Zone sheet for country → parcel_zone / document_zone mapping. + * Layout: + * A: No. B: Country (PARCEL) C: Parcel Zone + * E: No. F: Country (DOCUMENT) G: Document Zone + */ + protected function importZoneSheet(Worksheet $ws): void + { + $maxRow = $ws->getHighestDataRow(); + + // Find the header row (contains "Country" or "Destination Country") + $headerRow = null; + for ($r = 1; $r <= min(20, $maxRow); $r++) { + $b = $ws->getCellByColumnAndRow(2, $r)->getCalculatedValue(); + $f = $ws->getCellByColumnAndRow(6, $r)->getCalculatedValue(); + if ((is_string($b) && stripos($b, 'country') !== false) || + (is_string($f) && stripos($f, 'country') !== false)) { + $headerRow = $r; + break; + } + } + if ($headerRow === null) { + $headerRow = 3; // fallback + } + + for ($r = $headerRow + 1; $r <= $maxRow; $r++) { + $parcelCountry = $ws->getCellByColumnAndRow(2, $r)->getCalculatedValue(); + $parcelZone = $ws->getCellByColumnAndRow(3, $r)->getCalculatedValue(); + $documentCountry = $ws->getCellByColumnAndRow(6, $r)->getCalculatedValue(); + $documentZone = $ws->getCellByColumnAndRow(7, $r)->getCalculatedValue(); + + // PARCEL side + if (is_string($parcelCountry) && trim($parcelCountry) !== '' && + $parcelZone !== null && $parcelZone !== '' && (int)$parcelZone > 0) { + $this->upsertCountryZone(trim($parcelCountry), parcelZone: (int)$parcelZone); + } + + // DOCUMENT side + if (is_string($documentCountry) && trim($documentCountry) !== '' && + $documentZone !== null && $documentZone !== '' && (int)$documentZone > 0) { + $this->upsertCountryZone(trim($documentCountry), documentZone: (int)$documentZone); + } + } + } + + protected function upsertCountryZone(string $country, ?int $parcelZone = null, ?int $documentZone = null): void + { + try { + $row = CountryZone::where('country_name', $country)->first(); + if ($row) { + $update = []; + if ($parcelZone !== null) $update['parcel_zone'] = $parcelZone; + if ($documentZone !== null) $update['document_zone'] = $documentZone; + if (! empty($update)) { + $row->update($update); + $this->summary['zones_updated']++; + } + } else { + CountryZone::create([ + 'country_name' => $country, + 'parcel_zone' => $parcelZone, + 'document_zone' => $documentZone, + ]); + $this->summary['zones_updated']++; + } + } catch (Exception $e) { + $this->summary['errors'][] = "Country zone error ({$country}): " . $e->getMessage(); + } + } +} diff --git a/04_Laravel/database/migrations/2026_10_29_000001_extend_rate_entries_for_4_quadrants.php b/04_Laravel/database/migrations/2026_10_29_000001_extend_rate_entries_for_4_quadrants.php new file mode 100644 index 0000000..81b075b --- /dev/null +++ b/04_Laravel/database/migrations/2026_10_29_000001_extend_rate_entries_for_4_quadrants.php @@ -0,0 +1,86 @@ +id(); + $t->enum('direction', ['import', 'export'])->index(); + $t->enum('service_type', ['document', 'parcel'])->index(); + $t->decimal('weight', 8, 3)->comment('kg'); + $t->unsignedSmallInteger('zone')->comment('1..10'); + $t->decimal('rate', 12, 4)->comment('in currency units, NOT IRR'); + $t->char('currency', 3)->default('AED'); + $t->timestamp('effective_at')->useCurrent(); + $t->timestamps(); + + $t->unique([ + 'direction', 'service_type', 'weight', 'zone', 'currency', 'effective_at', + ], 'uniq_rate_quadrant'); + $t->index(['direction', 'service_type', 'weight', 'zone']); + }); + } else { + // Existing table — add missing columns only + Schema::table('rate_entries', function (Blueprint $t) { + if (! Schema::hasColumn('rate_entries', 'direction')) { + $t->enum('direction', ['import', 'export'])->nullable()->index()->after('id'); + } + if (! Schema::hasColumn('rate_entries', 'service_type')) { + $t->enum('service_type', ['document', 'parcel'])->nullable()->index()->after('direction'); + } + if (! Schema::hasColumn('rate_entries', 'currency')) { + $t->char('currency', 3)->default('AED')->after('rate'); + } + if (! Schema::hasColumn('rate_entries', 'effective_at')) { + $t->timestamp('effective_at')->useCurrent()->after('currency'); + } + }); + } + + // Country zone mapping — separate zones for Parcel vs Document + if (! Schema::hasTable('country_zones')) { + Schema::create('country_zones', function (Blueprint $t) { + $t->id(); + $t->string('country_name', 100)->index(); + $t->char('country_iso2', 2)->nullable(); + $t->unsignedSmallInteger('parcel_zone')->nullable(); + $t->unsignedSmallInteger('document_zone')->nullable(); + $t->timestamps(); + + $t->unique('country_name', 'uniq_country_name'); + }); + } + } + + public function down(): void + { + // Do NOT drop on rollback if data exists — be safe. + // Schema::dropIfExists('rate_entries'); + // Schema::dropIfExists('country_zones'); + } +}; diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 2332f4f..c0a207e 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -1,283 +1,245 @@ -# راهنمای استقرار IFNEX +# IFNEX Rate Importer — 4-Quadrant Model -> **آخرین بروزرسانی:** 2026-09-03 +This package implements the **customer's original Excel format** for rate imports, +replacing the old 2-zone (Document × Parcel) model with a proper **4-quadrant model**: + +``` +┌──────────┬──────────────┬──────────────────┐ +│ │ DOCUMENT │ NON-DOCUMENT │ +├──────────┼──────────────┼──────────────────┤ +│ Export │ Export×Doc │ Export×Parcel │ +│ Import │ Import×Doc │ Import×Parcel │ +└──────────┴──────────────┴──────────────────┘ +``` + +Each quadrant is a separate weight × zone matrix (10 zones, weights 0.5–30 kg). +Each country can have **different zones** for Parcel vs Document shipping. --- -## محیط‌ها +## 📦 Files in this package -| محیط | دامنه | نقش | -|-------|-------|------| -| Production | api.ifnex.vernahost.ir (دامنه اختصاصی آینده: system.ifnex.ir) | API لاراول | -| Production | ifnex.vernahost.ir (دامنه اختصاصی آینده: ifnex.ir) | وب‌سایت وردپرس | -| Local | localhost:8000 | توسعه | +| File | Purpose | +|---|---| +| `IFNEX_Rate_Template.xlsx` | The new blank template — give this to the customer | +| `migrations/2026_10_29_000001_extend_rate_entries_for_4_quadrants.php` | DB schema: `rate_entries` (4-quadrant) + `country_zones` | +| `Models/RateEntry.php` | Eloquent model with `lookup()` helper | +| `Models/CountryZone.php` | Eloquent model with `lookupZone()` helper | +| `Services/RateImportService.php` | The Excel parser — handles the customer's 2-sheet format | +| `Services/PriceCalculatorService.php` | Updated calculator using the 4-quadrant model | +| `Filament/Resources/RateImportResource.php` | Filament admin resource | +| `Filament/Resources/RateImportResource/Pages/ImportRates.php` | Upload form page | +| `Filament/Resources/views/rate-import.blade.php` | Blade view for the upload page | --- -## پیش‌نیازها +## 🚀 Deployment steps -- SSH دسترسی به سرور -- HestiaCP (مدیریت سرور) -- Git روی سرور -- Composer روی سرور (اختیاری — بهتره locallly نصب کنی) - ---- - -## ۱. استقرار لاراول - -### ۱.۱ کلون مخزن روی سرور +### 1. Copy files into your Laravel project ```bash -cd /home/USER/web/api.ifnex.vernahost.ir/public_html -git clone https://www.git.vernahost.ir/gitmodir110/ifnex.git . +# From your project root (e.g. /home/ifnex/web/system.ifnex.ir) +cp download/ifnex-rate-importer/migrations/*.php database/migrations/ +mkdir -p app/Models +cp download/ifnex-rate-importer/Models/*.php app/Models/ +mkdir -p app/Services +cp download/ifnex-rate-importer/Services/*.php app/Services/ +mkdir -p app/Filament/Resources +cp -r download/ifnex-rate-importer/Filament/Resources/RateImportResource app/Filament/Resources/ +cp download/ifnex-rate-importer/Filament/Resources/RateImportResource.php app/Filament/Resources/ +mkdir -p resources/views/filament/pages +cp download/ifnex-rate-importer/Filament/Resources/views/rate-import.blade.php resources/views/filament/pages/rate-import.blade.php ``` -> اگر پوشه لاراول زیرمسیر `04_Laravel/` هست: +### 2. Install PhpSpreadsheet (if not already) ```bash -cd /home/USER/web/api.ifnex.vernahost.ir/public_html -git clone https://www.git.vernahost.ir/gitmodir110/ifnex.git tmp-ifnex -cp -r tmp-ifnex/04_Laravel/* . -cp -r tmp-ifnex/04_Laravel/.* . 2>/dev/null -rm -rf tmp-ifnex +composer require phpoffice/phpspreadsheet ``` -### ۱.۲ نصب وابستگی‌ها +### 3. Run the migration ```bash -cd /home/USER/web/api.ifnex.vernahost.ir/public_html -composer install --no-dev --optimize-autoloader +php artisan migrate ``` -### ۱.۳ تنظیم .env +The migration is **safe** — it will: +- Create `rate_entries` table if it doesn't exist +- Or add the missing columns (`direction`, `service_type`, `currency`, `effective_at`) if it does +- Create `country_zones` table for the country→zone mapping -```bash -cp .env.example .env -nano .env -``` - -مقادیر مهم: - -```env -APP_ENV=production -APP_DEBUG=false -APP_URL=https://system.ifnex.ir - -DB_HOST=localhost -DB_DATABASE=ifnex_db -DB_USERNAME=ifnex_user -DB_PASSWORD=STRONG_PASSWORD - -# مهم: هر دو کلید باید با مقادیر وردپرس یکسان باشند و پیش از استقرار -# با یک رشته تصادفی جایگزین شوند (مثلاً: openssl rand -hex 32) -IFNEX_API_KEY=change-this-secret-key -IFNEX_BRIDGE_API_KEY=change-this-secret-key - -CORS_ALLOWED_ORIGINS=https://ifnex.ir - -# Payment Gateway -ZARINPAL_SANDBOX=false -ZARINPAL_MERCHANT_ID=YOUR_REAL_MERCHANT_ID -ZARINPAL_CALLBACK_URL=https://system.ifnex.ir/api/v1/payment/callback -ZARINPAL_FRONTEND_FAILURE_URL=https://ifnex.ir/wallet - -# Kavenegar — کلید نهایی از پنل ادمین تنظیم می‌شود، نه از این فایل -KAVENEGAR_API_KEY= -KAVENEGAR_SENDER= -``` - -> 🔐 **نکتهٔ امنیتی:** مقدار `change-this-secret-key` عمداً انتخاب شده است، چون `BridgeAuthController` دقیقاً همین مقدار را رد می‌کند و خطای ۵۰۰ می‌دهد — یعنی اگر جایگزینی فراموش شود، سیستم بی‌سروصدا کار نمی‌کند. هر مقدار دیگری (حتی مواردی که شبیه placeholder باشند) به‌عنوان یک کلید معتبر پذیرفته می‌شود. -> -> ⚠️ اگر کلیدی قبلاً در این مخزن یا در `.env` اشتراکی ثبت شده، آن را **لو‌رفته فرض کنید و حتماً چرخش دهید**. - -> 📱 **نکتهٔ `ZARINPAL_FRONTEND_SUCCESS_URL`:** این کلید در کد خوانده نمی‌شود. ریدایرکت بعد از پرداخت موفق از طریق `frontend_callback` که وردپرس در هر درخواست پرداخت ارسال می‌کند تعیین می‌شود؛ فقط `ZARINPAL_FRONTEND_FAILURE_URL` به‌عنوان fallback استفاده می‌شود. - -> 📱 **نکتهٔ SMS:** سرویس پیامک از تنظیمات پنل ادمین (Filament → تنظیمات سیستم → SystemSetting) خوانده می‌شود: `kavenegar_api_key` و سوییچ‌های `kavenegar_send_*` برای هر نوع اعلان. مقداردهی این متغیرها در `.env` کافی نیست. - -### ۱.۴ دیتابیس - -```bash -# ایجاد دیتابیس (اگر وجود نداره) -mysql -u root -p -e "CREATE DATABASE ifnex_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" - -# اجرای migrations -php artisan migrate --force - -# اجرای seeders (فقط بار اول) -php artisan db:seed --force -``` - -### ۱.۵ پیکربندی نهایی - -```bash -php artisan storage:link -php artisan config:cache -php artisan route:cache -php artisan view:cache -php artisan filament:clear-cached-components -``` - -### ۱.۶ آپلود تعهدنامه‌ها و دیسک امن - -فایل‌های آپلودی مشتری (تعهدنامه‌های امضاشده) روی دیسک `secure` در `storage/app/secure` ذخیره می‌شوند و URL عمومی ندارند — فقط از طریق روت‌های محافظت‌شده (`/api/v1/customer/...` و `/admin/shipments/...`) سرو می‌شوند: - -```bash -# مسیر دیسک امن باید قابل نوشتن باشد -mkdir -p storage/app/secure && chmod -R 775 storage -``` - -محدودیت آپلود PHP سرور باید با اعتبارسنجی اپلیکیشن (۵ مگابایت) همخوان باشد — در php.ini مربوط به PHP-FPM: - -```ini -upload_max_filesize = 8M -post_max_size = 8M -``` - ---- - -## ۲. استقرار وردپرس (پلاگین و قالب) - -### ۲.۱ آپلود فایل‌ها - -```bash -# قالب IFNEX -cd /home/USER/web/ifnex.vernahost.ir/public_html/wp-content/themes/ -# فایل‌های قالب را اینجا آپلود/بروزرسانی کن - -# پلاگین IFNEX Bridge -cd /home/USER/web/ifnex.vernahost.ir/public_html/wp-content/plugins/ -# فایل‌های پلاگین را اینجا آپلود/بروزرسانی کن -``` - -### ۲.۲ تنظیم پلاگین - -در پیشخوان وردپرس → تنظیمات → IFNEX Bridge: - -| تنظیم | مقدار | -|--------|-------| -| API URL | `https://api.ifnex.vernahost.ir/api/v1` | -| API Key (عمومی) | برای APIهای عمومی مثل Tracking (همان `IFNEX_API_KEY` در `.env` لاراول) | -| Bridge API Key | کلید مشترک بین وردپرس و لاراول (همان `IFNEX_BRIDGE_API_KEY` در `.env` لاراول) | - -> ⚠️ **مهم:** Bridge API Key باید در لاراول (`.env`) و وردپرس (پنل تنظیمات پلاگین) دقیقاً یکسان باشد. در غیر این صورت، کاربران وردپرس نمی‌توانند توکن Sanctum دریافت کنند. - -### ۲.۳ صفحات مورد نیاز در وردپرس - -این صفحات باید با شورت‌کدهای مشخص ساخته شوند: - -| آدرس | شورت‌کد | توضیح | -|------|---------|--------| -| `/my-account/` | `[ifnex_customer_dashboard]` | پنل جامع مشتری | -| `/my-orders/` | `[ifnex_orders_list]` | لیست سفارشات | -| `/new-order/` | `[ifnex_order_form]` | فرم ثبت سفارش | -| `/order-detail/` | `[ifnex_order_detail]` | جزئیات سفارش | -| `/order-payment/` | `[ifnex_order_payment]` | صفحه پرداخت | -| `/wallet/` | `[ifnex_wallet_charge]` | شارژ کیف پول | -| `/tracking/` | `[ifnex_tracking_form]` | رهگیری مرسوله | - ---- - -## ۳. بروزرسانی (بعد از تغییرات جدید) - -### ۳.۱ لاراول - -```bash -cd /home/USER/web/api.ifnex.vernahost.ir/public_html - -# خاموش کردن موقت سایت -php artisan down - -# بکاپ -cp .env .env.backup - -# دریافت تغییرات -git fetch --all -git pull origin main - -# وابستگی‌ها (اگه composer.json تغییر کرده) -composer install --no-dev --optimize-autoloader - -# مایگریشن‌های جدید -php artisan migrate --force - -# پاک‌سازی کش -php artisan config:clear -php artisan cache:clear -php artisan route:clear -php artisan view:clear -php artisan filament:clear-cached-components - -# کش مجدد -php artisan config:cache -php artisan route:cache -php artisan view:cache - -# روشن کردن سایت -php artisan up -``` - -### ۳.۲ وردپرس - -```bash -cd /home/USER/web/ifnex.vernahost.ir/public_html/wp-content/plugins/ifnex-bridge -git pull origin main -``` - -> اگر پلاگین از طریق گیت کلون نشده، فایل‌ها را دستی آپلود کن. - ---- - -## ۴. تنظیمات Cron - -```bash -crontab -e -``` - -```cron -# به‌روزرسانی نرخ ارز -0 0 * * * cd /home/USER/web/api.ifnex.vernahost.ir/public_html && php artisan ifnex:update-rates --source=freecurrencyapi >> /dev/null 2>&1 -``` - ---- - -## ۵. عیب‌یابی - -### بررسی لاگ‌ها - -```bash -# لاگ لاراول -tail -f /home/USER/web/api.ifnex.vernahost.ir/public_html/storage/logs/laravel.log - -# لاگ HestiaCP -tail -f /var/log/hestia.log -``` - -### کلیر کش +### 4. Clear caches ```bash php artisan optimize:clear +php artisan config:clear +php artisan route:clear +php artisan view:clear +php artisan filament:assets ``` -### بررسی وضعیت +### 5. Visit the admin panel + +Navigate to: `https://system.ifnex.ir/admin/rate-import` + +You should see the upload form. Upload the `IFNEX_Rate_Template.xlsx` (with your filled-in rates) and click **Import Rates**. + +--- + +## 🔧 Filament v3 compatibility notes + +This page targets **Filament 3.x**. Key points: + +1. **No `CanPoll` trait** — that trait was removed in Filament 3. We use `InteractsWithForms` instead. +2. **Form state path** — form values are stored in `$data` array, set via `->statePath('data')` on the form. +3. **`mount()` method** — required to initialize the form on page load via `$this->form->fill()`. +4. **Form rendering** — the view calls `{{ $this->form }}` directly inside the `
` tag. +5. **FileUpload disk** — uses the `local` disk (storage/app/). The uploaded file path returned is relative to that disk, so we prepend `storage_path('app/')`. + +If you see "Trait not found" errors, double-check you're on Filament 3.x: +```bash +composer show filament/filament | grep versions +``` + +--- + +## 📊 Template structure (matches customer's Excel exactly) + +### Sheet 1: `Import Rate` — ROW → IRAN rates + +``` +Row 1: IFNEX IMPORT RATE TABLE — ROW → IRAN +Row 2: (subtitle) +Row 4: [merged E4:K4] "DOCUMENT" +Row 5: Weight (kg) | Zone 1 | Zone 2 | ... | Zone 10 +Row 6: 0.5 | | | ... | +Row 7: 1 | | ... +Row 8: 1.5 | ... +Row 9: 2 | ... + +Row 11: [merged] "NON - DOCUMENT" +Row 12: Weight (kg) | Zone 1 | ... | Zone 10 +Row 13: 0.5 | +... (60 rows: 0.5 → 30.0 in 0.5 steps) +Row 73: 30.0 | +Row 75: ★ Anything above 30 kg requires Spot/Special rates. +``` + +### Sheet 2: `Export Rate` — IRAN → ROW rates +Same structure as Import Rate. + +### Sheet 3: `Zone` — country → zone mapping + +``` +Cols A-C: No. | Destination Country | PARCEL Zone +Cols E-G: No. | Destination Country | DOCUMENT Zone + +CRUCIAL: same country can have DIFFERENT zones! + Afghanistan → Parcel Zone 7, Document Zone 5 + Belgium → Parcel Zone 1, Document Zone 3 + China → Parcel Zone 6, Document Zone 4 +``` + +### Sheet 4: `README` — instructions (not parsed) + +--- + +## 🔍 How the importer detects sections + +The `RateImportService` walks every row and looks for: + +1. **Section markers** — text cells in column E containing `DOCUMENT` or `NON - DOCUMENT` +2. **Column headers** — `Weight (kg)` in column A + at least one `Zone N` header +3. **Data rows** — numeric weight in column A + numeric rates in zone columns + +Section markers are normalized: `DOCUMENT`, `Doc`, `DOC NORMAL` all map to `'document'`. +Similarly `NON - DOCUMENT`, `NON-DOCUMENT`, `NONDOC`, `PARCEL` all map to `'parcel'`. + +Empty cells in the rate matrix are skipped (not stored) — the calculator will use the +next-higher weight tier. + +--- + +## 💱 Currency flow + +1. Rates are stored in **AED** (matches customer's Excel format). +2. `PriceCalculatorService::calculate()` looks up the AED rate. +3. `ExchangeRateService::convertToIrr($aedAmount, 'AED')` converts to IRR at the current rate. +4. Result is rounded to the nearest 1,000 IRR. + +If you ever store IRR rates directly (e.g. by uploading with currency = IRR), the calculator +will return them without conversion. + +--- + +## ✅ What changed from the old model + +| Aspect | Old (2-zone) | New (4-quadrant) | +|---|---|---| +| Quadrants | 2 (Doc / Parcel) | 4 (Import×Doc, Import×Parcel, Export×Doc, Export×Parcel) | +| Zone mapping | Single zone per country | Separate Parcel Zone + Document Zone per country | +| Currency | Mixed | AED in DB, IRR at runtime | +| History | No `effective_at` | `effective_at` timestamp + lookup picks latest | +| Weight rounding | Buggy (`<=` matched wrong) | Correct (`>=` rounds UP to next tier) | +| Excel format | Custom narrow format | Customer's exact operational Excel format | + +--- + +## 🧪 Verifying the import worked + +After uploading, you should see a notification like: + +``` +✓ Imported: 480 | Updated: 0 | Skipped: 12 | Zone rows: 32 +``` + +Math check: +- Import Rate sheet: 4 doc weights × 10 zones = 40 cells +- Import Rate sheet: 60 parcel weights × 10 zones = 600 cells +- Export Rate sheet: same — 40 + 600 = 640 cells +- Total expected per direction: 640 → 1,280 across both sheets +- Minus skipped empty cells + +You can verify in `tinker`: ```bash -php artisan about -php artisan route:list --path=api/v1 +php artisan tinker +>>> App\Models\RateEntry::count(); +=> 1280 // expected count + +>>> App\Models\RateEntry::where('direction', 'export')->where('service_type', 'document')->count(); +=> 40 + +>>> App\Models\CountryZone::count(); +=> 32 // countries in the Zone sheet ``` --- -## ۶. نکات امنیتی +## 🐛 Common issues -- `.env` هرگز در گیت کامیت نشود (در `.gitignore` باشد) -- `APP_DEBUG=false` در Production -- `CORS_ALLOWED_ORIGINS` فقط دامنه وردپرس -- `IFNEX_API_KEY` یک کلید قوی و تصادفی باشد -- `IFNEX_BRIDGE_API_KEY` یک کلید قوی و تصادفی باشد (متفاوت از API Key) -- SSL/HTTPS فعال باشد -- دسترسی پوشه `storage` و `bootstrap/cache` قابل نوشتن باشد (۷۷۵) -- درگاه Zarinpal در Production باید `SANDBOX=false` و `MERCHANT_ID` واقعی باشد +| Symptom | Cause | Fix | +|---|---|---| +| `No rate found` error when calculating | Country has no zone in `country_zones` | Upload the Zone sheet, OR add the country manually in admin | +| `Non-numeric rate at weight=0.5 zone=3` warning | Cell contains text (e.g. "TBD") | Replace with empty cell or a number | +| Imported count is 0 | Sheet names don't match exactly | Rename sheets to `Import Rate` and `Export Rate` | +| Section marker not detected | Marker text in wrong column | Move `DOCUMENT` / `NON - DOCUMENT` text to column E | +| Calculator returns wrong weight tier | Old `PriceCalculatorService` cached | `php artisan optimize:clear` + restart PHP-FPM | --- -© 2026 VernaSoft Group. Internal use only. +## 🔄 Re-importing rates + +You can re-upload the same file anytime. Existing rows with the same +`(direction, service_type, weight, zone, currency, effective_at)` will be UPDATED +rather than duplicated. Old rates from previous dates are preserved for history. + +--- + +## 📝 Roadmap (not yet implemented) + +- [ ] Filament admin page to view/edit `rate_entries` directly (matrix view) +- [ ] Country management page for `country_zones` +- [ ] Bulk export current rates back to Excel +- [ ] Pre-import preview ("This will import X rows, update Y. Continue?") +- [ ] Scheduled rate imports from S3/URL