# -*- 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 FpQualityHold(models.Model): """Quality Hold — parts pulled from production for quality review. Enables the Steelhead-style "Move Parts Into Quality Management" workflow. An operator can split a partial quantity off a job and place it on hold for inspection, rework, or scrap. """ _name = 'fusion.plating.quality.hold' _description = 'Fusion Plating — Quality Hold' _inherit = ['mail.thread', 'mail.activity.mixin'] _order = 'create_date desc' name = fields.Char( string='Reference', required=True, copy=False, readonly=True, default=lambda self: self._default_name(), tracking=True, ) # ----- What's on hold ----- # NOTE: workorder_id, production_id, and portal_job_id live in # fusion_plating_bridge_mrp (which depends on mrp and # fusion_plating_portal). Keeping them here would force hard # dependencies and break minimal CE-only installs. part_ref = fields.Char(string='Part Number') # ----- Hold details ----- qty_on_hold = fields.Integer(string='Qty on Hold', required=True) qty_original = fields.Integer(string='Original Qty') mark_for_scrap = fields.Boolean(string='Mark for Scrap', default=False) hold_reason = fields.Selection( [ ('damaged', 'Parts Damaged'), ('out_of_spec', 'Out of Specification'), ('contamination', 'Contamination'), ('customer_complaint', 'Customer Complaint'), ('process_deviation', 'Process Deviation'), ('other', 'Other'), ], string='Hold Reason', default='other', tracking=True, ) description = fields.Text(string='Description') attachment_ids = fields.Many2many( 'ir.attachment', string='Attachments', ) # ----- Location / station context ----- facility_id = fields.Many2one( 'fusion.plating.facility', string='Facility', ) work_center_id = fields.Many2one( 'fusion.plating.work.center', string='Station', ) current_process_node = fields.Char(string='Current Process Node') # ----- Status ----- state = fields.Selection( [ ('on_hold', 'On Hold'), ('under_review', 'Under Review'), ('released', 'Released to Production'), ('scrapped', 'Scrapped'), ('reworked', 'Sent to Rework'), ], string='Status', default='on_hold', required=True, tracking=True, ) # ----- Resolution ----- ncr_id = fields.Many2one('fusion.plating.ncr', string='Linked NCR') resolved_by_id = fields.Many2one('res.users', string='Resolved By') resolution_date = fields.Datetime(string='Resolution Date') resolution_notes = fields.Text(string='Resolution Notes') # ----- Housekeeping ----- operator_id = fields.Many2one( 'res.users', string='Held By', default=lambda self: self.env.user, ) company_id = fields.Many2one( 'res.company', default=lambda self: self.env.company, ) active = fields.Boolean(default=True) # ------------------------------------------------------------------ # Defaults / create # ------------------------------------------------------------------ @api.model def _default_name(self): seq = self.env['ir.sequence'].next_by_code( 'fusion.plating.quality.hold', ) return seq or '/' @api.model_create_multi def create(self, vals_list): for vals in vals_list: if not vals.get('name') or vals.get('name') == '/': vals['name'] = self._default_name() return super().create(vals_list) # ------------------------------------------------------------------ # Actions # ------------------------------------------------------------------ def action_start_review(self): self.write({'state': 'under_review'}) self._post_state_message('Under Review') def action_release(self): self.write({ 'state': 'released', 'resolved_by_id': self.env.user.id, 'resolution_date': fields.Datetime.now(), }) self._post_state_message('Released to Production') def action_scrap(self): self.write({ 'state': 'scrapped', 'mark_for_scrap': True, 'resolved_by_id': self.env.user.id, 'resolution_date': fields.Datetime.now(), }) self._post_state_message('Scrapped') def action_send_to_rework(self): self.write({ 'state': 'reworked', 'resolved_by_id': self.env.user.id, 'resolution_date': fields.Datetime.now(), }) self._post_state_message('Sent to Rework') def action_create_ncr(self): """Create a linked NCR from this hold record.""" self.ensure_one() ncr = self.env['fusion.plating.ncr'].create({ 'facility_id': self.facility_id.id, 'source': 'shop_floor', 'severity': 'medium', 'part_ref': self.part_ref, 'quantity_affected': self.qty_on_hold, 'description': self.description or '', }) self.write({'ncr_id': ncr.id}) self._post_state_message(f'NCR {ncr.name} created') return { 'name': 'NCR', 'type': 'ir.actions.act_window', 'res_model': 'fusion.plating.ncr', 'res_id': ncr.id, 'view_mode': 'form', 'target': 'current', } # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ def _post_state_message(self, label): for rec in self: rec.message_post( body=f"Hold status changed to {label}.", message_type='comment', subtype_xmlid='mail.mt_note', )