# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) # Part of the Fusion Plating product family. from markupsafe import Markup 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', 'fp.parent.numbered.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 ----- # Phase 1 (Sub 11) — native plating-job link replaces the legacy # workorder_id / production_id pair that lived in bridge_mrp. # The bridge fields stay during the migration window so existing # records keep their FKs; Phase 5 removes bridge_mrp entirely. job_id = fields.Many2one( 'fp.job', string='Work Order', index=True, ondelete='set null', ) step_id = fields.Many2one( 'fp.job.step', string='Job Step', domain="[('job_id', '=', job_id)]", ondelete='set null', ) 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'), # v19.0.4.8.0 — Distinct bucket so QA can split QC-failed # holds (auto-spawned by fusion.plating.quality.check.action_fail) # from operator-flagged process deviations / contamination. ('qc_failure', 'QC Failure'), ('other', 'Other'), ], string='Hold Reason', required=True, tracking=True, help='Required so QA can triage holds by category.', ) description = fields.Text( string='Description', required=True, help='Required — every hold needs an inspector narrative.', ) 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 # ------------------------------------------------------------------ # Parent-numbered mixin hooks. Holds reach the SO through their # linked fp.job (the standard authoring path on the shop floor). def _fp_parent_sale_order(self): return self.job_id.sale_order_id if self.job_id else self.env['sale.order'] def _fp_name_prefix(self): return 'HOLD' def _fp_parent_counter_field(self): return 'x_fc_pn_hold_count' @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'] = 'New' records = super().create(vals_list) for rec in records: if rec.name and rec.name != 'New': continue if not rec._fp_assign_parent_name(): seq = self.env['ir.sequence'].next_by_code('fusion.plating.quality.hold') or 'New' self.env.cr.execute( "UPDATE fusion_plating_quality_hold SET name = %s WHERE id = %s", (seq, rec.id), ) rec.invalidate_recordset(['name']) return records # ------------------------------------------------------------------ # 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=Markup("Hold status changed to %s.") % label, message_type='comment', subtype_xmlid='mail.mt_note', )