Files
Odoo-Modules/fusion_plating/docs/superpowers/specs/2026-05-18-phase-a-shipping-carrier-foundation-design.md
gsinghpal 091f98e1f9 changes
2026-05-18 22:33:23 -04:00

163 lines
9.7 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.
# Phase A — Shipping Carrier Foundation
**Date:** 2026-05-18
**Status:** Approved for implementation
**Author:** Brainstorming session (gsinghpal)
**Project:** Full shipping integration (Phases AF). This spec covers Phase A only — the field-level foundation linking plating records to `fusion_shipping`'s existing shipment infrastructure.
## Goal
Replace the free-text `carrier_name` on `fp.receiving` with a proper M2O to `delivery.carrier`, and link both `fp.receiving` and `fp.delivery` to the `fusion.shipment` model that already exists in `fusion_shipping`. After Phase A, the receiver can pick a carrier from a 15-option dropdown and create a draft outbound shipment record — wiring is in place for Phase B (manual label entry) and Phase E (auto-label generation at receiving time).
## Out of scope
- Weight, dimensions, label PDF, tracking number on `fp.receiving` / `fp.delivery` themselves — these live on the linked `fusion.shipment` record (already implemented by `fusion_shipping`).
- Bridge module (Phase C), Purolator integration (Phase D), at-receiving auto-label (Phase E), printer hookup (Phase F).
- Modifying `fusion_shipping`'s existing models — Phase A is additive on the plating side only.
## Decisions reached
| # | Decision | Rationale |
|---|---|---|
| D1 | Carrier field: M2O to `delivery.carrier` (not Selection) | Matches `fusion_shipping`'s framework; allows API integration on the same record without conversion. |
| D2 | Architecture: mirror fields on both `fp.receiving` and `fp.delivery`, auto-sync at delivery creation | Self-contained records; loose coupling; shipping crew can override per-stage. |
| D3 | Source of truth for weight / dims / label / tracking: `fusion.shipment`, NOT mirrored on plating records | Shipment model already has every field; avoid duplicating + the sync logic. |
| D4 | 15 carriers seeded as `delivery.carrier` data records (XML), all `delivery_type='fixed'` initially | Phase D will flip Purolator (and any others added) to their integration types. Manual carriers (Customer Pickup etc.) stay `fixed` permanently. |
| D5 | Existing `carrier_name` (Char) and `carrier_tracking` (Char) kept as legacy | Migration populates `x_fc_carrier_id` by name match; unmatched text stays for operator review. |
| D6 | `fusion_plating_receiving` + `fusion_plating_logistics` gain hard `fusion_shipping` dependency | The M2O to `fusion.shipment` requires the model to exist; no conditional compilation. |
## Architecture
```
┌─ fp.receiving ─────────────────────────────────────────────────────┐
│ NEW: x_fc_carrier_id M2O delivery.carrier │
│ NEW: x_fc_outbound_shipment_id M2O fusion.shipment │
│ NEW: x_fc_outbound_shipment_count Integer (smart-button counter) │
│ Existing: carrier_name (Char) — legacy, populated by migration │
│ Existing: carrier_tracking (Char) — legacy │
│ │
│ ACTION: action_create_outbound_shipment() │
│ → creates fusion.shipment with sale_order_id + carrier_id │
│ → idempotent: returns existing if already linked │
│ ACTION: action_view_outbound_shipment() │
│ → opens linked fusion.shipment in form view │
│ ONCHANGE: x_fc_carrier_id propagates to linked shipment │
│ → only if shipment.status == 'draft' │
└────────────────────────────────────────────────────────────────────┘
copy at delivery creation (fp.job._fp_create_delivery)
┌─ fp.delivery ──────────────────────────────────────────────────────┐
│ NEW: x_fc_carrier_id M2O delivery.carrier │
│ NEW: x_fc_outbound_shipment_id M2O fusion.shipment │
│ NEW: x_fc_outbound_shipment_count Integer │
│ Same ACTIONs and propagation as fp.receiving │
└────────────────────────────────────────────────────────────────────┘
┌─ delivery.carrier (seed data) ─────────────────────────────────────┐
│ Already on entech: Standard delivery, Canada Post, Customer Pickup│
│ Phase A adds (delivery_type='fixed', product_id=delivery. │
│ product_product_delivery): │
│ UPS, FedEx, USPS, DHL, Purolator, CCT, Canpar Express, │
│ GLS Canada, Loomis Express, Day & Ross, Dicom Transportation, │
│ Customer Drop-off, Local Delivery │
│ Idempotent: XML uses noupdate=1 + record ids check existing names │
└────────────────────────────────────────────────────────────────────┘
```
## Field details
**On `fp.receiving`:**
```python
x_fc_carrier_id = fields.Many2one(
'delivery.carrier', string='Outbound Carrier', tracking=True,
ondelete='set null',
help='Who picks up the parts when work is done. Used to generate '
'the return shipping label on the linked Outbound Shipment.',
)
x_fc_outbound_shipment_id = fields.Many2one(
'fusion.shipment', string='Outbound Shipment', tracking=True,
ondelete='set null',
help='The shipment record carrying weight, dimensions, label PDF, '
'and tracking. Created via the "Create Outbound Shipment" '
'button.',
)
x_fc_outbound_shipment_count = fields.Integer(
compute='_compute_x_fc_outbound_shipment_count',
)
```
Identical pair on `fp.delivery`.
## Module changes
| Module | Bump | Files |
|---|---|---|
| `fusion_plating_receiving` | 19.0.3.9.0 → 19.0.3.10.0 | manifest (+depends), `models/fp_receiving.py`, `views/fp_receiving_views.xml`, `data/delivery_carrier_seed_data.xml` (NEW), `migrations/19.0.3.10.0/post-migrate.py` (NEW), `tests/test_carrier_fields.py` (NEW) |
| `fusion_plating_logistics` | bump | manifest (+depends), `models/fp_delivery.py`, `views/fp_delivery_views.xml`, `tests/test_delivery_shipping_fields.py` (NEW) |
| `fusion_plating_jobs` | bump | `models/fp_job.py` (mirror at `_fp_create_delivery`), extend existing milestone-cascade test class |
## Migration logic (post-migrate)
```python
def migrate(cr, version):
# Name-match existing carrier_name text → delivery.carrier.name
cr.execute("""
UPDATE fp_receiving r
SET x_fc_carrier_id = dc.id
FROM delivery_carrier dc
WHERE r.carrier_name IS NOT NULL
AND r.carrier_name <> ''
AND r.x_fc_carrier_id IS NULL
AND LOWER(TRIM(r.carrier_name)) =
LOWER(TRIM((dc.name->>'en_US')))
""")
```
`delivery.carrier.name` is jsonb in Odoo 19 (translatable). The migration strips to `en_US` for the match.
## Edge cases
| Case | Behavior |
|---|---|
| Receiving has no SO link | Shipment creation works without `sale_order_id` (set_null on shipment side). |
| Carrier picked but no shipment yet | Smart button reads "Create Outbound Shipment" → one click creates + opens. |
| User changes carrier on receiving after shipment exists | Onchange propagates only when `shipment.status == 'draft'`. Confirmed/shipped shipments are left alone. |
| Two receivings on same SO (split deliveries) | Each has its own `x_fc_outbound_shipment_id`. Mirror picks first one; user can change. |
| Migration finds ambiguous name | Case-insensitive exact match only. Unmatched stays in `carrier_name` text. |
| Shipment is deleted | `ondelete='set null'` — receiving keeps carrier but smart button reverts to "Create". |
| `fusion_shipping` not installed | Manifest dependency fails fast on module load — correct failure mode. |
## Test plan
**Unit tests** in `fusion_plating_receiving/tests/test_carrier_fields.py`:
- `test_carrier_id_field_exists_on_receiving`
- `test_outbound_shipment_id_field_exists_on_receiving`
- `test_action_create_outbound_shipment_creates_draft`
- `test_action_create_outbound_shipment_idempotent`
- `test_carrier_id_change_propagates_to_draft_shipment`
- `test_carrier_id_change_does_not_propagate_to_confirmed_shipment`
**Unit tests** in `fusion_plating_logistics/tests/test_delivery_shipping_fields.py`:
- `test_carrier_id_field_exists_on_delivery`
- `test_outbound_shipment_id_field_exists_on_delivery`
**Unit tests** extending `fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`:
- `test_create_delivery_mirrors_carrier_from_receiving`
- `test_create_delivery_mirrors_outbound_shipment`
- `test_create_delivery_no_receiving_no_mirror`
**Manual verification post-deploy:**
1. Open RCV-30041 → carrier dropdown shows 15 options.
2. Pick FedEx → click "Create Outbound Shipment" → fusion.shipment opens in draft.
3. Confirm `x_fc_outbound_shipment_id` is populated on RCV-30041.
4. Confirm same fields/buttons on a fresh fp.delivery record auto-created via mark-done.
## Deployment
- 3 module upgrades: `fusion_plating_receiving`, `fusion_plating_logistics`, `fusion_plating_jobs`.
- `fusion_shipping` is already installed — no action needed.
- Migration runs automatically; spot-check by querying `fp_receiving.x_fc_carrier_id` post-deploy.