Files
Odoo-Modules/fusion_plating/docs/superpowers/plans/2026-04-27-sub12a-simple-recipe-editor.md
gsinghpal c75b22aaf7 docs(sub12a): implementation plan — 15 tasks for simple editor + library
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) <noreply@anthropic.com>
2026-04-27 20:22:20 -04:00

104 KiB
Raw Blame History

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:

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:
    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:
    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

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):

        'security/ir.model.access.csv',
        'views/fp_step_template_views.xml',
        'views/res_config_settings_views.xml',

Add (or extend if exists) 'assets' block:

    '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.

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
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) <noreply@anthropic.com>"

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

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

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
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:

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

from . import fp_step_template
  • Step 6: Run the test — model exists but child models don't yet, so 2 of 3 tests fail
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
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) <noreply@anthropic.com>"

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:

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

from . import fp_step_template_input
  • Step 3: Run the tests
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
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) <noreply@anthropic.com>"

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):

    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
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:

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

from . import fp_step_template_transition_input
  • Step 5: Run the test
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
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) <noreply@anthropic.com>"

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:

    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)
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
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):

    # ===== 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:

    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:

        ('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
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
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) <noreply@anthropic.com>"

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:

    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
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:

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

from . import res_config_settings
  • Step 5: Create the settings view file

fusion_plating/views/res_config_settings_views.xml:

<?xml version="1.0" encoding="utf-8"?>
<!--
    Copyright 2026 Nexa Systems Inc.
    License OPL-1 (Odoo Proprietary License v1.0)
    Part of the Fusion Plating product family.
-->
<odoo>
    <record id="res_config_settings_view_form_fp" model="ir.ui.view">
        <field name="name">res.config.settings.form.fp.simple_editor</field>
        <field name="model">res.config.settings</field>
        <field name="inherit_id" ref="base.res_config_settings_view_form"/>
        <field name="arch" type="xml">
            <xpath expr="//block[1]" position="before">
                <block id="fp_simple_editor_block" title="Recipe Editor">
                    <setting id="fp_default_recipe_editor_setting"
                             string="Default Recipe Editor"
                             help="Which editor opens for new recipes.">
                        <field name="fp_default_recipe_editor"/>
                    </setting>
                </block>
            </xpath>
        </field>
    </record>
</odoo>
  • Step 6: Run the test
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
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) <noreply@anthropic.com>"

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

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):

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
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
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) <noreply@anthropic.com>"

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 version="1.0" encoding="utf-8"?>
<!--
    Copyright 2026 Nexa Systems Inc.
    License OPL-1 (Odoo Proprietary License v1.0)
    Part of the Fusion Plating product family.
