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:
@@ -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
|
||||
|
||||
104
fusion_plating/fusion_plating_jobs/models/fp_job_sticker.py
Normal file
104
fusion_plating/fusion_plating_jobs/models/fp_job_sticker.py
Normal 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()
|
||||
Reference in New Issue
Block a user