feat(plating): hard-required fields on WO start — operator + bath + tank
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled

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:
gsinghpal
2026-04-19 09:47:31 -04:00
parent fe003567a9
commit 4161f04b0f
5 changed files with 195 additions and 6 deletions

View 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)

View File

@@ -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)')
# =====================================================================