# Shipping Zone System — Phase 2
### `DeliveryApp/ShippingZone` module · Multi-Tenant Laravel + Inertia/React

> **Scope:** Country / Division / District / Zone hierarchy with carrier × zone shipping rate calculation.
> **Depends on:** Phase 1 (`01_courier_driver_plan.md`) — needs `Delivery` model with `provider_key`.
> **Next:** `03_courier_order_plan.md` (zone resolution at order dispatch time).

---

## Zone Resolution Flow

```
POST /delivery/checkout                  POST /courier-orders
      ↓                                         ↓
ShippingZoneService::resolveForAddress($address)
  1. Match by district  (highest priority)
  2. Match by division
  3. Match by country (fallback)
  4. No match → null  (block checkout or use default rate)
      ↓
ShippingZoneService::calculateShippingCost(Zone $zone, Delivery $courier, float $weight, float $orderTotal)
  → reads from shipping_zone_rates (zone × courier, weight tiers)
  → applies free shipping threshold
```

---

## Database Schema (Tables 3–7)

**`shipping_countries` table**
```
id, uid, name, iso_code (char 2), phone_code, is_active (bool default true), timestamps
Index: is_active, iso_code
```

**`shipping_divisions` table**
```
id, uid, name, country_id (FK shipping_countries), is_active bool, timestamps
Index: country_id, is_active
```

**`shipping_districts` table**
```
id, uid, name, division_id (FK shipping_divisions), is_active bool, timestamps
Index: division_id, is_active
```

**`shipping_zones` table**
```
id, uid, name, description, status (tinyint StatusEnum),
country_id  (FK shipping_countries nullable),
division_id (FK shipping_divisions nullable),
district_id (FK shipping_districts nullable),
priority    (tinyint default 10 — lower = higher priority),
soft_deletes, timestamps

Constraint: at least one of country_id/division_id/district_id non-null (enforced in service)
Index: (country_id, division_id, district_id, priority)
```

**`shipping_zone_rates` table**
```
id, uid,
zone_id    (FK shipping_zones),
courier_id (FK deliveries),
rate_type  (enum: 'flat' | 'weight'),
base_rate  (decimal 10,2 default 0),
weight_rate_per_kg (decimal 10,2 default 0  — used when rate_type='weight'),
free_shipping_threshold (decimal 10,2 nullable),
min_delivery_days (tinyint nullable),
max_delivery_days (tinyint nullable),
is_active  (bool default true),
soft_deletes, timestamps

Unique: (zone_id, courier_id)
Index: (zone_id, is_active)
```

> **No `shipping_rates` table** — rates live in `shipping_zone_rates`. The rate is either
> flat or weight-based per zone × courier pair. Advanced distance-based pricing is v2 scope.

---

## Module Structure (ShippingZone subtree)

```
DeliveryApp/ShippingZone/
├── app/
│   ├── Http/Controllers/
│   │   ├── ShippingZoneController.php
│   │   ├── ShippingZoneRateController.php
│   │   └── ShippingCountryController.php
│   ├── ModelFilters/
│   │   └── ShippingZoneFilter.php
│   ├── Models/
│   │   ├── ShippingCountry.php
│   │   ├── ShippingDivision.php
│   │   ├── ShippingDistrict.php
│   │   ├── ShippingZone.php
│   │   └── ShippingZoneRate.php
│   ├── Resources/
│   │   ├── ShippingZoneResource.php
│   │   └── ShippingZoneRateResource.php
│   └── Services/
│       └── ShippingZoneService.php
├── database/
│   ├── migrations/
│   │   ├── XXXX_create_shipping_countries_table.php
│   │   ├── XXXX_create_shipping_divisions_table.php
│   │   ├── XXXX_create_shipping_districts_table.php
│   │   ├── XXXX_create_shipping_zones_table.php
│   │   └── XXXX_create_shipping_zone_rates_table.php
│   └── seeders/
│       └── ShippingCountrySeeder.php
├── resources/assets/js/pages/ShippingZone/
│   └── Index.tsx
└── routes/
    └── tenant.php
```

---

## Phase 2 — Shipping Zone Module

### Step 2.1 · Scaffold New Module

> 🤖 **Agent Prompt:**
> Create module `DeliveryApp/ShippingZone` using nwidart module structure.
> Namespace: `DeliveryApp\ShippingZone`. Module type: `delivery-feature`.
> Register in `app/modules_statuses.json` as `true` and `app/composer.json` merge-plugin.
> Create `module.json` with `"name": "ShippingZone"`.

