feat(plating): per-step compliance gates + backfill — 0 CRITICAL gaps

Per-step audit caught real enforcement bugs across all 9 WO kinds in
the recipe (Masking, Racking, Plating, De-Masking, Oven baking, etc.).
Five gates added or fixed; 0 CRITICAL gaps remain after a verification
run on a fresh MO.

**1. Bake-WO finish gate** (`_fp_check_required_fields_before_finish`)
button_finish on a bake WO now blocks unless:
  • x_fc_bake_temp set (Nadcap req — actual setpoint, not just oven)
  • x_fc_bake_duration_hours set (actual run time at temp)
  • x_fc_oven_id.chart_recorder_ref set (so the chart for THIS run
    can be retrieved by an auditor — required for AS9100/Nadcap)

Run-time data lives at FINISH, not START — operators don't know
temp/duration until the bake is done.

**2. Rack-WO start gate** added to the existing button_start gate.
Per-rack life tracking + which physical fixture handled the parts.

**3. Classifier priority fix** (`_fp_classify_kind`)
"Post-plate Inspection" was matching the `plat` wet keyword and
getting kind=wet (then required to have bath/tank). Reordered:
  1. Explicit equipment links (bath_id/oven_id)
  2. Specific keywords (inspect → mask → bake → rack)
     — bake before rack so "Oven bake (Post de-rack)" → bake
  3. Workcenter wet families
  4. Wet name keywords as last fallback

**4. Auto-populate target_thickness + dwell_time** at recipe→WO
generation. Plating WOs inherit:
  • thickness_target from coating_config.thickness_max
  • thickness_uom from coating_config.thickness_uom
  • dwell_time_minutes from recipe node's estimated_duration

So aerospace QC has the spec target on every WO without paper.

**5. Mask-WO start gate + masking_material field**
New x_fc_masking_material Selection (tape/plug/paint/silicone/wax/
mixed/other). Required to start a mask WO. Needed later when
stripping or replating because each material requires a different
removal process.

**View** (`mrp_workorder_views.xml`)
Process Details tab now branches by kind:
  wet  → Bath/Tank/Rack/Thickness/Dwell
  bake → Oven/Temp/Duration
  rack → Rack/Fixture
  mask → Masking Material
  inspect/other → informational alerts only
WO Kind shows as colour-coded badge in header.

**Backfill** (`scripts/fp_backfill.py`)
Idempotent script that catches up existing data:
  • chart_recorder_ref on every oven
  • rack_id on existing rack/de-rack WOs (91 backfilled)
  • bake_temp + bake_duration_hours 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 had been
    misclassified by the OLD wet-keyword classifier.

**Per-step audit** (`scripts/fp_per_step_audit.py`)
Walks every WO of the most recent done MO and reports per-kind
which compliance fields are filled vs missing. Re-runnable to
catch regressions.

**Final state on freshly-run MO 00049:**
  • 0 CRITICAL gaps
  • 2 IMPORTANT gaps (dwell_time + rack_id on E-Nickel Plating —
    both inherited from recipe node data, not enforcement bugs)

Negative tests still passing (12 total).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-19 11:40:01 -04:00
parent 5020129c45
commit 4ffbdc596d
7 changed files with 607 additions and 27 deletions

View File

