9.7 KiB
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.deliverythemselves — these live on the linkedfusion.shipmentrecord (already implemented byfusion_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:
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)
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_receivingtest_outbound_shipment_id_field_exists_on_receivingtest_action_create_outbound_shipment_creates_drafttest_action_create_outbound_shipment_idempotenttest_carrier_id_change_propagates_to_draft_shipmenttest_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_deliverytest_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_receivingtest_create_delivery_mirrors_outbound_shipmenttest_create_delivery_no_receiving_no_mirror
Manual verification post-deploy:
- Open RCV-30041 → carrier dropdown shows 15 options.
- Pick FedEx → click "Create Outbound Shipment" → fusion.shipment opens in draft.
- Confirm
x_fc_outbound_shipment_idis populated on RCV-30041. - 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_shippingis already installed — no action needed.- Migration runs automatically; spot-check by querying
fp_receiving.x_fc_carrier_idpost-deploy.