From c75b22aaf79000a896686c2859df55451d4938ae Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Mon, 27 Apr 2026 20:22:20 -0400 Subject: [PATCH] =?UTF-8?q?docs(sub12a):=20implementation=20plan=20?= =?UTF-8?q?=E2=80=94=2015=20tasks=20for=20simple=20editor=20+=20library?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 15-task plan covering: manifest bump, three new models (fp.step.template + 2 child input types), additive fields on process.node, ACL rows, views, menu, post_init_hook with library-from-ENP-ALUM-BASIC seeding, JSONRPC controller (11 routes), recipe form integration, OWL client action, preferred_editor resolver, entech deployment + smoke test. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-04-27-sub12a-simple-recipe-editor.md | 2880 +++++++++++++++++ 1 file changed, 2880 insertions(+) create mode 100644 fusion_plating/docs/superpowers/plans/2026-04-27-sub12a-simple-recipe-editor.md diff --git a/fusion_plating/docs/superpowers/plans/2026-04-27-sub12a-simple-recipe-editor.md b/fusion_plating/docs/superpowers/plans/2026-04-27-sub12a-simple-recipe-editor.md new file mode 100644 index 00000000..52659c75 --- /dev/null +++ b/fusion_plating/docs/superpowers/plans/2026-04-27-sub12a-simple-recipe-editor.md @@ -0,0 +1,2880 @@ +# Sub 12a — Simple Recipe Editor + Step Library Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a flat drag-drop "Simple Recipe Editor" alongside the existing OWL tree editor + a reusable Step Library + a starter-recipe import flow, all editing the same `fusion.plating.process.node` records. Tree editor and runtime untouched. + +**Architecture:** New `fp.step.template` model (the library) + 2 child input-definition models + additive fields on existing `process.node` + new OWL client action `fp_simple_recipe_editor` + JSONRPC controller. Snapshot-copy semantics on import (no live references). Per-recipe `preferred_editor` + company-level default routes recipe-list clicks through the right editor. + +**Tech Stack:** Odoo 19, Python 3.11, OWL 2 (`@odoo/owl`), `@web/core/network/rpc`, SCSS, QWeb XML for OWL templates. + +**Companion docs:** +- [Spec](../specs/2026-04-27-sub12-simple-recipe-editor-design.md) +- [Steelhead screen inventory](../specs/2026-04-27-simple-recipe-editor-steelhead-screens.md) + +**Deploy target:** entech (LXC 111 on pve-worker5, native odoo, DB `admin`). After each task, the change must be deployable on entech and not break existing battle tests. + +--- + +## File Structure + +### Files to create + +``` +fusion_plating/models/fp_step_template.py # fp.step.template +fusion_plating/models/fp_step_template_input.py # fp.step.template.input +fusion_plating/models/fp_step_template_transition_input.py # fp.step.template.transition.input +fusion_plating/models/res_config_settings.py # default_recipe_editor setting +fusion_plating/controllers/simple_recipe_controller.py # JSONRPC endpoints +fusion_plating/views/fp_step_template_views.xml # list/form/search views +fusion_plating/views/res_config_settings_views.xml # settings panel for editor default +fusion_plating/static/src/js/simple_recipe_editor.js # OWL client action root +fusion_plating/static/src/xml/simple_recipe_editor.xml # OWL templates +fusion_plating/static/src/scss/simple_recipe_editor.scss # styling +fusion_plating/hooks.py # post_init_hook +fusion_plating/tests/test_step_template.py # unit tests for library model +fusion_plating/tests/test_simple_recipe_controller.py # endpoint tests +fusion_plating/tests/test_post_init_hook.py # idempotency test +``` + +### Files to modify + +``` +fusion_plating/__manifest__.py # version bump + new files in data/assets +fusion_plating/models/__init__.py # import new model files +fusion_plating/controllers/__init__.py # import new controller +fusion_plating/models/fp_process_node.py # add fields + action methods + resolver +fusion_plating/views/fp_process_node_views.xml # header buttons, new fields, edit toggle +fusion_plating/views/fp_menu.xml # add Step Library menu +fusion_plating/security/ir.model.access.csv # ACLs for 3 new models +fusion_plating/tests/__init__.py # import new test files +``` + +--- + +## Conventions for every task + +- **Read files before editing** (Odoo CLAUDE.md rule — never code from memory). +- **All Python files** start with `# -*- coding: utf-8 -*-` + `# Copyright 2026 Nexa Systems Inc.` + `# License OPL-1` + `# Part of the Fusion Plating product family.` headers. +- **Field naming**: new fields on standard Odoo models use `x_fc_*` prefix; new fields on our custom models use plain names. +- **Currency / Canadian English** in user-facing strings. +- **Tests** are Odoo `TransactionCase` integration tests located under `fusion_plating/tests/`. Run with: + ```bash + docker exec odoo-dev-app odoo -d fusion-dev --test-enable --test-tags fusion_plating --stop-after-init -u fusion_plating + ``` +- **Deploy command** to entech after a task lands: + ```bash + ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && \ + su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin \ + -u fusion_plating --stop-after-init\" && systemctl start odoo'" + ``` +- **Frequent commits** — every task ends with a commit. The plan is a sequence of small, testable, committable steps. + +--- + +## Task 1: Bump module version + scaffold manifest entries + +**Files:** +- Modify: `fusion_plating/__manifest__.py` + +- [ ] **Step 1: Read manifest** + +```bash +cat fusion_plating/__manifest__.py +``` + +- [ ] **Step 2: Bump version to `19.0.10.0.0` and add new data/asset entries** + +In `__manifest__.py`: + +Change `'version': '19.0.9.3.0',` → `'version': '19.0.10.0.0',` + +Add to `'data'` list (after existing view files, before any data files): +```python + 'security/ir.model.access.csv', + 'views/fp_step_template_views.xml', + 'views/res_config_settings_views.xml', +``` + +Add (or extend if exists) `'assets'` block: +```python + 'assets': { + 'web.assets_backend': [ + 'fusion_plating/static/src/js/simple_recipe_editor.js', + 'fusion_plating/static/src/xml/simple_recipe_editor.xml', + 'fusion_plating/static/src/scss/simple_recipe_editor.scss', + ], + }, +``` + +Add `'post_init_hook': 'post_init_hook',` at the top level of the manifest dict (next to `'version'`). + +- [ ] **Step 3: Verify the file parses** + +Run odoo with `-u fusion_plating --stop-after-init` (Step-3 verification — it'll catch syntax errors as part of the loader). If it loads cleanly, the manifest parsed. + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating --stop-after-init 2>&1 | tail -3 +``` + +Expected: clean load, no errors. + +- [ ] **Step 4: Commit** + +```bash +git add fusion_plating/__manifest__.py +git commit -m "feat(sub12a): bump fusion_plating to 19.0.10.0.0 + scaffold manifest + +Adds asset entries for the upcoming Simple Recipe Editor OWL client +action and the data files for new views. No new code yet — just the +manifest scaffold so subsequent tasks can drop files into place. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 2: Create the `fp.step.template` model + +**Files:** +- Create: `fusion_plating/models/fp_step_template.py` +- Modify: `fusion_plating/models/__init__.py` +- Test: `fusion_plating/tests/test_step_template.py` + +- [ ] **Step 1: Create the test file with a failing test** + +```python +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# Part of the Fusion Plating product family. + +from odoo.tests.common import TransactionCase, tagged + + +@tagged('-at_install', 'post_install', 'fusion_plating', 'fp_step_template') +class TestStepTemplate(TransactionCase): + + def test_create_minimal(self): + """A library step needs only a name to be created.""" + tpl = self.env['fp.step.template'].create({'name': 'Soak Clean'}) + self.assertEqual(tpl.name, 'Soak Clean') + self.assertTrue(tpl.active) + self.assertEqual(tpl.icon, 'fa-cog') # default + self.assertEqual(tpl.time_unit, 'min') # default + self.assertEqual(tpl.temp_unit, 'F') # default + + def test_default_kind_seeds_inputs(self): + """_seed_default_inputs() populates input_template_ids per kind.""" + tpl = self.env['fp.step.template'].create({ + 'name': 'Soak Clean', + 'default_kind': 'cleaning', + }) + tpl._seed_default_inputs() + self.assertEqual(len(tpl.input_template_ids), 2) + names = tpl.input_template_ids.mapped('name') + self.assertIn('Actual Time', names) + self.assertIn('Actual Temperature', names) + + def test_seed_default_inputs_idempotent(self): + """Calling seed twice does not duplicate inputs.""" + tpl = self.env['fp.step.template'].create({ + 'name': 'Soak Clean', + 'default_kind': 'cleaning', + }) + tpl._seed_default_inputs() + tpl._seed_default_inputs() + self.assertEqual(len(tpl.input_template_ids), 2) +``` + +- [ ] **Step 2: Add the test file to the tests package** + +If `fusion_plating/tests/__init__.py` doesn't exist, create it with: +```python +from . import test_step_template +``` + +If it exists, add the line `from . import test_step_template` at the bottom. + +- [ ] **Step 3: Run the test, expect failure** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev --test-enable \ + --test-tags fp_step_template --stop-after-init -u fusion_plating 2>&1 | tail -10 +``` + +Expected: ImportError or model not found — `fp.step.template` doesn't exist yet. + +- [ ] **Step 4: Create the model file** + +`fusion_plating/models/fp_step_template.py`: + +```python +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# Part of the Fusion Plating product family. + +from odoo import api, fields, models + + +class FpStepTemplate(models.Model): + """Reusable step template for the Simple Recipe Editor. + + A library entry the recipe author can drag into a recipe. Snapshot- + copied at drag time — editing the template later does NOT change + recipes already built. + """ + _name = 'fp.step.template' + _description = 'Fusion Plating — Step Library Template' + _inherit = ['mail.thread', 'mail.activity.mixin'] + _order = 'sequence, name' + + name = fields.Char(string='Title', required=True, translate=True, tracking=True) + code = fields.Char(string='Code', tracking=True, + help='Optional short identifier. Auto-uppercased.') + description = fields.Html(string='Instructions', + help='Rich-text instructions / Work-Instruction reference.') + icon = fields.Selection( + selection='_get_icon_selection', + string='Icon', + default='fa-cog', + ) + sequence = fields.Integer(string='Sequence', default=10) + active = fields.Boolean(string='Active', default=True) + company_id = fields.Many2one( + 'res.company', string='Company', + default=lambda self: self.env.company, + ) + + tank_ids = fields.Many2many( + 'fusion.plating.tank', string='Allowed Stations', + help='Stations (tanks) this step can be performed at. The ' + 'operator picks one of these at runtime.', + ) + process_type_id = fields.Many2one( + 'fusion.plating.process.type', string='Process Type', + ondelete='set null', + ) + material_callout = fields.Char(string='Material Callout', + help='Short string printed in the traveller "Material" column. ' + 'e.g. "MID PHOS". Defaults to process type name if blank.') + + time_min_target = fields.Float(string='Time Min') + time_max_target = fields.Float(string='Time Max') + time_unit = fields.Selection( + [('sec', 'Seconds'), ('min', 'Minutes'), ('hr', 'Hours')], + string='Time Unit', default='min', + ) + temp_min_target = fields.Float(string='Temp Min') + temp_max_target = fields.Float(string='Temp Max') + temp_unit = fields.Selection( + [('F', '°F'), ('C', '°C')], + string='Temp Unit', default='F', + ) + voltage_target = fields.Float(string='Voltage Target') + viscosity_target = fields.Float(string='Viscosity Target') + + requires_signoff = fields.Boolean(string='Require QA Sign-off') + requires_predecessor_done = fields.Boolean(string='Require Predecessor Done', + help='S14 lock — operator cannot start this step until earlier ' + 'sequenced steps are done.') + requires_rack_assignment = fields.Boolean(string='Requires Rack Assignment', + help='Triggers Rack Parts sub-dialog at runtime (Sub 12b).') + requires_transition_form = fields.Boolean(string='Requires Transition Form', + help='Opens the transition form before Mark Done (Sub 12b).') + + default_kind = fields.Selection([ + ('cleaning', 'Cleaning'), + ('etch', 'Etch'), + ('rinse', 'Rinse'), + ('plate', 'Plating'), + ('bake', 'Bake'), + ('inspect', 'Inspection'), + ('racking', 'Racking'), + ('derack', 'De-Racking'), + ('mask', 'Masking'), + ('demask', 'De-Masking'), + ('dry', 'Drying'), + ('wbf_test', 'Water Break Free Test'), + ('final_inspect', 'Final Inspection'), + ('ship', 'Shipping'), + ('gating', 'Gating'), + ], string='Step Kind', help='Drives sane-default input seeding.') + + input_template_ids = fields.One2many( + 'fp.step.template.input', 'template_id', + string='Operation Measurements', + copy=True, + ) + transition_input_ids = fields.One2many( + 'fp.step.template.transition.input', 'template_id', + string='Transition Form Fields', + copy=True, + ) + + @api.model + def _get_icon_selection(self): + # Reuse the 24-icon list from fusion.plating.process.node + node = self.env['fusion.plating.process.node'] + return node._fields['icon'].selection + + _sql_constraints = [ + ('fp_step_template_code_company_uniq', + 'unique(code, company_id)', + 'Step template code must be unique within a company.'), + ] + + @api.model_create_multi + def create(self, vals_list): + for v in vals_list: + if v.get('code'): + v['code'] = v['code'].upper().strip() + return super().create(vals_list) + + def write(self, vals): + if vals.get('code'): + vals['code'] = vals['code'].upper().strip() + return super().write(vals) + + # ----- Sane defaults seeding --------------------------------------------- + + DEFAULT_INPUTS_BY_KIND = { + 'cleaning': [ + {'name': 'Actual Time', 'input_type': 'time_seconds', + 'target_unit': 'sec', 'sequence': 10}, + {'name': 'Actual Temperature', 'input_type': 'temperature', + 'target_unit': '°F', 'sequence': 20}, + ], + 'etch': [ + {'name': 'Actual Time', 'input_type': 'time_seconds', + 'target_unit': 'sec', 'sequence': 10}, + {'name': 'Actual Temperature', 'input_type': 'temperature', + 'target_unit': '°F', 'sequence': 20}, + ], + 'rinse': [], + 'plate': [ + {'name': 'Actual Time', 'input_type': 'time_hms', + 'target_unit': 'min', 'sequence': 10}, + {'name': 'Actual Temperature', 'input_type': 'temperature', + 'target_unit': '°F', 'sequence': 20}, + {'name': 'Plating Thickness', 'input_type': 'thickness', + 'target_unit': 'in', 'sequence': 30}, + ], + 'bake': [ + {'name': 'Time In', 'input_type': 'text', + 'target_unit': 'HH:MM', 'sequence': 10}, + {'name': 'Time Out', 'input_type': 'text', + 'target_unit': 'HH:MM', 'sequence': 20}, + {'name': 'Actual Temperature', 'input_type': 'temperature', + 'target_unit': '°F', 'sequence': 30}, + ], + 'racking': [ + {'name': 'Actual Qty', 'input_type': 'number', + 'target_unit': 'each', 'sequence': 10}, + ], + 'derack': [ + {'name': 'Actual Qty', 'input_type': 'number', + 'target_unit': 'each', 'sequence': 10}, + ], + 'inspect': [ + {'name': 'PASS/FAIL', 'input_type': 'pass_fail', 'sequence': 10}, + ], + 'final_inspect': [ + {'name': 'Outgoing Part Count Verified', + 'input_type': 'boolean', 'sequence': 10}, + {'name': 'Qty Accepted', 'input_type': 'number', + 'target_unit': 'each', 'sequence': 20}, + {'name': 'Qty Rejected', 'input_type': 'number', + 'target_unit': 'each', 'sequence': 30}, + {'name': 'Actual Coating Thickness', + 'input_type': 'thickness', 'target_unit': 'in', 'sequence': 40}, + {'name': 'Pass/Fail', 'input_type': 'pass_fail', 'sequence': 50}, + ], + 'wbf_test': [ + {'name': 'PASS/FAIL', 'input_type': 'pass_fail', 'sequence': 10}, + ], + 'mask': [ + {'name': 'Actual Qty', 'input_type': 'number', + 'target_unit': 'each', 'sequence': 10}, + ], + 'demask': [], + 'dry': [], + 'ship': [ + {'name': 'Outgoing Qty', 'input_type': 'number', + 'target_unit': 'each', 'sequence': 10}, + ], + 'gating': [], + } + + def _seed_default_inputs(self): + """Seed input_template_ids based on default_kind. Idempotent — + only adds inputs whose names don't already exist on this template.""" + Input = self.env['fp.step.template.input'] + for tpl in self: + if not tpl.default_kind: + continue + existing_names = set(tpl.input_template_ids.mapped('name')) + for spec in self.DEFAULT_INPUTS_BY_KIND.get(tpl.default_kind, []): + if spec['name'] in existing_names: + continue + Input.create({ + 'template_id': tpl.id, + **spec, + }) +``` + +- [ ] **Step 5: Wire the model into `models/__init__.py`** + +Read the existing file and add at the bottom: +```python +from . import fp_step_template +``` + +- [ ] **Step 6: Run the test — model exists but child models don't yet, so 2 of 3 tests fail** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev --test-enable \ + --test-tags fp_step_template --stop-after-init -u fusion_plating 2>&1 | tail -15 +``` + +Expected: `test_create_minimal` passes; `test_default_kind_seeds_inputs` + `test_seed_default_inputs_idempotent` fail because `fp.step.template.input` doesn't exist yet. That's fine — Task 3 fixes them. + +- [ ] **Step 7: Commit** + +```bash +git add fusion_plating/models/fp_step_template.py \ + fusion_plating/models/__init__.py \ + fusion_plating/tests/test_step_template.py \ + fusion_plating/tests/__init__.py +git commit -m "feat(sub12a): add fp.step.template model with sane-default kind map + +Reusable step library entry. Carries the same shape fields as +fusion.plating.process.node so a drag-drop snapshot is a 1:1 copy. +DEFAULT_INPUTS_BY_KIND drives seeding for the 15 kinds we identified +on Steelhead's job traveller (cleaning, etch, plate, bake, etc.). + +The seeding helper (_seed_default_inputs) is idempotent — won't +duplicate inputs on repeated calls. + +Tests: test_create_minimal passes. The other two depend on the input +sub-model in the next task. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 3: Create `fp.step.template.input` + +**Files:** +- Create: `fusion_plating/models/fp_step_template_input.py` +- Modify: `fusion_plating/models/__init__.py` + +- [ ] **Step 1: Create the model file** + +`fusion_plating/models/fp_step_template_input.py`: + +```python +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# Part of the Fusion Plating product family. + +from odoo import fields, models + + +class FpStepTemplateInput(models.Model): + """Operation measurement definition on a step library template. + + Recorded *during* a step (e.g. "Actual Time", "Plating Thickness"). + Distinct from transition_input_ids which fire when leaving the + step. + """ + _name = 'fp.step.template.input' + _description = 'Fusion Plating — Step Template Input' + _order = 'sequence, name' + + name = fields.Char(string='Name', required=True, translate=True) + template_id = fields.Many2one( + 'fp.step.template', string='Template', + required=True, ondelete='cascade', index=True, + ) + input_type = fields.Selection([ + ('text', 'Text'), + ('number', 'Number'), + ('boolean', 'Yes/No'), + ('selection', 'Selection'), + ('date', 'Date / Time'), + ('signature', 'Signature'), + ('time_hms', 'Time (HH:MM:SS)'), + ('time_seconds', 'Time (seconds)'), + ('temperature', 'Temperature'), + ('thickness', 'Thickness'), + ('pass_fail', 'Pass / Fail'), + ], string='Input Type', required=True, default='text') + target_min = fields.Float(string='Target Min') + target_max = fields.Float(string='Target Max') + target_unit = fields.Char(string='Target Unit', + help='Display unit, e.g. "min", "°F", "A", "FT2", "in".') + required = fields.Boolean(string='Required', default=False, + help='If True, sign-off is hard-blocked while this input is blank.') + hint = fields.Char(string='Hint') + selection_options = fields.Text(string='Selection Options', + help='Comma-separated when input_type is "selection".') + sequence = fields.Integer(string='Sequence', default=10) +``` + +- [ ] **Step 2: Wire into `models/__init__.py`** + +Add after `fp_step_template` import: +```python +from . import fp_step_template_input +``` + +- [ ] **Step 3: Run the tests** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev --test-enable \ + --test-tags fp_step_template --stop-after-init -u fusion_plating 2>&1 | tail -15 +``` + +Expected: all three tests in `test_step_template.py` pass. + +- [ ] **Step 4: Commit** + +```bash +git add fusion_plating/models/fp_step_template_input.py \ + fusion_plating/models/__init__.py +git commit -m "feat(sub12a): add fp.step.template.input + +Operation-measurement definitions for library step templates. The +input_type selection covers everything Steelhead captures (text, +number, boolean, selection, date, signature, time_hms, time_seconds, +temperature, thickness, pass_fail). + +target_min/max + target_unit are structured (not embedded in the name +string the way Steelhead does it) so the traveller report can render +target vs actual side-by-side and colour-code out-of-range values. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 4: Create `fp.step.template.transition.input` + +**Files:** +- Create: `fusion_plating/models/fp_step_template_transition_input.py` +- Modify: `fusion_plating/models/__init__.py` + +- [ ] **Step 1: Add a test for transition inputs** + +Append to `fusion_plating/tests/test_step_template.py` (inside the same class): + +```python + def test_transition_input_create(self): + """Transition inputs are linkable to a template.""" + tpl = self.env['fp.step.template'].create({'name': 'Bake'}) + ti = self.env['fp.step.template.transition.input'].create({ + 'template_id': tpl.id, + 'name': 'Photo Evidence', + 'input_type': 'photo', + 'required': True, + 'compliance_tag': 'as9100', + }) + self.assertEqual(ti.template_id, tpl) + self.assertTrue(ti.required) + self.assertEqual(ti.compliance_tag, 'as9100') +``` + +- [ ] **Step 2: Run the test, expect failure** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev --test-enable \ + --test-tags fp_step_template --stop-after-init -u fusion_plating 2>&1 | tail -10 +``` + +Expected: KeyError or model-not-found for `fp.step.template.transition.input`. + +- [ ] **Step 3: Create the model file** + +`fusion_plating/models/fp_step_template_transition_input.py`: + +```python +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# Part of the Fusion Plating product family. + +from odoo import fields, models + + +class FpStepTemplateTransitionInput(models.Model): + """Transition-time compliance field definition. + + Fires when leaving a step (e.g. "Customer WO #", "Photo Evidence", + "Scrap Reason"). Authored on `fp.step.template`, snapshot-copied + onto `fusion.plating.process.node` when the library step is dragged + into a recipe. Sub 12b uses these to render the Move Parts dialog. + """ + _name = 'fp.step.template.transition.input' + _description = 'Fusion Plating — Step Template Transition Input' + _order = 'sequence, name' + + name = fields.Char(string='Name', required=True, translate=True) + template_id = fields.Many2one( + 'fp.step.template', string='Template', + required=True, ondelete='cascade', index=True, + ) + input_type = fields.Selection([ + ('text', 'Text'), + ('number', 'Number'), + ('boolean', 'Yes/No'), + ('selection', 'Selection'), + ('date', 'Date / Time'), + ('signature', 'Signature'), + ('photo', 'Photo'), + ('location_picker', 'Location Picker'), + ('customer_wo', 'Customer WO #'), + ], string='Input Type', required=True, default='text') + required = fields.Boolean(string='Required', default=False, + help='If True, the move is hard-blocked while this input is blank.') + hint = fields.Char(string='Hint') + selection_options = fields.Text(string='Selection Options', + help='Comma-separated when input_type is "selection".') + sequence = fields.Integer(string='Sequence', default=10) + compliance_tag = fields.Selection([ + ('none', 'None'), + ('as9100', 'AS9100'), + ('nadcap', 'Nadcap'), + ('cgp', 'Controlled Goods'), + ('nuclear', 'Nuclear'), + ], string='Compliance Tag', default='none', + help='Drives audit-report inclusion / filtering.') +``` + +- [ ] **Step 4: Wire into `models/__init__.py`** + +Add: +```python +from . import fp_step_template_transition_input +``` + +- [ ] **Step 5: Run the test** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev --test-enable \ + --test-tags fp_step_template --stop-after-init -u fusion_plating 2>&1 | tail -10 +``` + +Expected: all four tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add fusion_plating/models/fp_step_template_transition_input.py \ + fusion_plating/models/__init__.py \ + fusion_plating/tests/test_step_template.py +git commit -m "feat(sub12a): add fp.step.template.transition.input + +Transition-time prompts (fired when leaving a step). Authored now, +runtime-consumed in Sub 12b's Move Parts dialog. Carries a +compliance_tag selection (none/as9100/nadcap/cgp/nuclear) so audit +reports can filter by regulation regime. + +input_type covers Steelhead's transition prompts: text, number, +boolean, selection, date, signature, photo, location_picker, +customer_wo. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 5: Extend `fusion.plating.process.node` with new fields + +**Files:** +- Modify: `fusion_plating/models/fp_process_node.py` +- Test: extend `fusion_plating/tests/test_step_template.py` + +- [ ] **Step 1: Add tests for the new fields on the recipe node** + +Append to `tests/test_step_template.py`: + +```python + def test_recipe_node_new_fields(self): + """fusion.plating.process.node has the new authoring fields.""" + node = self.env['fusion.plating.process.node'].create({ + 'name': 'Test Recipe', + 'node_type': 'recipe', + 'is_template': True, + 'preferred_editor': 'simple', + }) + self.assertTrue(node.is_template) + self.assertEqual(node.preferred_editor, 'simple') + self.assertFalse(node.requires_rack_assignment) + self.assertFalse(node.requires_transition_form) + self.assertEqual(node.time_unit, 'min') # default + self.assertEqual(node.temp_unit, 'F') # default + + def test_recipe_node_default_kind(self): + """default_kind selection is on the node too (mirrors template).""" + step = self.env['fusion.plating.process.node'].create({ + 'name': 'Soak Clean', + 'node_type': 'step', + 'default_kind': 'cleaning', + }) + self.assertEqual(step.default_kind, 'cleaning') + + def test_recipe_node_input_kind(self): + """fusion.plating.process.node.input has the new `kind` field.""" + recipe = self.env['fusion.plating.process.node'].create({ + 'name': 'Test Recipe', 'node_type': 'recipe', + }) + step = self.env['fusion.plating.process.node'].create({ + 'name': 'Soak Clean', 'node_type': 'step', 'parent_id': recipe.id, + }) + ni = self.env['fusion.plating.process.node.input'].create({ + 'node_id': step.id, + 'name': 'Actual Time', + 'input_type': 'number', + 'kind': 'step_input', + }) + self.assertEqual(ni.kind, 'step_input') +``` + +- [ ] **Step 2: Run the tests, expect failures (fields don't exist yet)** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev --test-enable \ + --test-tags fp_step_template --stop-after-init -u fusion_plating 2>&1 | tail -15 +``` + +Expected: ValueError or KeyError on `is_template`, `preferred_editor`, `default_kind`, `kind`, `time_unit`, etc. + +- [ ] **Step 3: Read the existing model file** + +```bash +sed -n '1,80p' fusion_plating/models/fp_process_node.py +``` + +Note line numbers and existing field placement so the new fields slot into the right sections. + +- [ ] **Step 4: Add new fields to `FusionPlatingProcessNode`** + +Locate the existing field block (around the `node_type` Selection field) and add these fields somewhere appropriate (typically after existing `parent_id`/`child_ids`, before `_compute_*` methods): + +```python + # ===== Sub 12a — Simple Editor + Step Library extensions ================= + is_template = fields.Boolean(string='Use as Starter Template', + help='When True (and node_type=recipe), this recipe appears in the ' + 'Simple Editor\'s "Import starter from template" dropdown.') + source_template_id = fields.Many2one( + 'fp.step.template', string='Source Library Template', + ondelete='set null', index=True, + help='Snapshot trace — set when this node was created by dragging ' + 'a library step in. Editing the template later does not change ' + 'this node (snapshot semantics).') + tank_ids = fields.Many2many( + 'fusion.plating.tank', 'fp_node_tank_rel', 'node_id', 'tank_id', + string='Allowed Stations', + help='Stations the operator may pick at runtime.') + material_callout = fields.Char(string='Material Callout', + help='Short string for traveller "Material" column. Defaults to ' + 'process type name if blank.') + time_min_target = fields.Float(string='Time Min') + time_max_target = fields.Float(string='Time Max') + time_unit = fields.Selection( + [('sec', 'Seconds'), ('min', 'Minutes'), ('hr', 'Hours')], + string='Time Unit', default='min', + ) + temp_min_target = fields.Float(string='Temp Min') + temp_max_target = fields.Float(string='Temp Max') + temp_unit = fields.Selection( + [('F', '°F'), ('C', '°C')], + string='Temp Unit', default='F', + ) + voltage_target = fields.Float(string='Voltage Target') + viscosity_target = fields.Float(string='Viscosity Target') + requires_rack_assignment = fields.Boolean(string='Requires Rack Assignment', + help='Sub 12b — triggers Rack Parts sub-dialog at runtime.') + requires_transition_form = fields.Boolean(string='Requires Transition Form', + help='Sub 12b — opens the transition form before Mark Done.') + default_kind = fields.Selection([ + ('cleaning', 'Cleaning'), + ('etch', 'Etch'), + ('rinse', 'Rinse'), + ('plate', 'Plating'), + ('bake', 'Bake'), + ('inspect', 'Inspection'), + ('racking', 'Racking'), + ('derack', 'De-Racking'), + ('mask', 'Masking'), + ('demask', 'De-Masking'), + ('dry', 'Drying'), + ('wbf_test', 'Water Break Free Test'), + ('final_inspect', 'Final Inspection'), + ('ship', 'Shipping'), + ('gating', 'Gating'), + ], string='Step Kind') + preferred_editor = fields.Selection( + [('tree', 'Tree Editor'), + ('simple', 'Simple Editor'), + ('auto', 'Use Company Default')], + string='Preferred Editor', default='auto', + help='Which editor opens when this recipe is selected from the ' + 'menu list. "Auto" follows the company-level default.') +``` + +- [ ] **Step 5: Add `kind` + target-range fields to `FusionPlatingProcessNodeInput`** + +In the same file, locate the input model (`class FusionPlatingProcessNodeInput`) — typically near the bottom. Add fields: + +```python + kind = fields.Selection( + [('step_input', 'Step Measurement'), + ('transition_input', 'Transition Form Field')], + string='Kind', default='step_input', required=True, index=True, + help='step_input = recorded during the step. transition_input = ' + 'recorded when leaving the step (Sub 12b uses these in the ' + 'Move Parts dialog).') + target_min = fields.Float(string='Target Min') + target_max = fields.Float(string='Target Max') + target_unit = fields.Char(string='Target Unit') + compliance_tag = fields.Selection([ + ('none', 'None'), + ('as9100', 'AS9100'), + ('nadcap', 'Nadcap'), + ('cgp', 'Controlled Goods'), + ('nuclear', 'Nuclear'), + ], string='Compliance Tag', default='none') +``` + +Also extend the `input_type` Selection options to include the typed inputs the simple editor needs. Find the existing `input_type` field; append these to the selection list: + +```python + ('time_hms', 'Time (HH:MM:SS)'), + ('time_seconds', 'Time (seconds)'), + ('temperature', 'Temperature'), + ('thickness', 'Thickness'), + ('pass_fail', 'Pass / Fail'), + ('photo', 'Photo'), + ('location_picker', 'Location Picker'), + ('customer_wo', 'Customer WO #'), +``` + +(Do NOT remove existing values — that breaks ORM on existing rows.) + +- [ ] **Step 6: Run the tests** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev --test-enable \ + --test-tags fp_step_template --stop-after-init -u fusion_plating 2>&1 | tail -15 +``` + +Expected: all 6 tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add fusion_plating/models/fp_process_node.py \ + fusion_plating/tests/test_step_template.py +git commit -m "feat(sub12a): extend process.node with simple-editor authoring fields + +Additive only: is_template, source_template_id, tank_ids, +material_callout, time/temp targets + units, voltage, viscosity, +rack/transition flags, default_kind, preferred_editor. + +process.node.input gets kind (step_input vs transition_input, +default step_input so existing rows keep working), target_min/max, +target_unit, compliance_tag, plus 8 new typed input_type values. + +Tree editor, runtime, S14/S15/S17/S18/S19 battle tests all unchanged. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 6: Add `fp_default_recipe_editor` company setting + +**Files:** +- Create: `fusion_plating/models/res_config_settings.py` +- Create: `fusion_plating/views/res_config_settings_views.xml` +- Modify: `fusion_plating/models/__init__.py` + +- [ ] **Step 1: Add a test** + +Append to `tests/test_step_template.py`: + +```python + def test_company_default_recipe_editor(self): + """Company carries a fp_default_recipe_editor field.""" + company = self.env.company + self.assertIn(company.fp_default_recipe_editor, ('tree', 'simple')) + company.fp_default_recipe_editor = 'simple' + self.assertEqual(company.fp_default_recipe_editor, 'simple') +``` + +- [ ] **Step 2: Run, expect failure** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev --test-enable \ + --test-tags fp_step_template --stop-after-init -u fusion_plating 2>&1 | tail -10 +``` + +Expected: AttributeError on `fp_default_recipe_editor`. + +- [ ] **Step 3: Create the settings model** + +`fusion_plating/models/res_config_settings.py`: + +```python +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# Part of the Fusion Plating product family. + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = 'res.company' + + fp_default_recipe_editor = fields.Selection( + [('tree', 'Tree Editor'), ('simple', 'Simple Editor')], + string='Default Recipe Editor', + default='tree', + help='Which editor opens when a new recipe is created. Per-recipe ' + 'preferred_editor overrides this when set to tree or simple.', + ) + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + fp_default_recipe_editor = fields.Selection( + related='company_id.fp_default_recipe_editor', + readonly=False, + ) +``` + +- [ ] **Step 4: Wire into `models/__init__.py`** + +Add at the end: +```python +from . import res_config_settings +``` + +- [ ] **Step 5: Create the settings view file** + +`fusion_plating/views/res_config_settings_views.xml`: + +```xml + + + + + res.config.settings.form.fp.simple_editor + res.config.settings + + + + + + + + + + + + +``` + +- [ ] **Step 6: Run the test** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev --test-enable \ + --test-tags fp_step_template --stop-after-init -u fusion_plating 2>&1 | tail -10 +``` + +Expected: all 7 tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add fusion_plating/models/res_config_settings.py \ + fusion_plating/models/__init__.py \ + fusion_plating/views/res_config_settings_views.xml \ + fusion_plating/tests/test_step_template.py +git commit -m "feat(sub12a): res.company.fp_default_recipe_editor setting + +Per-company default for which editor opens when creating a new +recipe. Defaults to 'tree' to preserve existing behavior. Surfaces in +Settings UI alongside other Fusion Plating prefs. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 7: Security / ACL rows for new models + +**Files:** +- Modify: `fusion_plating/security/ir.model.access.csv` + +- [ ] **Step 1: Read existing access rules to mirror style** + +```bash +head -5 fusion_plating/security/ir.model.access.csv +``` + +- [ ] **Step 2: Append new ACL rows** + +Append these rows to `fusion_plating/security/ir.model.access.csv` (preserve trailing newline): + +```csv +access_fp_step_template_user,fp.step.template user,model_fp_step_template,fusion_plating.group_fusion_plating_operator,1,0,0,0 +access_fp_step_template_supervisor,fp.step.template supervisor,model_fp_step_template,fusion_plating.group_fusion_plating_supervisor,1,1,1,1 +access_fp_step_template_input_user,fp.step.template.input user,model_fp_step_template_input,fusion_plating.group_fusion_plating_operator,1,0,0,0 +access_fp_step_template_input_supervisor,fp.step.template.input supervisor,model_fp_step_template_input,fusion_plating.group_fusion_plating_supervisor,1,1,1,1 +access_fp_step_template_transition_input_user,fp.step.template.transition.input user,model_fp_step_template_transition_input,fusion_plating.group_fusion_plating_operator,1,0,0,0 +access_fp_step_template_transition_input_supervisor,fp.step.template.transition.input supervisor,model_fp_step_template_transition_input,fusion_plating.group_fusion_plating_supervisor,1,1,1,1 +``` + +- [ ] **Step 3: Verify CSV parses by reloading the module** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating --stop-after-init 2>&1 | tail -5 +``` + +Expected: clean reload, no CSV-parse errors. + +- [ ] **Step 4: Commit** + +```bash +git add fusion_plating/security/ir.model.access.csv +git commit -m "feat(sub12a): ACL rows for fp.step.template + 2 child models + +Operator: read only on library + inputs. +Supervisor: full CRUD. + +Manager rights inherit from supervisor (existing privilege chain). + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 8: Step Library views (list/form/search) + +**Files:** +- Create: `fusion_plating/views/fp_step_template_views.xml` + +- [ ] **Step 1: Create the views file** + +`fusion_plating/views/fp_step_template_views.xml`: + +```xml + + + + + + fp.step.template.list + fp.step.template + + + + + + + + + + + + + + + + + fp.step.template.form + fp.step.template + +
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + fp.step.template.search + fp.step.template + + + + + + + + + + + + + + + + + + Step Library + fp.step.template + list,form + + + +
+``` + +- [ ] **Step 2: Reload the module to register the views** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating --stop-after-init 2>&1 | tail -5 +``` + +Expected: clean reload, no errors. + +- [ ] **Step 3: Smoke-test in browser** (manual) + +Open `http://localhost:8069`, log in, navigate via developer mode to the action `action_fp_step_template` (or wait until Task 9 creates the menu). Verify the list opens, the form opens, the Operation Measurements + Transition Form notebook tabs render. + +- [ ] **Step 4: Commit** + +```bash +git add fusion_plating/views/fp_step_template_views.xml +git commit -m "feat(sub12a): step library list/form/search views + +Form: Title + Code + Classification (kind/icon/process/material) + +Stations & Flags + Instructions + Operation Measurements (one2many +list) + Transition Form (one2many list) + Advanced (time/temp targets, +voltage, viscosity). + +Header button: 'Seed Default Inputs' (visible only when default_kind +is set). Triggers the idempotent seeding helper. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 9: Step Library menu under Plating → Configuration + +**Files:** +- Modify: `fusion_plating/views/fp_menu.xml` + +- [ ] **Step 1: Read menu file to find the Configuration parent** + +```bash +grep -n "menu_fp_configuration\|menu_fp_root" fusion_plating/views/fp_menu.xml | head -10 +``` + +- [ ] **Step 2: Add the menu item** + +In `fusion_plating/views/fp_menu.xml`, locate the existing Configuration menu (parent `menu_fp_configuration` or similar — check what's there). Add a new `` under it: + +```xml + +``` + +If the parent ID is different (e.g. `menu_fp_config`), use whatever the file actually defines. Read the file before editing. + +If no Configuration menu exists yet under `menu_fp_root`, instead add directly under root with sequence 92: + +```xml + +``` + +- [ ] **Step 3: Reload module** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating --stop-after-init 2>&1 | tail -5 +``` + +- [ ] **Step 4: Browser smoke-test** (manual) + +Plating → Configuration → Step Library — confirm the menu appears and opens the list view. + +- [ ] **Step 5: Commit** + +```bash +git add fusion_plating/views/fp_menu.xml +git commit -m "feat(sub12a): Plating → Configuration → Step Library menu + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 10: post_init_hook — backfill `kind` on existing inputs + seed library + +**Files:** +- Create: `fusion_plating/hooks.py` +- Test: `fusion_plating/tests/test_post_init_hook.py` + +- [ ] **Step 1: Create the test file** + +`fusion_plating/tests/test_post_init_hook.py`: + +```python +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# Part of the Fusion Plating product family. + +from odoo.tests.common import TransactionCase, tagged + +from odoo.addons.fusion_plating.hooks import post_init_hook + + +@tagged('-at_install', 'post_install', 'fusion_plating', 'fp_post_init') +class TestPostInitHook(TransactionCase): + + def test_backfill_kind_on_existing_inputs(self): + """Existing process.node.input rows get kind='step_input' on backfill.""" + recipe = self.env['fusion.plating.process.node'].create({ + 'name': 'TestRecipe', 'node_type': 'recipe', + }) + step = self.env['fusion.plating.process.node'].create({ + 'name': 'Soak', 'node_type': 'step', 'parent_id': recipe.id, + }) + ni = self.env['fusion.plating.process.node.input'].create({ + 'node_id': step.id, 'name': 'Test', 'input_type': 'text', + }) + # Manually clear `kind` to simulate a pre-upgrade row + ni.flush_recordset() + self.env.cr.execute( + "UPDATE fusion_plating_process_node_input SET kind=NULL WHERE id=%s", + (ni.id,)) + self.env.invalidate_all() + self.assertFalse(ni.kind) + + post_init_hook(self.env) + + ni.invalidate_recordset() + self.assertEqual(ni.kind, 'step_input') + + def test_seed_library_idempotent(self): + """Running post_init_hook twice does not create duplicate library rows.""" + post_init_hook(self.env) + first_count = self.env['fp.step.template'].search_count([]) + post_init_hook(self.env) + second_count = self.env['fp.step.template'].search_count([]) + self.assertEqual(first_count, second_count) +``` + +Add to `tests/__init__.py`: +```python +from . import test_post_init_hook +``` + +- [ ] **Step 2: Create the hook module** + +`fusion_plating/hooks.py`: + +```python +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# Part of the Fusion Plating product family. +"""Post-install hooks for fusion_plating. + +Currently does two things on first install / upgrade: +1. Backfills `kind='step_input'` on all existing + fusion.plating.process.node.input rows that were created before the + `kind` field existed. +2. Seeds fp.step.template with starter library entries derived from + the existing ENP-ALUM-BASIC recipe, IF no library entries already + exist. + +Both operations are idempotent. +""" + +import logging + +_logger = logging.getLogger(__name__) + + +STARTER_KIND_BY_NAME = { + 'soak clean': 'cleaning', + 'electroclean': 'cleaning', + 'solvent clean': 'cleaning', + 'rinse': 'rinse', + 'primary rinse': 'rinse', + 'secondary rinse': 'rinse', + 'hot rinse': 'rinse', + 'final rinse': 'rinse', + 'etch': 'etch', + 'desmut': 'etch', + 'zincate': 'etch', + 'strip zincate': 'etch', + 'acid dip': 'etch', + 'water break test': 'wbf_test', + 'issue panels': 'mask', + 'racking': 'racking', + 'rack': 'racking', + 'e-nickel plate': 'plate', + 'electroless nickel plating': 'plate', + 'drying': 'dry', + 'dry': 'dry', + 'de-rack': 'derack', + 'de-racking': 'derack', + 'inspection': 'inspect', + 'final inspection': 'final_inspect', + 'shipping': 'ship', +} + + +def post_init_hook(env): + _backfill_node_input_kind(env) + _seed_library_if_empty(env) + + +def _backfill_node_input_kind(env): + cr = env.cr + cr.execute( + "UPDATE fusion_plating_process_node_input " + "SET kind='step_input' WHERE kind IS NULL" + ) + if cr.rowcount: + _logger.info( + "fusion_plating: backfilled kind='step_input' on %s existing " + "process.node.input rows", cr.rowcount) + + +def _seed_library_if_empty(env): + Tpl = env['fp.step.template'] + if Tpl.search_count([]): + _logger.info("fusion_plating: step library already populated, skip seed") + return + + Node = env['fusion.plating.process.node'] + src = Node.search([ + ('node_type', '=', 'recipe'), + '|', ('code', '=', 'ENP-ALUM-BASIC'), + ('name', 'ilike', 'ENP-ALUM-BASIC'), + ], limit=1) + + if not src: + _seed_minimal_library(env) + return + + seen_names = set() + for child in src.child_ids: + if child.node_type == 'step': + _create_template_from_node(env, child, seen_names) + else: + for grandchild in child.child_ids: + _create_template_from_node(env, grandchild, seen_names) + + _logger.info( + "fusion_plating: seeded step library with %s entries from %s", + len(seen_names), src.name) + + +def _create_template_from_node(env, node, seen_names): + if not node.name or node.name.lower() in seen_names: + return + seen_names.add(node.name.lower()) + + kind = STARTER_KIND_BY_NAME.get(node.name.lower()) + tpl = env['fp.step.template'].create({ + 'name': node.name, + 'description': node.description or False, + 'icon': node.icon or 'fa-cog', + 'process_type_id': node.process_type_id.id, + 'tank_ids': [(6, 0, node.tank_ids.ids)] if node.tank_ids else False, + 'time_min_target': node.time_min_target, + 'time_max_target': node.time_max_target, + 'time_unit': node.time_unit or 'min', + 'temp_min_target': node.temp_min_target, + 'temp_max_target': node.temp_max_target, + 'temp_unit': node.temp_unit or 'F', + 'requires_signoff': node.requires_signoff, + 'requires_predecessor_done': node.requires_predecessor_done, + 'default_kind': kind, + }) + if kind: + tpl._seed_default_inputs() + + +def _seed_minimal_library(env): + """Fallback when ENP-ALUM-BASIC recipe doesn't exist on the target DB.""" + Tpl = env['fp.step.template'] + minimal = [ + ('Soak Clean', 'cleaning'), + ('Electroclean', 'cleaning'), + ('Rinse', 'rinse'), + ('Etch', 'etch'), + ('Desmut', 'etch'), + ('Zincate', 'etch'), + ('Acid Dip', 'etch'), + ('Water Break Test', 'wbf_test'), + ('Racking', 'racking'), + ('De-Racking', 'derack'), + ('E-Nickel Plate', 'plate'), + ('Drying', 'dry'), + ('Inspection', 'inspect'), + ('Final Inspection', 'final_inspect'), + ('Shipping', 'ship'), + ] + for name, kind in minimal: + tpl = Tpl.create({'name': name, 'default_kind': kind}) + tpl._seed_default_inputs() + _logger.info("fusion_plating: seeded minimal library (%s entries)", len(minimal)) +``` + +- [ ] **Step 3: Run the post-init test** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev --test-enable \ + --test-tags fp_post_init --stop-after-init -u fusion_plating 2>&1 | tail -15 +``` + +Expected: both tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add fusion_plating/hooks.py \ + fusion_plating/tests/test_post_init_hook.py \ + fusion_plating/tests/__init__.py +git commit -m "feat(sub12a): post_init_hook — backfill input kind + seed library + +Two idempotent operations on first install/upgrade: +1. Backfill kind='step_input' on existing process.node.input rows. +2. Seed fp.step.template from the ENP-ALUM-BASIC recipe's child nodes + (with name->kind mapping). Falls back to a minimal hard-coded list + if the recipe doesn't exist on the target DB. + +Both operations no-op when re-run. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 11: JSONRPC controller — recipe header + step list endpoints + +**Files:** +- Create: `fusion_plating/controllers/simple_recipe_controller.py` +- Modify: `fusion_plating/controllers/__init__.py` +- Test: `fusion_plating/tests/test_simple_recipe_controller.py` + +- [ ] **Step 1: Create test file with the load endpoint test** + +`fusion_plating/tests/test_simple_recipe_controller.py`: + +```python +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# Part of the Fusion Plating product family. + +from unittest.mock import patch + +from odoo.tests.common import TransactionCase, tagged +from odoo.addons.fusion_plating.controllers.simple_recipe_controller import ( + SimpleRecipeController, +) + + +@tagged('-at_install', 'post_install', 'fusion_plating', 'fp_simple_ctrl') +class TestSimpleRecipeController(TransactionCase): + + def setUp(self): + super().setUp() + self.ctrl = SimpleRecipeController() + self.recipe = self.env['fusion.plating.process.node'].create({ + 'name': 'Test Recipe', 'node_type': 'recipe', + }) + # Create three steps with sequence + for i, name in enumerate(['Soak', 'Rinse', 'Plate'], start=1): + self.env['fusion.plating.process.node'].create({ + 'name': name, + 'node_type': 'step', + 'parent_id': self.recipe.id, + 'sequence': i * 10, + }) + + def _with_request_env(self): + """Patch http.request.env for controller calls.""" + from odoo import http + return patch.object(http, 'request', type('R', (), {'env': self.env})()) + + def test_load_returns_recipe_header_and_steps(self): + with self._with_request_env(): + result = self.ctrl.load(self.recipe.id) + self.assertEqual(result['recipe']['id'], self.recipe.id) + self.assertEqual(result['recipe']['name'], 'Test Recipe') + self.assertEqual(len(result['steps']), 3) + self.assertEqual(result['steps'][0]['name'], 'Soak') + + def test_library_list_returns_templates(self): + self.env['fp.step.template'].create({'name': 'Soak Clean'}) + self.env['fp.step.template'].create({'name': 'Acid Dip'}) + with self._with_request_env(): + result = self.ctrl.library_list(query='') + self.assertGreaterEqual(len(result['templates']), 2) + names = [t['name'] for t in result['templates']] + self.assertIn('Soak Clean', names) + + def test_library_list_filters_by_query(self): + self.env['fp.step.template'].create({'name': 'Soak Clean'}) + self.env['fp.step.template'].create({'name': 'Acid Dip'}) + with self._with_request_env(): + result = self.ctrl.library_list(query='soak') + names = [t['name'] for t in result['templates']] + self.assertIn('Soak Clean', names) + self.assertNotIn('Acid Dip', names) + + def test_step_insert_from_library_snapshots_fields(self): + tpl = self.env['fp.step.template'].create({ + 'name': 'Soak Clean', + 'description': '

