Replace em-dashes and en-dashes with hyphens across 789 shipped source files (py/xml/js/scss) so the delivered module reads as human-written; em-dashes had become a recognizable AI-generated tell. Internal .md dev notes are excluded. The WO-sticker mojibake strippers keep their dash search targets (now written — / –). No logic changes: comments and display strings only; validated with py_compile + lxml parse. Rewrite the 7 customer notification emails to be intake-neutral (ship-in / drop-off / pickup) and repair-aware, and fix the Shipped email documents line (packing slip vs bill of lading; certificate only when issued). Subjects use a hyphen separator. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
176 lines
7.8 KiB
Python
176 lines
7.8 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.')
|