---

### Step 2.2 · Migrations

> 🤖 **Agent Prompt:**
> Create 5 migration files in `DeliveryApp/ShippingZone/database/migrations/` matching the
> schema defined above. Use correct `$table->foreignId()` with `->constrained()` and `->cascadeOnDelete()`
> for all FK columns. All tables have `uid` (unique, string, 26 chars — set in `boot()`).
>
> Order: countries → divisions → districts → zones → zone_rates.
>
> `shipping_zone_rates` unique constraint: `->unique(['zone_id', 'courier_id'])`.

---

### Step 2.3 · Models

> 🤖 **Agent Prompt:**
> Create 5 models: `ShippingCountry`, `ShippingDivision`, `ShippingDistrict`, `ShippingZone`, `ShippingZoneRate`.
>
> `ShippingZone` traits: `HasFactory`, `SoftDeletes`, `Filterable`.
> Add `modelFilter()` returning `ShippingZoneFilter::class`.
>
> `ShippingZone` relationships:
> - `belongsTo(ShippingCountry::class)`
> - `belongsTo(ShippingDivision::class)`
> - `belongsTo(ShippingDistrict::class)`
> - `hasMany(ShippingZoneRate::class, 'zone_id')`
>
> `ShippingZoneRate` relationships:
> - `belongsTo(ShippingZone::class, 'zone_id')`
> - `belongsTo(Delivery::class, 'courier_id')` (Delivery model from DeliveryApp\Delivery\Models)
>
> `ShippingZone` casts: `status` → integer. `ShippingZoneRate` casts:
> `base_rate` decimal, `weight_rate_per_kg` decimal, `free_shipping_threshold` decimal, `is_active` bool.

---

### Step 2.4 · `ShippingZoneFilter`

> 🤖 **Agent Prompt:**
> Create `DeliveryApp/ShippingZone/app/ModelFilters/ShippingZoneFilter.php` extending `ModelFilter`.
> Use `CommonFilter` trait.
>
> Methods:
> - `search(string $value)` — where name LIKE %value%
> - `countryId(int $value)` — where country_id = $value
> - `divisionId(int $value)` — where division_id = $value

---

### Step 2.5 · `ShippingZoneService`

> 🤖 **Agent Prompt:**
> Create `DeliveryApp/ShippingZone/app/Services/ShippingZoneService.php`.
>
> Inject nothing (stateless queries).
>
> **`resolveForAddress(array $address): ?ShippingZone`**
> - `$address` keys: `country_id`, `division_id`, `district_id` (all nullable)
> - Priority cascade: match district (if not null), then division, then country
> - Each match: `ShippingZone::where('...', $id)->where('status', StatusEnum::ACTIVE->value)->orderBy('priority')->first()`
> - Return first match found, or null
>
> **`calculateShippingCost(ShippingZone $zone, Delivery $courier, float $weight, float $orderTotal): float`**
> - Load `ShippingZoneRate::where('zone_id', $zone->id)->where('courier_id', $courier->id)->where('is_active', true)->first()`
> - If null → return 0.0 (free / not configured)
> - If `free_shipping_threshold` set and `$orderTotal >= threshold` → return 0.0
> - If `rate_type = 'flat'` → return `$rate->base_rate`
> - If `rate_type = 'weight'` → return `$rate->base_rate + ($weight * $rate->weight_rate_per_kg)`
>
> **`getRatesForZone(ShippingZone $zone): Collection`**
> - `ShippingZoneRate::with('courier')->where('zone_id', $zone->id)->get()`
>
> **`baseQuery(array $params): Builder`** — one line: `ShippingZone::filter($params)->with(['country', 'division', 'district'])`
>
> **`paginate(array $params): LengthAwarePaginator`** — `$this->baseQuery($params)->paginate()`
>
> **`statuses(): array`** — return active/inactive map using StatusEnum

---

### Step 2.6 · `ShippingZoneResource` + `ShippingZoneRateResource`

