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,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.12.0.1',
'version': '19.0.12.1.0',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',

View File

@@ -7,6 +7,7 @@
from . import fp_job_workflow_state # Sub 14 — must load before fp_job (FK target)
from . import fp_job
from . import fp_job_sticker
from . import fp_job_step
from . import fp_job_node_override
from . import fp_portal_job

View File

@@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
"""Display helpers for the redesigned job stickers (Internal = Layout A,
one per job; External = Layout B, one per box).
Keeps the QWeb templates thin: all field resolution, the customer
short-code (shop-floor "secrecy cover"), em-dash/smart-quote cleanup for
the entech wkhtmltopdf font, and the length-tiered notes font size live
here in Python.
"""
from odoo import models
def _clean(text):
"""Strip the glyphs entech's wkhtmltopdf font mojibakes."""
if not text:
return ''
t = str(text)
for a, b in ((u'', '-'), (u'', '-'), (u'', "'"),
(u'', "'"), (u'', '"'), (u'', '"'),
(u'', '...')):
t = t.replace(a, b)
return t.strip()
class FpJob(models.Model):
_inherit = 'fp.job'
def _fp_sticker_shortcode(self, partner):
"""ABC Manufacturing Inc -> 'ABC-MANU'. First 3 of word[0] + first 4
of word[1] (alnum-only), uppercase. Single word -> first 3."""
name = (partner.name or '') if partner else ''
words = [''.join(c for c in w if c.isalnum()) for w in name.split()]
words = [w for w in words if w]
if len(words) >= 2:
return (words[0][:3] + '-' + words[1][:4]).upper()
if words:
return words[0][:3].upper()
return name or '-'
def _fp_note_pt(self, text):
"""Length-tiered notes font (pt) so long instructions stay on one
label. Mirrors the approved mockups."""
n = len(text or '')
if n <= 180:
return 11.0
if n <= 320:
return 10.0
if n <= 520:
return 9.0
return 8.5
def _fp_sticker_data(self):
"""Resolved display values for the job sticker (both variants)."""
self.ensure_one()
job = self
line = job.sale_order_line_ids[:1] if 'sale_order_line_ids' in job._fields \
else job.env['sale.order.line']
part = (('part_catalog_id' in job._fields and job.part_catalog_id)
or (line and 'x_fc_part_catalog_id' in line._fields and line.x_fc_part_catalog_id)
or False)
so = job.sale_order_id
rev = ''
if part and getattr(part, 'revision', False):
rev = (part.revision or '').strip()
if rev.lower().startswith('rev '):
rev = rev[4:].strip()
due = job.date_deadline or (so and so.commitment_date) or False
due_s = due.strftime('%b %d %Y') if due else ''
thk = ''
if line and 'x_fc_thickness_range' in line._fields and line.x_fc_thickness_range:
thk = _clean(line.x_fc_thickness_range)
q = job.qty or 0
qty = int(q) if float(q).is_integer() else q
return {
'wo': job.name or '',
'part': ((part.part_number if part and getattr(part, 'part_number', False)
else (part.name if part else '')) or ''),
'rev': rev,
'customer': self._fp_sticker_shortcode(job.partner_id),
'customer_full': job.partner_id.name or '',
'po': (so and getattr(so, 'x_fc_po_number', False)) or '',
'qty': qty,
'due': due_s,
'thk': thk,
'mask': bool(line and 'x_fc_masking_enabled' in line._fields and line.x_fc_masking_enabled),
'bake': _clean(line.x_fc_bake_instructions) if (line and 'x_fc_bake_instructions' in line._fields) else '',
'internal_notes': _clean(line.x_fc_internal_description) if (line and 'x_fc_internal_description' in line._fields) else '',
'customer_notes': _clean(line.name) if line else '',
}
def _fp_sticker_boxes(self):
"""The job's tracked boxes (External sticker prints one label each).
Empty recordset when none yet — the template falls back to 1/1."""
self.ensure_one()
if self.sale_order_id and 'fp.box' in self.env:
return self.env['fp.box'].sudo().search(
[('sale_order_id', '=', self.sale_order_id.id)], order='box_number')
return self.env['fp.box'] if 'fp.box' in self.env else self.browse()

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>