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

@@ -86,6 +86,14 @@ class FpReceiving(models.Model):
'dropped off. Receiving is box count only — parts are '
'inspected by the racking crew when boxes are opened.',
)
box_ids = fields.One2many(
'fp.box', 'receiving_id', string='Tracked Boxes',
help='One record per physical box (box-level tracking). Auto-created '
'when the receiving is marked Counted.',
)
box_count_tracked = fields.Integer(
string='Boxes Tracked', compute='_compute_box_count_tracked',
)
expected_qty = fields.Integer(string='Expected Qty', help='Total quantity expected from the sale order.')
received_qty = fields.Integer(string='Received Qty', help='Total quantity actually received.')
qty_match = fields.Boolean(
@@ -1182,6 +1190,56 @@ class FpReceiving(models.Model):
# -------------------------------------------------------------------------
# Sub 8 — box-count-only actions (new primary flow)
# -------------------------------------------------------------------------
@api.depends('box_ids')
def _compute_box_count_tracked(self):
for rec in self:
rec.box_count_tracked = len(rec.box_ids)
def _fp_sync_boxes(self):
"""Create/sync one fp.box per received box (idempotent).
Grows the box set when box_count_in increases; removes only the
trailing boxes that are still 'received' when it shrinks (never
touches a box that has already advanced through the shop).
Resolves job_id from the SO's first job when one exists.
"""
Box = self.env['fp.box']
for rec in self:
n = int(rec.box_count_in or 0)
existing = rec.box_ids.sorted('box_number')
if existing:
existing.write({'box_count': n})
job = False
if rec.sale_order_id and 'fp.job' in self.env:
job = self.env['fp.job'].sudo().search(
[('sale_order_id', '=', rec.sale_order_id.id)], limit=1)
cur = len(existing)
if n > cur:
Box.create([{
'receiving_id': rec.id,
'box_number': i,
'box_count': n,
'job_id': job.id if job else False,
} for i in range(cur + 1, n + 1)])
elif n < cur:
drop = existing.filtered(
lambda b: b.box_number > n and b.state == 'received')
drop.unlink()
if job:
rec.box_ids.filtered(lambda b: not b.job_id).write({'job_id': job.id})
def action_view_boxes(self):
self.ensure_one()
return {
'name': _('Boxes'),
'type': 'ir.actions.act_window',
'res_model': 'fp.box',
'view_mode': 'list,form',
'domain': [('receiving_id', '=', self.id)],
'context': {'default_receiving_id': self.id,
'default_box_count': self.box_count_in or 1},
}
def action_mark_counted(self):
"""Receiver has counted the boxes on the dock. Move to Counted."""
for rec in self:
@@ -1197,6 +1255,7 @@ class FpReceiving(models.Model):
rec.message_post(body=_(
'%(user)s counted %(n)d box(es) at receiving.'
) % {'user': self.env.user.name, 'n': rec.box_count_in})
rec._fp_sync_boxes()
def action_mark_staged(self):
"""Deprecated 2026-05-20 — `staged` state was dead ceremony