225 lines
12 KiB
Markdown
225 lines
12 KiB
Markdown
# 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
|
|
|
|
```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
|
|
<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:
|
|
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.
|