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

@@ -5,3 +5,4 @@
from . import models
from . import wizards
from . import controllers

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Receiving & Inspection',
'version': '19.0.3.28.5',
'version': '19.0.3.29.1',
'category': 'Manufacturing/Plating',
'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.',
'description': """
@@ -44,6 +44,7 @@ Provides:
'views/fp_racking_inspection_views.xml',
'views/sale_order_views.xml',
'views/fp_receiving_menu.xml',
'views/fp_box_views.xml',
'views/fusion_shipment_inherit_views.xml',
'wizards/fp_label_manual_wizard_views.xml',
'wizards/fp_label_generate_wizard_views.xml',

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
from . import fp_box_controller

View File

@@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
"""Box scan endpoint. The per-box QR on the External Job Sticker encodes
``/fp/box/<id>``; scanning it (logged-in operator on the tablet) lands on
the box's backend form where they can advance its status."""
from odoo import http
from odoo.http import request
class FpBoxScan(http.Controller):
@http.route(['/fp/box/<int:box_id>'], type='http', auth='user', website=False)
def fp_box_scan(self, box_id, **kw):
box = request.env['fp.box'].sudo().browse(box_id).exists()
if not box:
return request.not_found()
# Land on the box form in the web client (operator advances status there).
return request.redirect('/web#id=%s&model=fp.box&view_type=form' % box.id)

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# Backfill fp.box records for receivings that were counted BEFORE box-level
# tracking shipped. Idempotent: skips any receiving that already has boxes.
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
from odoo import api, SUPERUSER_ID
env = api.Environment(cr, SUPERUSER_ID, {})
recs = env['fp.receiving'].search([('box_count_in', '>', 0)])
done = 0
for rec in recs:
if not rec.box_ids:
rec._fp_sync_boxes()
done += 1
_logger.info('fp.box backfill: created boxes for %s receiving(s)', done)

View File

@@ -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

View 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',
}

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

View File

@@ -26,3 +26,6 @@ access_fp_label_generate_wizard_manager,fp.label.generate.wizard.manager,model_f
access_fp_outbound_package_receiver,fp.outbound.package.receiver,model_fp_outbound_package,fusion_plating.group_fp_shop_manager_v2,1,1,1,1
access_fp_outbound_package_supervisor,fp.outbound.package.supervisor,model_fp_outbound_package,fusion_plating.group_fp_shop_manager_v2,1,1,1,1
access_fp_outbound_package_manager,fp.outbound.package.manager,model_fp_outbound_package,fusion_plating.group_fp_manager,1,1,1,1
access_fp_box_operator,fp.box.operator,model_fp_box,fusion_plating.group_fp_technician,1,1,1,0
access_fp_box_supervisor,fp.box.supervisor,model_fp_box,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
access_fp_box_manager,fp.box.manager,model_fp_box,fusion_plating.group_fp_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
26 access_fp_outbound_package_receiver fp.outbound.package.receiver model_fp_outbound_package fusion_plating.group_fp_shop_manager_v2 1 1 1 1
27 access_fp_outbound_package_supervisor fp.outbound.package.supervisor model_fp_outbound_package fusion_plating.group_fp_shop_manager_v2 1 1 1 1
28 access_fp_outbound_package_manager fp.outbound.package.manager model_fp_outbound_package fusion_plating.group_fp_manager 1 1 1 1
29 access_fp_box_operator fp.box.operator model_fp_box fusion_plating.group_fp_technician 1 1 1 0
30 access_fp_box_supervisor fp.box.supervisor model_fp_box fusion_plating.group_fp_shop_manager_v2 1 1 1 0
31 access_fp_box_manager fp.box.manager model_fp_box fusion_plating.group_fp_manager 1 1 1 1

View File

