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",
|
"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': """
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 & Tank">
|
<group string="Bath & 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>
|
||||||
|
|||||||
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')
|
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)')
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user