Files
Odoo-Modules/fusion_plating/fusion_plating_quality/models/fp_quality_hold.py
gsinghpal 11837ed4f5 fix(plating): Manager Desk premature-advance + 6 workflow enforcement gates
**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>
2026-04-19 12:54:00 -04:00

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',
)