201 lines
6.6 KiB
Python
201 lines
6.6 KiB
Python
# -*- 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']
|
|
_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='Plating Job',
|
|
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'),
|
|
('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
|
|
# ------------------------------------------------------------------
|
|
@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=Markup("Hold status changed to <b>%s</b>.") % label,
|
|
message_type='comment',
|
|
subtype_xmlid='mail.mt_note',
|
|
)
|