-->
<odoo>

    <record id="view_fp_step_template_list" model="ir.ui.view">
        <field name="name">fp.step.template.list</field>
        <field name="model">fp.step.template</field>
        <field name="arch" type="xml">
            <list string="Step Library">
                <field name="sequence" widget="handle"/>
                <field name="name"/>
                <field name="code"/>
                <field name="default_kind"/>
                <field name="tank_ids" widget="many2many_tags" optional="show"/>
                <field name="requires_signoff" optional="hide"/>
                <field name="requires_rack_assignment" optional="hide"/>
                <field name="requires_transition_form" optional="hide"/>
                <field name="active" widget="boolean_toggle" optional="hide"/>
            </list>
        </field>
    </record>

    <record id="view_fp_step_template_form" model="ir.ui.view">
        <field name="name">fp.step.template.form</field>
        <field name="model">fp.step.template</field>
        <field name="arch" type="xml">
            <form string="Step Library Template">
                <header>
                    <button name="_seed_default_inputs" type="object"
                            string="Seed Default Inputs" class="btn-secondary"
                            invisible="not default_kind"/>
                </header>
                <sheet>
                    <div class="oe_title">
                        <label for="name" string="Title"/>
                        <h1><field name="name" placeholder="e.g. Soak Clean"/></h1>
                        <div class="text-muted">
                            <field name="code" placeholder="SOAK_CLEAN"/>
                        </div>
                    </div>
                    <group>
                        <group string="Classification">
                            <field name="default_kind"/>
                            <field name="icon"/>
                            <field name="process_type_id"/>
                            <field name="material_callout"/>
                        </group>
                        <group string="Stations + Flags">
                            <field name="tank_ids" widget="many2many_tags"/>
                            <field name="requires_signoff"/>
                            <field name="requires_predecessor_done"/>
                            <field name="requires_rack_assignment"/>
                            <field name="requires_transition_form"/>
                        </group>
                    </group>
                    <notebook>
                        <page string="Instructions" name="instructions">
                            <field name="description"
                                   placeholder="Rich-text instructions / WI reference."/>
                        </page>
                        <page string="Operation Measurements" name="op_measurements">
                            <field name="input_template_ids">
                                <list editable="bottom">
                                    <field name="sequence" widget="handle"/>
                                    <field name="name"/>
                                    <field name="input_type"/>
                                    <field name="target_min"/>
                                    <field name="target_max"/>
                                    <field name="target_unit"/>
                                    <field name="required" widget="boolean_toggle"/>
                                    <field name="hint"/>
                                </list>
                            </field>
                        </page>
                        <page string="Transition Form" name="transition_form">
                            <field name="transition_input_ids">
                                <list editable="bottom">
                                    <field name="sequence" widget="handle"/>
                                    <field name="name"/>
                                    <field name="input_type"/>
                                    <field name="required" widget="boolean_toggle"/>
                                    <field name="compliance_tag"/>
                                    <field name="hint"/>
                                </list>
                            </field>
                        </page>
                        <page string="Advanced" name="advanced">
                            <group>
                                <group string="Time Target">
                                    <field name="time_min_target"/>
                                    <field name="time_max_target"/>
                                    <field name="time_unit"/>
                                </group>
                                <group string="Temperature Target">
                                    <field name="temp_min_target"/>
                                    <field name="temp_max_target"/>
                                    <field name="temp_unit"/>
                                </group>
                                <group string="Other Targets">
                                    <field name="voltage_target"/>
                                    <field name="viscosity_target"/>
                                </group>
                            </group>
                        </page>
                    </notebook>
                </sheet>
                <chatter/>
            </form>
        </field>
    </record>

    <record id="view_fp_step_template_search" model="ir.ui.view">
        <field name="name">fp.step.template.search</field>
        <field name="model">fp.step.template</field>
        <field name="arch" type="xml">
            <search string="Step Library">
                <field name="name"/>
                <field name="code"/>
                <field name="default_kind"/>
                <field name="tank_ids"/>
                <separator/>
                <filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
                <group>
                    <filter string="Step Kind" name="group_kind"
                            context="{'group_by':'default_kind'}"/>
                    <filter string="Process Type" name="group_proc"
                            context="{'group_by':'process_type_id'}"/>
                </group>
            </search>
        </field>
    </record>

    <record id="action_fp_step_template" model="ir.actions.act_window">
        <field name="name">Step Library</field>
        <field name="res_model">fp.step.template</field>
        <field name="view_mode">list,form</field>
        <field name="search_view_id" ref="view_fp_step_template_search"/>
    </record>

</odoo>
  • Step 2: Reload the module to register the views
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
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) <noreply@anthropic.com>"

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

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 <menuitem> under it:

    <menuitem id="menu_fp_step_library"
              name="Step Library"
              parent="menu_fp_configuration"
              action="action_fp_step_template"
              sequence="35"/>

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:

    <menuitem id="menu_fp_step_library"
              name="Step Library"
              parent="menu_fp_root"
              action="action_fp_step_template"
              sequence="92"/>
  • Step 3: Reload module
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
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) <noreply@anthropic.com>"

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:

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

from . import test_post_init_hook
  • Step 2: Create the hook module

fusion_plating/hooks.py:

# -*- 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
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
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) <noreply@anthropic.com>"

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:

# -*- 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': '<p>Soak it</p>',
            '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:

from . import test_simple_recipe_controller
  • Step 2: Create the controller file

fusion_plating/controllers/simple_recipe_controller.py:

# -*- 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
from . import simple_recipe_controller
  • Step 4: Run tests
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
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) <noreply@anthropic.com>"

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

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 <header> block, add:

                    <button name="action_open_in_simple_editor" type="object"
                            string="Open in Simple Editor" class="btn-primary"
                            invisible="node_type != 'recipe'"/>
                    <button name="action_open_in_tree_editor" type="object"
                            string="Open in Tree Editor" class="btn-secondary"
                            invisible="node_type != 'recipe'"/>
  • Step 3: Add is_template + preferred_editor to the form sheet

