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:
@@ -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': """
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 & 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 & 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>
|
||||||
|
|
||||||
|
|||||||
100
fusion_plating/scripts/fp_backfill.py
Normal file
100
fusion_plating/scripts/fp_backfill.py
Normal 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.')
|
||||||
@@ -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':
|
||||||
|
|||||||
175
fusion_plating/scripts/fp_per_step_audit.py
Normal file
175
fusion_plating/scripts/fp_per_step_audit.py
Normal 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.')
|
||||||
Reference in New Issue
Block a user