# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) # Part of the Fusion Plating product family. from odoo import _, api, fields, models class FpSerial(models.Model): """Serial number registry. One record per "occurrence of a part on an order line". The same part ordered six months later gets a different serial. The serial is the common thread linking the SO line to the MO, Delivery, and Invoice records it spawns downstream. Most serials are customer-supplied (pass-through from the customer's own end-user); a smaller share are shop-generated via the sequence. The registry is optional — SO lines can carry no serial at all. """ _name = 'fp.serial' _description = 'Fusion Plating — Serial Number' _inherit = ['mail.thread', 'mail.activity.mixin'] _order = 'create_date desc, id desc' _rec_name = 'name' name = fields.Char( required=True, tracking=True, help='Customer-supplied serial (most common) or shop-generated ' 'sequence value. Typed-in values are accepted as-is.', ) company_id = fields.Many2one( 'res.company', required=True, default=lambda s: s.env.company, ) sale_order_line_id = fields.Many2one( 'sale.order.line', string='Source Sale Order Line', ondelete='set null', copy=False, tracking=True, ) sale_order_id = fields.Many2one( related='sale_order_line_id.order_id', store=True, string='Sale Order', ) customer_id = fields.Many2one( related='sale_order_line_id.order_id.partner_id', store=True, string='Customer', ) part_id = fields.Many2one( related='sale_order_line_id.x_fc_part_catalog_id', store=True, string='Part', ) notes = fields.Text(string='Notes') # ================================================================== # Phase 2 (2026-04-28) — per-serial state machine # ================================================================== # Each physical part owns its own state independent of the parent # job's qty roll-ups. When 30 parts arrive on one SO line, all 30 # serials are independently trackable through the shop. State # auto-promotes from job-step transitions (see fp.job.button_* # overrides in fusion_plating_jobs); operator can also flip a # single serial manually (e.g. mark serial #5 scrapped after a # plating defect). state = fields.Selection( [ ('received', 'Received'), ('racked', 'Racked'), ('in_process', 'In Process'), ('inspected', 'Inspected'), ('packed', 'Packed'), ('shipped', 'Shipped'), ('returned', 'Returned'), ('scrapped', 'Scrapped'), ('on_hold', 'On Hold'), ], string='Status', default='received', required=True, tracking=True, index=True, help='Per-serial workflow state. Transitions auto-promote from ' 'parent job step events; supervisors can also flip a single ' 'serial manually (e.g. scrap one part out of a 30-part rack).', ) state_color = fields.Integer( string='Status Color', compute='_compute_state_color', help='Kanban / many2many_tags color index derived from state.', ) last_state_change = fields.Datetime( string='Last Status Change', readonly=True, help='Timestamp of the most recent state transition. Auto-stamped ' 'by every state-changing action.', ) scrap_reason = fields.Text( string='Scrap / Return Reason', help='Captured when state transitions to scrapped or returned. ' 'Surfaces on per-serial CoC entries (Phase 4).', ) # Reverse from move log — Phase 3 will populate this directly when # operators record per-serial moves on the tablet. Defined here so # views can already render the count column. move_count = fields.Integer( compute='_compute_move_count', string='# Moves', ) @api.depends('state') def _compute_state_color(self): # Odoo color-index mapping aligned with the standard kanban palette. # 0 default · 1 red · 2 orange · 3 yellow · 4 green · 5 purple · # 6 magenta · 7 sky · 8 blue · 9 brown · 10 grey · 11 olive mapping = { 'received': 8, # blue — fresh 'racked': 7, # sky — staged 'in_process': 3, # yellow — running 'inspected': 11, # olive — passed QC, ready to ship 'packed': 4, # green — boxed 'shipped': 4, # green — out the door 'returned': 2, # orange — back from customer 'scrapped': 1, # red 'on_hold': 1, # red — quality issue } for rec in self: rec.state_color = mapping.get(rec.state, 0) @api.depends_context('uid') def _compute_move_count(self): # Phase 3 will replace this with a real reverse link via # fp.job.step.move.serial_ids (M2M added next phase). # Defined here as 0-stub so views don't break on upgrade. for rec in self: rec.move_count = 0 # ------------------------------------------------------------------ # State transitions — log each one to chatter and stamp last_state_change # ------------------------------------------------------------------ def _set_state(self, new_state, message=None): """Internal helper. Validates the source state, flips, stamps, chatters. Raises UserError on illegal transitions.""" labels = dict(self._fields['state'].selection) for rec in self: old = rec.state if old == new_state: continue # Terminal states are write-protected (operator must explicitly # un-set via action_reopen if they really need to). if old in ('shipped', 'scrapped') and new_state not in ('returned', 'received'): from odoo.exceptions import UserError raise UserError(_( 'Serial %(name)s is %(old)s — cannot transition to ' '%(new)s. Use Reopen if this is a correction.' ) % { 'name': rec.name, 'old': labels.get(old, old), 'new': labels.get(new_state, new_state), }) rec.state = new_state rec.last_state_change = fields.Datetime.now() body = message or _('Status %(old)s → %(new)s by %(user)s') % { 'old': labels.get(old, old), 'new': labels.get(new_state, new_state), 'user': self.env.user.name, } rec.message_post(body=body) return True def action_mark_racked(self): return self._set_state('racked') def action_mark_in_process(self): return self._set_state('in_process') def action_mark_inspected(self): return self._set_state('inspected') def action_mark_packed(self): return self._set_state('packed') def action_mark_shipped(self): return self._set_state('shipped') def action_mark_returned(self): return self._set_state('returned') def action_mark_on_hold(self): return self._set_state('on_hold') def action_release_hold(self): """Lift on_hold and return the serial to in_process. Used when a hold is resolved without scrap (e.g. visual blemish was actually within tolerance after re-inspection).""" return self._set_state('in_process') def action_mark_scrapped(self): """Scrap a single serial. Operator should fill scrap_reason next — view enforces it via a wizard form. Phase 3 hooks this into the move log so the parent job's qty_scrapped auto-increments.""" return self._set_state('scrapped') def action_reopen(self): """Manager-only override — un-pin a terminal state when a correction is needed (e.g. wrong serial marked shipped). Audit trail preserved via chatter; never silently rewrites history.""" for rec in self: if not self.env.user.has_group('fusion_plating.group_fusion_plating_manager'): from odoo.exceptions import UserError raise UserError(_( 'Only the Plating Manager group can reopen a terminal ' 'serial state. Contact your shop manager.' )) return self._set_state('in_process', message=_( 'Serial reopened by %s — terminal state reverted for correction.' ) % self.env.user.name) # Reverse link to invoice lines — safe here because account.move.line # lives in this same module. Production (mrp) and delivery (logistics) # reverse links are defined in their own modules' fp_serial inherits # to keep module load order consistent. invoice_line_ids = fields.One2many( 'account.move.line', 'x_fc_serial_id', string='Invoice Lines', ) invoice_ids = fields.Many2many( 'account.move', compute='_compute_invoice_ids', string='Invoices', ) invoice_count = fields.Integer(compute='_compute_counts') # production_count / delivery_count are declared in the inheriting # modules (bridge_mrp / logistics) so the O2Ms exist alongside them. _sql_constraints = [ ('fp_serial_name_company_uniq', 'unique(company_id, name)', 'Serial number must be unique within the company.'), ] # ---- Computes ------------------------------------------------------------ @api.depends('invoice_line_ids.move_id') def _compute_counts(self): # Base compute sets invoice_count only. bridge_mrp + logistics # override this to also populate production_count / delivery_count. for rec in self: rec.invoice_count = len(rec.invoice_line_ids.mapped('move_id')) @api.depends('invoice_line_ids.move_id') def _compute_invoice_ids(self): for rec in self: rec.invoice_ids = rec.invoice_line_ids.mapped('move_id') # ---- Actions ------------------------------------------------------------- def action_view_sale_order(self): self.ensure_one() if not self.sale_order_id: return False return { 'type': 'ir.actions.act_window', 'res_model': 'sale.order', 'res_id': self.sale_order_id.id, 'view_mode': 'form', } def action_view_productions(self): self.ensure_one() return { 'type': 'ir.actions.act_window', 'name': _('Manufacturing Orders'), 'res_model': 'mrp.production', 'domain': [('id', 'in', self.production_ids.ids)], 'view_mode': 'list,form', } def action_view_deliveries(self): self.ensure_one() return { 'type': 'ir.actions.act_window', 'name': _('Deliveries'), 'res_model': 'fusion.plating.delivery', 'domain': [('id', 'in', self.delivery_ids.ids)], 'view_mode': 'list,form', } def action_view_invoices(self): self.ensure_one() return { 'type': 'ir.actions.act_window', 'name': _('Invoices'), 'res_model': 'account.move', 'domain': [('id', 'in', self.invoice_ids.ids)], 'view_mode': 'list,form', } def action_view_part(self): self.ensure_one() if not self.part_id: return False return { 'type': 'ir.actions.act_window', 'res_model': 'fp.part.catalog', 'res_id': self.part_id.id, 'view_mode': 'form', }