> 🤖 **Agent Prompt:**
> Create `DeliveryApp/ShippingZone/app/Resources/ShippingZoneResource.php`:
> ```php
> [
>     'id'          => $this->id,
>     'uid'         => $this->uid,
>     'name'        => $this->name,
>     'description' => $this->description,
>     'status'      => $this->status,
>     'priority'    => $this->priority,
>     'country'     => $this->whenLoaded('country', fn() => ['id' => $this->country->id, 'name' => $this->country->name]),
>     'division'    => $this->whenLoaded('division', fn() => ['id' => $this->division->id, 'name' => $this->division->name]),
>     'district'    => $this->whenLoaded('district', fn() => ['id' => $this->district->id, 'name' => $this->district->name]),
>     'rates_count' => $this->whenCounted('rates'),
>     'created_at'  => $this->created_at->toDateTimeString(),
> ]
> ```
>
> Create `ShippingZoneRateResource.php`:
> ```php
> [
>     'id'                      => $this->id,
>     'zone_id'                 => $this->zone_id,
>     'courier_id'              => $this->courier_id,
>     'courier_name'            => $this->whenLoaded('courier', fn() => $this->courier->title),
>     'rate_type'               => $this->rate_type,
>     'base_rate'               => $this->base_rate,
>     'weight_rate_per_kg'      => $this->weight_rate_per_kg,
>     'free_shipping_threshold' => $this->free_shipping_threshold,
>     'min_delivery_days'       => $this->min_delivery_days,
>     'max_delivery_days'       => $this->max_delivery_days,
>     'is_active'               => $this->is_active,
> ]
> ```

---

### Step 2.7 · `ShippingZoneController`

> 🤖 **Agent Prompt:**
> Create `DeliveryApp/ShippingZone/app/Http/Controllers/ShippingZoneController.php`.
> Inject `ShippingZoneService $service`.
>
> `index(Request $request)`:
> - `$queryParams` from `$request->only([...])`
> - if `wantsJson()` → return JSON (fetchDatatable path)
> - else → `Inertia::render('ShippingZone/Index', ['shippingZoneData' => [...]])`
>
> Standard resource controller with:
> - `store(Request $request)` → validate + `ShippingZone::create()` → `redirect()->route('shipping-zones.index')->with('success', ...)`
> - `update(Request $request, int $id)` → find + update → redirect
> - `destroy(int $id)` → find + `$zone->delete()` → redirect
> - `bulkAction(Request $request)` → standard bulk pattern from CLAUDE.md
>
> `index` Inertia props shape:
> ```php
> 'shippingZoneData' => [
>     'data'        => ShippingZoneResource::collection($items->items()),
>     'meta'        => simple_pagination_meta($items),
>     'links'       => ['prev' => ..., 'next' => ...],
>     'queryParams' => $queryParams,
>     'summary'     => ['total' => $total, 'active' => $active],
>     'countries'   => ShippingCountry::where('is_active', true)->get(['id', 'name']),
>     'divisions'   => ShippingDivision::where('is_active', true)->get(['id', 'name', 'country_id']),
>     'couriers'    => Delivery::active()->get(['id', 'title', 'provider_key']),
> ]
> ```

---

### Step 2.8 · `ShippingZoneRateController`

> 🤖 **Agent Prompt:**
> Create `DeliveryApp/ShippingZone/app/Http/Controllers/ShippingZoneRateController.php`.
>
> - `store(Request $request, int $zoneId)`: validate + `ShippingZoneRate::create(['zone_id'=>$zoneId, ...])` → redirect
> - `update(Request $request, int $zoneId, int $id)`: validate + update → redirect
> - `destroy(int $zoneId, int $id)`: softDelete → redirect back
>
> Validation rules for rate:
> - `courier_id` required|exists:deliveries,id
> - `rate_type` required|in:flat,weight
> - `base_rate` required|numeric|min:0
> - `weight_rate_per_kg` numeric|min:0
> - `free_shipping_threshold` nullable|numeric|min:0
> - `min_delivery_days` nullable|integer|min:1
> - `max_delivery_days` nullable|integer|min:1

---

### Step 2.9 · Routes

> 🤖 **Agent Prompt:**
> Create `DeliveryApp/ShippingZone/routes/tenant.php` with standard tenant middleware.
>
> ```php
> Route::post('shipping-zones/bulk-action', [ShippingZoneController::class, 'bulkAction'])->name('shipping-zones.bulk-action');
> Route::resource('shipping-zones', ShippingZoneController::class)->names('shipping-zones');
>
> Route::post('shipping-zones/{zoneId}/rates', [ShippingZoneRateController::class, 'store'])->name('shipping-zone-rates.store');
> Route::put('shipping-zones/{zoneId}/rates/{id}', [ShippingZoneRateController::class, 'update'])->name('shipping-zone-rates.update');
> Route::delete('shipping-zones/{zoneId}/rates/{id}', [ShippingZoneRateController::class, 'destroy'])->name('shipping-zone-rates.destroy');
>
> // JSON endpoint — used by frontend to populate district/division dropdowns
> Route::get('shipping/districts', fn(Request $request) =>
>     response()->json(ShippingDistrict::where('division_id', $request->division_id)->where('is_active', true)->get(['id', 'name']))
> )->name('shipping.districts');
> ```

