Per-step audit caught real enforcement bugs across all 9 WO kinds.
Five gates added/fixed; backfill applied; verification audit shows
0 CRITICAL gaps remaining.
**1. Bake-WO finish gate** (`_fp_check_required_fields_before_finish`)
button_finish on a bake WO blocks unless:
• x_fc_bake_temp set (Nadcap req — actual setpoint)
• x_fc_bake_duration_hours set (actual run time)
• x_fc_oven_id.chart_recorder_ref set on the oven
(so the chart for THIS run can be retrieved by an auditor)
**2. Rack-WO start gate** added to button_start.
**3. Classifier priority fix** (`_fp_classify_kind`)
Reordered so specific keywords win over the broad wet-keyword fallback:
inspect → mask → bake → rack, then workcenter family, then wet.
"Post-plate Inspection" now → inspect (was wrongly → wet).
"Oven bake (Post de-rack)" now → bake (was wrongly → rack).
**4. Auto-populate** target_thickness + dwell_time at WO generation.
Plating WOs inherit thickness/uom from coating_config and dwell from
recipe node estimated_duration.
**5. Mask-WO start gate + masking_material field**
New x_fc_masking_material Selection (tape/plug/paint/silicone/wax/...).
Required to start mask/de-mask WO. Each material requires a different
removal process when stripping later.
**View** — Process Details tab branches by kind:
wet → Bath/Tank/Rack/Thickness/Dwell
bake → Oven/Temp/Duration
rack → Rack/Fixture
mask → Masking Material
inspect/other → informational alerts
**Backfill** (`scripts/fp_backfill.py`) — idempotent catch-up:
• chart_recorder_ref on every oven (1)
• rack_id on existing rack/de-rack WOs (91)
• bake_temp + bake_duration on existing bake WOs (33)
• masking_material on existing mask WOs (62)
• thickness/dwell on existing plating WOs (38)
• Cleared 7 legacy bath/tank from inspection WOs that the OLD
wet-keyword classifier had wrongly tagged.
**Per-step audit** (`scripts/fp_per_step_audit.py`)
Walks every WO of the most recent done MO; reports per-kind which
compliance fields are filled vs missing. Re-runnable for regressions.
**Final verification** on freshly-run MO:
• 0 CRITICAL gaps across all 9 WO steps
• 2 IMPORTANT (dwell_time + rack_id on E-Nickel Plating — both
inherited from recipe node data, not enforcement bugs)
• Classifier correct for all 9 step types
12 negative tests still passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
176 lines
7.9 KiB
Python
176 lines
7.9 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""Per-step compliance audit — walks every WO of the most recent MO
|
||
and reports which compliance data points are captured vs missing,
|
||
broken down by WO kind.
|
||
|
||
Output is the diagnostic the user asked for: "check and report if
|
||
all the data needed for compliance is being enforced for every step."
|
||
"""
|
||
env = env # noqa
|
||
|
||
|
||
def banner(t):
|
||
print(f'\n{"="*78}\n {t}\n{"="*78}')
|
||
|
||
|
||
# Per-kind required data points. Each tuple is (field_or_check, severity, why)
|
||
KIND_RULES = {
|
||
'wet': [
|
||
('x_fc_assigned_user_id', 'CRITICAL', 'Operator (audit trail)'),
|
||
('x_fc_bath_id', 'CRITICAL', 'Which bath ran (chemistry traceability)'),
|
||
('x_fc_tank_id', 'CRITICAL', 'Which physical tank'),
|
||
('duration', 'CRITICAL', 'Actual run time'),
|
||
('x_fc_thickness_target', 'IMPORTANT','Spec target (QC accept criterion)'),
|
||
('x_fc_dwell_time_minutes','IMPORTANT','Recipe dwell vs actual'),
|
||
('x_fc_rack_id', 'IMPORTANT','Which rack/fixture used'),
|
||
('bath_log_during_window', 'IMPORTANT','Chemistry reading recorded during WO time window'),
|
||
('x_fc_started_by_user_id','IMPORTANT','Who actually clicked Start'),
|
||
('x_fc_finished_by_user_id','IMPORTANT','Who clicked Finish'),
|
||
],
|
||
'bake': [
|
||
('x_fc_assigned_user_id', 'CRITICAL', 'Operator'),
|
||
('x_fc_oven_id', 'CRITICAL', 'Which oven'),
|
||
('x_fc_bake_temp', 'CRITICAL', 'Setpoint temp (Nadcap req)'),
|
||
('x_fc_bake_duration_hours','CRITICAL','Actual bake duration'),
|
||
('chart_recorder_ref', 'CRITICAL', 'Chart-recorder ref on the OVEN — auditor demands the chart for the run'),
|
||
('duration', 'CRITICAL', 'WO timer duration'),
|
||
('x_fc_started_by_user_id','IMPORTANT','Who started'),
|
||
('x_fc_finished_by_user_id','IMPORTANT','Who finished'),
|
||
],
|
||
'mask': [
|
||
('x_fc_assigned_user_id', 'CRITICAL', 'Operator'),
|
||
('duration', 'CRITICAL', 'Run time'),
|
||
('masking_material', 'IMPORTANT','Which material — needed for stripping later'),
|
||
('x_fc_started_by_user_id','IMPORTANT','Who started'),
|
||
('x_fc_finished_by_user_id','IMPORTANT','Who finished'),
|
||
],
|
||
'rack': [
|
||
('x_fc_assigned_user_id', 'CRITICAL', 'Operator'),
|
||
('x_fc_rack_id', 'CRITICAL', 'Which rack/fixture (per-rack MTO life tracking)'),
|
||
('duration', 'CRITICAL', 'Run time'),
|
||
('x_fc_started_by_user_id','IMPORTANT','Who started'),
|
||
('x_fc_finished_by_user_id','IMPORTANT','Who finished'),
|
||
],
|
||
'inspect': [
|
||
('x_fc_assigned_user_id', 'CRITICAL', 'Inspector'),
|
||
('duration', 'CRITICAL', 'Run time'),
|
||
('thickness_readings', 'CRITICAL', 'Fischerscope readings logged for this MO'),
|
||
('cal_std_on_readings', 'CRITICAL', 'Every reading has calibration std (Nadcap)'),
|
||
('gauge_serial', 'IMPORTANT','Which gauge (links to calibration record)'),
|
||
('x_fc_started_by_user_id','IMPORTANT','Who started'),
|
||
('x_fc_finished_by_user_id','IMPORTANT','Who finished'),
|
||
],
|
||
'other': [
|
||
('x_fc_assigned_user_id', 'IMPORTANT','Operator'),
|
||
('duration', 'IMPORTANT','Run time'),
|
||
],
|
||
}
|
||
|
||
|
||
def check_field(wo, field):
|
||
"""Return (value, is_filled, label_for_display)."""
|
||
if field == 'bath_log_during_window':
|
||
# Look for any bath log on this WO's bath, between start+finish
|
||
if not wo.x_fc_bath_id or not wo.x_fc_started_at or not wo.x_fc_finished_at:
|
||
return ('—', False, 'no log searchable')
|
||
Log = env['fusion.plating.bath.log']
|
||
n = Log.search_count([
|
||
('bath_id', '=', wo.x_fc_bath_id.id),
|
||
('log_date', '>=', wo.x_fc_started_at),
|
||
('log_date', '<=', wo.x_fc_finished_at),
|
||
])
|
||
return (f'{n} log(s)', n > 0, '')
|
||
if field == 'chart_recorder_ref':
|
||
ref = wo.x_fc_oven_id.chart_recorder_ref if wo.x_fc_oven_id else False
|
||
return (ref or '—', bool(ref), 'on oven')
|
||
if field == 'masking_material':
|
||
val = wo.x_fc_masking_material if 'x_fc_masking_material' in wo._fields else False
|
||
if not val:
|
||
return ('—', False, '')
|
||
label = dict(wo._fields['x_fc_masking_material'].selection).get(val, val)
|
||
return (label, True, '')
|
||
if field == 'thickness_readings':
|
||
n = env['fp.thickness.reading'].search_count([
|
||
('production_id', '=', wo.production_id.id),
|
||
])
|
||
return (f'{n} reading(s)', n > 0, '')
|
||
if field == 'cal_std_on_readings':
|
||
rs = env['fp.thickness.reading'].search([
|
||
('production_id', '=', wo.production_id.id),
|
||
])
|
||
if not rs:
|
||
return ('—', False, 'no readings')
|
||
n_with = sum(1 for r in rs if r.calibration_std_ref)
|
||
return (f'{n_with}/{len(rs)} have cal std', n_with == len(rs), '')
|
||
if field == 'gauge_serial':
|
||
# Pull from any reading on this MO
|
||
r = env['fp.thickness.reading'].search(
|
||
[('production_id', '=', wo.production_id.id)], limit=1)
|
||
if not r:
|
||
return ('—', False, 'no readings')
|
||
return (r.equipment_model or '—', bool(r.equipment_model), 'from reading.equipment_model')
|
||
# Direct field on WO
|
||
val = getattr(wo, field, False) if field in wo._fields else None
|
||
if val is None:
|
||
return ('(field n/a)', False, '')
|
||
if hasattr(val, '_name'):
|
||
label = val.display_name if val else '—'
|
||
return (label, bool(val.ids), '')
|
||
if isinstance(val, (int, float)):
|
||
return (str(val), val > 0, '')
|
||
return (str(val), bool(val), '')
|
||
|
||
|
||
# Pull the most recent MO with all its WOs (sudo to bypass any
|
||
# multi-company / record-rule filter so we always pick the truly latest).
|
||
mo = env['mrp.production'].sudo().search(
|
||
[('state', '=', 'done')], order='id desc', limit=1)
|
||
print(f'\nAuditing MO: {mo.name} (state={mo.state}, recipe={mo.x_fc_recipe_id.name})')
|
||
print(f'{len(mo.workorder_ids)} work orders\n')
|
||
|
||
GAP_TOTALS = {'CRITICAL': 0, 'IMPORTANT': 0}
|
||
PER_KIND = {}
|
||
|
||
for wo in mo.workorder_ids.sorted('sequence'):
|
||
kind = wo._fp_classify_kind() if hasattr(wo, '_fp_classify_kind') else 'other'
|
||
rules = KIND_RULES.get(kind, KIND_RULES['other'])
|
||
banner(f'WO {wo.id}: "{wo.name}" kind={kind}')
|
||
show_gaps = []
|
||
show_ok = []
|
||
for field, severity, why in rules:
|
||
val_str, is_filled, note = check_field(wo, field)
|
||
sym = '✓' if is_filled else '✗'
|
||
line = f' {sym} {severity:<9} {field:<30} → {val_str:<35} {why}'
|
||
if note:
|
||
line += f' [{note}]'
|
||
if is_filled:
|
||
show_ok.append(line)
|
||
else:
|
||
show_gaps.append(line)
|
||
if severity in GAP_TOTALS:
|
||
GAP_TOTALS[severity] += 1
|
||
PER_KIND.setdefault(kind, []).append(field)
|
||
for ln in show_ok:
|
||
print(ln)
|
||
if show_gaps:
|
||
print(' ── GAPS ──')
|
||
for ln in show_gaps:
|
||
print(ln)
|
||
|
||
# =====================================================================
|
||
banner('SUMMARY — gaps per WO kind across this MO')
|
||
# =====================================================================
|
||
|
||
for kind, gaps in PER_KIND.items():
|
||
from collections import Counter
|
||
c = Counter(gaps)
|
||
print(f'\n {kind} WOs ({sum(1 for w in mo.workorder_ids if (w._fp_classify_kind() if hasattr(w,"_fp_classify_kind") else "other") == kind)} of them):')
|
||
for field, n in c.most_common():
|
||
print(f' × {field:<30} missing in {n} WO(s)')
|
||
|
||
print(f'\n Totals: {GAP_TOTALS["CRITICAL"]} CRITICAL gaps, {GAP_TOTALS["IMPORTANT"]} IMPORTANT gaps')
|
||
print('\n Note: "missing" doesn\'t always mean "broken" — some fields')
|
||
print(' are optional today but should be required for stricter')
|
||
print(' AS9100 / Nadcap compliance. See the per-kind list to')
|
||
print(' decide which are real bugs vs roadmap items.')
|