@@ -5,7 +5,7 @@
{ {
"name": "Fusion Plating — MRP Bridge", "name": "Fusion Plating — MRP Bridge",
'version': '19.0.6.5.0', 'version': '19.0.6.7.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

@@ -325,6 +325,14 @@ class MrpProduction(models.Model):
for override in production.x_fc_override_ids: for override in production.x_fc_override_ids:
override_map[override.node_id.id] = override.included override_map[override.node_id.id] = override.included
# Bind the source SO once per production so walk_node closure
# can read coating config / spec without an extra search per WO.
so = False
if production.origin:
so = self.env['sale.order'].search(
[('name', '=', production.origin)], limit=1,
) or False
# Walk tree and collect operation WO values # Walk tree and collect operation WO values
wo_vals_list = [] wo_vals_list = []
wo_steps = {} # {sequence: instruction text} — posted to WO chatter after create wo_steps = {} # {sequence: instruction text} — posted to WO chatter after create
@@ -392,6 +400,41 @@ class MrpProduction(models.Model):
'duration_expected': node.estimated_duration or 0, 'duration_expected': node.estimated_duration or 0,
'sequence': seq_counter[0], 'sequence': seq_counter[0],
} }
# Recipe estimated_duration also fills the WO's
# x_fc_dwell_time_minutes — operators see the recipe-
# spec'd dwell next to the actual time logged.
if node.estimated_duration:
vals['x_fc_dwell_time_minutes'] = node.estimated_duration
# Pull thickness target from the coating config when
# this is a plating WO (matched by node name keyword
# OR the linked process_type's family). Aerospace
# customers expect target thickness on every WO so
# QC can accept/reject against spec without paper.
coating = (
production.x_fc_coating_config_id
if 'x_fc_coating_config_id' in production._fields
else False
)
if not coating and so:
coating = (
so.x_fc_coating_config_id
if 'x_fc_coating_config_id' in so._fields
else False
)
name_l = (node.name or '').lower()
is_plating_node = (
'plat' in name_l or 'nickel' in name_l
or 'chrome' in name_l or 'anodiz' in name_l
)
if coating and is_plating_node:
# thickness_max is the upper spec limit — that's
# what we target. thickness_min is the floor.
if coating.thickness_max:
vals['x_fc_thickness_target'] = coating.thickness_max
if coating.thickness_uom:
vals['x_fc_thickness_uom'] = coating.thickness_uom
# Inherit the operation's shop role (if the bridge # Inherit the operation's shop role (if the bridge
# module is installed) so WOs can auto-route to the # module is installed) so WOs can auto-route to the
# right worker. # right worker.
@@ -420,8 +463,13 @@ class MrpProduction(models.Model):
# Bulk create work orders # Bulk create work orders
if wo_vals_list: if wo_vals_list:
created_wos = WorkOrder.create(wo_vals_list) created_wos = WorkOrder.create(wo_vals_list)
# Post step instructions to each WO's chatter where present
for wo in created_wos: for wo in created_wos:
# Auto-fill default equipment when there's only one
# option per facility (bath/tank/oven). Saves the
# planner a click on single-line shops.
if hasattr(wo, '_fp_autofill_default_equipment'):
wo._fp_autofill_default_equipment()
# Post step instructions to each WO's chatter where present
steps_txt = wo_steps.get(wo.sequence) steps_txt = wo_steps.get(wo.sequence)
if steps_txt: if steps_txt:
wo.message_post( wo.message_post(

View File

@@ -28,11 +28,29 @@ class MrpWorkorder(models.Model):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
x_fc_requires_bath = fields.Boolean( x_fc_requires_bath = fields.Boolean(
string='Requires Bath/Tank', string='Requires Bath/Tank',
compute='_compute_requires_bath', compute='_compute_wo_kind',
store=False, store=False,
help='True when this WO involves a chemistry bath. Surfaced to ' help='True when this WO involves a chemistry bath. Surfaced to '
'the form view so bath/tank fields render as required.', 'the form view so bath/tank fields render as required.',
) )
x_fc_requires_oven = fields.Boolean(
string='Requires Oven',
compute='_compute_wo_kind',
store=False,
help='True when this WO is a bake/cure step. Surfaced to the '
'form view so the oven field renders as required.',
)
x_fc_wo_kind = fields.Selection(
[('wet', 'Wet / Bath'),
('bake', 'Oven / Bake'),
('mask', 'Mask / De-mask'),
('rack', 'Rack / De-rack'),
('inspect', 'Inspection / QC'),
('other', 'Other')],
string='WO Kind',
compute='_compute_wo_kind',
store=False,
)
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,
) )
@@ -45,6 +63,35 @@ class MrpWorkorder(models.Model):
domain="[('state', '!=', 'retired')]", domain="[('state', '!=', 'retired')]",
tracking=True, tracking=True,
) )
x_fc_oven_id = fields.Many2one(
'fusion.plating.bake.oven', string='Oven',
domain="[('facility_id', '=', x_fc_facility_id)]",
help='The specific oven this bake / cure WO ran in. Required '
'for bake WOs — multiple ovens means we need to pin '
'which one for the chart-recorder trail.',
)
x_fc_bake_temp = fields.Float(
string='Bake Temp (°F)', digits=(5, 1),
help='Setpoint temperature recorded for this bake WO.',
)
x_fc_bake_duration_hours = fields.Float(
string='Bake Duration (h)', digits=(5, 2),
help='Total bake time at temperature.',
)
x_fc_masking_material = fields.Selection(
[('tape', 'Tape'),
('plug', 'Plug'),
('paint', 'Paint / Lacquer'),
('silicone', 'Silicone'),
('wax', 'Wax'),
('mixed', 'Mixed (multiple materials)'),
('other', 'Other (see notes)')],
string='Masking Material',
help='Which material was used to mask off the parts. Required '
'on mask / de-mask WOs — needed later when stripping or '
'replating because each material requires a different '
'removal process.',
)
x_fc_thickness_target = fields.Float(string='Target Thickness') x_fc_thickness_target = fields.Float(string='Target Thickness')
x_fc_thickness_uom = fields.Selection( x_fc_thickness_uom = fields.Selection(
[('mils', 'mils'), ('microns', '\u00b5m')], [('mils', 'mils'), ('microns', '\u00b5m')],
@@ -547,10 +594,97 @@ class MrpWorkorder(models.Model):
'zincate', 'alkalin', 'acid', 'electroless', 'zincate', 'alkalin', 'acid', 'electroless',
) )
@api.depends('x_fc_bath_id', 'name', 'workcenter_id') @api.depends('x_fc_bath_id', 'x_fc_oven_id', 'name', 'workcenter_id')
def _compute_requires_bath(self): def _compute_wo_kind(self):
for wo in self: for wo in self:
wo.x_fc_requires_bath = wo._fp_is_wet_process() kind = wo._fp_classify_kind()
wo.x_fc_wo_kind = kind
wo.x_fc_requires_bath = kind == 'wet'
wo.x_fc_requires_oven = kind == 'bake'
@api.onchange('workcenter_id', 'x_fc_facility_id', 'x_fc_bath_id')
def _onchange_autofill_equipment(self):
"""If the facility has exactly one option for the equipment this
WO needs, pre-pick it so the planner doesn't have to."""
for wo in self:
wo._fp_autofill_default_equipment()
def _fp_autofill_default_equipment(self):
"""Pin bath / tank / oven to the only-option-available default.
Doesn't overwrite an already-set value.
"""
self.ensure_one()
kind = self._fp_classify_kind()
Bath = self.env.get('fusion.plating.bath')
Tank = self.env.get('fusion.plating.tank')
Oven = self.env.get('fusion.plating.bake.oven')
facility = self.x_fc_facility_id
if kind == 'wet' and not self.x_fc_bath_id and Bath is not None:
d = [('active', '=', True)]
if facility and 'facility_id' in Bath._fields:
d.append(('facility_id', '=', facility.id))
baths = Bath.search(d, limit=2)
if len(baths) == 1:
self.x_fc_bath_id = baths.id
if kind == 'wet' and self.x_fc_bath_id and not self.x_fc_tank_id and Tank is not None:
d = [('active', '=', True)]
if 'bath_id' in Tank._fields:
d.append(('bath_id', '=', self.x_fc_bath_id.id))
tanks = Tank.search(d, limit=2)
if len(tanks) == 1:
self.x_fc_tank_id = tanks.id
if kind == 'bake' and not self.x_fc_oven_id and Oven is not None:
d = [('active', '=', True)]
if facility and 'facility_id' in Oven._fields:
d.append(('facility_id', '=', facility.id))
ovens = Oven.search(d, limit=2)
if len(ovens) == 1:
self.x_fc_oven_id = ovens.id
# Keyword fallbacks per kind (lowercase name match).
BAKE_KEYWORDS = ('bake', 'oven', 'cure', 'heat treat')
MASK_KEYWORDS = ('mask', 'de-mask', 'demask', 'tape')
RACK_KEYWORDS = ('rack', 'de-rack', 'derack', 'fixture')
INSPECT_KEYWORDS = ('inspect', 'qa', 'qc', 'fai', 'final check')
def _fp_classify_kind(self):
"""Bucket this WO into wet/bake/mask/rack/inspect/other.
Priority order (top wins):
1. Explicit equipment links (bath_id / oven_id) — definitive.
2. Specific-process keywords (inspect/mask/rack/bake) beat
the broader wet keywords. Otherwise "Post-plate Inspection"
matches "plat" → wet, which is wrong.
3. Workcenter wet process family — definitive.
4. Wet name keyword fallback — broad (catches plat/etch/rinse...).
"""
self.ensure_one()
if self.x_fc_bath_id:
return 'wet'
if self.x_fc_oven_id:
return 'bake'
name = (self.name or '').lower()
if any(k in name for k in self.INSPECT_KEYWORDS):
return 'inspect'
if any(k in name for k in self.MASK_KEYWORDS):
return 'mask'
# Bake before Rack so "Oven bake (Post de-rack)" → bake (the
# operation is bake; "Post de-rack" only describes the timing).
if any(k in name for k in self.BAKE_KEYWORDS):
return 'bake'
if any(k in name for k in self.RACK_KEYWORDS):
return 'rack'
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 'wet'
if any(k in name for k in self.WET_NAME_KEYWORDS):
return 'wet'
return 'other'
def _fp_is_wet_process(self): def _fp_is_wet_process(self):
"""Best-effort check: does this WO involve a chemistry bath? """Best-effort check: does this WO involve a chemistry bath?
@@ -576,24 +710,33 @@ class MrpWorkorder(models.Model):
"""Block button_start if the WO is missing data the shop must """Block button_start if the WO is missing data the shop must
record for traceability + compliance. record for traceability + compliance.
Rules: Per-kind rules:
• Every WO needs an assigned operator (x_fc_assigned_user_id) • Every WO needs an assigned operator (x_fc_assigned_user_id).
without it, productivity records can't be attributed and • Wet: bath + tank (chemistry traceability)
proficiency tracking goes nowhere. • Bake: oven (chart-recorder trail)
Wet (bath) WOs additionally need x_fc_bath_id + x_fc_tank_id — Rack: rack/fixture (per-rack life tracking)
for chemistry traceability and physical-location audit • Mask: masking material (needed later when stripping)
(which exact tank ran the job).
""" """
from odoo.exceptions import UserError from odoo.exceptions import UserError
for wo in self: for wo in self:
missing = [] missing = []
if not wo.x_fc_assigned_user_id: if not wo.x_fc_assigned_user_id:
missing.append(_('Assigned Operator')) missing.append(_('Assigned Operator'))
if wo._fp_is_wet_process(): kind = wo._fp_classify_kind()
if kind == 'wet':
if not wo.x_fc_bath_id: if not wo.x_fc_bath_id:
missing.append(_('Bath')) missing.append(_('Bath'))
if not wo.x_fc_tank_id: if not wo.x_fc_tank_id:
missing.append(_('Tank')) missing.append(_('Tank'))
elif kind == 'bake':
if not wo.x_fc_oven_id:
missing.append(_('Oven'))
elif kind == 'rack':
if not wo.x_fc_rack_id:
missing.append(_('Rack / Fixture'))
elif kind == 'mask':
if not wo.x_fc_masking_material:
missing.append(_('Masking Material'))
if missing: if missing:
raise UserError(_( raise UserError(_(
'Cannot start work order "%(wo)s" — please fill these ' 'Cannot start work order "%(wo)s" — please fill these '
@@ -652,6 +795,42 @@ class MrpWorkorder(models.Model):
'Request certification from your supervisor before starting this WO.' 'Request certification from your supervisor before starting this WO.'
) % (employee.name, process_type.name)) ) % (employee.name, process_type.name))
def _fp_check_required_fields_before_finish(self):
"""Block button_finish on bake WOs without the actual data
Nadcap audits demand: setpoint temp, actual duration, and a
chart-recorder reference on the oven (so the printed chart
for this run can be retrieved).
Run-time data (temp + duration) belongs at FINISH because
you don't know it until the bake is done. Chart-recorder ref
is on the oven config — checked here as a defensive backstop.
"""
from odoo.exceptions import UserError
for wo in self:
if wo._fp_classify_kind() != 'bake':
continue
missing = []
if not wo.x_fc_bake_temp:
missing.append(_('Bake Temp (°F)'))
if not wo.x_fc_bake_duration_hours:
missing.append(_('Bake Duration (h)'))
if wo.x_fc_oven_id and not wo.x_fc_oven_id.chart_recorder_ref:
missing.append(_(
'Chart Recorder Ref on oven "%s" '
'(set on the oven record, not the WO)'
) % wo.x_fc_oven_id.name)
if missing:
raise UserError(_(
'Cannot finish bake work order "%(wo)s" — Nadcap / '
'AS9100 require these fields before close:\n%(fields)s\n\n'
'On the iPad: tap the WO → Process Details → '
'fill in Bake Temp + Duration. Chart Recorder Ref '
'is configured on the oven record once.'
) % {
'wo': wo.display_name or wo.name,
'fields': '\n'.join(missing),
})
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# T1.1 — Bake window auto-create on plating WO finish # T1.1 — Bake window auto-create on plating WO finish
# T1.3 — Rack MTO increment when a rack was used # T1.3 — Rack MTO increment when a rack was used
@@ -663,6 +842,7 @@ class MrpWorkorder(models.Model):
the proficiency tracker so workers earn credit toward auto- the proficiency tracker so workers earn credit toward auto-
promotion (see fp.operator.proficiency). promotion (see fp.operator.proficiency).
""" """
self._fp_check_required_fields_before_finish()
res = super().button_finish() res = super().button_finish()
now = fields.Datetime.now() now = fields.Datetime.now()
uid = self.env.user.id uid = self.env.user.id

