# Phase C — Generate Label End-to-End
**Date:** 2026-05-18
**Status:** Approved for implementation
**Author:** Brainstorming session (gsinghpal)
**Project:** Shipping integration phase 3 of 5 (after Phase A foundation; Phase B was merged in as a fallback path).
## Goal
Complete the at-receiving outbound-label workflow: receiver enters weight + dimensions + picks carrier, clicks one button, system generates the carrier's shipping label PDF + tracking number (API when available, manual fallback when not). Operator prints the label, ships the box, customer gets the tracking link by email and on the portal.
## Workflow
```
[Receiver] enters weight + dims + picks carrier on RECEIVING FORM
↓
Click "Generate Outbound Label"
↓
Carrier has API integration?
├─ YES → carrier.send_shipping([picking]) → label PDF + tracking
│ saved to fusion.shipment
│
└─ NO/API FAILS → open manual entry wizard
operator pastes PDF + types tracking
saved to fusion.shipment
↓
[Shipping] "Print Label" button → opens PDF in browser print dialog
↓
[Notification] fp.notification.template fires (event: shipment_labeled)
with tracking_number + tracking_url placeholders
↓
[Portal] Job page renders tracking_number as clickable link to
carrier.tracking_url template
```
## Decisions reached
| # | Decision | Rationale |
|---|---|---|
| D1 | Weight + dimensions live on fp.receiving as `related=` fields → fusion.shipment | Receiver enters them on the receiving form (their workflow); shipment stays as source of truth. |
| D2 | One button: "Generate Outbound Label". API path is primary; manual is fallback | One UX, two branches inside. No separate "Manual Label Entry" flow surfaced to operator. |
| D3 | Manual fallback opens automatically on API failure OR when carrier has no API integration | Operator never has to think about which path to take. |
| D4 | Adapter approach: synthesize a stock.picking just for the API call (locked Phase C question) | Max reuse of existing fusion_shipping methods; picking is hidden from operator UIs. |
| D5 | Notification trigger fires whenever tracking_number gets set (API OR manual), not at label generation | Same downstream behavior regardless of how the label was obtained. |
| D6 | Portal renders tracking as `` using delivery.carrier.tracking_url template | Standard Odoo carrier tracking URL pattern. |
## Out of scope
- Purolator integration (Phase D — independent).
- Auto-print to a network printer (Phase F).
- Multi-package shipments (single package per shipment in Phase C).
- Rate quote / carrier shopping (just label generation).
- Job sticker auto-print at same moment (Phase F).
- Return labels (different API call; can come later).
## Files changing
| File | Change |
|---|---|
| `fusion_plating_receiving/models/fp_receiving.py` | NEW related fields: `x_fc_weight`, `x_fc_weight_uom`, `x_fc_length`, `x_fc_width`, `x_fc_height`, `x_fc_dim_uom` (related to fusion.shipment / fusion.order.package). NEW `x_fc_shipping_picking_id` (M2O stock.picking, back-link). NEW `action_generate_outbound_label()`. NEW `action_print_label()`. NEW helper `_fp_build_shipping_picking()`. |
| `fusion_plating_receiving/wizards/__init__.py` (NEW) | Wizard module init. |
| `fusion_plating_receiving/wizards/fp_label_manual_wizard.py` (NEW) | Transient model: `receiving_id`, `label_pdf` (Binary), `label_filename` (Char), `tracking_number` (Char), `note` (Char — context why manual fallback). `action_confirm()` writes to fusion.shipment + closes wizard. |
| `fusion_plating_receiving/wizards/fp_label_manual_wizard_views.xml` (NEW) | Wizard form view. |
| `fusion_plating_receiving/views/fp_receiving_views.xml` | Add weight + dimensions group (Reception group). Add header buttons "Generate Outbound Label" + "Print Label". |
| `fusion_plating_receiving/__manifest__.py` | Bump 19.0.3.10.0 → 19.0.3.11.0. Register new wizard files. Add `stock`, `delivery` to depends. |
| `fusion_plating_receiving/security/ir.model.access.csv` | ACLs for the new wizard models. |
| `fusion_plating_notifications/data/notification_templates.xml` (EXISTING — extend) | Add `shipment_labeled` trigger entry with default template. |
| `fusion_plating_portal/views/fp_portal_templates.xml` (EXISTING — extend) | Render tracking_number as `` link on job page. |
| Tests | Three new files + extensions. |
## Implementation details
### Related fields on fp.receiving
```python
x_fc_weight = fields.Float(
related='x_fc_outbound_shipment_id.weight',
readonly=False, store=False,
)
# Similar for length/width/height — these come from fusion.order.package, not fusion.shipment directly.
# Decision: write to the shipment's first package (auto-create if absent).
```
Wait — `fusion.shipment.weight` exists, but length/width/height live on `fusion.order.package`. The shipment has a one2many relationship via `sale_order_id.package_ids`. For Phase C, the simplest path: store dimensions on the shipment by adding them as fields, OR auto-create a package per shipment.
**Resolved:** Phase C reads/writes weight + dimensions on the shipment record directly. If `fusion.shipment` doesn't have dimension fields, we add them via inheritance from this side (this is in fusion_shipping's model — would require touching it). Alternative: store on a synthetic fusion.order.package.
**Decision for spec:** add length/width/height + dim_uom as new fields directly on `fusion.shipment` via inheritance from `fusion_plating_receiving` (or move to fusion_shipping if appropriate during implementation). Cleaner than the package indirection for a single-package flow.
### action_generate_outbound_label
```python
def action_generate_outbound_label(self):
self.ensure_one()
self._fp_validate_label_inputs() # carrier, weight, recipient addr, shipment exists
carrier = self.x_fc_carrier_id
if carrier.delivery_type == 'fixed':
return self._fp_open_manual_label_wizard(
note=_('Carrier "%s" has no API integration. Enter the '
'label PDF and tracking number manually.') % carrier.name,
)
try:
picking = self._fp_build_shipping_picking()
shipping_data = carrier.send_shipping([picking]) # standard Odoo call
self._fp_apply_shipping_result(picking, shipping_data)
except Exception as e:
_logger.warning("Label gen failed for %s: %s", self.name, e)
return self._fp_open_manual_label_wizard(
note=_('API call failed: %s\n\nEnter the label manually below.') % e,
)
return self._fp_open_outbound_shipment_action() # smart-button target
```
### Manual fallback wizard
Small transient model `fp.label.manual.wizard` with:
- `receiving_id` (M2O fp.receiving, required)
- `label_pdf` (Binary, required at confirm time)
- `label_filename` (Char)
- `tracking_number` (Char, required at confirm time)
- `note` (Char, readonly — explanatory message)
`action_confirm()`:
- Validate label + tracking present.
- Write to the receiving's linked fusion.shipment: `label_attachment_id` (create ir.attachment) + `tracking_number` + `status='confirmed'`.
- Close wizard, post chatter to receiving.
### Synthetic stock.picking
```python
def _fp_build_shipping_picking(self):
self.ensure_one()
Picking = self.env['stock.picking']
warehouse = self.env['stock.warehouse'].search([
('company_id', '=', self.env.company.id)
], limit=1)
picking_type = warehouse.out_type_id
so = self.sale_order_id
return Picking.create({
'partner_id': so.partner_shipping_id.id,
'picking_type_id': picking_type.id,
'origin': so.name,
'sale_id': so.id,
'carrier_id': self.x_fc_carrier_id.id,
# Synthetic single move from a generic shipping product:
'move_ids': [(0, 0, {
'name': 'Outbound Shipment %s' % self.name,
'product_id': self.env.ref('product.product_product_4').id, # default service-type
'product_uom_qty': 1,
'product_uom': self.env.ref('uom.product_uom_unit').id,
'location_id': picking_type.default_location_src_id.id,
'location_dest_id': picking_type.default_location_dest_id.id,
})],
'x_fc_fp_receiving_id': self.id, # back-link, defined on stock.picking
})
```
Then immediately after `send_shipping` succeeds:
- `picking.action_confirm()` + `picking.action_assign()` + `picking.button_validate()` to take the picking to 'done' state (so it doesn't sit as draft in operator views).
### Notification trigger
Add event `shipment_labeled` to fp.notification.template selection. Default email template:
```
Subject: Your order is ready to ship — Tracking #{{ tracking_number }}
Body: Hi {{ partner_name }},
Your order for SO {{ sale_order_name }} has shipped.
Tracking number: {{ tracking_number }}
Track here: {{ tracking_url }}
```
Fired by an `on_write` hook on `fusion.shipment` when `tracking_number` transitions from empty to non-empty.
### Portal display
In `fusion_plating_portal/views/fp_portal_templates.xml`, locate the job-card / job-detail rendering. Wherever tracking_ref is shown, replace with:
```xml
```
`tracking_url` is a computed field on `fusion.shipment` that resolves the `delivery.carrier.tracking_url` template (already exists in Odoo).
## Test plan
| Test | Verifies |
|---|---|
| `test_generate_label_blocks_when_no_carrier` | UserError raised |
| `test_generate_label_blocks_when_no_shipment` | UserError raised |
| `test_generate_label_blocks_when_no_weight` | UserError raised |
| `test_generate_label_routes_manual_for_fixed_carrier` | Wizard opens, no API call made |
| `test_generate_label_calls_api_for_integrated_carrier` | carrier.send_shipping called once (mocked) |
| `test_generate_label_writes_result_to_shipment_on_success` | tracking_number + label_attachment populated |
| `test_generate_label_falls_back_to_wizard_on_api_failure` | Mock raises → wizard opens with note |
| `test_manual_wizard_confirm_writes_shipment` | label + tracking saved; status confirmed |
| `test_print_label_returns_attachment_action` | Action dict points to the label PDF |
| `test_notification_fires_when_tracking_set` | fp.notification.template._dispatch called with shipment_labeled event |
| `test_portal_renders_tracking_link` | Render contains `` with tracking URL |
## Edge cases
| Case | Behavior |
|---|---|
| No warehouse configured | UserError: "No warehouse for the company — configure one in Settings > Warehouse." |
| sale_order.partner_shipping_id missing | Falls back to `sale_order.partner_id`. |
| Multi-package SO (rare) | Phase C single-package only. Multi-package raises with a "Phase E" note. |
| Carrier API timeout | Caught as `Exception` in the try block; manual wizard opens with error in note. |
| Operator generates label twice | Second call sees existing tracking, refuses and prompts to void/regenerate. |
| Customer changes weight after label generated | Block weight edit when shipment.status == 'confirmed'. Manager can void shipment to re-generate. |
## Deployment
3 modules upgraded: `fusion_plating_receiving` (main), `fusion_plating_notifications` (trigger), `fusion_plating_portal` (link).
Manual verification on entech:
1. Open RCV-30041. Set weight (e.g. 5), dimensions, carrier = FedEx.
2. Click Generate Outbound Label. Expected: UserError because the seeded FedEx carrier has `delivery_type='fixed'` — manual wizard opens.
3. Paste a sample PDF + tracking number in wizard. Confirm.
4. Verify fusion.shipment has the label and tracking saved.
5. Verify Print Label button works (opens PDF).
6. (If admin configures FedEx REST credentials and changes delivery_type) — re-test API path.