316 lines
12 KiB
Python
316 lines
12 KiB
Python
# -*- 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',
|
|
}
|