12 KiB
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 <a href="..."> 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 <a> link on job page. |
| Tests | Three new files + extensions. |
Implementation details
Related fields on fp.receiving
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
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
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:
<t t-if="job.delivery_id and job.delivery_id.x_fc_outbound_shipment_id">
<a t-att-href="job.delivery_id.x_fc_outbound_shipment_id.tracking_url"
target="_blank">
<t t-esc="job.delivery_id.x_fc_outbound_shipment_id.tracking_number"/>
</a>
</t>
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 <a href="..."> 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:
- Open RCV-30041. Set weight (e.g. 5), dimensions, carrier = FedEx.
- Click Generate Outbound Label. Expected: UserError because the seeded FedEx carrier has
delivery_type='fixed'— manual wizard opens. - Paste a sample PDF + tracking number in wizard. Confirm.
- Verify fusion.shipment has the label and tracking saved.
- Verify Print Label button works (opens PDF).
- (If admin configures FedEx REST credentials and changes delivery_type) — re-test API path.