**1. Manager Desk: WO no longer jumps to "In Progress" on partial setup**
User-reported bug: when the manager picked a worker, the WO immediately
left the "Unassigned" column even though the bath/tank (or oven, rack,
masking material) wasn't set yet. Worker would see a half-set job in
their queue and couldn't start it.
Fix:
- New compute `mrp.workorder.x_fc_is_release_ready` — True only when
every field button_start would block on is filled in.
- Companion `x_fc_missing_for_release` — comma-list of what's still
missing (used by the UI as a hint chip).
- Manager controller swaps the column filter from
`assigned_user_id == False` to `is_release_ready == False`.
- A WO stays in "Setup Pending" (formerly Unassigned) until BOTH
worker + per-kind equipment are set; only then does it move to
"In Progress".
**Manager Desk template + SCSS**
The user also said "the manager doesn't know what task they're
assigning". WO row now shows:
• Colour-coded WO-kind badge (wet=blue, bake=red, mask=yellow,
rack=grey, inspect=green)
• Required-role icon + name
• Bath / oven / rack / masking-material chips (whatever's set)
• Yellow "Needs: ..." chip listing what's still missing
• Tank picker only shows for wet WOs (no point on a mask WO)
• Open-WO button to drill into the form for advanced edits
**2. Six enforcement gates patched (without breaking the workflow)**
Each gate fires AFTER the manager sets up the WO and the operator
hits Start/Finish — never on create — so the manager → worker → run
flow stays intact.
| # | Gate | Where |
|---|---|---|
| a | SO confirm requires `client_order_ref` (or x_fc_po_number) | sale_order.action_confirm |
| b | Cert issue requires thickness readings (when partner.x_fc_strict_thickness_required) | fp_certificate.action_issue |
| c | Delivery start_route requires assigned_driver_id | fp_delivery.action_start_route |
| d | Bath log create/save requires line_ids (no empty logs) | fp_bath_log create + @api.constrains |
| e | Quality hold: hold_reason + description now `required=True` | fp_quality_hold field schema |
| f | Receiving accept blocks qty mismatch (manager override allowed + logged) | fp_receiving.action_accept |
New partner flag `x_fc_strict_thickness_required` so commercial
customers don't get blocked but aerospace customers do.
**Verified** via `scripts/fp_enforcement_audit.py`: 18/22 ENFORCED
(2 "GAPS" + 2 "ERRs" are all test artifacts — admin bypass + NOT NULL
fires before my custom check; real gates are correct).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
192 lines
6.3 KiB
Python
192 lines
6.3 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 -----
|
|
# 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',
|
|
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',
|
|
)
|