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>
2881 lines
104 KiB
Markdown
2881 lines
104 KiB
Markdown
# 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.**
|