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>
105 lines
4.1 KiB
Python
105 lines
4.1 KiB
Python
# -*- 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()
|