@@ -0,0 +1,145 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Box-level tracking — fp.box list / form / search / kanban + menu.
-->
<odoo>
<!-- ===== List ===== -->
<record id="fp_box_view_list" model="ir.ui.view">
<field name="name">fp.box.list</field>
<field name="model">fp.box</field>
<field name="arch" type="xml">
<list string="Boxes" decoration-muted="state in ('shipped','cancelled')" decoration-danger="state == 'lost'">
<field name="box_number"/>
<field name="box_count"/>
<field name="name"/>
<field name="sale_order_id"/>
<field name="partner_id"/>
<field name="job_id"/>
<field name="location_note"/>
<field name="state" widget="badge"
decoration-info="state == 'received'"
decoration-warning="state in ('racked','in_process','packed')"
decoration-success="state == 'shipped'"
decoration-danger="state == 'lost'"/>
</list>
</field>
</record>
<!-- ===== Form ===== -->
<record id="fp_box_view_form" model="ir.ui.view">
<field name="name">fp.box.form</field>
<field name="model">fp.box</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_set_racked" type="object" string="Mark Racked"
class="btn-primary" invisible="state != 'received'"/>
<button name="action_set_in_process" type="object" string="Mark In Process"
class="btn-primary" invisible="state != 'racked'"/>
<button name="action_set_packed" type="object" string="Mark Packed"
class="btn-primary" invisible="state != 'in_process'"/>
<button name="action_set_shipped" type="object" string="Mark Shipped"
class="btn-primary" invisible="state != 'packed'"/>
<button name="action_set_lost" type="object" string="Flag Lost"
invisible="state in ('shipped','lost','cancelled')"/>
<button name="action_reset_received" type="object" string="Reset to Received"
groups="fusion_plating.group_fp_shop_manager_v2"
invisible="state == 'received'"/>
<field name="state" widget="statusbar"
statusbar_visible="received,racked,in_process,packed,shipped"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name" readonly="1"/></h1>
</div>
<group>
<group>
<label for="box_number" string="Box"/>
<div>
<field name="box_number" class="oe_inline"/> /
<field name="box_count" class="oe_inline"/>
</div>
<field name="receiving_id"/>
<field name="sale_order_id"/>
<field name="job_id"/>
</group>
<group>
<field name="partner_id"/>
<field name="location_note"/>
<field name="scan_url" widget="url" readonly="1"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- ===== Search ===== -->
<record id="fp_box_view_search" model="ir.ui.view">
<field name="name">fp.box.search</field>
<field name="model">fp.box</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="sale_order_id"/>
<field name="partner_id"/>
<field name="job_id"/>
<field name="receiving_id"/>
<filter name="open" string="Open (not shipped)" domain="[('state','not in',('shipped','cancelled'))]"/>
<filter name="received" string="Received" domain="[('state','=','received')]"/>
<filter name="in_process" string="In Process" domain="[('state','in',('racked','in_process','packed'))]"/>
<filter name="shipped" string="Shipped" domain="[('state','=','shipped')]"/>
<filter name="lost" string="Lost" domain="[('state','=','lost')]"/>
<group>
<filter name="g_state" string="Status" context="{'group_by':'state'}"/>
<filter name="g_customer" string="Customer" context="{'group_by':'partner_id'}"/>
<filter name="g_receiving" string="Receiving" context="{'group_by':'receiving_id'}"/>
</group>
</search>
</field>
</record>
<!-- ===== Kanban (by status) ===== -->
<record id="fp_box_view_kanban" model="ir.ui.view">
<field name="name">fp.box.kanban</field>
<field name="model">fp.box</field>
<field name="arch" type="xml">
<kanban default_group_by="state" class="o_kanban_small_column">
<field name="state"/>
<templates>
<t t-name="card">
<div class="oe_kanban_content">
<strong><field name="name"/></strong>
<div><field name="partner_id"/></div>
<div t-if="record.location_note.raw_value">
<span class="text-muted">@ </span><field name="location_note"/>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- ===== Action ===== -->
<record id="action_fp_box" model="ir.actions.act_window">
<field name="name">Boxes</field>
<field name="res_model">fp.box</field>
<field name="view_mode">list,kanban,form</field>
<field name="search_view_id" ref="fp_box_view_search"/>
<field name="context">{'search_default_open': 1}</field>
</record>
<!-- ===== Menu ===== -->
<menuitem id="menu_fp_box"
name="Boxes"
parent="menu_fp_receiving_root"
action="action_fp_box"
sequence="35"/>
</odoo>

View File

@@ -125,6 +125,15 @@
</div>
<field name="x_fc_has_label_zpl" invisible="1"/>
</button>
<button name="action_view_boxes"
type="object"
class="oe_stat_button"
icon="fa-cubes"
invisible="box_count_tracked == 0">
<field name="box_count_tracked"
widget="statinfo"
string="Boxes"/>
</button>
</div>
<div class="alert alert-info" role="alert">
<i class="fa fa-info-circle me-2"/>