Files
Odoo-Modules/fusion_plating/scripts/fp_per_step_audit.py
gsinghpal 7fa54d8fc9 feat(plating): per-step compliance gates + backfill — 0 CRITICAL gaps
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>
2026-04-19 11:42:12 -04:00

176 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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.')