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

2881 lines
104 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Sub 12a — Simple Recipe Editor + Step Library Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a flat drag-drop "Simple Recipe Editor" alongside the existing OWL tree editor + a reusable Step Library + a starter-recipe import flow, all editing the same `fusion.plating.process.node` records. Tree editor and runtime untouched.
**Architecture:** New `fp.step.template` model (the library) + 2 child input-definition models + additive fields on existing `process.node` + new OWL client action `fp_simple_recipe_editor` + JSONRPC controller. Snapshot-copy semantics on import (no live references). Per-recipe `preferred_editor` + company-level default routes recipe-list clicks through the right editor.
**Tech Stack:** Odoo 19, Python 3.11, OWL 2 (`@odoo/owl`), `@web/core/network/rpc`, SCSS, QWeb XML for OWL templates.
**Companion docs:**
- [Spec](../specs/2026-04-27-sub12-simple-recipe-editor-design.md)
- [Steelhead screen inventory](../specs/2026-04-27-simple-recipe-editor-steelhead-screens.md)
**Deploy target:** entech (LXC 111 on pve-worker5, native odoo, DB `admin`). After each task, the change must be deployable on entech and not break existing battle tests.
---
## File Structure
### Files to create
```
fusion_plating/models/fp_step_template.py # fp.step.template
fusion_plating/models/fp_step_template_input.py # fp.step.template.input
fusion_plating/models/fp_step_template_transition_input.py # fp.step.template.transition.input
fusion_plating/models/res_config_settings.py # default_recipe_editor setting
fusion_plating/controllers/simple_recipe_controller.py # JSONRPC endpoints
fusion_plating/views/fp_step_template_views.xml # list/form/search views
fusion_plating/views/res_config_settings_views.xml # settings panel for editor default
fusion_plating/static/src/js/simple_recipe_editor.js # OWL client action root
fusion_plating/static/src/xml/simple_recipe_editor.xml # OWL templates
fusion_plating/static/src/scss/simple_recipe_editor.scss # styling
fusion_plating/hooks.py # post_init_hook
fusion_plating/tests/test_step_template.py # unit tests for library model
fusion_plating/tests/test_simple_recipe_controller.py # endpoint tests
fusion_plating/tests/test_post_init_hook.py # idempotency test
```
### Files to modify
```
fusion_plating/__manifest__.py # version bump + new files in data/assets
fusion_plating/models/__init__.py # import new model files
fusion_plating/controllers/__init__.py # import new controller
fusion_plating/models/fp_process_node.py # add fields + action methods + resolver
fusion_plating/views/fp_process_node_views.xml # header buttons, new fields, edit toggle
fusion_plating/views/fp_menu.xml # add Step Library menu
fusion_plating/security/ir.model.access.csv # ACLs for 3 new models
fusion_plating/tests/__init__.py # import new test files
```
---
## Conventions for every task
- **Read files before editing** (Odoo CLAUDE.md rule — never code from memory).
- **All Python files** start with `# -*- coding: utf-8 -*-` + `# Copyright 2026 Nexa Systems Inc.` + `# License OPL-1` + `# Part of the Fusion Plating product family.` headers.
- **Field naming**: new fields on standard Odoo models use `x_fc_*` prefix; new fields on our custom models use plain names.
- **Currency / Canadian English** in user-facing strings.
- **Tests** are Odoo `TransactionCase` integration tests located under `fusion_plating/tests/`. Run with:
```bash
docker exec odoo-dev-app odoo -d fusion-dev --test-enable --test-tags fusion_plating --stop-after-init -u fusion_plating
```
- **Deploy command** to entech after a task lands:
```bash
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && \
su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin \
-u fusion_plating --stop-after-init\" && systemctl start odoo'"
```
- **Frequent commits** — every task ends with a commit. The plan is a sequence of small, testable, committable steps.
---
## Task 1: Bump module version + scaffold manifest entries
**Files:**
- Modify: `fusion_plating/__manifest__.py`
- [ ] **Step 1: Read manifest**
```bash
cat fusion_plating/__manifest__.py
```
- [ ] **Step 2: Bump version to `19.0.10.0.0` and add new data/asset entries**
In `__manifest__.py`:
Change `'version': '19.0.9.3.0',` → `'version': '19.0.10.0.0',`
Add to `'data'` list (after existing view files, before any data files):
```python
'security/ir.model.access.csv',
'views/fp_step_template_views.xml',
'views/res_config_settings_views.xml',
```
Add (or extend if exists) `'assets'` block:
```python
'assets': {
'web.assets_backend': [
'fusion_plating/static/src/js/simple_recipe_editor.js',
'fusion_plating/static/src/xml/simple_recipe_editor.xml',
'fusion_plating/static/src/scss/simple_recipe_editor.scss',
],
},
```
Add `'post_init_hook': 'post_init_hook',` at the top level of the manifest dict (next to `'version'`).
- [ ] **Step 3: Verify the file parses**
Run odoo with `-u fusion_plating --stop-after-init` (Step-3 verification — it'll catch syntax errors as part of the loader). If it loads cleanly, the manifest parsed.
```bash
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating --stop-after-init 2>&1 | tail -3
```
Expected: clean load, no errors.
- [ ] **Step 4: Commit**
```bash
git add fusion_plating/__manifest__.py
git commit -m "feat(sub12a): bump fusion_plating to 19.0.10.0.0 + scaffold manifest
Adds asset entries for the upcoming Simple Recipe Editor OWL client
action and the data files for new views. No new code yet — just the
manifest scaffold so subsequent tasks can drop files into place.
Co-Authored-By: Claude Opus 4.7 (1M context) <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**
```python
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo.tests.common import TransactionCase, tagged
@tagged('-at_install', 'post_install', 'fusion_plating', 'fp_step_template')
class TestStepTemplate(TransactionCase):
def test_create_minimal(self):
"""A library step needs only a name to be created."""
tpl = self.env['fp.step.template'].create({'name': 'Soak Clean'})
self.assertEqual(tpl.name, 'Soak Clean')
self.assertTrue(tpl.active)
self.assertEqual(tpl.icon, 'fa-cog') # default
self.assertEqual(tpl.time_unit, 'min') # default
self.assertEqual(tpl.temp_unit, 'F') # default
def test_default_kind_seeds_inputs(self):
"""_seed_default_inputs() populates input_template_ids per kind."""
tpl = self.env['fp.step.template'].create({
'name': 'Soak Clean',
'default_kind': 'cleaning',
})
tpl._seed_default_inputs()
self.assertEqual(len(tpl.input_template_ids), 2)
names = tpl.input_template_ids.mapped('name')
self.assertIn('Actual Time', names)
self.assertIn('Actual Temperature', names)
def test_seed_default_inputs_idempotent(self):
"""Calling seed twice does not duplicate inputs."""
tpl = self.env['fp.step.template'].create({
'name': 'Soak Clean',
'default_kind': 'cleaning',
})
tpl._seed_default_inputs()
tpl._seed_default_inputs()
self.assertEqual(len(tpl.input_template_ids), 2)
```
- [ ] **Step 2: Add the test file to the tests package**
If `fusion_plating/tests/__init__.py` doesn't exist, create it with:
```python
from . import test_step_template
```
If it exists, add the line `from . import test_step_template` at the bottom.
- [ ] **Step 3: Run the test, expect failure**
```bash
docker exec odoo-dev-app odoo -d fusion-dev --test-enable \
--test-tags fp_step_template --stop-after-init -u fusion_plating 2>&1 | tail -10
```
Expected: ImportError or model not found — `fp.step.template` doesn't exist yet.
- [ ] **Step 4: Create the model file**
`fusion_plating/models/fp_step_template.py`:
```python
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models
class FpStepTemplate(models.Model):
"""Reusable step template for the Simple Recipe Editor.
A library entry the recipe author can drag into a recipe. Snapshot-
copied at drag time — editing the template later does NOT change
recipes already built.
"""
_name = 'fp.step.template'
_description = 'Fusion Plating — Step Library Template'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'sequence, name'
name = fields.Char(string='Title', required=True, translate=True, tracking=True)
code = fields.Char(string='Code', tracking=True,
help='Optional short identifier. Auto-uppercased.')
description = fields.Html(string='Instructions',
help='Rich-text instructions / Work-Instruction reference.')
icon = fields.Selection(
selection='_get_icon_selection',
string='Icon',
default='fa-cog',
)
sequence = fields.Integer(string='Sequence', default=10)
active = fields.Boolean(string='Active', default=True)
company_id = fields.Many2one(
'res.company', string='Company',
default=lambda self: self.env.company,
)
tank_ids = fields.Many2many(
'fusion.plating.tank', string='Allowed Stations',
help='Stations (tanks) this step can be performed at. The '
'operator picks one of these at runtime.',
)
process_type_id = fields.Many2one(
'fusion.plating.process.type', string='Process Type',
ondelete='set null',
)
material_callout = fields.Char(string='Material Callout',
help='Short string printed in the traveller "Material" column. '
'e.g. "MID PHOS". Defaults to process type name if blank.')
time_min_target = fields.Float(string='Time Min')
time_max_target = fields.Float(string='Time Max')
time_unit = fields.Selection(
[('sec', 'Seconds'), ('min', 'Minutes'), ('hr', 'Hours')],
string='Time Unit', default='min',
)
temp_min_target = fields.Float(string='Temp Min')
temp_max_target = fields.Float(string='Temp Max')
temp_unit = fields.Selection(
[('F', '°F'), ('C', '°C')],
string='Temp Unit', default='F',
)
voltage_target = fields.Float(string='Voltage Target')
viscosity_target = fields.Float(string='Viscosity Target')
requires_signoff = fields.Boolean(string='Require QA Sign-off')
requires_predecessor_done = fields.Boolean(string='Require Predecessor Done',
help='S14 lock — operator cannot start this step until earlier '
'sequenced steps are done.')
requires_rack_assignment = fields.Boolean(string='Requires Rack Assignment',
help='Triggers Rack Parts sub-dialog at runtime (Sub 12b).')
requires_transition_form = fields.Boolean(string='Requires Transition Form',
help='Opens the transition form before Mark Done (Sub 12b).')
default_kind = fields.Selection([
('cleaning', 'Cleaning'),
('etch', 'Etch'),
('rinse', 'Rinse'),
('plate', 'Plating'),
('bake', 'Bake'),
('inspect', 'Inspection'),
('racking', 'Racking'),
('derack', 'De-Racking'),
('mask', 'Masking'),
('demask', 'De-Masking'),
('dry', 'Drying'),
('wbf_test', 'Water Break Free Test'),
('final_inspect', 'Final Inspection'),
('ship', 'Shipping'),
('gating', 'Gating'),
], string='Step Kind', help='Drives sane-default input seeding.')
input_template_ids = fields.One2many(
'fp.step.template.input', 'template_id',
string='Operation Measurements',
copy=True,
)
transition_input_ids = fields.One2many(
'fp.step.template.transition.input', 'template_id',
string='Transition Form Fields',
copy=True,
)
@api.model
def _get_icon_selection(self):
# Reuse the 24-icon list from fusion.plating.process.node
node = self.env['fusion.plating.process.node']
return node._fields['icon'].selection
_sql_constraints = [
('fp_step_template_code_company_uniq',
'unique(code, company_id)',
'Step template code must be unique within a company.'),
]
@api.model_create_multi
def create(self, vals_list):
for v in vals_list:
if v.get('code'):
v['code'] = v['code'].upper().strip()
return super().create(vals_list)
def write(self, vals):
if vals.get('code'):
vals['code'] = vals['code'].upper().strip()
return super().write(vals)
# ----- Sane defaults seeding ---------------------------------------------
DEFAULT_INPUTS_BY_KIND = {
'cleaning': [
{'name': 'Actual Time', 'input_type': 'time_seconds',
'target_unit': 'sec', 'sequence': 10},
{'name': 'Actual Temperature', 'input_type': 'temperature',
'target_unit': '°F', 'sequence': 20},
],
'etch': [
{'name': 'Actual Time', 'input_type': 'time_seconds',
'target_unit': 'sec', 'sequence': 10},
{'name': 'Actual Temperature', 'input_type': 'temperature',
'target_unit': '°F', 'sequence': 20},
],
'rinse': [],
'plate': [
{'name': 'Actual Time', 'input_type': 'time_hms',
'target_unit': 'min', 'sequence': 10},
{'name': 'Actual Temperature', 'input_type': 'temperature',
'target_unit': '°F', 'sequence': 20},
{'name': 'Plating Thickness', 'input_type': 'thickness',
'target_unit': 'in', 'sequence': 30},
],
'bake': [
{'name': 'Time In', 'input_type': 'text',
'target_unit': 'HH:MM', 'sequence': 10},
{'name': 'Time Out', 'input_type': 'text',
'target_unit': 'HH:MM', 'sequence': 20},
{'name': 'Actual Temperature', 'input_type': 'temperature',
'target_unit': '°F', 'sequence': 30},
],
'racking': [
{'name': 'Actual Qty', 'input_type': 'number',
'target_unit': 'each', 'sequence': 10},
],
'derack': [
{'name': 'Actual Qty', 'input_type': 'number',
'target_unit': 'each', 'sequence': 10},
],
'inspect': [
{'name': 'PASS/FAIL', 'input_type': 'pass_fail', 'sequence': 10},
],
'final_inspect': [
{'name': 'Outgoing Part Count Verified',
'input_type': 'boolean', 'sequence': 10},
{'name': 'Qty Accepted', 'input_type': 'number',
'target_unit': 'each', 'sequence': 20},
{'name': 'Qty Rejected', 'input_type': 'number',
'target_unit': 'each', 'sequence': 30},
{'name': 'Actual Coating Thickness',
'input_type': 'thickness', 'target_unit': 'in', 'sequence': 40},
{'name': 'Pass/Fail', 'input_type': 'pass_fail', 'sequence': 50},
],
'wbf_test': [
{'name': 'PASS/FAIL', 'input_type': 'pass_fail', 'sequence': 10},
],
'mask': [
{'name': 'Actual Qty', 'input_type': 'number',
'target_unit': 'each', 'sequence': 10},
],
'demask': [],
'dry': [],
'ship': [
{'name': 'Outgoing Qty', 'input_type': 'number',
'target_unit': 'each', 'sequence': 10},
],
'gating': [],
}
def _seed_default_inputs(self):
"""Seed input_template_ids based on default_kind. Idempotent —
only adds inputs whose names don't already exist on this template."""
Input = self.env['fp.step.template.input']
for tpl in self:
if not tpl.default_kind:
continue
existing_names = set(tpl.input_template_ids.mapped('name'))
for spec in self.DEFAULT_INPUTS_BY_KIND.get(tpl.default_kind, []):
if spec['name'] in existing_names:
continue
Input.create({
'template_id': tpl.id,
**spec,
})
```
- [ ] **Step 5: Wire the model into `models/__init__.py`**
Read the existing file and add at the bottom:
```python
from . import fp_step_template
```
- [ ] **Step 6: Run the test — model exists but child models don't yet, so 2 of 3 tests fail**
```bash
docker exec odoo-dev-app odoo -d fusion-dev --test-enable \
--test-tags fp_step_template --stop-after-init -u fusion_plating 2>&1 | tail -15
```
Expected: `test_create_minimal` passes; `test_default_kind_seeds_inputs` + `test_seed_default_inputs_idempotent` fail because `fp.step.template.input` doesn't exist yet. That's fine — Task 3 fixes them.
- [ ] **Step 7: Commit**
```bash
git add fusion_plating/models/fp_step_template.py \
fusion_plating/models/__init__.py \
fusion_plating/tests/test_step_template.py \
fusion_plating/tests/__init__.py
git commit -m "feat(sub12a): add fp.step.template model with sane-default kind map
Reusable step library entry. Carries the same shape fields as
fusion.plating.process.node so a drag-drop snapshot is a 1:1 copy.
DEFAULT_INPUTS_BY_KIND drives seeding for the 15 kinds we identified
on Steelhead's job traveller (cleaning, etch, plate, bake, etc.).
The seeding helper (_seed_default_inputs) is idempotent — won't
duplicate inputs on repeated calls.
Tests: test_create_minimal passes. The other two depend on the input
sub-model in the next task.
Co-Authored-By: Claude Opus 4.7 (1M context) <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`:
```python
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
class FpStepTemplateInput(models.Model):
"""Operation measurement definition on a step library template.
Recorded *during* a step (e.g. "Actual Time", "Plating Thickness").
Distinct from transition_input_ids which fire when leaving the
step.
"""
_name = 'fp.step.template.input'
_description = 'Fusion Plating — Step Template Input'
_order = 'sequence, name'
name = fields.Char(string='Name', required=True, translate=True)
template_id = fields.Many2one(
'fp.step.template', string='Template',
required=True, ondelete='cascade', index=True,
)
input_type = fields.Selection([
('text', 'Text'),
('number', 'Number'),
('boolean', 'Yes/No'),
('selection', 'Selection'),
('date', 'Date / Time'),
('signature', 'Signature'),
('time_hms', 'Time (HH:MM:SS)'),
('time_seconds', 'Time (seconds)'),
('temperature', 'Temperature'),
('thickness', 'Thickness'),
('pass_fail', 'Pass / Fail'),
], string='Input Type', required=True, default='text')
target_min = fields.Float(string='Target Min')
target_max = fields.Float(string='Target Max')
target_unit = fields.Char(string='Target Unit',
help='Display unit, e.g. "min", "°F", "A", "FT2", "in".')
required = fields.Boolean(string='Required', default=False,
help='If True, sign-off is hard-blocked while this input is blank.')
hint = fields.Char(string='Hint')
selection_options = fields.Text(string='Selection Options',
help='Comma-separated when input_type is "selection".')
sequence = fields.Integer(string='Sequence', default=10)
```
- [ ] **Step 2: Wire into `models/__init__.py`**
Add after `fp_step_template` import:
```python
from . import fp_step_template_input
```
- [ ] **Step 3: Run the tests**
```bash
docker exec odoo-dev-app odoo -d fusion-dev --test-enable \
--test-tags fp_step_template --stop-after-init -u fusion_plating 2>&1 | tail -15
```
Expected: all three tests in `test_step_template.py` pass.
- [ ] **Step 4: Commit**
```bash
git add fusion_plating/models/fp_step_template_input.py \
fusion_plating/models/__init__.py
git commit -m "feat(sub12a): add fp.step.template.input
Operation-measurement definitions for library step templates. The
input_type selection covers everything Steelhead captures (text,
number, boolean, selection, date, signature, time_hms, time_seconds,
temperature, thickness, pass_fail).
target_min/max + target_unit are structured (not embedded in the name
string the way Steelhead does it) so the traveller report can render
target vs actual side-by-side and colour-code out-of-range values.
Co-Authored-By: Claude Opus 4.7 (1M context) <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):
```python
def test_transition_input_create(self):
"""Transition inputs are linkable to a template."""
tpl = self.env['fp.step.template'].create({'name': 'Bake'})
ti = self.env['fp.step.template.transition.input'].create({
'template_id': tpl.id,
'name': 'Photo Evidence',
'input_type': 'photo',
'required': True,
'compliance_tag': 'as9100',
})
self.assertEqual(ti.template_id, tpl)
self.assertTrue(ti.required)
self.assertEqual(ti.compliance_tag, 'as9100')
```
- [ ] **Step 2: Run the test, expect failure**
```bash
docker exec odoo-dev-app odoo -d fusion-dev --test-enable \
--test-tags fp_step_template --stop-after-init -u fusion_plating 2>&1 | tail -10
```
Expected: KeyError or model-not-found for `fp.step.template.transition.input`.
- [ ] **Step 3: Create the model file**
`fusion_plating/models/fp_step_template_transition_input.py`:
```python
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
class FpStepTemplateTransitionInput(models.Model):
"""Transition-time compliance field definition.
Fires when leaving a step (e.g. "Customer WO #", "Photo Evidence",
"Scrap Reason"). Authored on `fp.step.template`, snapshot-copied
onto `fusion.plating.process.node` when the library step is dragged
into a recipe. Sub 12b uses these to render the Move Parts dialog.
"""
_name = 'fp.step.template.transition.input'
_description = 'Fusion Plating — Step Template Transition Input'
_order = 'sequence, name'
name = fields.Char(string='Name', required=True, translate=True)
template_id = fields.Many2one(
'fp.step.template', string='Template',
required=True, ondelete='cascade', index=True,
)
input_type = fields.Selection([
('text', 'Text'),
('number', 'Number'),
('boolean', 'Yes/No'),
('selection', 'Selection'),
('date', 'Date / Time'),
('signature', 'Signature'),
('photo', 'Photo'),
('location_picker', 'Location Picker'),
('customer_wo', 'Customer WO #'),
], string='Input Type', required=True, default='text')
required = fields.Boolean(string='Required', default=False,
help='If True, the move is hard-blocked while this input is blank.')
hint = fields.Char(string='Hint')
selection_options = fields.Text(string='Selection Options',
help='Comma-separated when input_type is "selection".')
sequence = fields.Integer(string='Sequence', default=10)
compliance_tag = fields.Selection([
('none', 'None'),
('as9100', 'AS9100'),
('nadcap', 'Nadcap'),
('cgp', 'Controlled Goods'),
('nuclear', 'Nuclear'),
], string='Compliance Tag', default='none',
help='Drives audit-report inclusion / filtering.')
```
- [ ] **Step 4: Wire into `models/__init__.py`**
Add:
```python
from . import fp_step_template_transition_input
```
- [ ] **Step 5: Run the test**
```bash
docker exec odoo-dev-app odoo -d fusion-dev --test-enable \
--test-tags fp_step_template --stop-after-init -u fusion_plating 2>&1 | tail -10
```
Expected: all four tests pass.
- [ ] **Step 6: Commit**
```bash
git add fusion_plating/models/fp_step_template_transition_input.py \
fusion_plating/models/__init__.py \
fusion_plating/tests/test_step_template.py
git commit -m "feat(sub12a): add fp.step.template.transition.input
Transition-time prompts (fired when leaving a step). Authored now,
runtime-consumed in Sub 12b's Move Parts dialog. Carries a
compliance_tag selection (none/as9100/nadcap/cgp/nuclear) so audit
reports can filter by regulation regime.
input_type covers Steelhead's transition prompts: text, number,
boolean, selection, date, signature, photo, location_picker,
customer_wo.
Co-Authored-By: Claude Opus 4.7 (1M context) <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`:
```python
def test_recipe_node_new_fields(self):
"""fusion.plating.process.node has the new authoring fields."""
node = self.env['fusion.plating.process.node'].create({
'name': 'Test Recipe',
'node_type': 'recipe',
'is_template': True,
'preferred_editor': 'simple',
})
self.assertTrue(node.is_template)
self.assertEqual(node.preferred_editor, 'simple')
self.assertFalse(node.requires_rack_assignment)
self.assertFalse(node.requires_transition_form)
self.assertEqual(node.time_unit, 'min') # default
self.assertEqual(node.temp_unit, 'F') # default
def test_recipe_node_default_kind(self):
"""default_kind selection is on the node too (mirrors template)."""
step = self.env['fusion.plating.process.node'].create({
'name': 'Soak Clean',
'node_type': 'step',
'default_kind': 'cleaning',
})
self.assertEqual(step.default_kind, 'cleaning')
def test_recipe_node_input_kind(self):
"""fusion.plating.process.node.input has the new `kind` field."""
recipe = self.env['fusion.plating.process.node'].create({
'name': 'Test Recipe', 'node_type': 'recipe',
})
step = self.env['fusion.plating.process.node'].create({
'name': 'Soak Clean', 'node_type': 'step', 'parent_id': recipe.id,
})
ni = self.env['fusion.plating.process.node.input'].create({
'node_id': step.id,
'name': 'Actual Time',
'input_type': 'number',
'kind': 'step_input',
})
self.assertEqual(ni.kind, 'step_input')
```
- [ ] **Step 2: Run the tests, expect failures (fields don't exist yet)**
```bash
docker exec odoo-dev-app odoo -d fusion-dev --test-enable \
--test-tags fp_step_template --stop-after-init -u fusion_plating 2>&1 | tail -15
```
Expected: ValueError or KeyError on `is_template`, `preferred_editor`, `default_kind`, `kind`, `time_unit`, etc.
- [ ] **Step 3: Read the existing model file**
```bash
sed -n '1,80p' fusion_plating/models/fp_process_node.py
```
Note line numbers and existing field placement so the new fields slot into the right sections.
- [ ] **Step 4: Add new fields to `FusionPlatingProcessNode`**
Locate the existing field block (around the `node_type` Selection field) and add these fields somewhere appropriate (typically after existing `parent_id`/`child_ids`, before `_compute_*` methods):
```python
# ===== Sub 12a — Simple Editor + Step Library extensions =================
is_template = fields.Boolean(string='Use as Starter Template',
help='When True (and node_type=recipe), this recipe appears in the '
'Simple Editor\'s "Import starter from template" dropdown.')
source_template_id = fields.Many2one(
'fp.step.template', string='Source Library Template',
ondelete='set null', index=True,
help='Snapshot trace — set when this node was created by dragging '
'a library step in. Editing the template later does not change '
'this node (snapshot semantics).')
tank_ids = fields.Many2many(
'fusion.plating.tank', 'fp_node_tank_rel', 'node_id', 'tank_id',
string='Allowed Stations',
help='Stations the operator may pick at runtime.')
material_callout = fields.Char(string='Material Callout',
help='Short string for traveller "Material" column. Defaults to '
'process type name if blank.')
time_min_target = fields.Float(string='Time Min')
time_max_target = fields.Float(string='Time Max')
time_unit = fields.Selection(
[('sec', 'Seconds'), ('min', 'Minutes'), ('hr', 'Hours')],
string='Time Unit', default='min',
)
temp_min_target = fields.Float(string='Temp Min')
temp_max_target = fields.Float(string='Temp Max')
temp_unit = fields.Selection(
[('F', '°F'), ('C', '°C')],
string='Temp Unit', default='F',
)
voltage_target = fields.Float(string='Voltage Target')
viscosity_target = fields.Float(string='Viscosity Target')
requires_rack_assignment = fields.Boolean(string='Requires Rack Assignment',
help='Sub 12b — triggers Rack Parts sub-dialog at runtime.')
requires_transition_form = fields.Boolean(string='Requires Transition Form',
help='Sub 12b — opens the transition form before Mark Done.')
default_kind = fields.Selection([
('cleaning', 'Cleaning'),
('etch', 'Etch'),
('rinse', 'Rinse'),
('plate', 'Plating'),
('bake', 'Bake'),
('inspect', 'Inspection'),
('racking', 'Racking'),
('derack', 'De-Racking'),
('mask', 'Masking'),
('demask', 'De-Masking'),
('dry', 'Drying'),
('wbf_test', 'Water Break Free Test'),
('final_inspect', 'Final Inspection'),
('ship', 'Shipping'),
('gating', 'Gating'),
], string='Step Kind')
preferred_editor = fields.Selection(
[('tree', 'Tree Editor'),
('simple', 'Simple Editor'),
('auto', 'Use Company Default')],
string='Preferred Editor', default='auto',
help='Which editor opens when this recipe is selected from the '
'menu list. "Auto" follows the company-level default.')
```
- [ ] **Step 5: Add `kind` + target-range fields to `FusionPlatingProcessNodeInput`**
In the same file, locate the input model (`class FusionPlatingProcessNodeInput`) — typically near the bottom. Add fields:
```python
kind = fields.Selection(
[('step_input', 'Step Measurement'),
('transition_input', 'Transition Form Field')],
string='Kind', default='step_input', required=True, index=True,
help='step_input = recorded during the step. transition_input = '
'recorded when leaving the step (Sub 12b uses these in the '
'Move Parts dialog).')
target_min = fields.Float(string='Target Min')
target_max = fields.Float(string='Target Max')
target_unit = fields.Char(string='Target Unit')
compliance_tag = fields.Selection([
('none', 'None'),
('as9100', 'AS9100'),
('nadcap', 'Nadcap'),
('cgp', 'Controlled Goods'),
('nuclear', 'Nuclear'),
], string='Compliance Tag', default='none')
```
Also extend the `input_type` Selection options to include the typed inputs the simple editor needs. Find the existing `input_type` field; append these to the selection list:
```python
('time_hms', 'Time (HH:MM:SS)'),
('time_seconds', 'Time (seconds)'),
('temperature', 'Temperature'),
('thickness', 'Thickness'),
('pass_fail', 'Pass / Fail'),
('photo', 'Photo'),
('location_picker', 'Location Picker'),
('customer_wo', 'Customer WO #'),
```
(Do NOT remove existing values — that breaks ORM on existing rows.)
- [ ] **Step 6: Run the tests**
```bash
docker exec odoo-dev-app odoo -d fusion-dev --test-enable \
--test-tags fp_step_template --stop-after-init -u fusion_plating 2>&1 | tail -15
```
Expected: all 6 tests pass.
- [ ] **Step 7: Commit**
```bash
git add fusion_plating/models/fp_process_node.py \
fusion_plating/tests/test_step_template.py
git commit -m "feat(sub12a): extend process.node with simple-editor authoring fields
Additive only: is_template, source_template_id, tank_ids,
material_callout, time/temp targets + units, voltage, viscosity,
rack/transition flags, default_kind, preferred_editor.
process.node.input gets kind (step_input vs transition_input,
default step_input so existing rows keep working), target_min/max,
target_unit, compliance_tag, plus 8 new typed input_type values.
Tree editor, runtime, S14/S15/S17/S18/S19 battle tests all unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <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`:
```python
def test_company_default_recipe_editor(self):
"""Company carries a fp_default_recipe_editor field."""
company = self.env.company
self.assertIn(company.fp_default_recipe_editor, ('tree', 'simple'))
company.fp_default_recipe_editor = 'simple'
self.assertEqual(company.fp_default_recipe_editor, 'simple')
```
- [ ] **Step 2: Run, expect failure**
```bash
docker exec odoo-dev-app odoo -d fusion-dev --test-enable \
--test-tags fp_step_template --stop-after-init -u fusion_plating 2>&1 | tail -10
```
Expected: AttributeError on `fp_default_recipe_editor`.
- [ ] **Step 3: Create the settings model**
`fusion_plating/models/res_config_settings.py`:
```python
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
class ResCompany(models.Model):
_inherit = 'res.company'
fp_default_recipe_editor = fields.Selection(
[('tree', 'Tree Editor'), ('simple', 'Simple Editor')],
string='Default Recipe Editor',
default='tree',
help='Which editor opens when a new recipe is created. Per-recipe '
'preferred_editor overrides this when set to tree or simple.',
)
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
fp_default_recipe_editor = fields.Selection(
related='company_id.fp_default_recipe_editor',
readonly=False,
)
```
- [ ] **Step 4: Wire into `models/__init__.py`**
Add at the end:
```python
from . import res_config_settings
```
- [ ] **Step 5: Create the settings view file**
`fusion_plating/views/res_config_settings_views.xml`:
```xml
<?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**
```bash
docker exec odoo-dev-app odoo -d fusion-dev --test-enable \
--test-tags fp_step_template --stop-after-init -u fusion_plating 2>&1 | tail -10
```
Expected: all 7 tests pass.
- [ ] **Step 7: Commit**
```bash
git add fusion_plating/models/res_config_settings.py \
fusion_plating/models/__init__.py \
fusion_plating/views/res_config_settings_views.xml \
fusion_plating/tests/test_step_template.py
git commit -m "feat(sub12a): res.company.fp_default_recipe_editor setting
Per-company default for which editor opens when creating a new
recipe. Defaults to 'tree' to preserve existing behavior. Surfaces in
Settings UI alongside other Fusion Plating prefs.
Co-Authored-By: Claude Opus 4.7 (1M context) <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**
```bash
head -5 fusion_plating/security/ir.model.access.csv
```
- [ ] **Step 2: Append new ACL rows**
Append these rows to `fusion_plating/security/ir.model.access.csv` (preserve trailing newline):
```csv
access_fp_step_template_user,fp.step.template user,model_fp_step_template,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_step_template_supervisor,fp.step.template supervisor,model_fp_step_template,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
access_fp_step_template_input_user,fp.step.template.input user,model_fp_step_template_input,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_step_template_input_supervisor,fp.step.template.input supervisor,model_fp_step_template_input,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
access_fp_step_template_transition_input_user,fp.step.template.transition.input user,model_fp_step_template_transition_input,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_step_template_transition_input_supervisor,fp.step.template.transition.input supervisor,model_fp_step_template_transition_input,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
```
- [ ] **Step 3: Verify CSV parses by reloading the module**
```bash
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating --stop-after-init 2>&1 | tail -5
```
Expected: clean reload, no CSV-parse errors.
- [ ] **Step 4: Commit**
```bash
git add fusion_plating/security/ir.model.access.csv
git commit -m "feat(sub12a): ACL rows for fp.step.template + 2 child models
Operator: read only on library + inputs.
Supervisor: full CRUD.
Manager rights inherit from supervisor (existing privilege chain).
Co-Authored-By: Claude Opus 4.7 (1M context) <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
<?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**
```bash
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating --stop-after-init 2>&1 | tail -5
```
Expected: clean reload, no errors.
- [ ] **Step 3: Smoke-test in browser** (manual)
Open `http://localhost:8069`, log in, navigate via developer mode to the action `action_fp_step_template` (or wait until Task 9 creates the menu). Verify the list opens, the form opens, the Operation Measurements + Transition Form notebook tabs render.
- [ ] **Step 4: Commit**
```bash
git add fusion_plating/views/fp_step_template_views.xml
git commit -m "feat(sub12a): step library list/form/search views
Form: Title + Code + Classification (kind/icon/process/material) +
Stations & Flags + Instructions + Operation Measurements (one2many
list) + Transition Form (one2many list) + Advanced (time/temp targets,
voltage, viscosity).
Header button: 'Seed Default Inputs' (visible only when default_kind
is set). Triggers the idempotent seeding helper.
Co-Authored-By: Claude Opus 4.7 (1M context) <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**
```bash
grep -n "menu_fp_configuration\|menu_fp_root" fusion_plating/views/fp_menu.xml | head -10
```
- [ ] **Step 2: Add the menu item**
In `fusion_plating/views/fp_menu.xml`, locate the existing Configuration menu (parent `menu_fp_configuration` or similar — check what's there). Add a new `<menuitem>` under it:
```xml
<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:
```xml
<menuitem id="menu_fp_step_library"
name="Step Library"
parent="menu_fp_root"
action="action_fp_step_template"
sequence="92"/>
```
- [ ] **Step 3: Reload module**
```bash
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating --stop-after-init 2>&1 | tail -5
```
- [ ] **Step 4: Browser smoke-test** (manual)
Plating → Configuration → Step Library — confirm the menu appears and opens the list view.
- [ ] **Step 5: Commit**
```bash
git add fusion_plating/views/fp_menu.xml
git commit -m "feat(sub12a): Plating → Configuration → Step Library menu
Co-Authored-By: Claude Opus 4.7 (1M context) <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`:
```python
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo.tests.common import TransactionCase, tagged
from odoo.addons.fusion_plating.hooks import post_init_hook
@tagged('-at_install', 'post_install', 'fusion_plating', 'fp_post_init')
class TestPostInitHook(TransactionCase):
def test_backfill_kind_on_existing_inputs(self):
"""Existing process.node.input rows get kind='step_input' on backfill."""
recipe = self.env['fusion.plating.process.node'].create({
'name': 'TestRecipe', 'node_type': 'recipe',
})
step = self.env['fusion.plating.process.node'].create({
'name': 'Soak', 'node_type': 'step', 'parent_id': recipe.id,
})
ni = self.env['fusion.plating.process.node.input'].create({
'node_id': step.id, 'name': 'Test', 'input_type': 'text',
})
# Manually clear `kind` to simulate a pre-upgrade row
ni.flush_recordset()
self.env.cr.execute(
"UPDATE fusion_plating_process_node_input SET kind=NULL WHERE id=%s",
(ni.id,))
self.env.invalidate_all()
self.assertFalse(ni.kind)
post_init_hook(self.env)
ni.invalidate_recordset()
self.assertEqual(ni.kind, 'step_input')
def test_seed_library_idempotent(self):
"""Running post_init_hook twice does not create duplicate library rows."""
post_init_hook(self.env)
first_count = self.env['fp.step.template'].search_count([])
post_init_hook(self.env)
second_count = self.env['fp.step.template'].search_count([])
self.assertEqual(first_count, second_count)
```
Add to `tests/__init__.py`:
```python
from . import test_post_init_hook
```
- [ ] **Step 2: Create the hook module**
`fusion_plating/hooks.py`:
```python
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""Post-install hooks for fusion_plating.
Currently does two things on first install / upgrade:
1. Backfills `kind='step_input'` on all existing
fusion.plating.process.node.input rows that were created before the
`kind` field existed.
2. Seeds fp.step.template with starter library entries derived from
the existing ENP-ALUM-BASIC recipe, IF no library entries already
exist.
Both operations are idempotent.
"""
import logging
_logger = logging.getLogger(__name__)
STARTER_KIND_BY_NAME = {
'soak clean': 'cleaning',
'electroclean': 'cleaning',
'solvent clean': 'cleaning',
'rinse': 'rinse',
'primary rinse': 'rinse',
'secondary rinse': 'rinse',
'hot rinse': 'rinse',
'final rinse': 'rinse',
'etch': 'etch',
'desmut': 'etch',
'zincate': 'etch',
'strip zincate': 'etch',
'acid dip': 'etch',
'water break test': 'wbf_test',
'issue panels': 'mask',
'racking': 'racking',
'rack': 'racking',
'e-nickel plate': 'plate',
'electroless nickel plating': 'plate',
'drying': 'dry',
'dry': 'dry',
'de-rack': 'derack',
'de-racking': 'derack',
'inspection': 'inspect',
'final inspection': 'final_inspect',
'shipping': 'ship',
}
def post_init_hook(env):
_backfill_node_input_kind(env)
_seed_library_if_empty(env)
def _backfill_node_input_kind(env):
cr = env.cr
cr.execute(
"UPDATE fusion_plating_process_node_input "
"SET kind='step_input' WHERE kind IS NULL"
)
if cr.rowcount:
_logger.info(
"fusion_plating: backfilled kind='step_input' on %s existing "
"process.node.input rows", cr.rowcount)
def _seed_library_if_empty(env):
Tpl = env['fp.step.template']
if Tpl.search_count([]):
_logger.info("fusion_plating: step library already populated, skip seed")
return
Node = env['fusion.plating.process.node']
src = Node.search([
('node_type', '=', 'recipe'),
'|', ('code', '=', 'ENP-ALUM-BASIC'),
('name', 'ilike', 'ENP-ALUM-BASIC'),
], limit=1)
if not src:
_seed_minimal_library(env)
return
seen_names = set()
for child in src.child_ids:
if child.node_type == 'step':
_create_template_from_node(env, child, seen_names)
else:
for grandchild in child.child_ids:
_create_template_from_node(env, grandchild, seen_names)
_logger.info(
"fusion_plating: seeded step library with %s entries from %s",
len(seen_names), src.name)
def _create_template_from_node(env, node, seen_names):
if not node.name or node.name.lower() in seen_names:
return
seen_names.add(node.name.lower())
kind = STARTER_KIND_BY_NAME.get(node.name.lower())
tpl = env['fp.step.template'].create({
'name': node.name,
'description': node.description or False,
'icon': node.icon or 'fa-cog',
'process_type_id': node.process_type_id.id,
'tank_ids': [(6, 0, node.tank_ids.ids)] if node.tank_ids else False,
'time_min_target': node.time_min_target,
'time_max_target': node.time_max_target,
'time_unit': node.time_unit or 'min',
'temp_min_target': node.temp_min_target,
'temp_max_target': node.temp_max_target,
'temp_unit': node.temp_unit or 'F',
'requires_signoff': node.requires_signoff,
'requires_predecessor_done': node.requires_predecessor_done,
'default_kind': kind,
})
if kind:
tpl._seed_default_inputs()
def _seed_minimal_library(env):
"""Fallback when ENP-ALUM-BASIC recipe doesn't exist on the target DB."""
Tpl = env['fp.step.template']
minimal = [
('Soak Clean', 'cleaning'),
('Electroclean', 'cleaning'),
('Rinse', 'rinse'),
('Etch', 'etch'),
('Desmut', 'etch'),
('Zincate', 'etch'),
('Acid Dip', 'etch'),
('Water Break Test', 'wbf_test'),
('Racking', 'racking'),
('De-Racking', 'derack'),
('E-Nickel Plate', 'plate'),
('Drying', 'dry'),
('Inspection', 'inspect'),
('Final Inspection', 'final_inspect'),
('Shipping', 'ship'),
]
for name, kind in minimal:
tpl = Tpl.create({'name': name, 'default_kind': kind})
tpl._seed_default_inputs()
_logger.info("fusion_plating: seeded minimal library (%s entries)", len(minimal))
```
- [ ] **Step 3: Run the post-init test**
```bash
docker exec odoo-dev-app odoo -d fusion-dev --test-enable \
--test-tags fp_post_init --stop-after-init -u fusion_plating 2>&1 | tail -15
```
Expected: both tests pass.
- [ ] **Step 4: Commit**
```bash
git add fusion_plating/hooks.py \
fusion_plating/tests/test_post_init_hook.py \
fusion_plating/tests/__init__.py
git commit -m "feat(sub12a): post_init_hook — backfill input kind + seed library
Two idempotent operations on first install/upgrade:
1. Backfill kind='step_input' on existing process.node.input rows.
2. Seed fp.step.template from the ENP-ALUM-BASIC recipe's child nodes
(with name->kind mapping). Falls back to a minimal hard-coded list
if the recipe doesn't exist on the target DB.
Both operations no-op when re-run.
Co-Authored-By: Claude Opus 4.7 (1M context) <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`:
```python
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from unittest.mock import patch
from odoo.tests.common import TransactionCase, tagged
from odoo.addons.fusion_plating.controllers.simple_recipe_controller import (
SimpleRecipeController,
)
@tagged('-at_install', 'post_install', 'fusion_plating', 'fp_simple_ctrl')
class TestSimpleRecipeController(TransactionCase):
def setUp(self):
super().setUp()
self.ctrl = SimpleRecipeController()
self.recipe = self.env['fusion.plating.process.node'].create({
'name': 'Test Recipe', 'node_type': 'recipe',
})
# Create three steps with sequence
for i, name in enumerate(['Soak', 'Rinse', 'Plate'], start=1):
self.env['fusion.plating.process.node'].create({
'name': name,
'node_type': 'step',
'parent_id': self.recipe.id,
'sequence': i * 10,
})
def _with_request_env(self):
"""Patch http.request.env for controller calls."""
from odoo import http
return patch.object(http, 'request', type('R', (), {'env': self.env})())
def test_load_returns_recipe_header_and_steps(self):
with self._with_request_env():
result = self.ctrl.load(self.recipe.id)
self.assertEqual(result['recipe']['id'], self.recipe.id)
self.assertEqual(result['recipe']['name'], 'Test Recipe')
self.assertEqual(len(result['steps']), 3)
self.assertEqual(result['steps'][0]['name'], 'Soak')
def test_library_list_returns_templates(self):
self.env['fp.step.template'].create({'name': 'Soak Clean'})
self.env['fp.step.template'].create({'name': 'Acid Dip'})
with self._with_request_env():
result = self.ctrl.library_list(query='')
self.assertGreaterEqual(len(result['templates']), 2)
names = [t['name'] for t in result['templates']]
self.assertIn('Soak Clean', names)
def test_library_list_filters_by_query(self):
self.env['fp.step.template'].create({'name': 'Soak Clean'})
self.env['fp.step.template'].create({'name': 'Acid Dip'})
with self._with_request_env():
result = self.ctrl.library_list(query='soak')
names = [t['name'] for t in result['templates']]
self.assertIn('Soak Clean', names)
self.assertNotIn('Acid Dip', names)
def test_step_insert_from_library_snapshots_fields(self):
tpl = self.env['fp.step.template'].create({
'name': 'Soak Clean',
'description': '<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`:
```python
from . import test_simple_recipe_controller
```
- [ ] **Step 2: Create the controller file**
`fusion_plating/controllers/simple_recipe_controller.py`:
```python
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""JSONRPC endpoints for the Simple Recipe Editor.
All endpoints expect the user to be authenticated. Permissions are
enforced by the underlying ACL on fp.step.template + process.node:
operators get read, supervisors+ get write.
"""
from odoo import http
from odoo.http import request
# Field list copied from a library template into a new recipe step on
# drag-drop. Snapshot semantics (Q4 from the design doc).
_SNAPSHOT_FIELDS = [
'name', 'code', 'description', 'icon', 'process_type_id',
'material_callout', 'time_min_target', 'time_max_target', 'time_unit',
'temp_min_target', 'temp_max_target', 'temp_unit', 'voltage_target',
'viscosity_target', 'requires_signoff', 'requires_predecessor_done',
'requires_rack_assignment', 'requires_transition_form', 'default_kind',
]
# Fields on fp.step.template.input that copy 1:1 into
# fusion.plating.process.node.input on snapshot
_INPUT_SNAPSHOT_FIELDS = [
'name', 'input_type', 'target_min', 'target_max', 'target_unit',
'required', 'hint', 'selection_options', 'sequence',
]
class SimpleRecipeController(http.Controller):
# ---------------------------------------------------------------- load
@http.route('/fp/simple_recipe/load', type='jsonrpc', auth='user')
def load(self, recipe_id):
recipe = request.env['fusion.plating.process.node'].browse(recipe_id)
recipe.check_access_rights('read')
recipe.check_access_rule('read')
steps = recipe.child_ids.sorted('sequence')
return {
'recipe': self._recipe_payload(recipe),
'steps': [self._step_payload(s) for s in steps],
}
def _recipe_payload(self, recipe):
return {
'id': recipe.id,
'name': recipe.name,
'code': recipe.code,
'is_template': recipe.is_template,
'preferred_editor': recipe.preferred_editor,
'process_type_id': (
[recipe.process_type_id.id, recipe.process_type_id.name]
if recipe.process_type_id else False
),
}
def _step_payload(self, step):
return {
'id': step.id,
'name': step.name,
'sequence': step.sequence,
'icon': step.icon,
'default_kind': step.default_kind,
'requires_signoff': step.requires_signoff,
'requires_rack_assignment': step.requires_rack_assignment,
'requires_transition_form': step.requires_transition_form,
'tank_ids': [
{'id': t.id, 'name': t.name, 'code': t.code}
for t in step.tank_ids
],
'tank_id': step.work_center_id.id if step.work_center_id else False,
'source_template_id': step.source_template_id.id or False,
}
# ------------------------------------------------------------ library
@http.route('/fp/simple_recipe/library/list', type='jsonrpc', auth='user')
def library_list(self, query='', limit=200):
Tpl = request.env['fp.step.template']
domain = [('active', '=', True)]
if query:
domain += ['|', '|',
('name', 'ilike', query),
('code', 'ilike', query),
('description', 'ilike', query)]
records = Tpl.search(domain, limit=limit)
return {
'templates': [
{
'id': t.id,
'name': t.name,
'code': t.code,
'icon': t.icon,
'default_kind': t.default_kind,
'station_count': len(t.tank_ids),
}
for t in records
],
}
@http.route('/fp/simple_recipe/library/create', type='jsonrpc', auth='user')
def library_create(self, vals):
tpl = request.env['fp.step.template'].create(vals)
return {'id': tpl.id, 'name': tpl.name}
@http.route('/fp/simple_recipe/library/write', type='jsonrpc', auth='user')
def library_write(self, template_id, vals):
tpl = request.env['fp.step.template'].browse(template_id)
tpl.write(vals)
return {'ok': True}
@http.route('/fp/simple_recipe/library/delete', type='jsonrpc', auth='user')
def library_delete(self, template_id):
tpl = request.env['fp.step.template'].browse(template_id)
Node = request.env['fusion.plating.process.node']
used_count = Node.search_count([('source_template_id', '=', template_id)])
if used_count:
tpl.write({'active': False})
return {'ok': True, 'soft_deleted': True, 'used_in': used_count}
tpl.unlink()
return {'ok': True, 'soft_deleted': False}
# --------------------------------------------------------------- step
@http.route('/fp/simple_recipe/step/insert', type='jsonrpc', auth='user')
def step_insert(self, recipe_id, template_id=False, position=99, vals=None):
recipe = request.env['fusion.plating.process.node'].browse(recipe_id)
target_seq = self._sequence_for_position(recipe, position)
new_vals = {
'parent_id': recipe.id,
'node_type': 'step',
'sequence': target_seq,
}
tpl = False
if template_id:
tpl = request.env['fp.step.template'].browse(template_id)
for f in _SNAPSHOT_FIELDS:
if f == 'process_type_id':
new_vals[f] = tpl.process_type_id.id or False
else:
new_vals[f] = tpl[f]
if tpl.tank_ids:
new_vals['tank_ids'] = [(6, 0, tpl.tank_ids.ids)]
new_vals['source_template_id'] = tpl.id
if vals:
new_vals.update(vals)
new_node = request.env['fusion.plating.process.node'].create(new_vals)
if tpl:
self._copy_inputs_from_template(tpl, new_node)
return {'id': new_node.id, 'sequence': new_node.sequence}
def _sequence_for_position(self, recipe, position):
siblings = recipe.child_ids.sorted('sequence')
if not siblings or position >= len(siblings):
return (siblings[-1].sequence + 10) if siblings else 10
if position <= 0:
return max(1, siblings[0].sequence - 10)
before = siblings[position - 1].sequence
after = siblings[position].sequence
return (before + after) // 2 if (after - before) > 1 else before + 1
def _copy_inputs_from_template(self, tpl, new_node):
NodeInput = request.env['fusion.plating.process.node.input']
for ti in tpl.input_template_ids:
payload = {f: ti[f] for f in _INPUT_SNAPSHOT_FIELDS}
payload['node_id'] = new_node.id
payload['kind'] = 'step_input'
NodeInput.create(payload)
for tt in tpl.transition_input_ids:
payload = {
'node_id': new_node.id,
'name': tt.name,
'input_type': tt.input_type,
'required': tt.required,
'hint': tt.hint,
'selection_options': tt.selection_options,
'sequence': tt.sequence,
'compliance_tag': tt.compliance_tag,
'kind': 'transition_input',
}
NodeInput.create(payload)
@http.route('/fp/simple_recipe/step/write', type='jsonrpc', auth='user')
def step_write(self, node_id, vals):
node = request.env['fusion.plating.process.node'].browse(node_id)
node.write(vals)
return {'ok': True}
@http.route('/fp/simple_recipe/step/remove', type='jsonrpc', auth='user')
def step_remove(self, node_id):
node = request.env['fusion.plating.process.node'].browse(node_id)
node.unlink()
return {'ok': True}
@http.route('/fp/simple_recipe/step/reorder', type='jsonrpc', auth='user')
def step_reorder(self, node_ids):
Node = request.env['fusion.plating.process.node']
for i, nid in enumerate(node_ids, start=1):
Node.browse(nid).write({'sequence': i * 10})
return {'ok': True}
# ----------------------------------------------------------- template
@http.route('/fp/simple_recipe/template/list', type='jsonrpc', auth='user')
def template_list(self):
Node = request.env['fusion.plating.process.node']
recipes = Node.search([
('node_type', '=', 'recipe'),
('is_template', '=', True),
('active', '=', True),
], order='name')
return {
'templates': [
{'id': r.id, 'name': r.name, 'code': r.code,
'step_count': len(r.child_ids)}
for r in recipes
],
}
@http.route('/fp/simple_recipe/template/import', type='jsonrpc', auth='user')
def template_import(self, source_recipe_id, target_recipe_id):
Node = request.env['fusion.plating.process.node']
source = Node.browse(source_recipe_id)
target = Node.browse(target_recipe_id)
imported = 0
for child in source.child_ids.sorted('sequence'):
self._snapshot_step_into(child, target)
imported += 1
return {'ok': True, 'imported_count': imported}
def _snapshot_step_into(self, src_node, target_recipe):
Node = request.env['fusion.plating.process.node']
new_vals = {
'parent_id': target_recipe.id,
'node_type': 'step',
'sequence': src_node.sequence,
'source_template_id': src_node.source_template_id.id or False,
}
for f in _SNAPSHOT_FIELDS:
if f == 'process_type_id':
new_vals[f] = src_node.process_type_id.id or False
else:
new_vals[f] = src_node[f]
if src_node.tank_ids:
new_vals['tank_ids'] = [(6, 0, src_node.tank_ids.ids)]
new_node = Node.create(new_vals)
NodeInput = request.env['fusion.plating.process.node.input']
for src_in in src_node.input_ids:
payload = {
'node_id': new_node.id,
'name': src_in.name,
'input_type': src_in.input_type,
'required': src_in.required,
'hint': src_in.hint,
'selection_options': src_in.selection_options,
'sequence': src_in.sequence,
'kind': src_in.kind or 'step_input',
'target_min': src_in.target_min,
'target_max': src_in.target_max,
'target_unit': src_in.target_unit,
'compliance_tag': src_in.compliance_tag,
}
NodeInput.create(payload)
```
> Note: the existing One2many on `fusion.plating.process.node` that points at `fusion.plating.process.node.input` is named `input_ids` in the existing codebase (verified during exploration). If when implementing you find it named differently, adjust the inverse name in `_snapshot_step_into` and rerun the relevant test.
- [ ] **Step 3: Wire into `controllers/__init__.py`**
```python
from . import simple_recipe_controller
```
- [ ] **Step 4: Run tests**
```bash
docker exec odoo-dev-app odoo -d fusion-dev --test-enable \
--test-tags fp_simple_ctrl --stop-after-init -u fusion_plating 2>&1 | tail -20
```
Expected: all 7 tests pass.
- [ ] **Step 5: Commit**
```bash
git add fusion_plating/controllers/simple_recipe_controller.py \
fusion_plating/controllers/__init__.py \
fusion_plating/tests/test_simple_recipe_controller.py \
fusion_plating/tests/__init__.py
git commit -m "feat(sub12a): JSONRPC endpoints for the Simple Recipe Editor
11 routes:
/fp/simple_recipe/load
/fp/simple_recipe/library/{list,create,write,delete}
/fp/simple_recipe/step/{insert,write,remove,reorder}
/fp/simple_recipe/template/{list,import}
Library/template imports snapshot-copy fields (Q4 = A locked) — no
live references. Tests cover library list+filter, step insert from
library, step insert blank, step reorder, template import.
Co-Authored-By: Claude Opus 4.7 (1M context) <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**
```bash
grep -n "node_type\|view_fp_process_node_form" fusion_plating/views/fp_process_node_views.xml | head -10
```
Identify where to slot in the new buttons + fields.
- [ ] **Step 2: Add header buttons**
In the recipe form's `<header>` block, add:
```xml
<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:
```xml
<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):
```xml
<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:
```python
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)
```bash
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**
```bash
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`:
```javascript
/** @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
<?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`:
```scss
// Simple Recipe Editor — flat drag-drop alternative to the tree editor.
// Tokens follow the existing fp_shopfloor pattern (CSS custom props with
// hex fallbacks; dark-mode aware via $o-webclient-color-scheme).
$o-webclient-color-scheme: bright !default;
$_fp_page_hex: #f3f4f6;
$_fp_card_hex: #ffffff;
$_fp_border_hex: #d8dadd;
$_fp_accent_hex: #2e7d6b;
$_fp_muted_hex: #6b7280;
$_fp_drop_hex: #e8f5f0;
@if $o-webclient-color-scheme == dark {
$_fp_page_hex: #1a1d21 !global;
$_fp_card_hex: #22262d !global;
$_fp_border_hex: #3a3f47 !global;
$_fp_drop_hex: #1f3a33 !global;
}
$fp-page: var(--fp-page-bg, #{$_fp_page_hex});
$fp-card: var(--fp-card-bg, #{$_fp_card_hex});
$fp-border: var(--fp-border-color, #{$_fp_border_hex});
$fp-accent: var(--fp-accent, #{$_fp_accent_hex});
$fp-muted: var(--fp-muted, #{$_fp_muted_hex});
$fp-drop: var(--fp-drop-bg, #{$_fp_drop_hex});
.o_fp_simple_editor {
background: $fp-page;
height: 100%;
overflow: auto;
padding: 1rem;
&_header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
h2 {
margin: 0;
flex: 1;
}
.o_fp_simple_editor_actions {
display: flex;
gap: .5rem;
}
}
&_meta {
background: $fp-card;
border: 1px solid $fp-border;
border-radius: 4px;
padding: 1rem;
margin-bottom: 1rem;
.o_fp_import_row {
display: flex;
align-items: center;
gap: .75rem;
label { font-weight: 500; margin: 0; }
select { flex: 1; }
}
}
&_body {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 1rem;
@media (max-width: 900px) {
grid-template-columns: 1fr;
}
}
}
.o_fp_selected_panel,
.o_fp_library_panel {
background: $fp-card;
border: 1px solid $fp-border;
border-radius: 4px;
padding: 1rem;
h3 {
margin: 0 0 .75rem 0;
font-size: 1rem;
color: $fp-accent;
}
}
.o_fp_step_row {
display: flex;
align-items: center;
gap: .5rem;
padding: .5rem;
border: 1px solid $fp-border;
border-radius: 4px;
margin-bottom: .25rem;
background: $fp-card;
cursor: grab;
&.o_fp_drag_over {
background: $fp-drop;
border-color: $fp-accent;
}
.o_fp_drag_handle { color: $fp-muted; cursor: grab; }
.o_fp_step_position { font-weight: 600; min-width: 1.5rem; }
.o_fp_step_name { flex: 1; }
.o_fp_station_badge {
font-size: .75rem;
color: $fp-muted;
background: $fp-page;
padding: .125rem .5rem;
border-radius: 999px;
}
.o_fp_step_remove {
background: none;
border: none;
color: $fp-muted;
font-size: 1.25rem;
cursor: pointer;
opacity: 0;
transition: opacity .1s;
}
&:hover .o_fp_step_remove { opacity: 1; }
}
.o_fp_step_dropzone {
border: 2px dashed $fp-border;
border-radius: 4px;
padding: 1rem;
text-align: center;
color: $fp-muted;
margin-top: .5rem;
&:hover { border-color: $fp-accent; background: $fp-drop; }
}
.o_fp_library_list {
margin-top: .5rem;
max-height: 65vh;
overflow: auto;
}
.o_fp_library_item {
display: flex;
align-items: center;
gap: .5rem;
padding: .5rem;
border: 1px solid $fp-border;
border-radius: 4px;
margin-bottom: .25rem;
background: $fp-card;
cursor: grab;
user-select: none;
.o_fp_library_name { flex: 1; }
.o_fp_library_meta {
font-size: .75rem;
color: $fp-muted;
}
&:hover {
border-color: $fp-accent;
}
}
.o_fp_loading {
padding: 2rem;
text-align: center;
color: $fp-muted;
}
```
- [ ] **Step 4: Reload the module to pick up new assets + clear asset cache**
```bash
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating --stop-after-init 2>&1 | tail -5
docker exec odoo-dev-db psql -U odoo -d fusion-dev -c "DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';"
```
- [ ] **Step 5: Browser smoke-test** (manual)
- Open a recipe → click **Open in Simple Editor** header button → editor renders.
- Library list shows seeded entries.
- Drag a library entry into Selected → step appears, sequence assigned.
- Drag-reorder a step → sequence updates.
- Click × on a step → confirm dialog → step removes.
- Click **Open in Tree Editor** → switches to tree editor on the same recipe.
- Build a recipe, mark `is_template=True`, build a new empty recipe, "Import starter from template" → all steps copy in.
- [ ] **Step 6: Commit**
```bash
git add fusion_plating/static/src/js/simple_recipe_editor.js \
fusion_plating/static/src/xml/simple_recipe_editor.xml \
fusion_plating/static/src/scss/simple_recipe_editor.scss
git commit -m "feat(sub12a): OWL Simple Recipe Editor client action
Single-file root component (FpSimpleRecipeEditor) with HTML5 drag-drop
between Library (right) and Selected (left) panels. Library search,
import-from-starter dropdown, inline-step add, per-row remove with
confirm.
SCSS uses the fp-token pattern with dark-mode SCSS @if branch (matches
fp_shopfloor and follows the Odoo-19 dark-mode rule from CLAUDE.md).
Tag: fp_simple_recipe_editor — registered via registry.category('actions').
Co-Authored-By: Claude Opus 4.7 (1M context) <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`:
```python
def _resolve_preferred_editor(self):
"""Returns 'tree' or 'simple' for this recipe.
Per-recipe `preferred_editor` wins. 'auto' falls back to the
company-level default."""
self.ensure_one()
if self.preferred_editor in ('tree', 'simple'):
return self.preferred_editor
return self.env.company.fp_default_recipe_editor or 'tree'
def action_open_recipe_with_preferred_editor(self):
"""Used by menu actions / context-menu opens — routes to whichever
editor the recipe (or company) prefers."""
self.ensure_one()
if self._resolve_preferred_editor() == 'simple':
return self.action_open_in_simple_editor()
return self.action_open_in_tree_editor()
```
- [ ] **Step 2: Add tests**
Append to `tests/test_step_template.py`:
```python
def test_resolve_preferred_editor_per_recipe(self):
recipe = self.env['fusion.plating.process.node'].create({
'name': 'R', 'node_type': 'recipe',
'preferred_editor': 'simple',
})
self.assertEqual(recipe._resolve_preferred_editor(), 'simple')
def test_resolve_preferred_editor_falls_back_to_company(self):
self.env.company.fp_default_recipe_editor = 'simple'
recipe = self.env['fusion.plating.process.node'].create({
'name': 'R', 'node_type': 'recipe',
'preferred_editor': 'auto',
})
self.assertEqual(recipe._resolve_preferred_editor(), 'simple')
self.env.company.fp_default_recipe_editor = 'tree'
self.assertEqual(recipe._resolve_preferred_editor(), 'tree')
```
- [ ] **Step 3: Run all tests**
```bash
docker exec odoo-dev-app odoo -d fusion-dev --test-enable \
--test-tags fusion_plating --stop-after-init -u fusion_plating 2>&1 | tail -20
```
Expected: all tests pass.
- [ ] **Step 4: Commit**
```bash
git add fusion_plating/models/fp_process_node.py \
fusion_plating/tests/test_step_template.py
git commit -m "feat(sub12a): preferred_editor resolver
Per-recipe preferred_editor wins; 'auto' falls back to company-level
fp_default_recipe_editor; falls back to 'tree' as the final default
(preserves existing behavior).
Co-Authored-By: Claude Opus 4.7 (1M context) <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**
```bash
for f in $(find fusion_plating -type f \
\( -name "*.py" -o -name "*.xml" -o -name "*.csv" \
-o -name "*.scss" -o -name "*.js" \) | grep -v __pycache__); do
cat "$f" | ssh pve-worker5 "pct exec 111 -- bash -c \
'mkdir -p \$(dirname /mnt/extra-addons/custom/$f) && cat > /mnt/extra-addons/custom/$f'"
done
```
- [ ] **Step 2: Update the module on entech**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && \
su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin \
-u fusion_plating --stop-after-init\" 2>&1 | tail -15 && \
systemctl start odoo'"
```
Expected: clean upgrade, no errors. `post_init_hook` runs, seeds the library.
- [ ] **Step 3: Clear asset cache**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \
\"sudo -u postgres psql admin -c \\\"DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';\\\"\""
```
- [ ] **Step 4: Manual smoke test in the entech browser**
Connect to entech. Log in as admin. Verify:
1. Plating → Configuration → **Step Library** menu appears.
2. Click it → list shows ≥15 seeded library entries (Soak Clean, Rinse, Etch, etc.).
3. Open any → Form shows: classification, stations + flags, instructions tab, operation measurements tab, transition form tab, advanced tab.
4. Open the existing **ENP-ALUM-BASIC** recipe → header shows both editor buttons.
5. Click **Open in Tree Editor** → existing tree editor renders, all steps present (regression check).
6. Back to recipe form → click **Open in Simple Editor** → simple editor renders, library on right, steps on left in correct sequence.
7. Drag "Acid Dip" from library → drop into Selected → new step appears in the recipe.
8. Drag-reorder a step → sequence updates.
9. Mark recipe as `is_template=True`, save. Build a new empty recipe → Open Simple Editor → "Import starter from template" → pick ENP-ALUM-BASIC → all steps copy in.
10. Edit ENP-ALUM-BASIC's "Acid Dip" name to "Acid Dip 2" in the library → confirm previously-imported recipes' Acid Dip stays "Acid Dip" (snapshot decoupling).
11. Settings → Fusion Plating → Default Recipe Editor → flip to "Simple" → save → re-open a recipe → confirm `preferred_editor='auto'` resolves to simple now.
12. Run a battle test on entech (existing one — pick `bt_s2` or similar) → confirm runtime still works on a new job created from a Simple-Editor recipe.
- [ ] **Step 5: Document the entech deployment in CLAUDE.md**
Update [CLAUDE.md](../../CLAUDE.md) Sub-Project Roadmap table — mark Sub 12a as **Shipped 2026-MM-DD** with the merge commit SHA.
- [ ] **Step 6: Commit the doc update**
```bash
git add CLAUDE.md
git commit -m "docs(sub12a): mark Sub 12a as shipped on entech
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
- [ ] **Step 7: Push to remote**
```bash
git push origin main
```
---
## Self-Review
### Spec coverage check
| Spec section | Task |
|---|---|
| 4.2 Data model — `fp.step.template` | Task 2 |
| 4.2 Data model — `fp.step.template.input` | Task 3 |
| 4.2 Data model — `fp.step.template.transition.input` | Task 4 |
| 4.2 Data model — `fusion.plating.process.node` field additions | Task 5 |
| 4.2 Data model — `fusion.plating.process.node.input` `kind` + targets | Task 5 |
| 4.2 Data model — `res.config.settings.default_recipe_editor` | Task 6 |
| 4.3 Simple Recipe Editor UI | Task 13 |
| 4.4 Backend controller endpoints | Task 11 |
| 4.5 Recipe form integration (header buttons + `is_template` + `preferred_editor`) | Task 12, Task 14 |
| 4.5 Menu integration (Step Library) | Task 9 |
| 4.5 ACL changes | Task 7 |
| 4.5 Step Library views | Task 8 |
| 4.6 Sane-default input seeding | Task 2 (`_seed_default_inputs`) |
| 4.7 Migration / install — `post_init_hook` | Task 10 |
| 4.7 Migration / install — manifest version bump | Task 1 |
| 4.8 Verification | Task 15 |
All spec sections covered. No gaps.
### Placeholder scan
- No "TBD", "TODO", "implement later", "fill in details".
- No "add appropriate error handling" without showing how.
- No "similar to Task N" without code repeated where needed.
### Type / signature consistency
- `fp.step.template` → `_seed_default_inputs(self)` defined Task 2, called by Task 8 form button + Task 10 post-init hook. Same name everywhere. ✓
- `_SNAPSHOT_FIELDS` defined in Task 11 controller, used by `step_insert` + `_snapshot_step_into`. ✓
- `_INPUT_SNAPSHOT_FIELDS` defined Task 11, used by `_copy_inputs_from_template`. ✓
- `action_open_in_simple_editor` / `action_open_in_tree_editor` defined Task 12, called by Task 14's `action_open_recipe_with_preferred_editor`. ✓
- Client-action tag `fp_simple_recipe_editor` registered Task 13, referenced by Task 12's `action_open_in_simple_editor`. ✓
- `fp_default_recipe_editor` field defined Task 6, used by Task 14's resolver. ✓
No inconsistencies found.
---
**Plan complete. 15 tasks, ~4 days end-to-end.**