# -*- 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, _ from odoo.exceptions import UserError class FpReceiving(models.Model): """Parts receiving record. Created automatically when a sale order is confirmed. Tracks quantity verification, condition inspection, and damage logging for customer parts arriving at the shop. """ _name = 'fp.receiving' _description = 'Fusion Plating — Receiving' _inherit = ['mail.thread', 'mail.activity.mixin'] _order = 'received_date desc, id desc' name = fields.Char(string='Reference', readonly=True, copy=False, default='New') sale_order_id = fields.Many2one( 'sale.order', string='Sale Order', required=True, ondelete='restrict', tracking=True, ) partner_id = fields.Many2one( 'res.partner', string='Customer', related='sale_order_id.partner_id', store=True, readonly=True, ) po_number = fields.Char( string='Customer PO #', related='sale_order_id.x_fc_po_number', store=True, readonly=True, ) received_by_id = fields.Many2one( 'res.users', string='Received By', default=lambda self: self.env.user, tracking=True, ) received_date = fields.Datetime( string='Received Date', default=fields.Datetime.now, tracking=True, ) # Sub 8 — simplified state machine. Receiving = box count only. The # part-level inspection that used to happen here now lives on # fp.racking.inspection (racking crew does it when they open the # boxes). Legacy state values are kept in the Selection so existing # records from before Sub 8 don't raise on upgrade. state = fields.Selection( [ ('draft', 'Awaiting Parts'), ('counted', 'Counted'), ('staged', 'Staged for Racking'), ('closed', 'Closed'), # Legacy values — kept readable, never written by new code ('inspecting', 'Inspecting (legacy)'), ('accepted', 'Accepted (legacy)'), ('discrepancy', 'Discrepancy (legacy)'), ('resolved', 'Resolved (legacy)'), ], string='Status', default='draft', tracking=True, required=True, ) box_count_in = fields.Integer( string='Boxes Received', tracking=True, help='Number of boxes the receiver counted when the truck ' 'dropped off. Receiving is box count only — parts are ' 'inspected by the racking crew when boxes are opened.', ) 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( string='Qty Match', compute='_compute_qty_match', store=True, ) carrier_name = fields.Char(string='Carrier', help='Who delivered the parts (Purolator, customer drop-off, etc.).') carrier_tracking = fields.Char(string='Inbound Tracking #') notes = fields.Html(string='Notes') line_ids = fields.One2many('fp.receiving.line', 'receiving_id', string='Receiving Lines') damage_ids = fields.One2many('fp.receiving.damage', 'receiving_id', string='Damage Log') damage_count = fields.Integer(string='Damage Count', compute='_compute_damage_count') unresolved_damage_count = fields.Integer( string='Unresolved Damage', compute='_compute_damage_count', ) attachment_ids = fields.Many2many( 'ir.attachment', 'fp_receiving_attachment_rel', 'receiving_id', 'attachment_id', string='Photos / Documents', ) @api.depends('expected_qty', 'received_qty') def _compute_qty_match(self): for rec in self: rec.qty_match = rec.expected_qty > 0 and rec.received_qty == rec.expected_qty @api.depends('damage_ids', 'damage_ids.resolved') def _compute_damage_count(self): for rec in self: rec.damage_count = len(rec.damage_ids) rec.unresolved_damage_count = len(rec.damage_ids.filtered(lambda d: not d.resolved)) # ------------------------------------------------------------------------- # Sequence # ------------------------------------------------------------------------- @api.model_create_multi def create(self, vals_list): for vals in vals_list: if vals.get('name', 'New') == 'New': vals['name'] = self.env['ir.sequence'].next_by_code('fp.receiving') or 'New' # Prefill received_qty from expected_qty so the operator only # types when the count is wrong (the common case is "all # arrived"). Saves a step on every routine receipt. if vals.get('expected_qty') and not vals.get('received_qty'): vals['received_qty'] = vals['expected_qty'] return super().create(vals_list) # ------------------------------------------------------------------------- # Sub 8 — box-count-only actions (new primary flow) # ------------------------------------------------------------------------- def action_mark_counted(self): """Receiver has counted the boxes on the dock. Move to Counted.""" for rec in self: if rec.state not in ('draft', 'inspecting'): # inspecting allows legacy records raise UserError(_('Only Awaiting-Parts or legacy-Inspecting ' 'records can be marked Counted.')) if not rec.box_count_in: raise UserError(_('Set the Boxes Received count before marking Counted.')) rec.state = 'counted' rec.received_by_id = self.env.user rec.received_date = fields.Datetime.now() rec.message_post(body=_( '%(user)s counted %(n)d box(es) at receiving.' ) % {'user': self.env.user.name, 'n': rec.box_count_in}) def action_mark_staged(self): """Boxes are in the racking area, awaiting the racking crew.""" for rec in self: if rec.state not in ('counted',): raise UserError(_('Only Counted records can be marked Staged.')) rec.state = 'staged' rec._update_so_receiving_status() rec.message_post(body=_('Boxes staged for racking.')) def action_close(self): """Close the receiving — all boxes opened, inspection complete.""" for rec in self: if rec.state not in ('staged', 'accepted', 'resolved'): raise UserError(_('Only Staged (or legacy Accepted / Resolved) ' 'records can be closed.')) rec.state = 'closed' rec._update_so_receiving_status() rec.message_post(body=_('Receiving closed.')) # ------------------------------------------------------------------------- # Legacy state actions — kept for backward compatibility. # Deprecated: Sub 8 moves part-level inspection to fp.racking.inspection. # Retained so existing UI bindings don't blow up. # ------------------------------------------------------------------------- def action_start_inspection(self): """Move from draft to inspecting.""" for rec in self: if rec.state != 'draft': raise UserError(_('Only draft records can start inspection.')) rec.state = 'inspecting' rec.received_by_id = self.env.user rec.received_date = fields.Datetime.now() def action_accept(self): """Accept the receiving — parts match and condition is OK. Quantity-mismatch policy: if expected_qty != received_qty, operators must use action_flag_discrepancy() instead. Managers can override (the override is logged on chatter for audit). """ is_manager = self.env.user.has_group( 'fusion_plating.group_fusion_plating_manager' ) for rec in self: if rec.state not in ('inspecting', 'resolved'): raise UserError(_('Can only accept from Inspecting or Resolved state.')) if rec.unresolved_damage_count > 0: raise UserError(_('Cannot accept — there are %d unresolved damage entries.') % rec.unresolved_damage_count) qty_match = rec.expected_qty > 0 and rec.received_qty == rec.expected_qty if not qty_match: if not is_manager: raise UserError(_( 'Cannot accept — quantity mismatch (expected %(exp)d, ' 'received %(rcv)d).\n\nUse "Flag Discrepancy" instead, ' 'or have a manager override.' ) % {'exp': rec.expected_qty, 'rcv': rec.received_qty}) rec.message_post(body=_( 'Manager override: accepted with quantity mismatch ' '(expected %(exp)d, received %(rcv)d).' ) % {'exp': rec.expected_qty, 'rcv': rec.received_qty}) rec.state = 'accepted' rec._update_so_receiving_status() rec.message_post(body=_('Parts accepted — quantity: %d, all checks passed.') % rec.received_qty) def action_flag_discrepancy(self): """Flag a discrepancy — qty mismatch or damage found.""" for rec in self: if rec.state != 'inspecting': raise UserError(_('Can only flag discrepancy from Inspecting state.')) rec.state = 'discrepancy' rec._update_so_receiving_status() # Create follow-up activity for the sales team rec.activity_schedule( 'mail.mail_activity_data_todo', summary=_('Receiving discrepancy — %s') % rec.name, note=_('Qty expected: %d, received: %d. Check damage log for details.') % ( rec.expected_qty, rec.received_qty), ) rec.message_post(body=_('Discrepancy flagged — follow-up required.')) def action_resolve(self): """Resolve a discrepancy after customer follow-up.""" for rec in self: if rec.state != 'discrepancy': raise UserError(_('Can only resolve from Discrepancy state.')) rec.state = 'resolved' rec._update_so_receiving_status() rec.message_post(body=_('Discrepancy resolved.')) def _update_so_receiving_status(self): """Update the linked sale order's receiving status.""" for rec in self: if rec.sale_order_id: if rec.state in ('accepted', 'resolved'): rec.sale_order_id.x_fc_receiving_status = 'received' elif rec.state == 'discrepancy': rec.sale_order_id.x_fc_receiving_status = 'partial' elif rec.state == 'inspecting': rec.sale_order_id.x_fc_receiving_status = 'partial'