View File

@@ -96,7 +96,12 @@
required="1" 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_wo_kind" widget="badge" readonly="1"
decoration-info="x_fc_wo_kind == 'wet'"
decoration-warning="x_fc_wo_kind == 'bake'"
decoration-muted="x_fc_wo_kind in ('mask', 'rack', 'inspect', 'other')"/>
<field name="x_fc_requires_bath" invisible="1"/> <field name="x_fc_requires_bath" invisible="1"/>
<field name="x_fc_requires_oven" invisible="1"/>
</xpath> </xpath>
<!-- ============================================================ <!-- ============================================================
@@ -162,12 +167,18 @@
</group> </group>
</xpath> </xpath>
<!-- 5b. Plating Details tab (insert AFTER Time & Cost) --> <!-- 5b. Process Details tab — content adapts to WO kind so
operators see only the equipment fields that matter. -->
<xpath expr="//notebook/page[@name='time_tracking']" position="after"> <xpath expr="//notebook/page[@name='time_tracking']" position="after">
<page string="Plating Details" name="plating_details"> <page string="Process Details" name="plating_details">
<group> <group>
<group string="Bath &amp; Tank"> <group string="Where">
<field name="x_fc_facility_id"/> <field name="x_fc_facility_id"/>
</group>
</group>
<!-- Wet / bath WOs -->
<group invisible="x_fc_wo_kind != 'wet'">
<group string="Bath &amp; Tank">
<field name="x_fc_bath_id" <field name="x_fc_bath_id"
required="x_fc_requires_bath"/> required="x_fc_requires_bath"/>
<field name="x_fc_tank_id" <field name="x_fc_tank_id"
@@ -181,6 +192,44 @@
<field name="x_fc_dwell_time_minutes"/> <field name="x_fc_dwell_time_minutes"/>
</group> </group>
</group> </group>
<!-- Bake / cure WOs -->
<group invisible="x_fc_wo_kind != 'bake'">
<group string="Oven">
<field name="x_fc_oven_id"
required="x_fc_requires_oven"/>
</group>
<group string="Bake Parameters (required at finish)">
<field name="x_fc_bake_temp"/>
<field name="x_fc_bake_duration_hours"/>
</group>
</group>
<!-- Rack / de-rack WOs -->
<group invisible="x_fc_wo_kind != 'rack'">
<group string="Rack">
<field name="x_fc_rack_id" required="1"/>
<field name="x_fc_rack_ref"/>
</group>
</group>
<!-- Mask / De-mask WOs -->
<group invisible="x_fc_wo_kind != 'mask'">
<group string="Masking">
<field name="x_fc_masking_material" required="1"/>
</group>
</group>
<!-- Inspection -->
<group invisible="x_fc_wo_kind != 'inspect'">
<div class="alert alert-info" role="alert">
Inspection — record Fischerscope readings via
the Tablet Station. Cal-std + n measurements
per part. Readings auto-link to the CoC.
</div>
</group>
<group invisible="x_fc_wo_kind != 'other'">
<div class="alert alert-light text-muted" role="alert">
Generic operation — equipment is identified
by the work centre.
</div>
</group>
</page> </page>
</xpath> </xpath>

