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.
8.9 KiB
IFNEX Rate Importer — 4-Quadrant Model
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 |
|---|---|
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
1. Copy files into your Laravel project
# 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
2. Install PhpSpreadsheet (if not already)
composer require phpoffice/phpspreadsheet
3. Run the migration
php artisan migrate
The migration is safe — it will:
- Create
rate_entriestable if it doesn't exist - Or add the missing columns (
direction,service_type,currency,effective_at) if it does - Create
country_zonestable for the country→zone mapping
4. Clear caches
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:
- No
CanPolltrait — that trait was removed in Filament 3. We useInteractsWithFormsinstead. - Form state path — form values are stored in
$dataarray, set via->statePath('data')on the form. mount()method — required to initialize the form on page load via$this->form->fill().- Form rendering — the view calls
{{ $this->form }}directly inside the<form wire:submit="import">tag. - FileUpload disk — uses the
localdisk (storage/app/). The uploaded file path returned is relative to that disk, so we prependstorage_path('app/').
If you see "Trait not found" errors, double-check you're on Filament 3.x:
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:
- Section markers — text cells in column E containing
DOCUMENTorNON - DOCUMENT - Column headers —
Weight (kg)in column A + at least oneZone Nheader - 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
- Rates are stored in AED (matches customer's Excel format).
PriceCalculatorService::calculate()looks up the AED rate.ExchangeRateService::convertToIrr($aedAmount, 'AED')converts to IRR at the current rate.- 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:
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
| 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 |
🔄 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_entriesdirectly (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