feat(fusion_plating): box-level tracking (fp.box) + thermal job-sticker redesign

Box registry: new fp.box model (fusion_plating_receiving), one record per
received box, auto-created when a receiving is marked Counted (idempotent
_fp_sync_boxes — grows/shrinks with box_count_in, never touches an advanced
box). Status received -> racked -> in_process -> packed -> shipped, per-box
scannable QR (/fp/box/<id> controller). Backfill migration for receivings
counted before tracking shipped. Boxes list/kanban/form + receiving smart
button.

Job stickers redesigned (thermal label, 6x4 in / 152x102mm, mm layout @
paperformat dpi=96 so mm maps 1:1 in wkhtmltopdf — see rule 14):
- Internal Job Sticker = Layout A, ONE per job (shop notes from
  x_fc_internal_description, job QR).
- External Job Sticker = Layout B, ONE per fp.box (BOX n/N, per-box QR,
  factory company logo, customer-facing notes). Dynamic MASK badge
  (x_fc_masking_enabled) + BAKE block (x_fc_bake_instructions), length-tiered
  notes font. Display logic in fp.job._fp_sticker_data().

Also retains the SO/WO box-sticker MemoryError fix in report_fp_wo_sticker.xml
(per-box loop sourced from fp.receiving.box_count_in + 100-label safety cap).

Verified live on entech: 111 boxes backfilled (31 receivings), External renders
one page per box, Internal one per job, scan endpoint 303->login.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-06-03 13:21:54 -04:00
parent 951cad0f81
commit d531faad12
17 changed files with 827 additions and 83 deletions

View File

