feat(api): implement 4-quadrant rate import and pricing model

Implement a new pricing architecture based on a 4-quadrant model (Export/Import
x Document/Non-Document) to replace the previous 2-zone model. This includes
the ability to handle different zones for parcels and documents per country.

- Add `RateEntry` and `CountryZone` models for managing complex rate matrices.
- Implement `RateImportService` to parse Excel templates using PhpSpreadsheet.
- Add `RateImportResource` in Filament for administrative rate uploads.
- Refactor `PriceCalculatorService` to utilize the new 4-quadrant lookup logic.
- Add database migrations to extend `rate_entries` and create `country_zones`.
- Update `DEPLOYMENT.md` with new architectural details and deployment steps.
This commit is contained in:
Kazem Alghasi 2026-10-29 07:18:52 +03:30
parent 4fac1dae01
commit 513e8d8e7e
10 changed files with 1117 additions and 527 deletions

View File

@ -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}} {"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}}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\RateImportResource\Pages;
use Filament\Resources\Resource;
use Filament\Resources\Pages\Page;
class RateImportResource extends Resource
{
protected static ?string $navigationIcon = 'heroicon-o-arrow-up-tray';
protected static ?string $navigationLabel = 'Rate Import';
protected static ?string $navigationGroup = 'Pricing';
protected static ?int $navigationSort = 5;
protected static ?string $slug = 'rate-import';
protected static ?string $recordTitleAttribute = 'Rate Import';
// No model — this is a single page resource for the upload form
public static function getPages(): array
{
return [
'index' => 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; }
}

View File

@ -0,0 +1,142 @@
<?php
namespace App\Filament\Resources\RateImportResource\Pages;
use App\Filament\Resources\RateImportResource;
use App\Services\RateImportService;
use Filament\Forms;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\Page;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
/**
* Rate Import page for Filament v3.
*
* URL: /admin/rate-import
*
* Provides a file upload form that hands off to RateImportService.
*/
class ImportRates extends Page implements HasForms
{
use InteractsWithForms;
protected static string $resource = RateImportResource::class;
protected static string $view = 'filament.pages.rate-import';
/** @var array<string, mixed> 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();
}
}
}

View File

@ -0,0 +1,41 @@
<x-filament-panels::page>
<div class="space-y-4">
<div class="filament-card p-6">
<h2 class="text-lg font-semibold mb-2">Rate Excel Import</h2>
<p class="text-sm text-gray-600 mb-4">
Upload an Excel file matching the IFNEX Rate Template format.
The importer expects sheets named <code>Import Rate</code>, <code>Export Rate</code>, and optionally <code>Zone</code>.
Each rate sheet must contain two sections: <code>DOCUMENT</code> and <code>NON-DOCUMENT</code>.
</p>
<form wire:submit="import">
{{ $this->form }}
<div class="mt-4">
<x-filament::button type="submit" color="success" icon="heroicon-o-arrow-up-tray">
Import Rates
</x-filament::button>
</div>
</form>
</div>
<div class="filament-card p-4 bg-amber-50 border border-amber-200">
<h3 class="text-sm font-semibold text-amber-800 mb-2">📋 Quick checklist</h3>
<ul class="text-xs text-amber-700 list-disc ml-5 space-y-1">
<li>Sheet names must be exactly <code>Import Rate</code> and <code>Export Rate</code>.</li>
<li>Section markers must appear in column E (merged): <code>DOCUMENT</code> / <code>NON - DOCUMENT</code>.</li>
<li>Column headers must be <code>Weight (kg)</code> and <code>Zone 1</code>..<code>Zone 10</code> (or just integers 1..10).</li>
<li>Weights must be decimal numbers in column A (0.5, 1, 1.5, ...).</li>
<li>Empty cells are skipped (no rate stored for that weight × zone).</li>
<li>Currency defaults to AED. System converts to IRR using current exchange rate.</li>
</ul>
</div>
<div class="filament-card p-4">
<h3 class="text-sm font-semibold mb-2">Download template</h3>
<p class="text-xs text-gray-600 mb-2">
Need the blank template? <a href="/admin/rate-import/template" class="text-blue-600 underline">Download IFNEX_Rate_Template.xlsx</a>
</p>
</div>
</div>
</x-filament-panels::page>

View File

@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* CountryZone
* -----------
* Stores the zone mapping for a country.
*
* Crucial: a country can have DIFFERENT zones for Parcel vs Document.
* Example from the customer's data:
* Afghanistan → Parcel Zone 7, Document Zone 5
* Belgium → Parcel Zone 1, Document Zone 3
* China → Parcel Zone 6, Document Zone 4
*/
class CountryZone extends Model
{
protected $table = 'country_zones';
protected $fillable = [
'country_name', 'country_iso2', 'parcel_zone', 'document_zone',
];
protected $casts = [
'parcel_zone' => '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;
}
}

View File

@ -0,0 +1,82 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
/**
* RateEntry
* --------
* Stores one rate cell from the rate matrix:
* direction + service_type + weight + zone + currency + effective_at → rate
*
* The latest effective rate for a given (direction, service_type, weight, zone)
* is used by PriceCalculatorService. Old rates are kept for history.
*/
class RateEntry extends Model
{
protected $table = 'rate_entries';
protected $fillable = [
'direction', 'service_type', 'weight', 'zone',
'rate', 'currency', 'effective_at',
];
protected $casts = [
'weight' => '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();
}
}

View File

@ -2,295 +2,98 @@
namespace App\Services; namespace App\Services;
use App\Models\RateEntry;
use App\Models\CountryZone;
use App\Enums\ShipmentDirection;
use App\Enums\ShipmentType; 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 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 public function calculate(
{
// 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(
string $direction, string $direction,
ShipmentType $type, string $serviceType,
float $weight, float $weightKg,
int $zone string $countryName,
): float { ): array {
$zoneColumn = 'zone_' . $zone; // 1. Look up zone for this country × service type
$zone = CountryZone::lookupZone($countryName, $serviceType);
// نرخی را پیدا کن که وزنش بزرگتر مساوی وزن واردشده باشد if ($zone === null) {
// مثلاً اگه وزن 0.3 است، نرخ 0.5 را برمی‌گرداند throw new \RuntimeException(
$rate = \App\Models\ShippingRate::query() "No zone mapping for country '{$countryName}' service type '{$serviceType}'"
->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(
'برای این مسیر و وزن، نرخی تعریف نشده است. لطفاً با پشتیبانی تماس بگیرید.'
); );
} }
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,
];
} }
}
/**
* 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);
}
}

View File

@ -0,0 +1,399 @@
<?php
namespace App\Services;
use App\Models\RateEntry;
use App\Models\CountryZone;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PhpOffice\PhpSpreadsheet\Cell\Cell;
use Exception;
/**
* RateImportService
* ----------------
* Parses the customer's operational Excel format and upserts rates
* into the rate_entries table (4-quadrant model).
*
* Expected Excel structure (matches IFNEX_Rate_Template.xlsx):
*
* Sheet "Import Rate" (or "Export Rate"):
* Row N: "DOCUMENT" <- section marker in column E (merged)
* Row N+1: "Weight (kg)" | Zone 1 | Zone 2 | ... | Zone 10
* Row N+2: 0.5 | <rate> | <rate> | ...
* Row N+3: 1 | <rate> | <rate> | ...
* ... (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 | <rate> | ...
* ... 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();
}
}
}

View File

@ -0,0 +1,86 @@
<?php
/**
* Migration: Extend rate_entries table for the 4-quadrant model.
*
* direction : 'import' | 'export'
* service_type : 'document' | 'parcel'
* currency : ISO 4217 code (default 'AED')
* effective_at : timestamp when this rate became active
*
* The 4-quadrant model:
* ┌──────────┬──────────────┬──────────────────┐
* │ │ DOCUMENT │ NON-DOCUMENT │
* ├──────────┼──────────────┼──────────────────┤
* │ Export │ Export×Doc │ Export×Parcel │
* │ Import │ Import×Doc │ Import×Parcel │
* └──────────┴──────────────┴──────────────────┘
*
* Upload date: 2026-10-29
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('rate_entries')) {
Schema::create('rate_entries', function (Blueprint $t) {
$t->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');
}
};

View File

@ -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
| محیط | دامنه | نقش | | File | Purpose |
|-------|-------|------| |---|---|
| Production | api.ifnex.vernahost.ir (دامنه اختصاصی آینده: system.ifnex.ir) | API لاراول | | `IFNEX_Rate_Template.xlsx` | The new blank template — give this to the customer |
| Production | ifnex.vernahost.ir (دامنه اختصاصی آینده: ifnex.ir) | وب‌سایت وردپرس | | `migrations/2026_10_29_000001_extend_rate_entries_for_4_quadrants.php` | DB schema: `rate_entries` (4-quadrant) + `country_zones` |
| Local | localhost:8000 | توسعه | | `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 دسترسی به سرور ### 1. Copy files into your Laravel project
- HestiaCP (مدیریت سرور)
- Git روی سرور
- Composer روی سرور (اختیاری — بهتره locallly نصب کنی)
---
## ۱. استقرار لاراول
### ۱.۱ کلون مخزن روی سرور
```bash ```bash
cd /home/USER/web/api.ifnex.vernahost.ir/public_html # From your project root (e.g. /home/ifnex/web/system.ifnex.ir)
git clone https://www.git.vernahost.ir/gitmodir110/ifnex.git . 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 ```bash
cd /home/USER/web/api.ifnex.vernahost.ir/public_html composer require phpoffice/phpspreadsheet
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
``` ```
### ۱.۲ نصب وابستگی‌ها ### 3. Run the migration
```bash ```bash
cd /home/USER/web/api.ifnex.vernahost.ir/public_html php artisan migrate
composer install --no-dev --optimize-autoloader
``` ```
### ۱.۳ تنظیم .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 ### 4. Clear caches
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
```
### کلیر کش
```bash ```bash
php artisan optimize:clear 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 `<form wire:submit="import">` 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 | <rate> | <rate> | ... | <rate>
Row 7: 1 | <rate> | ...
Row 8: 1.5 | ...
Row 9: 2 | ...
Row 11: [merged] "NON - DOCUMENT"
Row 12: Weight (kg) | Zone 1 | ... | Zone 10
Row 13: 0.5 | <rate>
... (60 rows: 0.5 → 30.0 in 0.5 steps)
Row 73: 30.0 | <rate>
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 ```bash
php artisan about php artisan tinker
php artisan route:list --path=api/v1 >>> 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` باشد) | Symptom | Cause | Fix |
- `APP_DEBUG=false` در Production |---|---|---|
- `CORS_ALLOWED_ORIGINS` فقط دامنه وردپرس | `No rate found` error when calculating | Country has no zone in `country_zones` | Upload the Zone sheet, OR add the country manually in admin |
- `IFNEX_API_KEY` یک کلید قوی و تصادفی باشد | `Non-numeric rate at weight=0.5 zone=3` warning | Cell contains text (e.g. "TBD") | Replace with empty cell or a number |
- `IFNEX_BRIDGE_API_KEY` یک کلید قوی و تصادفی باشد (متفاوت از API Key) | Imported count is 0 | Sheet names don't match exactly | Rename sheets to `Import Rate` and `Export Rate` |
- SSL/HTTPS فعال باشد | Section marker not detected | Marker text in wrong column | Move `DOCUMENT` / `NON - DOCUMENT` text to column E |
- دسترسی پوشه `storage` و `bootstrap/cache` قابل نوشتن باشد (۷۷۵) | Calculator returns wrong weight tier | Old `PriceCalculatorService` cached | `php artisan optimize:clear` + restart PHP-FPM |
- درگاه Zarinpal در Production باید `SANDBOX=false` و `MERCHANT_ID` واقعی باشد
--- ---
&copy; 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