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>
This commit is contained in:
gsinghpal
2026-04-19 11:42:12 -04:00
parent c7ecd90982
commit 7fa54d8fc9
7 changed files with 483 additions and 134 deletions

View File

@@ -5,7 +5,7 @@
{
"name": "Fusion Plating — MRP Bridge",
'version': '19.0.6.6.0',
'version': '19.0.6.7.0',
'category': 'Manufacturing/Plating',
'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.',
'description': """

View File

@@ -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,13 +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.
# Also auto-fill the default equipment per WO (bath / tank /
# oven) when there's only one option for the facility — saves
# the planner a click on single-line shops.
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(

View File

@@ -50,8 +50,18 @@ class MrpWorkorder(models.Model):
string='WO Kind',
compute='_compute_wo_kind',
store=False,
help='High-level classification used by the form view to show '
'only the equipment fields that apply to this kind of WO.',
)
x_fc_bath_id = fields.Many2one(
'fusion.plating.bath', string='Bath', tracking=True,
)
x_fc_tank_id = fields.Many2one(
'fusion.plating.tank', string='Tank',
)
x_fc_rack_ref = fields.Char(string='Rack / Fixture Ref (legacy)')
x_fc_rack_id = fields.Many2one(
'fusion.plating.rack', string='Rack / Fixture',
domain="[('state', '!=', 'retired')]",
tracking=True,
)
x_fc_oven_id = fields.Many2one(
'fusion.plating.bake.oven', string='Oven',
@@ -68,17 +78,19 @@ class MrpWorkorder(models.Model):
string='Bake Duration (h)', digits=(5, 2),
help='Total bake time at temperature.',
)
x_fc_bath_id = fields.Many2one(
'fusion.plating.bath', string='Bath', tracking=True,
)
x_fc_tank_id = fields.Many2one(
'fusion.plating.tank', string='Tank',
)
x_fc_rack_ref = fields.Char(string='Rack / Fixture Ref (legacy)')
x_fc_rack_id = fields.Many2one(
'fusion.plating.rack', string='Rack / Fixture',
domain="[('state', '!=', 'retired')]",
tracking=True,
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(
@@ -582,12 +594,6 @@ class MrpWorkorder(models.Model):
'zincate', 'alkalin', 'acid', 'electroless',
)
# Keyword fallbacks per kind. Lowercased 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')
@api.depends('x_fc_bath_id', 'x_fc_oven_id', 'name', 'workcenter_id')
def _compute_wo_kind(self):
for wo in self:
@@ -598,24 +604,15 @@ class MrpWorkorder(models.Model):
@api.onchange('workcenter_id', 'x_fc_facility_id', 'x_fc_bath_id')
def _onchange_autofill_equipment(self):
"""If the facility has exactly ONE choice for the equipment this
WO needs, pre-pick it so the planner doesn't have to. Saves a
click per WO on shops that run a single line."""
"""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.
Rules (none of these overwrite an already-set value):
• Wet WO with no bath: if the facility has exactly one active
bath (or globally one bath when no facility set), pick it.
• Wet WO with a bath but no tank: if the bath has exactly
one tank, pick it.
• Bake WO with no oven: if the facility has exactly one
active oven, pick it.
Idempotent and safe to call repeatedly.
Doesn't overwrite an already-set value.
"""
self.ensure_one()
kind = self._fp_classify_kind()
@@ -624,54 +621,69 @@ class MrpWorkorder(models.Model):
Oven = self.env.get('fusion.plating.bake.oven')
facility = self.x_fc_facility_id
# ---- Bath ----
if kind == 'wet' and not self.x_fc_bath_id and Bath is not None:
bath_domain = [('active', '=', True)]
d = [('active', '=', True)]
if facility and 'facility_id' in Bath._fields:
bath_domain.append(('facility_id', '=', facility.id))
baths = Bath.search(bath_domain, limit=2)
d.append(('facility_id', '=', facility.id))
baths = Bath.search(d, limit=2)
if len(baths) == 1:
self.x_fc_bath_id = baths.id
# ---- Tank ----
if kind == 'wet' and self.x_fc_bath_id and not self.x_fc_tank_id and Tank is not None:
tank_domain = [('active', '=', True)]
d = [('active', '=', True)]
if 'bath_id' in Tank._fields:
tank_domain.append(('bath_id', '=', self.x_fc_bath_id.id))
tanks = Tank.search(tank_domain, limit=2)
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
# ---- Oven ----
if kind == 'bake' and not self.x_fc_oven_id and Oven is not None:
oven_domain = [('active', '=', True)]
d = [('active', '=', True)]
if facility and 'facility_id' in Oven._fields:
oven_domain.append(('facility_id', '=', facility.id))
ovens = Oven.search(oven_domain, limit=2)
d.append(('facility_id', '=', facility.id))
ovens = Oven.search(d, limit=2)
if len(ovens) == 1:
self.x_fc_oven_id = ovens.id
def _fp_classify_kind(self):
"""Bucket this WO into one of: wet / bake / mask / rack / inspect / other.
# 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')
Priority: explicit linked equipment > workcenter process family >
WO name keyword. Wet wins over bake when both signals appear
(you can have an "alkaline clean before bake" — that's still wet).
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._fp_is_wet_process():
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.BAKE_KEYWORDS):
return 'bake'
if any(k in name for k in self.MASK_KEYWORDS):
return 'mask'
if any(k in name for k in self.RACK_KEYWORDS):
return 'rack'
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):
@@ -698,15 +710,12 @@ 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).
• Bake WOs need x_fc_oven_id — multiple ovens means we have
to pin which one for the chart-recorder trail.
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:
@@ -722,6 +731,12 @@ class MrpWorkorder(models.Model):
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 '
@@ -780,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
@@ -791,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

