feat(reports): MO-bound WO sticker + polished professional layout

User reported two issues with the sticker:

1. "Print → WO Box Sticker" didn't appear on the MO form
   (WH/MO/00067). The operator workflow lives on the MO form, not
   the WO — binding only to mrp.workorder meant they couldn't see
   the option. Now bound to BOTH:
     * mrp.workorder (per-WO sticker)
     * mrp.production (per-MO sticker — prints the MO friendly
       name after "WO #" so it reads naturally in shop-floor
       vocabulary)
   Internal refactor: factored the layout into a shared inner
   template report_fp_wo_sticker_inner; the two outer templates
   normalise their input to the same _order_id / _scan_id / _mo
   variables and t-call the inner.

2. Design polish. The previous layout was a plain label/value
   table that looked rough. Redesigned with:
     * Proper sticker chrome: 0.5mm black border, 1.5mm rounded
       corners, edge padding.
     * Header row with bottom border rule separating logo+WO-# on
       the left from QR+caption on the right.
     * Grid rows now alternate white / #f4f5f7 zebra-striping with
       a right-aligned vertical rule between label and value.
     * ALL-CAPS, letter-spaced, gray-333 labels at 7.5pt; values
       at 8.5pt with strong (9.5pt, 700) emphasis on the key data
       (PO, Part Number, Qty) so it reads at a glance from across
       the warehouse.
     * Helvetica Neue font stack.
     * "SCAN TO OPEN" caption under the QR.

Scan endpoint updated: /fp/wo/<id> now tries mrp.production first
(operator home form) then falls back to mrp.workorder. Numeric
collisions between the two id spaces are possible; MO wins because
the MO view carries the full context.

