From 6a368993bfe2429ee2e34f2779a1417f65447824 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 12 Apr 2026 19:22:22 -0400 Subject: [PATCH] feat(receiving): fp.receiving model with state machine and views Co-Authored-By: Claude Opus 4.6 (1M context) --- .../models/fp_receiving.py | 147 ++++++++++++++- .../views/fp_receiving_views.xml | 178 +++++++++++++++++- 2 files changed, 323 insertions(+), 2 deletions(-) diff --git a/fusion-plating/fusion_plating_receiving/models/fp_receiving.py b/fusion-plating/fusion_plating_receiving/models/fp_receiving.py index 0c8fe912..4350feed 100644 --- a/fusion-plating/fusion_plating_receiving/models/fp_receiving.py +++ b/fusion-plating/fusion_plating_receiving/models/fp_receiving.py @@ -2,4 +2,149 @@ # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) # Part of the Fusion Plating product family. -# Placeholder — implemented in a later task. + +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, + ) + state = fields.Selection( + [ + ('draft', 'Awaiting Parts'), + ('inspecting', 'Inspecting'), + ('accepted', 'Accepted'), + ('discrepancy', 'Discrepancy'), + ('resolved', 'Resolved'), + ], + string='Status', default='draft', tracking=True, required=True, + ) + 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' + return super().create(vals_list) + + # ------------------------------------------------------------------------- + # State actions + # ------------------------------------------------------------------------- + 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.""" + 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) + 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' diff --git a/fusion-plating/fusion_plating_receiving/views/fp_receiving_views.xml b/fusion-plating/fusion_plating_receiving/views/fp_receiving_views.xml index 85d8c23e..0192286f 100644 --- a/fusion-plating/fusion_plating_receiving/views/fp_receiving_views.xml +++ b/fusion-plating/fusion_plating_receiving/views/fp_receiving_views.xml @@ -1,2 +1,178 @@ - + + + + + + fp.receiving.list + fp.receiving + + + + + + + + + + + + + + + + + + fp.receiving.form + fp.receiving + +
+
+
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+
+
+
+ + + + fp.receiving.search + fp.receiving + + + + + + + + + + + + + + + + + + + + + + + Receiving + fp.receiving + list,form + + {'search_default_draft': 1} + +

+ No receiving records yet +

+

+ Receiving records are created automatically when a sale order is + confirmed. They track quantity verification, condition inspection, + and damage logging for customer parts arriving at the shop. +

+
+
+ +