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

@@ -5,7 +5,7 @@
{ {
"name": "Fusion Plating — MRP Bridge", "name": "Fusion Plating — MRP Bridge",
'version': '19.0.6.3.0', 'version': '19.0.6.4.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.', 'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.',
'description': """ 'description': """

View File

@@ -26,6 +26,13 @@ class MrpWorkorder(models.Model):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Plating-specific fields # 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( x_fc_bath_id = fields.Many2one(
'fusion.plating.bath', string='Bath', tracking=True, 'fusion.plating.bath', string='Bath', tracking=True,
) )
@@ -512,10 +519,82 @@ class MrpWorkorder(models.Model):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# T2.2 — Certification gate on WO start # 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): def button_start(self):
"""Block start unless the current user's linked employee holds """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() self._fp_check_operator_certification()
res = super().button_start() res = super().button_start()
# Capture audit AFTER the super call so we don't stamp WOs that # Capture audit AFTER the super call so we don't stamp WOs that

View File

@@ -93,8 +93,10 @@
<field name="x_fc_priority" widget="priority"/> <field name="x_fc_priority" widget="priority"/>
<field name="x_fc_assigned_user_id" <field name="x_fc_assigned_user_id"
string="Assigned To" string="Assigned To"
required="1"
options="{'no_create': True}"/> options="{'no_create': True}"/>
<field name="x_fc_work_role_id" readonly="1"/> <field name="x_fc_work_role_id" readonly="1"/>
<field name="x_fc_requires_bath" invisible="1"/>
</xpath> </xpath>
<!-- ============================================================ <!-- ============================================================
@@ -166,8 +168,10 @@
<group> <group>
<group string="Bath &amp; Tank"> <group string="Bath &amp; Tank">
<field name="x_fc_facility_id"/> <field name="x_fc_facility_id"/>
<field name="x_fc_bath_id"/> <field name="x_fc_bath_id"
<field name="x_fc_tank_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_id"/>
<field name="x_fc_rack_ref"/> <field name="x_fc_rack_ref"/>
</group> </group>

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') 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 = [] assignments = []
wet_assignments = []
for wo in mo.workorder_ids: for wo in mo.workorder_ids:
name_l = (wo.name or '').lower() name_l = (wo.name or '').lower()
operator_key = None operator_key = None
@@ -231,16 +263,79 @@ for wo in mo.workorder_ids:
if kw in name_l: if kw in name_l:
operator_key = k operator_key = k
break break
operator_key = operator_key or 'john' # fallback operator_key = operator_key or 'john'
op_user = users[operator_key] op_user = users[operator_key]
wo.sudo().x_fc_assigned_user_id = op_user.id 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)) 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) assigned_count = sum(1 for w, _, _ in assignments if w.x_fc_assigned_user_id)
finding('PASS' if assigned_count == n_wos else 'FAIL', finding('PASS' if assigned_count == n_wos else 'FAIL',
'WO assignment', f'{assigned_count}/{n_wos} have x_fc_assigned_user_id') '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)') banner('PHASE 5 — Operators run their work orders (REAL-TIME timers)')
# ===================================================================== # =====================================================================