fusion_plating_reports → 19.0.7.1.0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-23 10:52:22 -04:00
parent be33a76ad2
commit e32ff4b056
4 changed files with 286 additions and 145 deletions

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Reports',
'version': '19.0.7.0.0',
'version': '19.0.7.1.0',
'category': 'Manufacturing/Plating',
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
'depends': [

View File

@@ -20,19 +20,27 @@ class FpWoScanController(http.Controller):
@http.route('/fp/wo/<int:wo_id>', type='http', auth='user', website=False)
def wo_scan_redirect(self, wo_id, **kwargs):
"""Redirect a scanned WO sticker to the work-order form.
"""Redirect a scanned sticker to the right backend form.
Uses Odoo 17+/19's action-URL format so the backend opens
directly on the WO's form view. Falls back to a generic
not-found URL if the id doesn't resolve.
Stickers are printed from two sources — mrp.workorder (WO) and
mrp.production (MO) — and both embed their own numeric id in
the QR. Try the MO table first (operators live on the MO
form — customer, SO, all WOs visible) and fall back to WO.
"""
wo = request.env['mrp.workorder'].sudo().browse(wo_id).exists()
if not wo:
# Land on the list of all WOs so staff can search manually.
return request.redirect('/odoo/manufacturing/work-orders')
# /odoo/action-<xmlid>/<id> opens the record's form view.
# Using the vanilla MRP action here so it works regardless of
# whether the user has Plating-specific menus.
MO = request.env['mrp.production'].sudo()
WO = request.env['mrp.workorder'].sudo()
mo = MO.browse(wo_id).exists()
if mo:
return request.redirect(
'/odoo/action-mrp.mrp_production_action/%d' % mo.id
)
wo = WO.browse(wo_id).exists()
if wo:
return request.redirect(
'/odoo/action-mrp.action_mrp_workorder/%d' % wo.id
)
# Neither resolved — land on the WO list so staff can search manually.
return request.redirect('/odoo/manufacturing/work-orders')

View File

@@ -348,6 +348,22 @@
<field name="paperformat_id" ref="paperformat_fp_wo_sticker"/>
</record>
<!-- Same sticker bound to mrp.production so "Print → WO Box
Sticker" appears on the MO form too. Operators live on the
MO form (WH/MO/00067 etc.); binding to both surfaces keeps
the workflow frictionless. -->
<record id="action_report_fp_mo_sticker" model="ir.actions.report">
<field name="name">WO Box Sticker</field>
<field name="model">mrp.production</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_fp_mo_sticker</field>
<field name="report_file">fusion_plating_reports.report_fp_mo_sticker</field>
<field name="print_report_name">'WO Sticker - %s' % (object.name or '').replace('/', '-')</field>
<field name="binding_model_id" ref="mrp.model_mrp_production"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_wo_sticker"/>
</record>
<!-- ============================================================= -->
<!-- 15. Packing Slip (Portrait + Landscape) -->
<!-- ============================================================= -->

View File

@@ -3,102 +3,178 @@
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
WO Box Sticker — 4x3" parts-box identification label.
Parts-box identification sticker — printed on a 4x3" label.
Layout mirrors the Steelhead sticker format the client is
migrating from:
* ENTECH logo top-left.
* QR code top-right — encodes the WO's scan URL
(/fp/wo/<id>) so warehouse staff can scan with any
phone/tablet and land on the WO form instantly.
* Grid of PO / Customer / Process / Part Number / Due Date
/ Qty / Notes rows.
Bound to BOTH mrp.production (MO) and mrp.workorder (WO) because
the shop talks in "WO #" terms (Steelhead legacy) but the data
hangs off the MO record. The inner template normalises either
input to the same set of resolved variables:
* _order_id — number to print as "WO #"
* _mo — the mrp.production record
* _so, _line — the originating sale order / line
* _part — fp.part.catalog
* _coating — fp.coating.config
* _process — the resolved fusion.plating.process.node tree
* _scan_url — base_url + /fp/wo/<id> (encoded into the QR)
Printed on a dedicated 4x3" paperformat; no header / footer.
The sticker works identically whether triggered from the MO form
or from any of its child WOs.
-->
<odoo>
<template id="report_fp_wo_sticker">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-set="_mo" t-value="doc.production_id"/>
<!-- ========== Shared inner template ========== -->
<template id="report_fp_wo_sticker_inner">
<t t-set="_base_url" t-value="env['ir.config_parameter'].sudo().get_param('web.base.url', '')"/>
<!-- _scan_id is always a numeric database id (mo.id or wo.id)
so the scan endpoint can resolve it cleanly. _order_id is
what gets printed next to "WO #" and can be a friendly
Odoo name like "WH/MO/00067". -->
<t t-set="_scan_url" t-value="_base_url + '/fp/wo/' + str(_scan_id)"/>
<t t-set="_so" t-value="_mo and env['sale.order'].sudo().search(
[('name', '=', _mo.origin)], limit=1) or False"/>
<t t-set="_line" t-value="_so and _so.order_line[:1] or False"/>
<t t-set="_part" t-value="
(_line and _line.x_fc_part_catalog_id)
or (doc.production_id and 'x_fc_part_catalog_ids' in _mo._fields
and _mo.x_fc_sale_order_line_ids[:1].x_fc_part_catalog_id)
<t t-set="_line" t-value="(_mo and 'x_fc_sale_order_line_ids' in _mo._fields
and _mo.x_fc_sale_order_line_ids[:1])
or (_so and _so.order_line[:1])
or False"/>
<t t-set="_part" t-value="_line and _line.x_fc_part_catalog_id or False"/>
<t t-set="_coating" t-value="_line and _line.x_fc_coating_config_id or False"/>
<t t-set="_process" t-value="(_part and _part.default_process_id)
or (_coating and _coating.recipe_id)
or False"/>
<t t-set="_base_url" t-value="env['ir.config_parameter'].sudo().get_param('web.base.url', '')"/>
<t t-set="_scan_url" t-value="_base_url + '/fp/wo/' + str(doc.id)"/>
<div class="page" style="font-family: Arial, sans-serif;
color: #000; width: 100%; height: 100%; padding: 4mm;
box-sizing: border-box;">
<t t-set="_due" t-value="(_mo and (_mo.date_deadline or _mo.date_finished))
or (_line and _line.x_fc_part_deadline)
or False"/>
<style>
.fp-stk-row { display: table-row; }
.fp-stk-lbl {
display: table-cell;
vertical-align: top;
font-weight: bold;
text-decoration: underline;
padding: 1mm 2mm 1mm 0;
white-space: nowrap;
.fp-sticker {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
color: #111;
width: 100%;
height: 100%;
padding: 3mm 4mm;
box-sizing: border-box;
border: 0.5mm solid #000;
border-radius: 1.5mm;
}
.fp-stk-val {
.fp-sticker-header {
display: table;
width: 100%;
border-bottom: 0.3mm solid #000;
padding-bottom: 2mm;
margin-bottom: 2mm;
}
.fp-sticker-header-left {
display: table-cell;
vertical-align: middle;
width: 66%;
padding-right: 2mm;
}
.fp-sticker-header-right {
display: table-cell;
vertical-align: middle;
width: 34%;
text-align: right;
}
.fp-sticker-logo {
max-height: 11mm;
max-width: 100%;
}
.fp-sticker-wo {
font-size: 15pt;
font-weight: 700;
letter-spacing: 0.3mm;
margin-top: 1.5mm;
}
.fp-sticker-qr {
width: 22mm;
height: 22mm;
display: block;
margin-left: auto;
margin-right: 0;
}
.fp-sticker-qr-caption {
font-size: 6pt;
color: #666;
text-align: right;
margin-top: 0.3mm;
letter-spacing: 0.1mm;
text-transform: uppercase;
}
.fp-sticker-grid {
display: table;
width: 100%;
font-size: 8.5pt;
border-collapse: collapse;
}
.fp-sticker-row { display: table-row; }
.fp-sticker-row-alt .fp-sticker-label,
.fp-sticker-row-alt .fp-sticker-value {
background-color: #f4f5f7;
}
.fp-sticker-label {
display: table-cell;
width: 28%;
vertical-align: top;
padding: 1mm 2mm 1mm 2mm;
font-weight: 700;
letter-spacing: 0.15mm;
text-transform: uppercase;
font-size: 7.5pt;
color: #333;
border-right: 0.3mm solid #000;
}
.fp-sticker-value {
display: table-cell;
vertical-align: top;
padding: 1mm 0 1mm 2mm;
border-left: 0.4mm solid #000;
padding: 1mm 2mm 1mm 2.5mm;
}
.fp-sticker-strong {
font-weight: 700;
font-size: 9.5pt;
}
</style>
<!-- Header: logo + WO # + QR -->
<div style="display: table; width: 100%;">
<!-- Logo + WO number -->
<div style="display: table-cell; vertical-align: top;
width: 65%; padding-right: 2mm;">
<div class="fp-sticker">
<!-- Header — logo + WO number + QR -->
<div class="fp-sticker-header">
<div class="fp-sticker-header-left">
<img t-if="env.company.logo"
t-att-src="image_data_uri(env.company.logo)"
style="max-height: 14mm; max-width: 100%;"/>
<div style="font-size: 14pt; font-weight: bold;
margin-top: 2mm;">
WO #<span t-esc="doc.id"/>
class="fp-sticker-logo"
t-att-src="image_data_uri(env.company.logo)"/>
<div class="fp-sticker-wo">
WO #<span t-esc="_order_id"/>
</div>
</div>
<!-- QR code — linked to the scan redirect endpoint -->
<div style="display: table-cell; vertical-align: top;
width: 35%; text-align: right;">
<img t-att-src="'/report/barcode/?barcode_type=QR&amp;value=%s&amp;width=200&amp;height=200' % quote(_scan_url)"
style="width: 26mm; height: 26mm;"/>
<div class="fp-sticker-header-right">
<img class="fp-sticker-qr"
t-att-src="'/report/barcode/?barcode_type=QR&amp;value=%s&amp;width=300&amp;height=300' % quote(_scan_url)"/>
<div class="fp-sticker-qr-caption">scan to open</div>
</div>
</div>
<!-- Data grid -->
<div style="display: table; width: 100%;
margin-top: 3mm; font-size: 9pt;">
<div class="fp-stk-row">
<div class="fp-stk-lbl">PO (RO):</div>
<div class="fp-stk-val">
<t t-esc="(_so and _so.x_fc_po_number) or '—'"/>
<t t-if="_mo"> (<span t-esc="_mo.id"/>)</t>
<div class="fp-sticker-grid">
<div class="fp-sticker-row">
<div class="fp-sticker-label">PO (RO)</div>
<div class="fp-sticker-value">
<span class="fp-sticker-strong"
t-esc="(_so and _so.x_fc_po_number) or '—'"/>
<t t-if="_mo">
<span style="color:#666;">
(<span t-esc="_mo.name"/>)
</span>
</t>
</div>
</div>
<div class="fp-stk-row">
<div class="fp-stk-lbl">Customer:</div>
<div class="fp-stk-val">
<t t-esc="(_so and _so.partner_id.name) or '—'"/>
<div class="fp-sticker-row fp-sticker-row-alt">
<div class="fp-sticker-label">Customer</div>
<div class="fp-sticker-value">
<span t-esc="(_so and _so.partner_id.name) or '—'"/>
</div>
</div>
<div class="fp-stk-row">
<div class="fp-stk-lbl">Process:</div>
<div class="fp-stk-val">
<div class="fp-sticker-row">
<div class="fp-sticker-label">Process</div>
<div class="fp-sticker-value">
<t t-if="_process">
<span t-esc="_process.name"/>
</t>
@@ -108,41 +184,82 @@
<t t-else=""></t>
</div>
</div>
<div class="fp-stk-row">
<div class="fp-stk-lbl">Part Number:</div>
<div class="fp-stk-val">
<div class="fp-sticker-row fp-sticker-row-alt">
<div class="fp-sticker-label">Part Number</div>
<div class="fp-sticker-value">
<t t-if="_part">
<span t-esc="_part.part_number"/>
<t t-if="_part.revision"> Rev <span t-esc="_part.revision"/></t>
<span class="fp-sticker-strong"
t-esc="_part.part_number"/>
<t t-if="_part.revision">
<span style="color:#666;">
Rev <span t-esc="_part.revision"/>
</span>
</t>
</t>
<t t-else=""></t>
</div>
</div>
<div class="fp-stk-row">
<div class="fp-stk-lbl">Due Date:</div>
<div class="fp-stk-val">
<t t-set="_due" t-value="_mo and (_mo.date_deadline or _mo.date_finished) or False"/>
<div class="fp-sticker-row">
<div class="fp-sticker-label">Due Date</div>
<div class="fp-sticker-value">
<t t-if="_due">
<span t-esc="_due.strftime('%m/%d/%Y')"/>
<span t-esc="_due.strftime('%b %d, %Y')"/>
</t>
<t t-else=""></t>
</div>
</div>
<div class="fp-stk-row">
<div class="fp-stk-lbl">Qty:</div>
<div class="fp-stk-val">
<span t-esc="int(doc.qty_production) if doc.qty_production == int(doc.qty_production) else doc.qty_production"/>
<div class="fp-sticker-row fp-sticker-row-alt">
<div class="fp-sticker-label">Qty</div>
<div class="fp-sticker-value">
<span class="fp-sticker-strong">
<t t-set="_qty" t-value="_mo and _mo.product_qty or 0"/>
<span t-esc="int(_qty) if _qty == int(_qty) else _qty"/>
</span>
</div>
</div>
<div class="fp-stk-row">
<div class="fp-stk-lbl">Notes:</div>
<div class="fp-stk-val">
<div class="fp-sticker-row">
<div class="fp-sticker-label">Notes</div>
<div class="fp-sticker-value"
style="font-size: 7.5pt; color: #333;">
<t t-esc="(_so and _so.x_fc_internal_note
and _so.x_fc_internal_note.striptags()[:120]) or ''"/>
and _so.x_fc_internal_note.striptags()[:140]) or ''"/>
</div>
</div>
</div>
</div>
</template>
<!-- ========== Outer template — mrp.workorder entry ========== -->
<template id="report_fp_wo_sticker">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<div class="page"
style="padding:0; margin:0; width:100%; height:100%;">
<t t-set="_order_id" t-value="doc.id"/>
<t t-set="_scan_id" t-value="doc.id"/>
<t t-set="_mo" t-value="doc.production_id"/>
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
</div>
</t>
</t>
</template>
<!-- ========== Outer template — mrp.production entry ========== -->
<template id="report_fp_mo_sticker">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<div class="page"
style="padding:0; margin:0; width:100%; height:100%;">
<!-- Print the MO's friendly name after "WO #" because
the shop floor terminology is "WO" for the top-
level order, regardless of Odoo's MO/WO split.
The QR still encodes the numeric id so scanning
resolves cleanly. -->
<t t-set="_order_id" t-value="doc.name or doc.id"/>
<t t-set="_scan_id" t-value="doc.id"/>
<t t-set="_mo" t-value="doc"/>
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
</div>
</t>
</t>
</template>