diff --git a/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py b/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py
index da80fde9..ab820314 100644
--- a/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py
+++ b/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py
@@ -5,7 +5,7 @@
{
"name": "Fusion Plating — MRP Bridge",
- 'version': '19.0.6.5.0',
+ 'version': '19.0.6.7.0',
'category': 'Manufacturing/Plating',
'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.',
'description': """
diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py
index 37a6d935..0caf28db 100644
--- a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py
+++ b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py
@@ -325,6 +325,14 @@ class MrpProduction(models.Model):
for override in production.x_fc_override_ids:
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
wo_vals_list = []
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,
'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
# module is installed) so WOs can auto-route to the
# right worker.
@@ -420,8 +463,13 @@ class MrpProduction(models.Model):
# Bulk create work orders
if 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:
+ # 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)
if steps_txt:
wo.message_post(
diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py
index 0c6987db..752cb34d 100644
--- a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py
+++ b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py
@@ -28,11 +28,29 @@ class MrpWorkorder(models.Model):
# ------------------------------------------------------------------
x_fc_requires_bath = fields.Boolean(
string='Requires Bath/Tank',
- compute='_compute_requires_bath',
+ compute='_compute_wo_kind',
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_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(
'fusion.plating.bath', string='Bath', tracking=True,
)
@@ -45,6 +63,35 @@ class MrpWorkorder(models.Model):
domain="[('state', '!=', 'retired')]",
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_uom = fields.Selection(
[('mils', 'mils'), ('microns', '\u00b5m')],
@@ -547,10 +594,97 @@ class MrpWorkorder(models.Model):
'zincate', 'alkalin', 'acid', 'electroless',
)
- @api.depends('x_fc_bath_id', 'name', 'workcenter_id')
- def _compute_requires_bath(self):
+ @api.depends('x_fc_bath_id', 'x_fc_oven_id', 'name', 'workcenter_id')
+ def _compute_wo_kind(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):
"""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
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).
+ Per-kind rules:
+ • Every WO needs an assigned operator (x_fc_assigned_user_id).
+ • Wet: bath + tank (chemistry traceability)
+ • Bake: oven (chart-recorder trail)
+ • Rack: rack/fixture (per-rack life tracking)
+ • Mask: masking material (needed later when stripping)
"""
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():
+ kind = wo._fp_classify_kind()
+ if kind == 'wet':
if not wo.x_fc_bath_id:
missing.append(_('Bath'))
if not wo.x_fc_tank_id:
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:
raise UserError(_(
'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.'
) % (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.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-
promotion (see fp.operator.proficiency).
"""
+ self._fp_check_required_fields_before_finish()
res = super().button_finish()
now = fields.Datetime.now()
uid = self.env.user.id
diff --git a/fusion_plating/fusion_plating_bridge_mrp/views/mrp_workorder_views.xml b/fusion_plating/fusion_plating_bridge_mrp/views/mrp_workorder_views.xml
index 7145ead3..cb6f3d06 100644
--- a/fusion_plating/fusion_plating_bridge_mrp/views/mrp_workorder_views.xml
+++ b/fusion_plating/fusion_plating_bridge_mrp/views/mrp_workorder_views.xml
@@ -96,7 +96,12 @@
required="1"
options="{'no_create': True}"/>
+
+
+
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Inspection — record Fischerscope readings via
+ the Tablet Station. Cal-std + n measurements
+ per part. Readings auto-link to the CoC.
+
+
+
+
+ Generic operation — equipment is identified
+ by the work centre.
+
+
diff --git a/fusion_plating/scripts/fp_backfill.py b/fusion_plating/scripts/fp_backfill.py
new file mode 100644
index 00000000..bee7b822
--- /dev/null
+++ b/fusion_plating/scripts/fp_backfill.py
@@ -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.')
diff --git a/fusion_plating/scripts/fp_e2e_workforce.py b/fusion_plating/scripts/fp_e2e_workforce.py
index 70c03946..035d617a 100644
--- a/fusion_plating/scripts/fp_e2e_workforce.py
+++ b/fusion_plating/scripts/fp_e2e_workforce.py
@@ -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
test_bath = env['fusion.plating.bath'].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
# 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]
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:
+ # Pin per-kind equipment using the new classifier (post inspect/mask/
+ # rack/bake priority fix), so Post-plate Inspection no longer gets
+ # bath assigned just because its name contains "plat".
+ kind = wo._fp_classify_kind() if hasattr(wo, '_fp_classify_kind') else 'other'
+ extras = f' [{kind}]'
+ if kind == '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)
+ 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))
- 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)
@@ -630,6 +650,14 @@ for wo, op_user, op_key in assignments:
n_readings = Reading.search_count([('production_id', '=', mo.id)])
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')
try:
if wo_op.state == 'progress':
diff --git a/fusion_plating/scripts/fp_per_step_audit.py b/fusion_plating/scripts/fp_per_step_audit.py
new file mode 100644
index 00000000..25f7677f
--- /dev/null
+++ b/fusion_plating/scripts/fp_per_step_audit.py
@@ -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.')