@@ -3,12 +3,17 @@
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Native fp.job sticker — reuses the canonical box-sticker design from
fusion_plating_reports.report_fp_wo_sticker_inner. The visual layout
(logo + WO# stack on the left, big QR on the right, 7-row body table
underneath, all wrapped in a 2px border) is the one shop staff have
been printing since the mrp.production days; we just feed it from
fp.job fields here instead of mrp.production.
Redesigned job stickers (thermal label, 6x4 in / 152x102 mm):
* Internal Job Sticker — Layout A (stacked, full-width notes),
ONE label per job. Shop copy: x_fc_internal_description notes,
job QR (/fp/job/<id>).
* External Job Sticker — Layout B (left rail + tall notes),
ONE label per fp.box. Customer copy: factory logo, BOX n/N,
per-box QR (/fp/box/<id>), customer-facing description notes.
Dynamic: MASK badge when masking enabled, BAKE block when bake
instructions present, length-tiered notes font. Field resolution +
short-code + cleanup live in fp.job._fp_sticker_data() (Python).
-->
<odoo>
@@ -25,8 +30,12 @@
<field name="header_line" eval="False"/>
<field name="header_spacing">0</field>
<field name="disable_shrinking" eval="True"/>
<!-- dpi=300 calibrated — see CLAUDE.md rule 14, 600 broke layout. -->
<field name="dpi">300</field>
<!-- dpi=96 (NOT 300): this label is laid out in mm (matches the
approved Chrome-rendered mockups). At dpi=300 wkhtmltopdf shrinks
mm content to ~96/300 of true size (CLAUDE.md rule 14). 96 maps
mm 1:1 so it fills the page; QR/logo stay crisp (embedded at their
own resolution, text is vector). Legacy px-based stickers keep 300. -->
<field name="dpi">96</field>
</record>
<record id="action_report_fp_job_sticker" model="ir.actions.report">
@@ -41,49 +50,6 @@
<field name="paperformat_id" ref="paperformat_fp_job_sticker"/>
</record>
<template id="report_fp_job_sticker_template">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="job">
<!-- Defaults block initialises every var the inner
reads (so `_so or ...` doesn't NameError). We
then override the ones we have data for. -->
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
<!-- Multi-line trigger: parent SO has 2+ part-bearing lines.
Even though this job is for a single specific part (jobs
are grouped by recipe+part+coating+thickness+SN), the
consolidated PO sticker is the requested behaviour. -->
<t t-set="_so_part_lines" t-value="job.sale_order_id
and job.sale_order_id.order_line.filtered(lambda l: l.x_fc_part_catalog_id)
or job.env['sale.order.line']"/>
<t t-set="_multi_line" t-value="len(_so_part_lines) &gt;= 2"/>
<!-- Pre-resolve the variables the shared inner template
expects, sourcing them from fp.job's native fields. -->
<t t-set="_order_id" t-value="job.name"/>
<t t-set="_scan_id" t-value="job.id"/>
<t t-set="_scan_path" t-value="'/fp/job/'"/>
<t t-set="_mo" t-value="False"/>
<t t-set="_so" t-value="job.sale_order_id"/>
<t t-set="_line" t-value="False if _multi_line else job.sale_order_line_ids[:1]"/>
<t t-set="_part" t-value="False if _multi_line else (('part_catalog_id' in job._fields and job.part_catalog_id) or False)"/>
<t t-set="_spec" t-value="False if _multi_line else (('customer_spec_id' in job._fields and job.customer_spec_id) or False)"/>
<t t-set="_process" t-value="False if _multi_line else (job.recipe_id or False)"/>
<t t-set="_due" t-value="(job.sale_order_id and job.sale_order_id.commitment_date) if _multi_line else (job.date_deadline or False)"/>
<t t-set="_qty" t-value="sum(_so_part_lines.mapped('product_uom_qty')) if _multi_line else job.qty"/>
<t t-set="_qty_total" t-value="1 if _multi_line else job.qty"/>
<t t-set="_partner_name" t-value="job.partner_id.name"/>
<!-- The fp.job's own name (WH/JOB/00033) is already
printed in the header as "WO #...", so suppress
the muted "(WH/MO/...)" suffix on the PO row. -->
<t t-set="_mo_ref" t-value="''"/>
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
</t>
</t>
</template>
<!-- Internal Job sticker — same fields as External, but the Notes
column reads x_fc_internal_description from the first linked
SO line (Sub 5 thickness+serial grouping means same-x_fc lines
share a job, so first-line is representative). -->
<record id="action_report_fp_job_sticker_internal" model="ir.actions.report">
<field name="name">Internal Job Sticker</field>
<field name="model">fp.job</field>
@@ -96,36 +62,191 @@
<field name="paperformat_id" ref="paperformat_fp_job_sticker"/>
</record>
<!-- ============================ Shared CSS ============================ -->
<template id="fp_job_sticker_styles">
<style>
@page { size: 152mm 102mm; margin: 0; }
* { box-sizing: border-box; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
html, body { margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; color: #000; }
.label-page { width: 152mm; height: 102mm; position: relative; overflow: hidden; page-break-after: always; }
.label { position: absolute; top: 2mm; left: 2mm; right: 2mm; bottom: 2mm; border: 0.9mm solid #000; overflow: hidden; }
.fpt { border-collapse: collapse; width: 100%; }
.fpt td { vertical-align: middle; }
.lbl { font-size: 7.5pt; font-weight: bold; letter-spacing: 0.4pt; text-transform: uppercase; display: block; }
.band { background: #000; color: #fff; }
.pad { padding: 1mm 2.5mm; }
.vrule { border-right: 0.5mm solid #000; }
.rule { border-bottom: 0.6mm solid #000; }
.badge { display: inline-block; background: #000; color: #fff; font-size: 10pt; font-weight: 900; padding: 0.6mm 2.2mm; margin-left: 1.5mm; }
.tag { display: inline-block; background: transparent; color: #fff; border: 0.5mm solid #fff; font-size: 8pt; font-weight: bold; padding: 0.4mm 2mm; }
.inshead { font-size: 8.5pt; font-weight: 900; letter-spacing: 0.6pt; background: #000; color: #fff; display: inline-block; padding: 0.5mm 2.5mm; }
.instext { font-weight: bold; }
/* Layout B rail + main */
.rail { position: absolute; left: 0; top: 0; bottom: 0; width: 50mm; border-right: 0.9mm solid #000; overflow: hidden; }
.main { position: absolute; left: 50mm; right: 0; top: 0; bottom: 0; overflow: hidden; }
.r-logo { height: 12mm; line-height: 12mm; text-align: center; }
.r-logo img { max-height: 10mm; max-width: 45mm; vertical-align: middle; }
.r-wo { height: 18mm; background: #000; color: #fff; padding: 0; }
.wobtbl { border-collapse: collapse; width: 100%; height: 100%; }
.wobtbl td { padding: 1mm 2.2mm; vertical-align: middle; }
.bignum { font-size: 17pt; font-weight: 900; line-height: 1; display: block; color: #fff; }
.r-flags { height: 8mm; text-align: center; padding-top: 1.2mm; }
.r-flags .badge { margin: 0 1mm; font-size: 10pt; padding: 0.6mm 2.4mm; }
.r-qr { height: 19mm; line-height: 19mm; text-align: center; }
.r-qr img { width: 17mm; height: 17mm; vertical-align: middle; }
.r-fld { padding: 1mm 2.2mm; }
.gtbl { border-collapse: collapse; width: 100%; height: 100%; }
.gtbl td { padding: 1mm 2.2mm; vertical-align: middle; }
.m-bake { padding: 1.3mm 2.6mm 1.8mm; }
.m-notes { padding: 1.3mm 2.6mm 3.5mm; }
</style>
</template>
<!-- ===================== Internal body — Layout A ===================== -->
<template id="fp_job_internal_body">
<div class="label-page"><div class="label">
<table class="fpt">
<tr style="height:22mm" class="rule">
<td class="band pad">
<span style="float:right"><span class="tag">INTERNAL</span></span>
<span class="lbl" style="color:#fff">Work Order</span>
<div style="font-size:30pt;font-weight:900;line-height:0.95"><t t-esc="d['wo']"/></div>
</td>
<td style="width:34mm;border-left:0.9mm solid #000;text-align:center;padding:1mm">
<img t-att-src="_qr" style="width:29mm;height:29mm"/>
</td>
</tr>
<tr style="height:12mm" class="rule">
<td class="pad" colspan="2">
<span class="lbl">Part#</span>
<span style="font-size:18pt;font-weight:900"> <t t-esc="d['part'] or '-'"/></span>
<t t-if="d['rev']"><span style="font-size:11pt;font-weight:bold"> Rev <t t-esc="d['rev']"/></span></t>
<span style="float:right">
<t t-if="d['mask']"><span class="badge">MASK</span></t>
<t t-if="d['bake']"><span class="badge">BAKE</span></t>
</span>
</td>
</tr>
<tr style="height:10mm" class="rule">
<td style="padding:0" colspan="2"><table class="fpt"><tr>
<td class="pad vrule" style="width:25%"><span class="lbl">Customer</span><br/><span style="font-size:12pt;font-weight:900"><t t-esc="d['customer']"/></span></td>
<td class="pad vrule" style="width:21%"><span class="lbl">PO#</span><br/><span style="font-size:11pt;font-weight:bold"><t t-esc="d['po'] or '-'"/></span></td>
<td class="pad vrule" style="width:13%"><span class="lbl">Qty</span><br/><span style="font-size:12pt;font-weight:900"><t t-esc="d['qty']"/></span></td>
<td class="pad vrule" style="width:22%"><span class="lbl">Due</span><br/><span style="font-size:10pt;font-weight:bold"><t t-esc="d['due'] or '-'"/></span></td>
<td class="pad"><span class="lbl">Thk</span><br/><span style="font-size:9.5pt;font-weight:bold"><t t-esc="d['thk'] or '-'"/></span></td>
</tr></table></td>
</tr>
<t t-if="d['bake']">
<tr style="height:13mm" class="rule">
<td class="pad" colspan="2" style="vertical-align:top;padding-top:1.5mm">
<span class="inshead">BAKE</span>
<span class="instext" style="font-size:10pt;line-height:1.18"> <t t-esc="d['bake']"/></span>
</td>
</tr>
</t>
<tr>
<td class="pad" colspan="2" style="vertical-align:top;padding:1.5mm 2.5mm 3.5mm 2.5mm;overflow:hidden">
<span class="inshead">NOTES</span>
<div class="instext" t-att-style="'font-size:%spt;line-height:1.18;margin-top:1.5mm' % _note_pt"><t t-esc="_note or '-'"/></div>
</td>
</tr>
</table>
</div></div>
</template>
<!-- ===================== External body — Layout B ===================== -->
<template id="fp_job_external_body">
<div class="label-page"><div class="label">
<div class="rail">
<div class="r-logo rule">
<img t-if="_logo" t-att-src="image_data_uri(_logo)"/>
<span t-if="not _logo" style="font-size:11pt;font-weight:900"><t t-esc="d['customer_full'][:18]"/></span>
</div>
<div class="r-wo">
<table class="wobtbl"><tr>
<td class="vrule" style="width:52%;border-right-color:#fff">
<span class="lbl" style="color:#fff">Work Order</span>
<span class="bignum"><t t-esc="d['wo'].split('-')[-1].split('/')[-1]"/></span>
</td>
<td>
<span class="lbl" style="color:#fff">Box</span>
<span class="bignum"><t t-esc="_box_num"/> / <t t-esc="_box_cnt"/></span>
</td>
</tr></table>
</div>
<div class="r-flags rule">
<t t-if="d['mask']"><span class="badge">MASK</span></t>
<t t-if="d['bake']"><span class="badge">BAKE</span></t>
</div>
<div class="r-qr rule"><img t-att-src="_qr"/></div>
<div class="r-fld rule">
<span class="lbl">Part#</span>
<span style="font-size:11.5pt;font-weight:900"><t t-esc="d['part'] or '-'"/></span>
<t t-if="d['rev']"><span style="font-size:8.5pt;font-weight:bold"> Rev <t t-esc="d['rev']"/></span></t>
</div>
<div class="r-fld rule"><span class="lbl">Customer</span><span style="font-size:11pt;font-weight:900"><t t-esc="d['customer']"/></span></div>
<div class="rule" style="height:9.5mm"><table class="gtbl"><tr>
<td class="vrule" style="width:55%"><span class="lbl">PO#</span><span style="font-size:9.5pt;font-weight:bold;display:block"><t t-esc="d['po'] or '-'"/></span></td>
<td><span class="lbl">Qty</span><span style="font-size:11pt;font-weight:900;display:block"><t t-esc="d['qty']"/></span></td>
</tr></table></div>
<div style="height:9.5mm"><table class="gtbl"><tr>
<td class="vrule" style="width:55%"><span class="lbl">Due</span><span style="font-size:9pt;font-weight:bold;display:block"><t t-esc="d['due'] or '-'"/></span></td>
<td><span class="lbl">Thk (mils)</span><span style="font-size:8.5pt;font-weight:bold;display:block"><t t-esc="d['thk'] or '-'"/></span></td>
</tr></table></div>
</div>
<div class="main">
<t t-if="d['bake']">
<div class="m-bake rule"><span class="inshead">BAKE</span>
<div class="instext" style="font-size:10pt;line-height:1.22;margin-top:1mm"><t t-esc="d['bake']"/></div>
</div>
</t>
<div class="m-notes"><span class="inshead">NOTES</span>
<div class="instext" t-att-style="'font-size:%spt;line-height:1.25;margin-top:1.3mm' % _note_pt"><t t-esc="_note or '-'"/></div>
</div>
</div>
</div></div>
</template>
<!-- ===================== Internal outer (per job) ===================== -->
<template id="report_fp_job_sticker_internal_template">
<t t-call="web.html_container">
<t t-call="fusion_plating_jobs.fp_job_sticker_styles"/>
<t t-foreach="docs" t-as="job">
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
<t t-set="_so_part_lines" t-value="job.sale_order_id
and job.sale_order_id.order_line.filtered(lambda l: l.x_fc_part_catalog_id)
or job.env['sale.order.line']"/>
<t t-set="_multi_line" t-value="len(_so_part_lines) &gt;= 2"/>
<t t-set="_order_id" t-value="job.name"/>
<t t-set="_scan_id" t-value="job.id"/>
<t t-set="_scan_path" t-value="'/fp/job/'"/>
<t t-set="_mo" t-value="False"/>
<t t-set="_so" t-value="job.sale_order_id"/>
<t t-set="_line" t-value="False if _multi_line else job.sale_order_line_ids[:1]"/>
<t t-set="_part" t-value="False if _multi_line else (('part_catalog_id' in job._fields and job.part_catalog_id) or False)"/>
<t t-set="_spec" t-value="False if _multi_line else (('customer_spec_id' in job._fields and job.customer_spec_id) or False)"/>
<t t-set="_process" t-value="False if _multi_line else (job.recipe_id or False)"/>
<t t-set="_due" t-value="(job.sale_order_id and job.sale_order_id.commitment_date) if _multi_line else (job.date_deadline or False)"/>
<t t-set="_qty" t-value="sum(_so_part_lines.mapped('product_uom_qty')) if _multi_line else job.qty"/>
<t t-set="_qty_total" t-value="1 if _multi_line else job.qty"/>
<t t-set="_partner_name" t-value="job.partner_id.name"/>
<t t-set="_mo_ref" t-value="''"/>
<!-- Internal override: read x_fc_internal_description from
the first linked SO line. Multi-line PO blanks it
since each line has its own description. -->
<t t-set="_notes_content" t-value="'-' if _multi_line else
((job.sale_order_line_ids[:1]
and 'x_fc_internal_description' in job.sale_order_line_ids[:1]._fields
and job.sale_order_line_ids[:1].x_fc_internal_description) or '-')"/>
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
<t t-set="d" t-value="job._fp_sticker_data()"/>
<t t-set="_note" t-value="d['internal_notes']"/>
<t t-set="_note_pt" t-value="job._fp_note_pt(_note)"/>
<t t-set="_base" t-value="job.env['ir.config_parameter'].sudo().get_param('web.base.url', '')"/>
<t t-set="_qr" t-value="job.env['ir.actions.report'].sudo().barcode_data_uri('QR', _base + '/fp/job/' + str(job.id), width=300, height=300)"/>
<t t-call="fusion_plating_jobs.fp_job_internal_body"/>
</t>
</t>
</template>
<!-- ===================== External outer (per box) ===================== -->
<template id="report_fp_job_sticker_template">
<t t-call="web.html_container">
<t t-call="fusion_plating_jobs.fp_job_sticker_styles"/>
<t t-foreach="docs" t-as="job">
<t t-set="d" t-value="job._fp_sticker_data()"/>
<t t-set="_note" t-value="d['customer_notes']"/>
<t t-set="_note_pt" t-value="job._fp_note_pt(_note)"/>
<t t-set="_logo" t-value="job.env.company.logo or job.env.company.logo_web or job.env.company.partner_id.image_1920 or False"/>
<t t-set="_base" t-value="job.env['ir.config_parameter'].sudo().get_param('web.base.url', '')"/>
<t t-set="boxes" t-value="job._fp_sticker_boxes()"/>
<t t-if="boxes">
<t t-foreach="boxes" t-as="box">
<t t-set="_box_num" t-value="box.box_number"/>
<t t-set="_box_cnt" t-value="box.box_count or len(boxes)"/>
<t t-set="_qr" t-value="job.env['ir.actions.report'].sudo().barcode_data_uri('QR', _base + '/fp/box/' + str(box.id), width=300, height=300)"/>
<t t-call="fusion_plating_jobs.fp_job_external_body"/>
</t>
</t>
<t t-else="">
<t t-set="_box_num" t-value="1"/>
<t t-set="_box_cnt" t-value="1"/>
<t t-set="_qr" t-value="job.env['ir.actions.report'].sudo().barcode_data_uri('QR', _base + '/fp/job/' + str(job.id), width=300, height=300)"/>
<t t-call="fusion_plating_jobs.fp_job_external_body"/>
</t>
</t>
</t>
</template>