feat(plating): hard-required fields on WO start — operator + bath + tank
User audit caught: in the workforce E2E run we had no idea which bath /
which tank ran the job. For aerospace traceability that's a deal-
breaker. Add a validation gate on mrp.workorder.button_start so
operators can't tap START without the data the shop floor MUST capture.
**Three new pieces on mrp.workorder:**
1. `_fp_is_wet_process()` — best-effort "does this WO involve a
chemistry bath?" check. Three signals in priority order:
a. A bath is already linked → definitely wet
b. The workcenter's FP work-centre supports a wet process family
(plating, pre/post-treatment, strip, passivation)
c. WO name contains a wet-process keyword (plat, nickel, chrome,
anodiz, zinc, etch, clean, rinse, strip, passivat, electroless…)
The keyword fallback is needed because most existing recipes have
no process_type_id set on their operation nodes.
2. `_fp_check_required_fields_before_start()` — runs before the
existing certification check. Rules:
• Every WO needs an assigned operator (x_fc_assigned_user_id).
Without it, productivity records can't be attributed and the
proficiency tracker has no employee to credit.
• Wet WOs additionally need x_fc_bath_id + x_fc_tank_id. So we
know exactly which chemistry bath ran the job and which physical
tank it sat in.
Raises a clear UserError listing the missing fields if any.
3. `x_fc_requires_bath` (compute, non-stored) — surfaces the wet check
to the form view so bath + tank fields render with `required=`.
**View changes:**
- `x_fc_assigned_user_id` is now `required="1"` on the form
- `x_fc_bath_id` + `x_fc_tank_id` use `required="x_fc_requires_bath"`
→ red asterisk only when the WO is actually wet
**Simulator updates** (scripts/fp_e2e_workforce.py):
- Hannah now explicitly assigns bath + tank to wet WOs during planning,
AND pre-issues operator certifications for the bath's process type
(real shop manager workflow).
- Two negative tests added that PROVE the gates fire:
• Test 1: strip the operator → button_start raises "missing Assigned Operator"
• Test 2: strip bath/tank on a wet WO → button_start raises "missing Bath/Tank"
**Final E2E:** 42 PASS / 2 WARN / 0 FAIL out of 44 checks.
Both remaining WARNs (bake-window auto-create, first-piece gate) are
expected behaviour — those are coating-driven and the test coating
intentionally doesn't trigger them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
"name": "Fusion Plating — MRP Bridge",
|
||||
'version': '19.0.6.3.0',
|
||||
'version': '19.0.6.4.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.',
|
||||
'description': """
|
||||
|
||||
@@ -26,6 +26,13 @@ class MrpWorkorder(models.Model):
|
||||
# ------------------------------------------------------------------
|
||||
# Plating-specific fields
|
||||
# ------------------------------------------------------------------
|
||||
x_fc_requires_bath = fields.Boolean(
|
||||
string='Requires Bath/Tank',
|
||||
compute='_compute_requires_bath',
|
||||
store=False,
|
||||
help='True when this WO involves a chemistry bath. Surfaced to '
|
||||
'the form view so bath/tank fields render as required.',
|
||||
)
|
||||
x_fc_bath_id = fields.Many2one(
|
||||
'fusion.plating.bath', string='Bath', tracking=True,
|
||||
)
|
||||
@@ -512,10 +519,82 @@ class MrpWorkorder(models.Model):
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# T2.2 — Certification gate on WO start
|
||||
# T2.3 — Required-field gate (bath/tank for wet WOs, assigned operator)
|
||||
# ------------------------------------------------------------------
|
||||
WET_FAMILIES = (
|
||||
'plating', 'pre_treatment', 'post_treatment',
|
||||
'strip', 'passivation',
|
||||
)
|
||||
# Keyword fallback used when the workcenter / process-type metadata
|
||||
# is missing — covers most shop floor naming conventions. Lowercased.
|
||||
WET_NAME_KEYWORDS = (
|
||||
'plat', 'nickel', 'chrome', 'anodiz', 'zinc',
|
||||
'etch', 'clean', 'rinse', 'strip', 'passivat',
|
||||
'zincate', 'alkalin', 'acid', 'electroless',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_bath_id', 'name', 'workcenter_id')
|
||||
def _compute_requires_bath(self):
|
||||
for wo in self:
|
||||
wo.x_fc_requires_bath = wo._fp_is_wet_process()
|
||||
|
||||
def _fp_is_wet_process(self):
|
||||
"""Best-effort check: does this WO involve a chemistry bath?
|
||||
|
||||
Three signals, in priority order:
|
||||
1. A bath is already linked → definitely wet
|
||||
2. The workcenter's FP work-centre supports a wet process family
|
||||
3. The WO's name contains a wet-process keyword
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.x_fc_bath_id:
|
||||
return True
|
||||
wc = self.workcenter_id
|
||||
fpwc = getattr(wc, 'x_fc_fp_work_center_id', False)
|
||||
if fpwc:
|
||||
families = set(fpwc.supported_process_ids.mapped('process_family'))
|
||||
if families & set(self.WET_FAMILIES):
|
||||
return True
|
||||
name = (self.name or '').lower()
|
||||
return any(k in name for k in self.WET_NAME_KEYWORDS)
|
||||
|
||||
def _fp_check_required_fields_before_start(self):
|
||||
"""Block button_start if the WO is missing data the shop must
|
||||
record for traceability + compliance.
|
||||
|
||||
Rules:
|
||||
• Every WO needs an assigned operator (x_fc_assigned_user_id) —
|
||||
without it, productivity records can't be attributed and
|
||||
proficiency tracking goes nowhere.
|
||||
• Wet (bath) WOs additionally need x_fc_bath_id + x_fc_tank_id —
|
||||
for chemistry traceability and physical-location audit
|
||||
(which exact tank ran the job).
|
||||
"""
|
||||
from odoo.exceptions import UserError
|
||||
for wo in self:
|
||||
missing = []
|
||||
if not wo.x_fc_assigned_user_id:
|
||||
missing.append(_('Assigned Operator'))
|
||||
if wo._fp_is_wet_process():
|
||||
if not wo.x_fc_bath_id:
|
||||
missing.append(_('Bath'))
|
||||
if not wo.x_fc_tank_id:
|
||||
missing.append(_('Tank'))
|
||||
if missing:
|
||||
raise UserError(_(
|
||||
'Cannot start work order "%(wo)s" — please fill these '
|
||||
'required fields first:\n • %(fields)s\n\n'
|
||||
'Open the work order form and have the planner set them.'
|
||||
) % {
|
||||
'wo': wo.display_name or wo.name,
|
||||
'fields': '\n • '.join(missing),
|
||||
})
|
||||
|
||||
def button_start(self):
|
||||
"""Block start unless the current user's linked employee holds
|
||||
an active certification for this WO's process type."""
|
||||
an active certification for this WO's process type AND every
|
||||
required field for traceability is filled in."""
|
||||
self._fp_check_required_fields_before_start()
|
||||
self._fp_check_operator_certification()
|
||||
res = super().button_start()
|
||||
# Capture audit AFTER the super call so we don't stamp WOs that
|
||||
|
||||
@@ -93,8 +93,10 @@
|
||||
<field name="x_fc_priority" widget="priority"/>
|
||||
<field name="x_fc_assigned_user_id"
|
||||
string="Assigned To"
|
||||
required="1"
|
||||
options="{'no_create': True}"/>
|
||||
<field name="x_fc_work_role_id" readonly="1"/>
|
||||
<field name="x_fc_requires_bath" invisible="1"/>
|
||||
</xpath>
|
||||
|
||||
<!-- ============================================================
|
||||
@@ -166,8 +168,10 @@
|
||||
<group>
|
||||
<group string="Bath & Tank">
|
||||
<field name="x_fc_facility_id"/>
|
||||
<field name="x_fc_bath_id"/>
|
||||
<field name="x_fc_tank_id"/>
|
||||
<field name="x_fc_bath_id"
|
||||
required="x_fc_requires_bath"/>
|
||||
<field name="x_fc_tank_id"
|
||||
required="x_fc_requires_bath"/>
|
||||
<field name="x_fc_rack_id"/>
|
||||
<field name="x_fc_rack_ref"/>
|
||||
</group>
|
||||
|
||||
11
fusion_plating/scripts/fp_audit_workorders.py
Normal file
11
fusion_plating/scripts/fp_audit_workorders.py
Normal file
@@ -0,0 +1,11 @@
|
||||
env = env # noqa
|
||||
recipe = env['fusion.plating.process.node'].search(
|
||||
[('node_type', '=', 'recipe'), ('name', '=', 'ENP-ALUM-BASIC')], limit=1)
|
||||
print(f'Recipe: {recipe.name}')
|
||||
def walk(node, indent=0):
|
||||
pt = node.process_type_id.process_family if node.process_type_id else '(none)'
|
||||
wc = node.work_center_id.name if node.work_center_id else '(none)'
|
||||
print(f'{" "*indent}- [{node.node_type:9}] {node.name!r:35} pt_family={pt!r:18} wc={wc}')
|
||||
for c in node.child_ids.sorted('sequence'):
|
||||
walk(c, indent+1)
|
||||
walk(recipe)
|
||||
@@ -223,7 +223,39 @@ WO_OPERATORS = {
|
||||
}
|
||||
|
||||
step('HANNAH', 'Assigns each WO to a specific operator')
|
||||
# Pick a bath + a tank for any WO that needs wet-process traceability
|
||||
test_bath = env['fusion.plating.bath'].search([], limit=1)
|
||||
test_tank = env['fusion.plating.tank'].search([], limit=1)
|
||||
|
||||
# Issue operator certifications for the bath's process type so the cert
|
||||
# gate doesn't block legitimate operators (in real life the manager
|
||||
# tracks training + issues certs; for a clean E2E we pre-issue).
|
||||
Cert = env.get('fp.operator.certification')
|
||||
if Cert is not None and test_bath and test_bath.process_type_id:
|
||||
pt = test_bath.process_type_id
|
||||
for op_key in ('john', 'maria', 'tom', 'ana', 'frank'):
|
||||
emp = env['hr.employee'].search(
|
||||
[('user_id', '=', users[op_key].id)], limit=1)
|
||||
if not emp:
|
||||
continue
|
||||
existing = Cert.sudo().search([
|
||||
('employee_id', '=', emp.id),
|
||||
('process_type_id', '=', pt.id),
|
||||
('revoked', '=', False),
|
||||
], limit=1)
|
||||
if not existing:
|
||||
Cert.sudo().create({
|
||||
'employee_id': emp.id,
|
||||
'process_type_id': pt.id,
|
||||
'issued_by_id': users['hannah'].id,
|
||||
'notes': 'Auto-issued for E2E workforce simulation',
|
||||
})
|
||||
show(' certifications', f'pre-issued for {pt.name} → 5 operators')
|
||||
show(' test bath', f'{test_bath.name}' if test_bath else '(none — wet-WO assignment will fail)')
|
||||
show(' test tank', f'{test_tank.name}' if test_tank else '(none — wet-WO assignment will fail)')
|
||||
|
||||
assignments = []
|
||||
wet_assignments = []
|
||||
for wo in mo.workorder_ids:
|
||||
name_l = (wo.name or '').lower()
|
||||
operator_key = None
|
||||
@@ -231,16 +263,79 @@ for wo in mo.workorder_ids:
|
||||
if kw in name_l:
|
||||
operator_key = k
|
||||
break
|
||||
operator_key = operator_key or 'john' # fallback
|
||||
operator_key = operator_key or 'john'
|
||||
op_user = users[operator_key]
|
||||
wo.sudo().x_fc_assigned_user_id = op_user.id
|
||||
|
||||
# If this is a wet-process WO (E-Nickel Plating, etch, rinse, etc.)
|
||||
# Hannah must also pin the exact bath + tank for traceability.
|
||||
is_wet = wo._fp_is_wet_process() if hasattr(wo, '_fp_is_wet_process') else False
|
||||
bath_assigned = tank_assigned = False
|
||||
if is_wet and test_bath and test_tank:
|
||||
wo.sudo().write({
|
||||
'x_fc_bath_id': test_bath.id,
|
||||
'x_fc_tank_id': test_tank.id,
|
||||
})
|
||||
bath_assigned = True
|
||||
tank_assigned = True
|
||||
wet_assignments.append(wo)
|
||||
|
||||
assignments.append((wo, op_user, operator_key))
|
||||
show(f' WO {wo.id}', f'"{wo.name}" → {op_user.name}')
|
||||
extras = ''
|
||||
if is_wet:
|
||||
extras = f' [WET — bath={test_bath.name if bath_assigned else "MISSING"}, tank={test_tank.name if tank_assigned else "MISSING"}]'
|
||||
show(f' WO {wo.id}', f'"{wo.name}" → {op_user.name}{extras}')
|
||||
|
||||
assigned_count = sum(1 for w, _, _ in assignments if w.x_fc_assigned_user_id)
|
||||
finding('PASS' if assigned_count == n_wos else 'FAIL',
|
||||
'WO assignment', f'{assigned_count}/{n_wos} have x_fc_assigned_user_id')
|
||||
|
||||
wet_with_bath = sum(1 for w in wet_assignments if w.x_fc_bath_id and w.x_fc_tank_id)
|
||||
finding('PASS' if (not wet_assignments) or (wet_with_bath == len(wet_assignments)) else 'FAIL',
|
||||
'wet-WO bath+tank set',
|
||||
f'{wet_with_bath}/{len(wet_assignments)} wet WOs have both bath + tank')
|
||||
|
||||
# ===== Negative tests: validation MUST block bad starts =====
|
||||
banner('PHASE 4b — Negative tests: validation gates fire correctly')
|
||||
|
||||
# Test 1: try to start a WO with operator stripped → expect UserError
|
||||
step('SYSTEM', 'Test 1 — un-assigning operator and trying to start')
|
||||
test_wo = mo.workorder_ids[0]
|
||||
saved_op = test_wo.x_fc_assigned_user_id.id
|
||||
test_wo.sudo().x_fc_assigned_user_id = False
|
||||
gate_fired = False
|
||||
try:
|
||||
test_wo.sudo().button_start()
|
||||
except Exception as e:
|
||||
gate_fired = 'Assigned Operator' in str(e) or 'required' in str(e).lower()
|
||||
show(' blocked with', str(e).splitlines()[0][:120])
|
||||
finding('PASS' if gate_fired else 'FAIL',
|
||||
'gate: missing operator',
|
||||
'blocked' if gate_fired else 'NOT blocked — validation broken')
|
||||
test_wo.sudo().x_fc_assigned_user_id = saved_op
|
||||
|
||||
# Test 2: try to start a WET WO without bath/tank → expect UserError
|
||||
if wet_assignments:
|
||||
step('SYSTEM', 'Test 2 — wet WO with bath/tank stripped')
|
||||
wet_wo = wet_assignments[0]
|
||||
saved_bath = wet_wo.x_fc_bath_id.id
|
||||
saved_tank = wet_wo.x_fc_tank_id.id
|
||||
wet_wo.sudo().write({'x_fc_bath_id': False, 'x_fc_tank_id': False})
|
||||
gate_fired = False
|
||||
try:
|
||||
wet_wo.sudo().button_start()
|
||||
except Exception as e:
|
||||
msg = str(e)
|
||||
gate_fired = ('Bath' in msg and 'Tank' in msg) or 'required' in msg.lower()
|
||||
show(' blocked with', msg.splitlines()[0][:120])
|
||||
finding('PASS' if gate_fired else 'FAIL',
|
||||
'gate: missing bath/tank on wet WO',
|
||||
'blocked' if gate_fired else 'NOT blocked — validation broken')
|
||||
wet_wo.sudo().write({
|
||||
'x_fc_bath_id': saved_bath,
|
||||
'x_fc_tank_id': saved_tank,
|
||||
})
|
||||
|
||||
# =====================================================================
|
||||
banner('PHASE 5 — Operators run their work orders (REAL-TIME timers)')
|
||||
# =====================================================================
|
||||
|
||||
Reference in New Issue
Block a user