# 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.**