163 lines
9.7 KiB
Markdown
163 lines
9.7 KiB
Markdown
# 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.
|