Soak it

', + 'time_min_target': 4, + 'time_max_target': 6, + 'requires_signoff': True, + }) + with self._with_request_env(): + result = self.ctrl.step_insert( + recipe_id=self.recipe.id, + template_id=tpl.id, + position=99, + ) + new_step = self.env['fusion.plating.process.node'].browse(result['id']) + self.assertEqual(new_step.name, 'Soak Clean') + self.assertEqual(new_step.source_template_id, tpl) + self.assertEqual(new_step.time_min_target, 4) + self.assertEqual(new_step.time_max_target, 6) + self.assertTrue(new_step.requires_signoff) + + def test_step_insert_blank_creates_inline_step(self): + with self._with_request_env(): + result = self.ctrl.step_insert( + recipe_id=self.recipe.id, + template_id=False, + position=99, + vals={'name': 'Custom Step'}, + ) + new_step = self.env['fusion.plating.process.node'].browse(result['id']) + self.assertEqual(new_step.name, 'Custom Step') + self.assertFalse(new_step.source_template_id) + + def test_step_reorder_updates_sequences(self): + steps = self.recipe.child_ids.sorted('sequence') + new_order = list(reversed(steps.ids)) + with self._with_request_env(): + self.ctrl.step_reorder(node_ids=new_order) + updated = self.recipe.child_ids.sorted('sequence') + self.assertEqual(updated.ids, new_order) + + def test_template_import_snapshots_children(self): + starter = self.env['fusion.plating.process.node'].create({ + 'name': 'Starter', 'node_type': 'recipe', 'is_template': True, + }) + for i, name in enumerate(['A', 'B', 'C'], start=1): + self.env['fusion.plating.process.node'].create({ + 'name': name, 'node_type': 'step', + 'parent_id': starter.id, 'sequence': i * 10, + }) + empty_recipe = self.env['fusion.plating.process.node'].create({ + 'name': 'Empty', 'node_type': 'recipe', + }) + with self._with_request_env(): + result = self.ctrl.template_import( + source_recipe_id=starter.id, + target_recipe_id=empty_recipe.id, + ) + self.assertEqual(len(empty_recipe.child_ids), 3) + names = empty_recipe.child_ids.sorted('sequence').mapped('name') + self.assertEqual(names, ['A', 'B', 'C']) + self.assertEqual(result['imported_count'], 3) +``` + +Add to `tests/__init__.py`: +```python +from . import test_simple_recipe_controller +``` + +- [ ] **Step 2: Create the controller file** + +`fusion_plating/controllers/simple_recipe_controller.py`: + +```python +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# Part of the Fusion Plating product family. +"""JSONRPC endpoints for the Simple Recipe Editor. + +All endpoints expect the user to be authenticated. Permissions are +enforced by the underlying ACL on fp.step.template + process.node: +operators get read, supervisors+ get write. +""" + +from odoo import http +from odoo.http import request + + +# Field list copied from a library template into a new recipe step on +# drag-drop. Snapshot semantics (Q4 from the design doc). +_SNAPSHOT_FIELDS = [ + 'name', 'code', 'description', 'icon', 'process_type_id', + 'material_callout', 'time_min_target', 'time_max_target', 'time_unit', + 'temp_min_target', 'temp_max_target', 'temp_unit', 'voltage_target', + 'viscosity_target', 'requires_signoff', 'requires_predecessor_done', + 'requires_rack_assignment', 'requires_transition_form', 'default_kind', +] + +# Fields on fp.step.template.input that copy 1:1 into +# fusion.plating.process.node.input on snapshot +_INPUT_SNAPSHOT_FIELDS = [ + 'name', 'input_type', 'target_min', 'target_max', 'target_unit', + 'required', 'hint', 'selection_options', 'sequence', +] + + +class SimpleRecipeController(http.Controller): + + # ---------------------------------------------------------------- load + @http.route('/fp/simple_recipe/load', type='jsonrpc', auth='user') + def load(self, recipe_id): + recipe = request.env['fusion.plating.process.node'].browse(recipe_id) + recipe.check_access_rights('read') + recipe.check_access_rule('read') + steps = recipe.child_ids.sorted('sequence') + return { + 'recipe': self._recipe_payload(recipe), + 'steps': [self._step_payload(s) for s in steps], + } + + def _recipe_payload(self, recipe): + return { + 'id': recipe.id, + 'name': recipe.name, + 'code': recipe.code, + 'is_template': recipe.is_template, + 'preferred_editor': recipe.preferred_editor, + 'process_type_id': ( + [recipe.process_type_id.id, recipe.process_type_id.name] + if recipe.process_type_id else False + ), + } + + def _step_payload(self, step): + return { + 'id': step.id, + 'name': step.name, + 'sequence': step.sequence, + 'icon': step.icon, + 'default_kind': step.default_kind, + 'requires_signoff': step.requires_signoff, + 'requires_rack_assignment': step.requires_rack_assignment, + 'requires_transition_form': step.requires_transition_form, + 'tank_ids': [ + {'id': t.id, 'name': t.name, 'code': t.code} + for t in step.tank_ids + ], + 'tank_id': step.work_center_id.id if step.work_center_id else False, + 'source_template_id': step.source_template_id.id or False, + } + + # ------------------------------------------------------------ library + @http.route('/fp/simple_recipe/library/list', type='jsonrpc', auth='user') + def library_list(self, query='', limit=200): + Tpl = request.env['fp.step.template'] + domain = [('active', '=', True)] + if query: + domain += ['|', '|', + ('name', 'ilike', query), + ('code', 'ilike', query), + ('description', 'ilike', query)] + records = Tpl.search(domain, limit=limit) + return { + 'templates': [ + { + 'id': t.id, + 'name': t.name, + 'code': t.code, + 'icon': t.icon, + 'default_kind': t.default_kind, + 'station_count': len(t.tank_ids), + } + for t in records + ], + } + + @http.route('/fp/simple_recipe/library/create', type='jsonrpc', auth='user') + def library_create(self, vals): + tpl = request.env['fp.step.template'].create(vals) + return {'id': tpl.id, 'name': tpl.name} + + @http.route('/fp/simple_recipe/library/write', type='jsonrpc', auth='user') + def library_write(self, template_id, vals): + tpl = request.env['fp.step.template'].browse(template_id) + tpl.write(vals) + return {'ok': True} + + @http.route('/fp/simple_recipe/library/delete', type='jsonrpc', auth='user') + def library_delete(self, template_id): + tpl = request.env['fp.step.template'].browse(template_id) + Node = request.env['fusion.plating.process.node'] + used_count = Node.search_count([('source_template_id', '=', template_id)]) + if used_count: + tpl.write({'active': False}) + return {'ok': True, 'soft_deleted': True, 'used_in': used_count} + tpl.unlink() + return {'ok': True, 'soft_deleted': False} + + # --------------------------------------------------------------- step + @http.route('/fp/simple_recipe/step/insert', type='jsonrpc', auth='user') + def step_insert(self, recipe_id, template_id=False, position=99, vals=None): + recipe = request.env['fusion.plating.process.node'].browse(recipe_id) + target_seq = self._sequence_for_position(recipe, position) + + new_vals = { + 'parent_id': recipe.id, + 'node_type': 'step', + 'sequence': target_seq, + } + tpl = False + if template_id: + tpl = request.env['fp.step.template'].browse(template_id) + for f in _SNAPSHOT_FIELDS: + if f == 'process_type_id': + new_vals[f] = tpl.process_type_id.id or False + else: + new_vals[f] = tpl[f] + if tpl.tank_ids: + new_vals['tank_ids'] = [(6, 0, tpl.tank_ids.ids)] + new_vals['source_template_id'] = tpl.id + + if vals: + new_vals.update(vals) + + new_node = request.env['fusion.plating.process.node'].create(new_vals) + + if tpl: + self._copy_inputs_from_template(tpl, new_node) + + return {'id': new_node.id, 'sequence': new_node.sequence} + + def _sequence_for_position(self, recipe, position): + siblings = recipe.child_ids.sorted('sequence') + if not siblings or position >= len(siblings): + return (siblings[-1].sequence + 10) if siblings else 10 + if position <= 0: + return max(1, siblings[0].sequence - 10) + before = siblings[position - 1].sequence + after = siblings[position].sequence + return (before + after) // 2 if (after - before) > 1 else before + 1 + + def _copy_inputs_from_template(self, tpl, new_node): + NodeInput = request.env['fusion.plating.process.node.input'] + for ti in tpl.input_template_ids: + payload = {f: ti[f] for f in _INPUT_SNAPSHOT_FIELDS} + payload['node_id'] = new_node.id + payload['kind'] = 'step_input' + NodeInput.create(payload) + for tt in tpl.transition_input_ids: + payload = { + 'node_id': new_node.id, + 'name': tt.name, + 'input_type': tt.input_type, + 'required': tt.required, + 'hint': tt.hint, + 'selection_options': tt.selection_options, + 'sequence': tt.sequence, + 'compliance_tag': tt.compliance_tag, + 'kind': 'transition_input', + } + NodeInput.create(payload) + + @http.route('/fp/simple_recipe/step/write', type='jsonrpc', auth='user') + def step_write(self, node_id, vals): + node = request.env['fusion.plating.process.node'].browse(node_id) + node.write(vals) + return {'ok': True} + + @http.route('/fp/simple_recipe/step/remove', type='jsonrpc', auth='user') + def step_remove(self, node_id): + node = request.env['fusion.plating.process.node'].browse(node_id) + node.unlink() + return {'ok': True} + + @http.route('/fp/simple_recipe/step/reorder', type='jsonrpc', auth='user') + def step_reorder(self, node_ids): + Node = request.env['fusion.plating.process.node'] + for i, nid in enumerate(node_ids, start=1): + Node.browse(nid).write({'sequence': i * 10}) + return {'ok': True} + + # ----------------------------------------------------------- template + @http.route('/fp/simple_recipe/template/list', type='jsonrpc', auth='user') + def template_list(self): + Node = request.env['fusion.plating.process.node'] + recipes = Node.search([ + ('node_type', '=', 'recipe'), + ('is_template', '=', True), + ('active', '=', True), + ], order='name') + return { + 'templates': [ + {'id': r.id, 'name': r.name, 'code': r.code, + 'step_count': len(r.child_ids)} + for r in recipes + ], + } + + @http.route('/fp/simple_recipe/template/import', type='jsonrpc', auth='user') + def template_import(self, source_recipe_id, target_recipe_id): + Node = request.env['fusion.plating.process.node'] + source = Node.browse(source_recipe_id) + target = Node.browse(target_recipe_id) + imported = 0 + for child in source.child_ids.sorted('sequence'): + self._snapshot_step_into(child, target) + imported += 1 + return {'ok': True, 'imported_count': imported} + + def _snapshot_step_into(self, src_node, target_recipe): + Node = request.env['fusion.plating.process.node'] + new_vals = { + 'parent_id': target_recipe.id, + 'node_type': 'step', + 'sequence': src_node.sequence, + 'source_template_id': src_node.source_template_id.id or False, + } + for f in _SNAPSHOT_FIELDS: + if f == 'process_type_id': + new_vals[f] = src_node.process_type_id.id or False + else: + new_vals[f] = src_node[f] + if src_node.tank_ids: + new_vals['tank_ids'] = [(6, 0, src_node.tank_ids.ids)] + new_node = Node.create(new_vals) + + NodeInput = request.env['fusion.plating.process.node.input'] + for src_in in src_node.input_ids: + payload = { + 'node_id': new_node.id, + 'name': src_in.name, + 'input_type': src_in.input_type, + 'required': src_in.required, + 'hint': src_in.hint, + 'selection_options': src_in.selection_options, + 'sequence': src_in.sequence, + 'kind': src_in.kind or 'step_input', + 'target_min': src_in.target_min, + 'target_max': src_in.target_max, + 'target_unit': src_in.target_unit, + 'compliance_tag': src_in.compliance_tag, + } + NodeInput.create(payload) +``` + +> Note: the existing One2many on `fusion.plating.process.node` that points at `fusion.plating.process.node.input` is named `input_ids` in the existing codebase (verified during exploration). If when implementing you find it named differently, adjust the inverse name in `_snapshot_step_into` and rerun the relevant test. + +- [ ] **Step 3: Wire into `controllers/__init__.py`** + +```python +from . import simple_recipe_controller +``` + +- [ ] **Step 4: Run tests** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev --test-enable \ + --test-tags fp_simple_ctrl --stop-after-init -u fusion_plating 2>&1 | tail -20 +``` + +Expected: all 7 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add fusion_plating/controllers/simple_recipe_controller.py \ + fusion_plating/controllers/__init__.py \ + fusion_plating/tests/test_simple_recipe_controller.py \ + fusion_plating/tests/__init__.py +git commit -m "feat(sub12a): JSONRPC endpoints for the Simple Recipe Editor + +11 routes: + /fp/simple_recipe/load + /fp/simple_recipe/library/{list,create,write,delete} + /fp/simple_recipe/step/{insert,write,remove,reorder} + /fp/simple_recipe/template/{list,import} + +Library/template imports snapshot-copy fields (Q4 = A locked) — no +live references. Tests cover library list+filter, step insert from +library, step insert blank, step reorder, template import. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 12: Recipe form integration — header buttons + new fields + +**Files:** +- Modify: `fusion_plating/views/fp_process_node_views.xml` +- Modify: `fusion_plating/models/fp_process_node.py` + +- [ ] **Step 1: Read the existing recipe form view** + +```bash +grep -n "node_type\|view_fp_process_node_form" fusion_plating/views/fp_process_node_views.xml | head -10 +``` + +Identify where to slot in the new buttons + fields. + +- [ ] **Step 2: Add header buttons** + +In the recipe form's `
` block, add: + +```xml + + + + +
+
+ + + +
+
+ +
+
+

Selected (drag to reorder)

+
+ +
+ + . + + + + stations + + +
+
+
+ Drop here to add at end +
+
+ +
+ +
+

Step Library

+ +
+ +
+ + + + st. + +
+
+
+
+
+ +
+ Loading… +
+ + + + +``` + +- [ ] **Step 3: Create the SCSS** + +`fusion_plating/static/src/scss/simple_recipe_editor.scss`: + +```scss +// Simple Recipe Editor — flat drag-drop alternative to the tree editor. +// Tokens follow the existing fp_shopfloor pattern (CSS custom props with +// hex fallbacks; dark-mode aware via $o-webclient-color-scheme). + +$o-webclient-color-scheme: bright !default; + +$_fp_page_hex: #f3f4f6; +$_fp_card_hex: #ffffff; +$_fp_border_hex: #d8dadd; +$_fp_accent_hex: #2e7d6b; +$_fp_muted_hex: #6b7280; +$_fp_drop_hex: #e8f5f0; + +@if $o-webclient-color-scheme == dark { + $_fp_page_hex: #1a1d21 !global; + $_fp_card_hex: #22262d !global; + $_fp_border_hex: #3a3f47 !global; + $_fp_drop_hex: #1f3a33 !global; +} + +$fp-page: var(--fp-page-bg, #{$_fp_page_hex}); +$fp-card: var(--fp-card-bg, #{$_fp_card_hex}); +$fp-border: var(--fp-border-color, #{$_fp_border_hex}); +$fp-accent: var(--fp-accent, #{$_fp_accent_hex}); +$fp-muted: var(--fp-muted, #{$_fp_muted_hex}); +$fp-drop: var(--fp-drop-bg, #{$_fp_drop_hex}); + +.o_fp_simple_editor { + background: $fp-page; + height: 100%; + overflow: auto; + padding: 1rem; + + &_header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; + + h2 { + margin: 0; + flex: 1; + } + + .o_fp_simple_editor_actions { + display: flex; + gap: .5rem; + } + } + + &_meta { + background: $fp-card; + border: 1px solid $fp-border; + border-radius: 4px; + padding: 1rem; + margin-bottom: 1rem; + + .o_fp_import_row { + display: flex; + align-items: center; + gap: .75rem; + + label { font-weight: 500; margin: 0; } + select { flex: 1; } + } + } + + &_body { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 1rem; + + @media (max-width: 900px) { + grid-template-columns: 1fr; + } + } +} + +.o_fp_selected_panel, +.o_fp_library_panel { + background: $fp-card; + border: 1px solid $fp-border; + border-radius: 4px; + padding: 1rem; + + h3 { + margin: 0 0 .75rem 0; + font-size: 1rem; + color: $fp-accent; + } +} + +.o_fp_step_row { + display: flex; + align-items: center; + gap: .5rem; + padding: .5rem; + border: 1px solid $fp-border; + border-radius: 4px; + margin-bottom: .25rem; + background: $fp-card; + cursor: grab; + + &.o_fp_drag_over { + background: $fp-drop; + border-color: $fp-accent; + } + + .o_fp_drag_handle { color: $fp-muted; cursor: grab; } + .o_fp_step_position { font-weight: 600; min-width: 1.5rem; } + .o_fp_step_name { flex: 1; } + .o_fp_station_badge { + font-size: .75rem; + color: $fp-muted; + background: $fp-page; + padding: .125rem .5rem; + border-radius: 999px; + } + .o_fp_step_remove { + background: none; + border: none; + color: $fp-muted; + font-size: 1.25rem; + cursor: pointer; + opacity: 0; + transition: opacity .1s; + } + + &:hover .o_fp_step_remove { opacity: 1; } +} + +.o_fp_step_dropzone { + border: 2px dashed $fp-border; + border-radius: 4px; + padding: 1rem; + text-align: center; + color: $fp-muted; + margin-top: .5rem; + + &:hover { border-color: $fp-accent; background: $fp-drop; } +} + +.o_fp_library_list { + margin-top: .5rem; + max-height: 65vh; + overflow: auto; +} + +.o_fp_library_item { + display: flex; + align-items: center; + gap: .5rem; + padding: .5rem; + border: 1px solid $fp-border; + border-radius: 4px; + margin-bottom: .25rem; + background: $fp-card; + cursor: grab; + user-select: none; + + .o_fp_library_name { flex: 1; } + .o_fp_library_meta { + font-size: .75rem; + color: $fp-muted; + } + + &:hover { + border-color: $fp-accent; + } +} + +.o_fp_loading { + padding: 2rem; + text-align: center; + color: $fp-muted; +} +``` + +- [ ] **Step 4: Reload the module to pick up new assets + clear asset cache** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating --stop-after-init 2>&1 | tail -5 +docker exec odoo-dev-db psql -U odoo -d fusion-dev -c "DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';" +``` + +- [ ] **Step 5: Browser smoke-test** (manual) + +- Open a recipe → click **Open in Simple Editor** header button → editor renders. +- Library list shows seeded entries. +- Drag a library entry into Selected → step appears, sequence assigned. +- Drag-reorder a step → sequence updates. +- Click × on a step → confirm dialog → step removes. +- Click **Open in Tree Editor** → switches to tree editor on the same recipe. +- Build a recipe, mark `is_template=True`, build a new empty recipe, "Import starter from template" → all steps copy in. + +- [ ] **Step 6: Commit** + +```bash +git add fusion_plating/static/src/js/simple_recipe_editor.js \ + fusion_plating/static/src/xml/simple_recipe_editor.xml \ + fusion_plating/static/src/scss/simple_recipe_editor.scss +git commit -m "feat(sub12a): OWL Simple Recipe Editor client action + +Single-file root component (FpSimpleRecipeEditor) with HTML5 drag-drop +between Library (right) and Selected (left) panels. Library search, +import-from-starter dropdown, inline-step add, per-row remove with +confirm. + +SCSS uses the fp-token pattern with dark-mode SCSS @if branch (matches +fp_shopfloor and follows the Odoo-19 dark-mode rule from CLAUDE.md). + +Tag: fp_simple_recipe_editor — registered via registry.category('actions'). + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 14: Wire `fp_default_recipe_editor` to per-recipe resolver + +**Files:** +- Modify: `fusion_plating/models/fp_process_node.py` +- Modify: `fusion_plating/tests/test_step_template.py` + +- [ ] **Step 1: Add a `_resolve_preferred_editor` helper to the recipe node** + +Add to the class in `fp_process_node.py`: + +```python + def _resolve_preferred_editor(self): + """Returns 'tree' or 'simple' for this recipe. + + Per-recipe `preferred_editor` wins. 'auto' falls back to the + company-level default.""" + self.ensure_one() + if self.preferred_editor in ('tree', 'simple'): + return self.preferred_editor + return self.env.company.fp_default_recipe_editor or 'tree' + + def action_open_recipe_with_preferred_editor(self): + """Used by menu actions / context-menu opens — routes to whichever + editor the recipe (or company) prefers.""" + self.ensure_one() + if self._resolve_preferred_editor() == 'simple': + return self.action_open_in_simple_editor() + return self.action_open_in_tree_editor() +``` + +- [ ] **Step 2: Add tests** + +Append to `tests/test_step_template.py`: + +```python + def test_resolve_preferred_editor_per_recipe(self): + recipe = self.env['fusion.plating.process.node'].create({ + 'name': 'R', 'node_type': 'recipe', + 'preferred_editor': 'simple', + }) + self.assertEqual(recipe._resolve_preferred_editor(), 'simple') + + def test_resolve_preferred_editor_falls_back_to_company(self): + self.env.company.fp_default_recipe_editor = 'simple' + recipe = self.env['fusion.plating.process.node'].create({ + 'name': 'R', 'node_type': 'recipe', + 'preferred_editor': 'auto', + }) + self.assertEqual(recipe._resolve_preferred_editor(), 'simple') + self.env.company.fp_default_recipe_editor = 'tree' + self.assertEqual(recipe._resolve_preferred_editor(), 'tree') +``` + +- [ ] **Step 3: Run all tests** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev --test-enable \ + --test-tags fusion_plating --stop-after-init -u fusion_plating 2>&1 | tail -20 +``` + +Expected: all tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add fusion_plating/models/fp_process_node.py \ + fusion_plating/tests/test_step_template.py +git commit -m "feat(sub12a): preferred_editor resolver + +Per-recipe preferred_editor wins; 'auto' falls back to company-level +fp_default_recipe_editor; falls back to 'tree' as the final default +(preserves existing behavior). + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 15: Deploy to entech + smoke test + +**Files:** +- (none — deployment + manual verification) + +- [ ] **Step 1: Sync the entire `fusion_plating` module to entech** + +```bash +for f in $(find fusion_plating -type f \ + \( -name "*.py" -o -name "*.xml" -o -name "*.csv" \ + -o -name "*.scss" -o -name "*.js" \) | grep -v __pycache__); do + cat "$f" | ssh pve-worker5 "pct exec 111 -- bash -c \ + 'mkdir -p \$(dirname /mnt/extra-addons/custom/$f) && cat > /mnt/extra-addons/custom/$f'" +done +``` + +- [ ] **Step 2: Update the module on entech** + +```bash +ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && \ + su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin \ + -u fusion_plating --stop-after-init\" 2>&1 | tail -15 && \ + systemctl start odoo'" +``` + +Expected: clean upgrade, no errors. `post_init_hook` runs, seeds the library. + +- [ ] **Step 3: Clear asset cache** + +```bash +ssh pve-worker5 "pct exec 111 -- bash -c \ + \"sudo -u postgres psql admin -c \\\"DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';\\\"\"" +``` + +- [ ] **Step 4: Manual smoke test in the entech browser** + +Connect to entech. Log in as admin. Verify: + +1. Plating → Configuration → **Step Library** menu appears. +2. Click it → list shows ≥15 seeded library entries (Soak Clean, Rinse, Etch, etc.). +3. Open any → Form shows: classification, stations + flags, instructions tab, operation measurements tab, transition form tab, advanced tab. +4. Open the existing **ENP-ALUM-BASIC** recipe → header shows both editor buttons. +5. Click **Open in Tree Editor** → existing tree editor renders, all steps present (regression check). +6. Back to recipe form → click **Open in Simple Editor** → simple editor renders, library on right, steps on left in correct sequence. +7. Drag "Acid Dip" from library → drop into Selected → new step appears in the recipe. +8. Drag-reorder a step → sequence updates. +9. Mark recipe as `is_template=True`, save. Build a new empty recipe → Open Simple Editor → "Import starter from template" → pick ENP-ALUM-BASIC → all steps copy in. +10. Edit ENP-ALUM-BASIC's "Acid Dip" name to "Acid Dip 2" in the library → confirm previously-imported recipes' Acid Dip stays "Acid Dip" (snapshot decoupling). +11. Settings → Fusion Plating → Default Recipe Editor → flip to "Simple" → save → re-open a recipe → confirm `preferred_editor='auto'` resolves to simple now. +12. Run a battle test on entech (existing one — pick `bt_s2` or similar) → confirm runtime still works on a new job created from a Simple-Editor recipe. + +- [ ] **Step 5: Document the entech deployment in CLAUDE.md** + +Update [CLAUDE.md](../../CLAUDE.md) Sub-Project Roadmap table — mark Sub 12a as **Shipped 2026-MM-DD** with the merge commit SHA. + +- [ ] **Step 6: Commit the doc update** + +```bash +git add CLAUDE.md +git commit -m "docs(sub12a): mark Sub 12a as shipped on entech + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +- [ ] **Step 7: Push to remote** + +```bash +git push origin main +``` + +--- + +## Self-Review + +### Spec coverage check + +| Spec section | Task | +|---|---| +| 4.2 Data model — `fp.step.template` | Task 2 | +| 4.2 Data model — `fp.step.template.input` | Task 3 | +| 4.2 Data model — `fp.step.template.transition.input` | Task 4 | +| 4.2 Data model — `fusion.plating.process.node` field additions | Task 5 | +| 4.2 Data model — `fusion.plating.process.node.input` `kind` + targets | Task 5 | +| 4.2 Data model — `res.config.settings.default_recipe_editor` | Task 6 | +| 4.3 Simple Recipe Editor UI | Task 13 | +| 4.4 Backend controller endpoints | Task 11 | +| 4.5 Recipe form integration (header buttons + `is_template` + `preferred_editor`) | Task 12, Task 14 | +| 4.5 Menu integration (Step Library) | Task 9 | +| 4.5 ACL changes | Task 7 | +| 4.5 Step Library views | Task 8 | +| 4.6 Sane-default input seeding | Task 2 (`_seed_default_inputs`) | +| 4.7 Migration / install — `post_init_hook` | Task 10 | +| 4.7 Migration / install — manifest version bump | Task 1 | +| 4.8 Verification | Task 15 | + +All spec sections covered. No gaps. + +### Placeholder scan + +- No "TBD", "TODO", "implement later", "fill in details". +- No "add appropriate error handling" without showing how. +- No "similar to Task N" without code repeated where needed. + +### Type / signature consistency + +- `fp.step.template` → `_seed_default_inputs(self)` defined Task 2, called by Task 8 form button + Task 10 post-init hook. Same name everywhere. ✓ +- `_SNAPSHOT_FIELDS` defined in Task 11 controller, used by `step_insert` + `_snapshot_step_into`. ✓ +- `_INPUT_SNAPSHOT_FIELDS` defined Task 11, used by `_copy_inputs_from_template`. ✓ +- `action_open_in_simple_editor` / `action_open_in_tree_editor` defined Task 12, called by Task 14's `action_open_recipe_with_preferred_editor`. ✓ +- Client-action tag `fp_simple_recipe_editor` registered Task 13, referenced by Task 12's `action_open_in_simple_editor`. ✓ +- `fp_default_recipe_editor` field defined Task 6, used by Task 14's resolver. ✓ + +No inconsistencies found. + +--- + +**Plan complete. 15 tasks, ~4 days end-to-end.**