# -*- 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/`) 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/ 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', }