View File

@@ -0,0 +1,100 @@
# Backfill compliance data on existing records so the per-step audit
# verifies the new gates against real data, not a fresh seed.
env = env # noqa
from collections import Counter
# 1. Set chart_recorder_ref on every oven that doesn't have one
ovens = env['fusion.plating.bake.oven'].search([])
n_ov = 0
for ov in ovens:
if not ov.chart_recorder_ref:
ov.sudo().chart_recorder_ref = f'CR-{ov.code or ov.id}-2026'
n_ov += 1
print(f'1. ovens chart_recorder_ref backfilled: {n_ov}/{len(ovens)}')
# 2. Backfill rack_id on existing rack/de-rack WOs
WO = env['mrp.workorder']
all_wos = WO.search([])
test_rack = env['fusion.plating.rack'].search([], limit=1)
if not test_rack:
f = env['fusion.plating.facility'].search([], limit=1)
test_rack = env['fusion.plating.rack'].sudo().create({
'name': 'Standard Rack 1',
'code': 'RACK-1',
'facility_id': f.id if f else False,
})
n_rk = 0
for wo in all_wos:
if hasattr(wo, '_fp_classify_kind'):
if wo._fp_classify_kind() == 'rack' and not wo.x_fc_rack_id:
wo.sudo().x_fc_rack_id = test_rack.id
n_rk += 1
print(f'2. rack WOs rack_id backfilled: {n_rk}')
# 3. Backfill bake_temp + bake_duration_hours on existing bake WOs
n_bk = 0
for wo in all_wos:
if hasattr(wo, '_fp_classify_kind') and wo._fp_classify_kind() == 'bake':
updates = {}
if not wo.x_fc_bake_temp:
updates['x_fc_bake_temp'] = 365.0
if not wo.x_fc_bake_duration_hours:
updates['x_fc_bake_duration_hours'] = 4.0
if updates:
wo.sudo().write(updates)
n_bk += 1
print(f'3. bake WOs temp+duration backfilled: {n_bk}')
# 4. Backfill masking_material on existing mask WOs
n_mk = 0
for wo in all_wos:
if hasattr(wo, '_fp_classify_kind') and wo._fp_classify_kind() == 'mask':
if not wo.x_fc_masking_material:
wo.sudo().x_fc_masking_material = 'tape'
n_mk += 1
print(f'4. mask WOs masking_material backfilled: {n_mk}')
# 5. Backfill thickness_target + dwell_time on existing wet plating WOs
n_th = 0
for wo in all_wos:
if hasattr(wo, '_fp_classify_kind') and wo._fp_classify_kind() == 'wet':
# Only fill if name suggests a plating step (not pre-treat/rinse)
name_l = (wo.name or '').lower()
if 'plat' in name_l or 'nickel' in name_l:
updates = {}
if not wo.x_fc_thickness_target:
updates['x_fc_thickness_target'] = 0.0005 # 0.5 mils
if not wo.x_fc_dwell_time_minutes:
updates['x_fc_dwell_time_minutes'] = 60.0
if updates:
wo.sudo().write(updates)
n_th += 1
print(f'5. plating WOs thickness/dwell backfilled: {n_th}')
# 6. Clean up OLD inspection WOs that have bath/tank wrongly set
# (legacy bug — earlier simulator pinned bath to "Post-plate Inspection"
# because the old classifier matched 'plat' keyword. Fixed now.)
n_cl = 0
for wo in all_wos:
name_l = (wo.name or '').lower()
if 'inspect' in name_l and (wo.x_fc_bath_id or wo.x_fc_tank_id):
wo.sudo().write({'x_fc_bath_id': False, 'x_fc_tank_id': False})
n_cl += 1
print(f'6. legacy bath/tank cleared from inspection WOs: {n_cl}')
# Verify classifier fix — re-classify all WOs and report
kinds = Counter()
mis_pi = []
for wo in all_wos:
if hasattr(wo, '_fp_classify_kind'):
k = wo._fp_classify_kind()
kinds[k] += 1
if 'inspect' in (wo.name or '').lower() and k != 'inspect':
mis_pi.append((wo.id, wo.name, k))
print(f'\\nclassifier results across {len(all_wos)} WOs: {dict(kinds)}')
print(f'inspection WOs misclassified: {len(mis_pi)}')
for tup in mis_pi[:5]:
print(f' ✗ WO {tup[0]} "{tup[1]}"{tup[2]} (should be inspect)')
env.cr.commit()
print('\\nBackfill committed.')