Locate the <group> block where recipe meta lives. Add:

                    <field name="is_template"
                           invisible="node_type != 'recipe'"
                           groups="fusion_plating.group_fusion_plating_supervisor"/>
                    <field name="preferred_editor"
                           invisible="node_type != 'recipe'"/>
  • Step 4: Add a "Step Authoring" notebook page (visible for step/operation)

In the same form (under an existing notebook block):

                    <page string="Step Authoring" name="step_authoring"
                          invisible="node_type not in ('step', 'operation')">
                        <group>
                            <group string="Stations">
                                <field name="tank_ids" widget="many2many_tags"/>
                                <field name="default_kind"/>
                                <field name="material_callout"/>
                            </group>
                            <group string="Flags">
                                <field name="requires_rack_assignment"/>
                                <field name="requires_transition_form"/>
                            </group>
                        </group>
                        <group>
                            <group string="Time Target">
                                <field name="time_min_target"/>
                                <field name="time_max_target"/>
                                <field name="time_unit"/>
                            </group>
                            <group string="Temperature Target">
                                <field name="temp_min_target"/>
                                <field name="temp_max_target"/>
                                <field name="temp_unit"/>
                            </group>
                        </group>
                        <group>
                            <field name="voltage_target"/>
                            <field name="viscosity_target"/>
                            <field name="source_template_id" readonly="1"/>
                        </group>
                    </page>
  • Step 5: Add the action methods to the model

Open fusion_plating/models/fp_process_node.py and add to the class:

    def action_open_in_simple_editor(self):
        self.ensure_one()
        return {
            'type': 'ir.actions.client',
            'tag': 'fp_simple_recipe_editor',
            'name': self.name,
            'params': {'recipe_id': self.id},
        }

    def action_open_in_tree_editor(self):
        self.ensure_one()
        return {
            'type': 'ir.actions.client',
            'tag': 'fp_recipe_tree_editor',
            'name': self.name,
            'params': {'recipe_id': self.id},
        }