View File

@@ -168,18 +168,15 @@
</xpath>
<!-- 5b. Process Details tab — content adapts to WO kind so
operators see only the equipment fields that matter for
their step (bath/tank for wet, oven for bake, etc.). -->
operators see only the equipment fields that matter. -->
<xpath expr="//notebook/page[@name='time_tracking']" position="after">
<page string="Process Details" name="plating_details">
<!-- Always-visible: facility (set everywhere) -->
<group>
<group string="Where">
<field name="x_fc_facility_id"/>
</group>
</group>
<!-- Wet / bath WOs (plating, etch, rinse, strip, ...) -->
<!-- Wet / bath WOs -->
<group invisible="x_fc_wo_kind != 'wet'">
<group string="Bath &amp; Tank">
<field name="x_fc_bath_id"
@@ -195,52 +192,42 @@
<field name="x_fc_dwell_time_minutes"/>
</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">
<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"/>
<field name="x_fc_rack_id" required="1"/>
<field name="x_fc_rack_ref"/>
</group>
</group>
<!-- Mask / De-mask WOs — workcenter is the bench;
no extra equipment fields, just a hint -->
<!-- Mask / De-mask WOs -->
<group invisible="x_fc_wo_kind != 'mask'">
<div class="alert alert-info" role="alert">
Masking / de-masking — work centre identifies
the bench. Use chatter for any per-job notes
on tape pattern, masking material, etc.
</div>
<group string="Masking">
<field name="x_fc_masking_material" required="1"/>
</group>
</group>
<!-- Inspection / QC WOs -->
<!-- Inspection -->
<group invisible="x_fc_wo_kind != 'inspect'">
<div class="alert alert-info" role="alert">
Inspection — record Fischerscope readings via
the Tablet Station (calibration std + n
measurements per part). Readings auto-link
to the CoC at MO done.
the Tablet Station. Cal-std + n measurements
per part. Readings auto-link to the CoC.
</div>
</group>
<!-- Generic WOs that don't fit any bucket -->
<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. Use chatter for job notes.
by the work centre.
</div>
</group>
</page>