Files
Odoo-Modules/fusion_plating/docs/superpowers/specs/2026-05-18-phase-c-generate-label-end-to-end-design.md
gsinghpal 091f98e1f9 changes
2026-05-18 22:33:23 -04:00

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

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:

  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.