feat(plating): per-WO-kind equipment fields + smart auto-fill defaults
User caught two related issues from screenshots of the WO form:
1. The "Plating Details" tab was meaningless for non-wet WOs —
bath/tank/dwell/thickness all show as empty for masking, oven
bake, racking, and inspection steps. A shop with multiple ovens
had no way to record which oven a bake WO ran in.
2. When there's only ONE option (single oven, single bath), forcing
the manager to pick it on every WO is busywork — pin it
automatically.
**1. WO classification + per-kind equipment**
New `x_fc_wo_kind` (compute, non-stored) Selection field that buckets
each WO into one of: wet / bake / mask / rack / inspect / other.
Classification by priority:
• bath linked → wet
• oven linked → bake
• workcenter's process families wet → wet
• WO name keyword match (bake/oven/cure → bake;
mask/de-mask → mask; rack/de-rack → rack;
inspect/qa/qc/fai → inspect; default → other)
New equipment fields per kind:
• `x_fc_oven_id` (m2o fp.bake.oven) for bake WOs
• `x_fc_bake_temp`, `x_fc_bake_duration_hours` — bake parameters
• Existing bath/tank/rack/thickness reused for wet
• Existing rack reused for rack WOs
**2. Required-fields gate extended**
button_start now also requires `x_fc_oven_id` for bake WOs (alongside
the existing operator + bath/tank rules). Without an oven the
chart-recorder trail can't be tied back to the WO for compliance.
**3. View reorganized**
Process Details tab now shows only the equipment groups that apply
to this WO's kind (using `invisible="x_fc_wo_kind != 'bake'"` etc.).
Mask + Inspection + Other show informational alerts instead of
empty form fields. WO header shows a colour-coded kind badge.
**4. Smart auto-fill defaults**
New `_fp_autofill_default_equipment()` method on mrp.workorder. When
the facility has exactly ONE active option, it pre-pins:
• Bath → if facility has 1 active bath
• Tank → if the chosen bath has 1 active tank
• Oven → if facility has 1 active oven
Hooked from:
• `@api.onchange('workcenter_id', 'x_fc_facility_id', 'x_fc_bath_id')`
→ fills as user edits in the form
• Recipe → WO generation `_generate_workorders_from_recipe()`
→ fills at creation time so single-line shops never see an
empty bath/oven field
None of this overwrites an already-set value. Multi-line shops still
get a blank field to choose from.
**Simulator updates** (scripts/fp_e2e_workforce.py)
• Creates an oven if none exists
• Pins per-kind equipment in Hannah's planning step
• New PASS check: bake-WO auto-pinned to default oven
• New negative test 2b: bake WO with oven stripped → blocked
**Final E2E**: 54 PASS / 2 WARN / 0 FAIL out of 56 checks.
12 negative tests passing — all gates fire when triggered:
Tests 1-2 + 2b: WO start (operator + bath/tank + oven)
Tests 3-7: MO facility, cert spec, delivery POD, invoice
payment terms, thickness cal std
Tests 8-11: NCR close, CAPA close, discharge close, invoice ref
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -420,8 +420,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
|
||||
# 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:
|
||||
if hasattr(wo, '_fp_autofill_default_equipment'):
|
||||
wo._fp_autofill_default_equipment()
|
||||
steps_txt = wo_steps.get(wo.sequence)
|
||||
if steps_txt:
|
||||
wo.message_post(
|
||||
|
||||
@@ -28,11 +28,46 @@ 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,
|
||||
help='High-level classification used by the form view to show '
|
||||
'only the equipment fields that apply to this kind of WO.',
|
||||
)
|
||||
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_bath_id = fields.Many2one(
|
||||
'fusion.plating.bath', string='Bath', tracking=True,
|
||||
)
|
||||
@@ -547,10 +582,97 @@ class MrpWorkorder(models.Model):
|
||||
'zincate', 'alkalin', 'acid', 'electroless',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_bath_id', 'name', 'workcenter_id')
|
||||
def _compute_requires_bath(self):
|
||||
# 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:
|
||||
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 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."""
|
||||
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.
|
||||
"""
|
||||
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
|
||||
|
||||
# ---- Bath ----
|
||||
if kind == 'wet' and not self.x_fc_bath_id and Bath is not None:
|
||||
bath_domain = [('active', '=', True)]
|
||||
if facility and 'facility_id' in Bath._fields:
|
||||
bath_domain.append(('facility_id', '=', facility.id))
|
||||
baths = Bath.search(bath_domain, 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)]
|
||||
if 'bath_id' in Tank._fields:
|
||||
tank_domain.append(('bath_id', '=', self.x_fc_bath_id.id))
|
||||
tanks = Tank.search(tank_domain, 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)]
|
||||
if facility and 'facility_id' in Oven._fields:
|
||||
oven_domain.append(('facility_id', '=', facility.id))
|
||||
ovens = Oven.search(oven_domain, 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.
|
||||
|
||||
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).
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self._fp_is_wet_process():
|
||||
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'
|
||||
return 'other'
|
||||
|
||||
def _fp_is_wet_process(self):
|
||||
"""Best-effort check: does this WO involve a chemistry bath?
|
||||
@@ -583,17 +705,23 @@ class MrpWorkorder(models.Model):
|
||||
• 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.
|
||||
"""
|
||||
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'))
|
||||
if missing:
|
||||
raise UserError(_(
|
||||
'Cannot start work order "%(wo)s" — please fill these '
|
||||
|
||||
Reference in New Issue
Block a user