From 2804168d9ef53fe3ebe8aec36581a3a5f38393f0 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 10:47:01 -0400 Subject: [PATCH] feat(plating): per-WO-kind equipment fields + smart auto-fill defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../fusion_plating_bridge_mrp/__manifest__.py | 2 +- .../models/mrp_production.py | 7 +- .../models/mrp_workorder.py | 138 +++++++++++++++++- .../views/mrp_workorder_views.xml | 68 ++++++++- fusion_plating/scripts/fp_e2e_workforce.py | 58 ++++++-- 5 files changed, 252 insertions(+), 21 deletions(-) diff --git a/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py b/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py index da80fde9..1b7efe62 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.6.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..1d13d928 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py +++ b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py @@ -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( 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..0e4ccae7 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,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 ' 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..e4491711 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}"/> + + + - + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fusion_plating/scripts/fp_e2e_workforce.py b/fusion_plating/scripts/fp_e2e_workforce.py index 70c03946..38ce8ff4 100644 --- a/fusion_plating/scripts/fp_e2e_workforce.py +++ b/fusion_plating/scripts/fp_e2e_workforce.py @@ -235,9 +235,19 @@ WO_OPERATORS = { } step('HANNAH', 'Assigns each WO to a specific operator') -# Pick a bath + a tank for any WO that needs wet-process traceability +# Pick equipment for each WO kind so the gate fires only for missing ones 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: + # Create one if none exists yet — the test recipe needs it + f = 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': f.id if f else False, + 'target_temp_min': 350.0, 'target_temp_max': 380.0, + }) + show(' oven created', test_oven.name) # 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 +289,21 @@ 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 based on the WO's classification. + 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().write({'x_fc_oven_id': test_oven.id}) + extras = f' [BAKE — oven={test_oven.name}]' 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) @@ -307,6 +315,15 @@ finding('PASS' if (not wet_assignments) or (wet_with_bath == len(wet_assignments 'wet-WO bath+tank set', f'{wet_with_bath}/{len(wet_assignments)} wet WOs have both bath + tank') +# ===== Smart-default verification: bake WOs auto-pinned to the only oven? ===== +bake_wos_check = mo.workorder_ids.filtered( + lambda w: hasattr(w, '_fp_classify_kind') and w._fp_classify_kind() == 'bake' +) +auto_oven = sum(1 for w in bake_wos_check if w.x_fc_oven_id) +finding('PASS' if (not bake_wos_check) or (auto_oven == len(bake_wos_check)) else 'FAIL', + 'bake-WO auto-pinned to default oven', + f'{auto_oven}/{len(bake_wos_check)} bake WOs got auto-filled oven') + # ===== Negative tests: validation MUST block bad starts ===== banner('PHASE 4b — Negative tests: validation gates fire correctly') @@ -326,6 +343,25 @@ finding('PASS' if gate_fired else 'FAIL', 'blocked' if gate_fired else 'NOT blocked — validation broken') test_wo.sudo().x_fc_assigned_user_id = saved_op +# Test 2b: try to start a BAKE WO without oven → expect UserError +bake_wo = mo.workorder_ids.filtered( + lambda w: hasattr(w, '_fp_classify_kind') and w._fp_classify_kind() == 'bake' +)[:1] +if bake_wo: + step('SYSTEM', 'Test 2b — bake WO with oven stripped') + saved_oven = bake_wo.x_fc_oven_id.id + bake_wo.sudo().x_fc_oven_id = False + gate_fired = False + try: + bake_wo.sudo().button_start() + except Exception as e: + gate_fired = 'Oven' in str(e) or 'oven' in str(e).lower() + show(' blocked with', str(e).splitlines()[0][:120]) + finding('PASS' if gate_fired else 'FAIL', + 'gate: missing oven on bake WO', + 'blocked' if gate_fired else 'NOT blocked') + bake_wo.sudo().x_fc_oven_id = saved_oven + # Test 2: try to start a WET WO without bath/tank → expect UserError if wet_assignments: step('SYSTEM', 'Test 2 — wet WO with bath/tank stripped')