ifnex/DEPLOYMENT.md
Kazem Alghasi 513e8d8e7e 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.
2026-10-29 07:18:52 +03:30

246 lines
8.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
```bash
# 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)
```bash
composer require phpoffice/phpspreadsheet
```
### 3. Run the migration
```bash
php artisan migrate
```
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
### 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 `<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
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_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