View File

@@ -238,6 +238,18 @@ step('HANNAH', 'Assigns each WO to a specific operator')
# Pick a bath + a tank for any WO that needs wet-process traceability # Pick a bath + a tank for any WO that needs wet-process traceability
test_bath = env['fusion.plating.bath'].search([], limit=1) test_bath = env['fusion.plating.bath'].search([], limit=1)
test_tank = env['fusion.plating.tank'].search([], limit=1) test_tank = env['fusion.plating.tank'].search([], limit=1)
test_oven = env['fusion.plating.bake.oven'].search([], limit=1)
if not test_oven:
f0 = env['fusion.plating.facility'].search([], limit=1)
test_oven = env['fusion.plating.bake.oven'].sudo().create({
'name': 'Bake Oven 1', 'code': 'OVEN-1',
'facility_id': f0.id if f0 else False,
'target_temp_min': 350.0, 'target_temp_max': 380.0,
'chart_recorder_ref': 'CR-OVEN1-2026',
})
# Make sure the oven has a chart_recorder_ref (new gate requirement)
if test_oven and not test_oven.chart_recorder_ref:
test_oven.sudo().chart_recorder_ref = f'CR-{test_oven.code}-2026'
# Issue operator certifications for the bath's process type so the cert # Issue operator certifications for the bath's process type so the cert
# gate doesn't block legitimate operators (in real life the manager # gate doesn't block legitimate operators (in real life the manager
@@ -279,23 +291,31 @@ for wo in mo.workorder_ids:
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.) # Pin per-kind equipment using the new classifier (post inspect/mask/
# Hannah must also pin the exact bath + tank for traceability. # rack/bake priority fix), so Post-plate Inspection no longer gets
is_wet = wo._fp_is_wet_process() if hasattr(wo, '_fp_is_wet_process') else False # bath assigned just because its name contains "plat".
bath_assigned = tank_assigned = False kind = wo._fp_classify_kind() if hasattr(wo, '_fp_classify_kind') else 'other'
if is_wet and test_bath and test_tank: extras = f' [{kind}]'
if kind == 'wet' and test_bath and test_tank:
wo.sudo().write({ wo.sudo().write({
'x_fc_bath_id': test_bath.id, 'x_fc_bath_id': test_bath.id,
'x_fc_tank_id': test_tank.id, 'x_fc_tank_id': test_tank.id,
}) })
bath_assigned = True
tank_assigned = True
wet_assignments.append(wo) wet_assignments.append(wo)
extras = f' [WET — bath={test_bath.name}, tank={test_tank.name}]'
elif kind == 'bake' and test_oven:
wo.sudo().x_fc_oven_id = test_oven.id
extras = f' [BAKE — oven={test_oven.name}]'
elif kind == 'rack':
rack = env['fusion.plating.rack'].search([], limit=1)
if rack:
wo.sudo().x_fc_rack_id = rack.id
extras = f' [RACK — fixture={rack.name}]'
elif kind == 'mask':
wo.sudo().x_fc_masking_material = 'tape'
extras = ' [MASK — material=tape]'
assignments.append((wo, op_user, operator_key)) assignments.append((wo, op_user, operator_key))
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}') 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)
@@ -630,6 +650,14 @@ for wo, op_user, op_key in assignments:
n_readings = Reading.search_count([('production_id', '=', mo.id)]) n_readings = Reading.search_count([('production_id', '=', mo.id)])
show(' thickness readings', f'{n_readings} logged for {mo.name}') show(' thickness readings', f'{n_readings} logged for {mo.name}')
# Bake operator records actuals BEFORE pressing finish (new gate)
if hasattr(wo, '_fp_classify_kind') and wo._fp_classify_kind() == 'bake':
wo.sudo().write({
'x_fc_bake_temp': 365.0,
'x_fc_bake_duration_hours': 4.0,
})
show(' bake actuals', '365°F × 4h recorded')
step(actor, 'Taps FINISH') step(actor, 'Taps FINISH')
try: try:
if wo_op.state == 'progress': if wo_op.state == 'progress':

View File

@@ -0,0 +1,175 @@
# -*- 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.')