# Phase A — Shipping Carrier Foundation **Date:** 2026-05-18 **Status:** Approved for implementation **Author:** Brainstorming session (gsinghpal) **Project:** Full shipping integration (Phases A–F). 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.