(The existing tree editor's tag is fp_recipe_tree_editor — confirmed in fusion_plating/static/src/js/recipe_tree_editor.js. If the actual registered name differs, adjust.)

  • Step 6: Reload module and smoke-test in browser (manual)
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating --stop-after-init 2>&1 | tail -5

Open a recipe in the browser. Confirm:

  • Both header buttons appear when node_type=recipe

  • "Open in Tree Editor" works (existing functionality preserved)

  • The new "Step Authoring" notebook page renders for step/operation nodes

  • is_template field appears on the recipe form for supervisors

  • Step 7: Commit

git add fusion_plating/views/fp_process_node_views.xml \
        fusion_plating/models/fp_process_node.py
git commit -m "feat(sub12a): recipe form — header buttons + step authoring tab

Header gets Open in Simple Editor + Open in Tree Editor (visible only
for recipe-type nodes).

Recipe form: is_template + preferred_editor fields surfaced.

Step/operation nodes: new 'Step Authoring' notebook page with
tank_ids, default_kind, material_callout, target ranges + units,
voltage, viscosity, transition flags, source_template_id (readonly).

Both client-action tags work — tree editor preserved exactly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 13: OWL — Simple Recipe Editor client action

Files:

  • Create: fusion_plating/static/src/js/simple_recipe_editor.js

  • Create: fusion_plating/static/src/xml/simple_recipe_editor.xml

  • Create: fusion_plating/static/src/scss/simple_recipe_editor.scss

  • Step 1: Create the OWL component JS

fusion_plating/static/src/js/simple_recipe_editor.js:

/** @odoo-module */

import { Component, onWillStart, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
import { _t } from "@web/core/l10n/translation";

export class FpSimpleRecipeEditor extends Component {
    static template = "fusion_plating.FpSimpleRecipeEditor";
    static props = ["*"];

    setup() {
        this.action = useService("action");
        this.notification = useService("notification");
        this.dialog = useService("dialog");

        this.state = useState({
            loading: true,
            recipe: null,
            steps: [],
            library: [],
            librarySearch: "",
            templateOptions: [],
            selectedTemplate: false,
            dragOverIndex: null,
        });

        onWillStart(async () => {
            await this.loadAll();
        });
    }

    get recipeId() {
        return this.props.action.params.recipe_id;
    }

    async loadAll() {
        this.state.loading = true;
        const [recipeData, libraryData, templateData] = await Promise.all([
            rpc("/fp/simple_recipe/load", { recipe_id: this.recipeId }),
            rpc("/fp/simple_recipe/library/list", { query: "" }),
            rpc("/fp/simple_recipe/template/list", {}),
        ]);
        this.state.recipe = recipeData.recipe;
        this.state.steps = recipeData.steps;
        this.state.library = libraryData.templates;
        this.state.templateOptions = templateData.templates;
        this.state.loading = false;
    }

    async onSearchLibrary(ev) {
        const q = ev.target.value;
        this.state.librarySearch = q;
        const data = await rpc("/fp/simple_recipe/library/list", { query: q });
        this.state.library = data.templates;
    }

    async onDropFromLibrary(templateId, position) {
        await rpc("/fp/simple_recipe/step/insert", {
            recipe_id: this.recipeId,
            template_id: templateId,
            position: position,
        });
        await this.loadAll();
        this.notification.add(_t("Step added"), { type: "success" });
    }

    async onReorderStep(stepId, newIndex) {
        const ids = this.state.steps.map((s) => s.id);
        const oldIndex = ids.indexOf(stepId);
        if (oldIndex < 0) return;
        ids.splice(oldIndex, 1);
        ids.splice(newIndex, 0, stepId);
        await rpc("/fp/simple_recipe/step/reorder", { node_ids: ids });
        await this.loadAll();
    }

    async onRemoveStep(stepId) {
        const confirmed = await new Promise((res) => {
            this.dialog.add(
                "web.ConfirmationDialog",
                {
                    body: _t("Remove this step from the recipe?"),
                    confirm: () => res(true),
                    cancel: () => res(false),
                },
                { onClose: () => res(false) }
            );
        });
        if (!confirmed) return;
        await rpc("/fp/simple_recipe/step/remove", { node_id: stepId });
        await this.loadAll();
    }

    async onAddInlineStep() {
        await rpc("/fp/simple_recipe/step/insert", {
            recipe_id: this.recipeId,
            template_id: false,
            position: 99,
            vals: { name: "New Step" },
        });
        await this.loadAll();
    }

    async onImportTemplate() {
        if (!this.state.selectedTemplate) return;
        const proceed = this.state.steps.length > 0
            ? await new Promise((res) => {
                this.dialog.add(
                    "web.ConfirmationDialog",
                    {
                        body: _t("This recipe already has steps. Import will append. Continue?"),
                        confirm: () => res(true),
                        cancel: () => res(false),
                    },
                    { onClose: () => res(false) }
                );
            })
            : true;
        if (!proceed) return;
        const result = await rpc("/fp/simple_recipe/template/import", {
            source_recipe_id: this.state.selectedTemplate,
            target_recipe_id: this.recipeId,
        });
        this.notification.add(
            _t("Imported %s steps", result.imported_count),
            { type: "success" }
        );
        await this.loadAll();
    }

    async openInTreeEditor() {
        this.action.doAction({
            type: "ir.actions.client",
            tag: "fp_recipe_tree_editor",
            name: this.state.recipe.name,
            params: { recipe_id: this.recipeId },
        });
    }

    onDragStart(stepId, ev) {
        ev.dataTransfer.effectAllowed = "move";
        ev.dataTransfer.setData("text/plain", String(stepId));
        ev.dataTransfer.setData("application/x-fp-step", String(stepId));
    }

    onDragOver(index, ev) {
        ev.preventDefault();
        ev.dataTransfer.dropEffect = "move";
        this.state.dragOverIndex = index;
    }

    async onDrop(targetIndex, ev) {
        ev.preventDefault();
        const fromLibrary = ev.dataTransfer.getData("application/x-fp-library");
        if (fromLibrary) {
            const tplId = parseInt(fromLibrary, 10);
            await this.onDropFromLibrary(tplId, targetIndex);
        } else {
            const fromStep = ev.dataTransfer.getData("application/x-fp-step");
            const draggedId = parseInt(fromStep, 10);
            if (draggedId) {
                await this.onReorderStep(draggedId, targetIndex);
            }
        }
        this.state.dragOverIndex = null;
    }

    onLibraryDragStart(templateId, ev) {
        ev.dataTransfer.effectAllowed = "copy";
        ev.dataTransfer.setData("application/x-fp-library", String(templateId));
        ev.dataTransfer.setData("text/plain", "library");
    }
}

registry.category("actions").add("fp_simple_recipe_editor", FpSimpleRecipeEditor);
  • Step 2: Create the OWL XML template

fusion_plating/static/src/xml/simple_recipe_editor.xml:

<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">

<t t-name="fusion_plating.FpSimpleRecipeEditor">
    <div class="o_fp_simple_editor">
        <div class="o_fp_simple_editor_header">
            <h2 t-if="state.recipe">
                Recipe: <span t-esc="state.recipe.name"/>
            </h2>
            <div class="o_fp_simple_editor_actions">
                <button class="btn btn-secondary" t-on-click="openInTreeEditor">
                    Open in Tree Editor
                </button>
            </div>
        </div>

        <div class="o_fp_simple_editor_meta" t-if="state.recipe">
            <div class="o_fp_import_row">
                <label>Import starter from template:</label>
                <select t-model="state.selectedTemplate">
                    <option value="">-- Select template --</option>
                    <t t-foreach="state.templateOptions" t-as="tpl" t-key="tpl.id">
                        <option t-att-value="tpl.id">
                            <t t-esc="tpl.name"/> (<t t-esc="tpl.step_count"/> steps)
                        </option>
                    </t>
                </select>
                <button class="btn btn-primary" t-on-click="onImportTemplate"
                        t-att-disabled="!state.selectedTemplate">
                    Import
                </button>
            </div>
        </div>

        <div class="o_fp_simple_editor_body" t-if="!state.loading">
            <div class="o_fp_selected_panel">
                <h3>Selected (drag to reorder)</h3>
                <div class="o_fp_steps_list">
                    <t t-foreach="state.steps" t-as="step" t-key="step.id">
                        <div class="o_fp_step_row"
                             t-att-class="state.dragOverIndex === step_index ? 'o_fp_drag_over' : ''"
                             draggable="true"
                             t-on-dragstart="(ev) => this.onDragStart(step.id, ev)"
                             t-on-dragover="(ev) => this.onDragOver(step_index, ev)"
                             t-on-drop="(ev) => this.onDrop(step_index, ev)">
                            <span class="o_fp_drag_handle"></span>
                            <span class="o_fp_step_position"><t t-esc="step_index + 1"/>.</span>
                            <i t-att-class="'fa ' + (step.icon || 'fa-cog')"/>
                            <span class="o_fp_step_name" t-esc="step.name"/>
                            <span class="o_fp_station_badge"
                                  t-if="step.tank_ids and step.tank_ids.length">
                                <t t-esc="step.tank_ids.length"/> stations
                            </span>
                            <button class="o_fp_step_remove"
                                    t-on-click="() => this.onRemoveStep(step.id)">
                                ×
                            </button>
                        </div>
                    </t>
                    <div class="o_fp_step_dropzone"
                         t-on-dragover="(ev) => this.onDragOver(state.steps.length, ev)"
                         t-on-drop="(ev) => this.onDrop(state.steps.length, ev)">
                        Drop here to add at end
                    </div>
                </div>
                <button class="btn btn-secondary" t-on-click="onAddInlineStep">
                    + Add Inline Step
                </button>
            </div>

            <div class="o_fp_library_panel">
                <h3>Step Library</h3>
                <input type="text" class="form-control"
                       placeholder="Search…"
                       t-on-input="onSearchLibrary"
                       t-att-value="state.librarySearch"/>
                <div class="o_fp_library_list">
                    <t t-foreach="state.library" t-as="tpl" t-key="tpl.id">
                        <div class="o_fp_library_item"
                             draggable="true"
                             t-on-dragstart="(ev) => this.onLibraryDragStart(tpl.id, ev)">
                            <i t-att-class="'fa ' + (tpl.icon || 'fa-cog')"/>
                            <span class="o_fp_library_name" t-esc="tpl.name"/>
                            <span class="o_fp_library_meta" t-if="tpl.station_count">
                                <t t-esc="tpl.station_count"/> st.
                            </span>
                        </div>
                    </t>
                </div>
            </div>
        </div>

        <div t-if="state.loading" class="o_fp_loading">
            Loading…
        </div>
    </div>
</t>

</templates>
  • Step 3: Create the SCSS

fusion_plating/static/src/scss/simple_recipe_editor.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
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

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) <noreply@anthropic.com>"

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:

    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:

    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
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
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) <noreply@anthropic.com>"

Task 15: Deploy to entech + smoke test

Files:

  • (none — deployment + manual verification)

  • Step 1: Sync the entire fusion_plating module to entech

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
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
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 Sub-Project Roadmap table — mark Sub 12a as Shipped 2026-MM-DD with the merge commit SHA.

  • Step 6: Commit the doc update
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) <noreply@anthropic.com>"
  • Step 7: Push to remote
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.