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:
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