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:
@@ -6,6 +6,7 @@
|
||||
from . import fp_receiving_damage
|
||||
from . import fp_receiving_line
|
||||
from . import fp_outbound_package
|
||||
from . import fp_box
|
||||
from . import fp_receiving
|
||||
from . import fp_racking_inspection
|
||||
from . import sale_order
|
||||
|
||||
111
fusion_plating/fusion_plating_receiving/models/fp_box.py
Normal file
111
fusion_plating/fusion_plating_receiving/models/fp_box.py
Normal file
@@ -0,0 +1,111 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
"""Per-box registry for box-level tracking.
|
||||
|
||||
One `fp.box` per physical box received against a `fp.receiving`. Auto-created
|
||||
when the receiver enters `box_count_in` and marks the receiving Counted
|
||||
(see `fp.receiving._fp_sync_boxes`). Each box carries a sequence number
|
||||
(n of N), a status that advances through the shop, and a scannable identity
|
||||
(`/fp/box/<id>`) printed on the External Job Sticker — one label per box.
|
||||
|
||||
Box-level tracking (not box CONTENTS): we track WHICH box and WHERE it is,
|
||||
not the per-box part breakdown. The same boxes go back to the customer
|
||||
(Sub 8), so reconciliation flags any box that never reaches `shipped`.
|
||||
"""
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
STATE_ORDER = ['received', 'racked', 'in_process', 'packed', 'shipped']
|
||||
|
||||
|
||||
class FpBox(models.Model):
|
||||
_name = 'fp.box'
|
||||
_description = 'Fusion Plating — Tracked Box'
|
||||
_inherit = ['mail.thread']
|
||||
_order = 'receiving_id, box_number'
|
||||
|
||||
name = fields.Char(string='Box', compute='_compute_name', store=True)
|
||||
box_number = fields.Integer(string='Box #', required=True, default=1, tracking=True)
|
||||
box_count = fields.Integer(string='Of', tracking=True,
|
||||
help='Total boxes in this receiving (N in "n of N").')
|
||||
|
||||
receiving_id = fields.Many2one('fp.receiving', string='Receiving', required=True,
|
||||
ondelete='cascade', index=True)
|
||||
sale_order_id = fields.Many2one('sale.order', string='Sale Order',
|
||||
related='receiving_id.sale_order_id', store=True, index=True)
|
||||
partner_id = fields.Many2one('res.partner', string='Customer',
|
||||
related='receiving_id.partner_id', store=True)
|
||||
job_id = fields.Many2one('fp.job', string='Work Order', index=True,
|
||||
help='Resolved job for this box (single-job SO). '
|
||||
'The sticker resolves boxes via the SO when blank.')
|
||||
company_id = fields.Many2one('res.company', string='Company',
|
||||
default=lambda self: self.env.company, index=True)
|
||||
|
||||
state = fields.Selection([
|
||||
('received', 'Received'),
|
||||
('racked', 'Racked'),
|
||||
('in_process', 'In Process'),
|
||||
('packed', 'Packed'),
|
||||
('shipped', 'Shipped'),
|
||||
('lost', 'Lost'),
|
||||
('cancelled', 'Cancelled'),
|
||||
], string='Status', default='received', required=True, tracking=True, index=True)
|
||||
|
||||
location_note = fields.Char(string='Location / Note', tracking=True,
|
||||
help='Free text — where is this box now (rack, bay, shelf).')
|
||||
scan_url = fields.Char(string='Scan URL', compute='_compute_scan_url')
|
||||
|
||||
_box_uniq = models.Constraint(
|
||||
'UNIQUE(receiving_id, box_number)',
|
||||
'Box number must be unique within a receiving.')
|
||||
|
||||
# ------------------------------------------------------------------ computes
|
||||
@api.depends('box_number', 'box_count', 'receiving_id.name', 'sale_order_id.name')
|
||||
def _compute_name(self):
|
||||
for rec in self:
|
||||
base = rec.receiving_id.name or (rec.sale_order_id.name if rec.sale_order_id else '') or 'BOX'
|
||||
rec.name = '%s · Box %d/%d' % (base, rec.box_number or 1, rec.box_count or 1)
|
||||
|
||||
def _compute_scan_url(self):
|
||||
base = self.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
|
||||
for rec in self:
|
||||
rec.scan_url = ('%s/fp/box/%s' % (base, rec.id)) if rec.id else ''
|
||||
|
||||
# ------------------------------------------------------------------ workflow
|
||||
def _set_state(self, new_state):
|
||||
for rec in self:
|
||||
old = dict(rec._fields['state'].selection).get(rec.state, rec.state)
|
||||
new = dict(rec._fields['state'].selection).get(new_state, new_state)
|
||||
rec.state = new_state
|
||||
rec.message_post(body=_(
|
||||
'Box %(n)s/%(N)s: %(old)s → %(new)s by %(u)s'
|
||||
) % {'n': rec.box_number, 'N': rec.box_count,
|
||||
'old': old, 'new': new, 'u': self.env.user.name})
|
||||
|
||||
def action_set_racked(self):
|
||||
self._set_state('racked')
|
||||
|
||||
def action_set_in_process(self):
|
||||
self._set_state('in_process')
|
||||
|
||||
def action_set_packed(self):
|
||||
self._set_state('packed')
|
||||
|
||||
def action_set_shipped(self):
|
||||
self._set_state('shipped')
|
||||
|
||||
def action_set_lost(self):
|
||||
self._set_state('lost')
|
||||
|
||||
def action_reset_received(self):
|
||||
self._set_state('received')
|
||||
|
||||
def action_open_record(self):
|
||||
"""Used by the /fp/box/<id> scan endpoint to land on the box form."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fp.box',
|
||||
'res_id': self.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user