# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) # Part of the Fusion Plating product family. # # Sub 8 — Racking-time inspection record. Captures the per-part # inspection the racking crew performs when they open the customer's # boxes (which is DIFFERENT from receiving — receiving is box count # only). One record per MO. from dateutil.relativedelta import relativedelta from odoo import _, api, fields, models from odoo.exceptions import UserError, ValidationError class FpRackingInspection(models.Model): _name = 'fp.racking.inspection' _description = 'Racking-time Inspection' _inherit = ['mail.thread', 'mail.activity.mixin'] _order = 'create_date desc, id desc' name = fields.Char(compute='_compute_name', store=True) # Phase 6 (Sub 11) — production_id retired (MRP module gone). # x_fc_job_id is the canonical link. Declared here so this module's # views can reference it at view-load time; fusion_plating_jobs adds # the constraints + compute overrides via inheritance. x_fc_job_id = fields.Many2one( 'fp.job', string='Plating Job', index=True, ondelete='cascade', ) sale_order_id = fields.Many2one( 'sale.order', string='Sale Order', compute='_compute_sale_order', store=True, ) partner_id = fields.Many2one( 'res.partner', string='Customer', compute='_compute_sale_order', store=True, ) receiving_id = fields.Many2one( 'fp.receiving', string='Source Receiving', compute='_compute_receiving_id', store=True, help='The receiving record whose boxes feed this inspection.', ) state = fields.Selection( [('draft', 'Draft'), ('inspecting', 'Inspecting'), ('done', 'Done'), ('discrepancy_flagged', 'Discrepancy Flagged')], default='draft', required=True, tracking=True, ) inspector_id = fields.Many2one( 'res.users', string='Inspector', readonly=True, copy=False, tracking=True, ) inspection_started = fields.Datetime(readonly=True, copy=False) inspection_completed = fields.Datetime(readonly=True, copy=False) line_ids = fields.One2many( 'fp.racking.inspection.line', 'inspection_id', copy=True, ) notes = fields.Text() company_id = fields.Many2one( 'res.company', required=True, default=lambda s: s.env.company, ) line_count = fields.Integer(compute='_compute_line_stats') ok_count = fields.Integer(compute='_compute_line_stats') flagged_count = fields.Integer(compute='_compute_line_stats') has_variance = fields.Boolean(compute='_compute_line_stats') # Phase 6 (Sub 11) — production_id retired (MRP module gone). The # uniqueness constraint that used to ride on production_id is now # enforced via @api.constrains on x_fc_job_id (added by # fusion_plating_jobs). # ---- Computes ------------------------------------------------------------ @api.depends('partner_id.name') def _compute_name(self): # Override in fusion_plating_jobs reads x_fc_job_id; this base # version is the bare-bones fallback. for rec in self: rec.name = _('Racking Inspection') @api.depends('partner_id') def _compute_sale_order(self): # Override in fusion_plating_jobs walks x_fc_job_id.sale_order_id; # this base version is a fallback. for rec in self: rec.sale_order_id = rec.sale_order_id or False rec.partner_id = rec.partner_id or False @api.depends('sale_order_id') def _compute_receiving_id(self): Receiving = self.env['fp.receiving'] for rec in self: rec.receiving_id = ( Receiving.search( [('sale_order_id', '=', rec.sale_order_id.id)], limit=1, ) if rec.sale_order_id else False ) @api.depends('line_ids.condition', 'line_ids.qty_variance') def _compute_line_stats(self): for rec in self: rec.line_count = len(rec.line_ids) rec.ok_count = len(rec.line_ids.filtered( lambda l: l.condition == 'ok' and l.qty_variance == 0 )) rec.flagged_count = len(rec.line_ids.filtered( lambda l: l.condition != 'ok' or l.qty_variance != 0 )) rec.has_variance = bool(rec.flagged_count) # ---- Actions ------------------------------------------------------------- def action_start(self): """Racker opens the boxes and begins inspecting.""" for rec in self: if rec.state != 'draft': raise UserError(_('Can only start a Draft inspection.')) rec.write({ 'state': 'inspecting', 'inspector_id': self.env.user.id, 'inspection_started': fields.Datetime.now(), }) rec.message_post(body=_('Inspection started by %s.') % self.env.user.name) def action_complete(self): """Racker is satisfied with the inspection. Advance to Done.""" for rec in self: if rec.state != 'inspecting': raise UserError(_('Can only complete an Inspecting record.')) new_state = 'discrepancy_flagged' if rec.flagged_count else 'done' rec.write({ 'state': new_state, 'inspection_completed': fields.Datetime.now(), }) if new_state == 'discrepancy_flagged': # 2026-04-28 — Activity must land on a real user. # Resolve the assignee in priority order: # 1. The job's plating manager (if set on fp.job) # 2. The inspector who just flagged it # 3. The current user (env.uid fallback) # `activity_schedule` defaults to env.uid only when the # record has a `user_id` field; fp.racking.inspection # has `inspector_id` but not `user_id`, so we'd land on # False if we let it default. Explicit assignment is # the only safe path. assignee = False if (rec.x_fc_job_id and 'manager_id' in rec.x_fc_job_id._fields and rec.x_fc_job_id.manager_id): assignee = rec.x_fc_job_id.manager_id.id elif rec.inspector_id: assignee = rec.inspector_id.id else: assignee = self.env.uid # 3-day deadline so it surfaces in "Overdue" dashboards # if not addressed before plating starts. deadline = fields.Date.today() + relativedelta(days=3) rec.activity_schedule( 'mail.mail_activity_data_todo', summary=_('Racking discrepancy on %s') % ( rec.name or '' ), note=_( '%(n)d line(s) flagged — review before starting ' 'the first plating WO.' ) % {'n': rec.flagged_count}, user_id=assignee, date_deadline=deadline, ) rec.message_post(body=_( 'Inspection completed — %(ok)d ok / %(flag)d flagged.' ) % {'ok': rec.ok_count, 'flag': rec.flagged_count}) def action_reopen(self): """Manager only — reopen a done inspection.""" if not self.env.user.has_group( 'fusion_plating.group_fusion_plating_manager'): raise UserError(_('Only a Plating Manager can reopen a completed ' 'racking inspection.')) for rec in self: rec.write({ 'state': 'inspecting', 'inspection_completed': False, }) rec.message_post(body=_('Reopened by %s.') % self.env.user.name) class FpRackingInspectionLine(models.Model): _name = 'fp.racking.inspection.line' _description = 'Racking Inspection Line' _order = 'inspection_id, sequence, id' inspection_id = fields.Many2one( 'fp.racking.inspection', required=True, ondelete='cascade', ) sequence = fields.Integer(default=10) part_catalog_id = fields.Many2one( 'fp.part.catalog', string='Part', ) part_number = fields.Char( related='part_catalog_id.part_number', store=True, ) part_revision = fields.Char( related='part_catalog_id.revision', store=True, ) qty_expected = fields.Integer(string='Expected Qty') qty_found = fields.Integer(string='Counted Qty') qty_variance = fields.Integer( compute='_compute_qty_variance', store=True, ) condition = fields.Selection( [('ok', 'OK'), ('minor', 'Minor Issue'), ('major', 'Major Issue'), ('reject', 'Reject')], default='ok', required=True, ) notes = fields.Char(string='Notes') # 2026-04-28 — photos on a line (compliance need: damage evidence, # box-by-box condition record). Many2many to ir.attachment so an # operator can shoot multiple angles per box from the floor without # leaving the form. Cascade-deleted with the line. photo_ids = fields.Many2many( 'ir.attachment', relation='fp_racking_insp_line_photo_rel', column1='line_id', column2='attachment_id', string='Photos', domain="[('mimetype', 'ilike', 'image/')]", help='Damage / condition photos for this box. Click + to upload ' 'one or more from the camera roll. Cascades on delete.', ) photo_count = fields.Integer( string='# Photos', compute='_compute_photo_count', ) @api.depends('photo_ids') def _compute_photo_count(self): for rec in self: rec.photo_count = len(rec.photo_ids) @api.model_create_multi def create(self, vals_list): # Auto-populate part_catalog_id from the parent inspection's job # when the operator added a line without picking a part. The # job's SO carries the customer's part — pre-fill the line so # the audit trail captures it without requiring extra clicks. for vals in vals_list: if not vals.get('part_catalog_id') and vals.get('inspection_id'): ri = self.env['fp.racking.inspection'].browse( vals['inspection_id']) if ri.exists() and ri.x_fc_job_id: so = ri.x_fc_job_id.sale_order_id if so: line = so.order_line.filtered( lambda l: l.x_fc_part_catalog_id )[:1] if line: vals['part_catalog_id'] = line.x_fc_part_catalog_id.id return super().create(vals_list) @api.depends('qty_expected', 'qty_found') def _compute_qty_variance(self): for rec in self: rec.qty_variance = (rec.qty_found or 0) - (rec.qty_expected or 0) @api.depends('part_catalog_id', 'part_number', 'qty_found', 'qty_expected') def _compute_display_name(self): for rec in self: label = (rec.part_catalog_id.display_name or rec.part_number or 'Inspection Line') qty = '%d/%d' % (rec.qty_found or 0, rec.qty_expected or 0) rec.display_name = '%s (%s)' % (label, qty)