---

### Step 2.10 · `ShippingZone/Index.tsx` (Frontend)

> 🤖 **Agent Prompt:**
> Create `DeliveryApp/ShippingZone/resources/assets/js/pages/ShippingZone/Index.tsx`.
>
> Follow CLAUDE.md STRICTLY:
> - `Index.layout` with AppLayout, breadcrumbs: Home → Delivery → Shipping Zones
> - Outer wrapper: `<div className="no-scrollbar rounded-xl bg-gray-100/55 p-2 sm:p-4">`
> - Two `StatisticsCard` (total zones, active zones) in a 2-column grid
> - `DataTable` with columns: Name, Coverage (country/division/district), Priority, Status, Actions
> - `BulkStatusEditModal` for bulk enable/disable
> - Filter row: search input + country dropdown + status dropdown
> - Inline rate management: expand zone row → show `ShippingZoneRatePanel` component
>   (table of rates with Add/Edit buttons per row)
> - Create/Edit via modal (inline form — no separate page)
>
> Props from controller: `shippingZoneData` (data, meta, links, queryParams, summary, countries, divisions, couriers)

---

## Phase 2 + Step 6.3 Checklist

### Phase 2 — Shipping Zone Module
- [ ] New module `DeliveryApp/ShippingZone` scaffolded + registered
- [ ] 5 migrations (countries, divisions, districts, zones, zone_rates)
- [ ] 5 models with correct relationships, casts, Filterable
- [ ] `ShippingZoneFilter` with CommonFilter + search/countryId/divisionId
- [ ] `ShippingZoneService` with resolveForAddress + calculateShippingCost + baseQuery
- [ ] `ShippingZoneResource` + `ShippingZoneRateResource`
- [ ] `ShippingZoneController` (wantsJson branch + Inertia branch + bulkAction)
- [ ] `ShippingZoneRateController` (store/update/destroy)
- [ ] Routes (bulk-action before resource, rate sub-routes, districts JSON endpoint)
- [ ] `ShippingZone/Index.tsx` (CLAUDE.md layout, DataTable, stats, inline rates)
- [ ] `ShippingCountrySeeder` (seed ISO countries + Bangladesh divisions + districts)

### Step 6.3 — Delivery Settings Layout
- [ ] `DeliverySettingsController::index()` renders `DeliverySettings/Index` with settings + providers
- [ ] `DeliverySettings/Index.tsx` — settings form (global delivery config)
- [ ] Courier credential sub-page links accessible from provider list
- [ ] Zones tab link from settings page (links to `/shipping-zones`)

---

## Tests

### `ShippingZoneTest`
> `DeliveryApp/ShippingZone/tests/Feature/ShippingZoneTest.php` — use `RefreshDatabase`.
>
> - `resolveForAddress` returns district match over division match over country match
> - `resolveForAddress` returns null when no zone covers the address
> - `calculateShippingCost` flat rate: `base_rate` returned regardless of weight
> - `calculateShippingCost` weight rate: `base_rate + weight * weight_rate_per_kg`
> - Free shipping threshold: 0.0 when `order_total >= threshold`
> - POST `/shipping-zones` → row created → redirect
> - POST `/shipping-zones/bulk-action` → status updated → redirect
> - Unauthenticated → 401

---

## Design Decisions

**Why no `shipping_rates` table (separate from zone_rates)?**
A `shipping_rates` table would require a separate join + resolution pass. `shipping_zone_rates`
has a compound unique index on `(zone_id, courier_id)`, so one query fetches the exact rate
for a zone × courier pair. Flat + weight tiers cover 95% of BD courier pricing.

**Why cascade district → division → country priority?**
An admin sets district-level rates for high-volume cities (Dhaka, Chittagong) and falls back
to division rates for the rest. Country is the catch-all. Priority column lets admins override
the hierarchy for special cases without deleting existing zones.

**Why expose `/shipping/districts` as a JSON endpoint?**
District dropdown depends on selected division (2,000+ rows). Eager-loading all districts in the
initial Inertia page payload wastes ~15 KB on every page load. The JSON endpoint loads on demand.
