Files
Odoo-Modules/fusion_plating/docs/superpowers/plans/2026-04-29-step-library-audit.md
2026-04-29 21:56:18 -04:00

1859 lines
74 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Step Library Expansion + Per-Recipe Configurability + Audit Coverage — 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:** Cover the full plating-shop workflow with new Step Kinds + audit-grade default measurements, expose per-recipe configurability for every prompt + master toggle + custom prompts + reset-to-library, surface office-to-operator instructions in the simple recipe editor with library/recipe override, wire all of it through to runtime + CoC report + tablet, and battle-test end-to-end on entech.
**Architecture:** Library `fp.step.template` is the smart default; recipe `fusion.plating.process.node` is the final say. New Selection values added to step kinds + input types. New per-row `collect` boolean (with master `collect_measurements` on the node) drives runtime filtering. Office instructions stay as `description` Html on both library and recipe; recipe override falls through to library when empty. Battle test exercises the full chain library → recipe → job → CoC report.
**Tech Stack:** Odoo 19, Python (models, computes, migrations), OWL (recipe editor), QWeb (CoC report), JSON-RPC controllers.
**Spec:** [`docs/superpowers/specs/2026-04-29-step-library-audit-design.md`](../specs/2026-04-29-step-library-audit-design.md)
**Deploy target:** entech (LXC 111 on pve-worker5, native Odoo, DB `admin`, addons at `/mnt/extra-addons/custom/`).
---
## File Map
| File | Status | Responsibility |
|---|---|---|
| `fusion_plating/models/fp_step_template.py` | MOD | Extend `default_kind`, expand `DEFAULT_INPUTS_BY_KIND`, add `action_add_common_audit_fields` |
| `fusion_plating/models/fp_step_template_input.py` | MOD | Add 4 new input types to selection |
| `fusion_plating/models/fp_process_node.py` | MOD | Add `collect_measurements` on node; add `collect` + `template_input_id` on input model; mirror new input types |
| `fusion_plating/views/fp_step_template_views.xml` | MOD | Add audit-fields button; relabel description |
| `fusion_plating/data/fp_step_template_data.xml` | NEW | Seed templates for the 8 new Step Kinds |
| `fusion_plating/migrations/19.0.18.7.0/post-migrate.py` | NEW | Backfill collect/collect_measurements; re-seed defaults |
| `fusion_plating/static/src/js/simple_recipe_editor.js` | MOD | Render Instructions + Measurements expansions; collect badge |
| `fusion_plating/static/src/xml/simple_recipe_editor.xml` | MOD | OWL templates for new affordances |
| `fusion_plating/static/src/scss/simple_recipe_editor.scss` | MOD | Styles for expansions and badges |
| `fusion_plating/controllers/simple_recipe_controller.py` | MOD | New endpoints for toggle/edit/reset |
| `fusion_plating_jobs/wizards/fp_job_step_input_wizard.py` | MOD | Filter `collect=True`; mirror new input types |
| `fusion_plating_jobs/wizards/fp_job_step_input_wizard_views.xml` | MOD | Per-input-type widgets |
| `fusion_plating_shopfloor/static/src/...` (tablet OWL) | MOD | Per-type rendering |
| `fusion_plating_reports/views/report_coc_chronological.xml` | MOD | Render branches for new types; filter `collect=True` |
| `fusion_plating/scripts/bt_step_library_audit.py` | NEW | 18-assertion battle-test |
| `fusion_plating/__manifest__.py` | MOD | Bump to `19.0.18.7.0` |
| `fusion_plating_jobs/__manifest__.py` | MOD | Bump version |
| `fusion_plating_reports/__manifest__.py` | MOD | Bump version |
---
## Phase A — Model Foundations
### Task A1: Add new input types to library template input model
**Files:**
- Modify: `fusion_plating/models/fp_step_template_input.py`
- [ ] **Step 1: Open the file and locate the `input_type` Selection at line ~27**
- [ ] **Step 2: Extend the selection with 4 new types**
Replace:
```python
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')
```
With:
```python
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'),
('photo', 'Photo'),
('multi_point_thickness', 'Multi-Point Thickness (avg)'),
('bath_chemistry_panel', 'Bath Chemistry Panel'),
('ph', 'pH'),
], string='Input Type', required=True, default='text')
```
- [ ] **Step 3: Verify Python parses cleanly**
Run: `python -c "import ast; ast.parse(open('K:/Github/Odoo-Modules/fusion_plating/fusion_plating/models/fp_step_template_input.py').read())"`
Expected: silent (no error).
### Task A2: Mirror new input types on the recipe-step (process node) input model
**Files:**
- Modify: `fusion_plating/models/fp_process_node.py:621-640` (the `input_type` Selection on the inline input model)
- [ ] **Step 1: Locate the input_type Selection block (search for `('thickness', 'Thickness')`)**
- [ ] **Step 2: Add the four new entries above the closing bracket**
Replace:
```python
input_type = fields.Selection(
[
('text', 'Text'),
('number', 'Number'),
('boolean', 'Yes/No'),
('selection', 'Selection'),
('time_hms', 'Time (HH:MM:SS)'),
('time_seconds', 'Time (seconds)'),
('temperature', 'Temperature'),
('thickness', 'Thickness'),
('pass_fail', 'Pass / Fail'),
('date', 'Date / Time'),
('signature', 'Signature'),
('location_picker', 'Location Picker'),
('customer_wo', 'Customer WO #'),
],
string='Input Type',
required=True,
default='text',
)
```
With:
```python
input_type = fields.Selection(
[
('text', 'Text'),
('number', 'Number'),
('boolean', 'Yes/No'),
('selection', 'Selection'),
('time_hms', 'Time (HH:MM:SS)'),
('time_seconds', 'Time (seconds)'),
('temperature', 'Temperature'),
('thickness', 'Thickness'),
('pass_fail', 'Pass / Fail'),
('date', 'Date / Time'),
('signature', 'Signature'),
('location_picker', 'Location Picker'),
('customer_wo', 'Customer WO #'),
('photo', 'Photo'),
('multi_point_thickness', 'Multi-Point Thickness (avg)'),
('bath_chemistry_panel', 'Bath Chemistry Panel'),
('ph', 'pH'),
],
string='Input Type',
required=True,
default='text',
)
```
### Task A3: Add `collect`, `template_input_id`, `collect_measurements` fields
**Files:**
- Modify: `fusion_plating/models/fp_process_node.py`
- [ ] **Step 1: Locate the inline input model class (it's a nested model inside fp_process_node.py — search for `_name = 'fusion.plating.process.node.input'`).**
- [ ] **Step 2: Add `collect` and `template_input_id` after the `compliance_tag` field (around line 698)**
Insert immediately before the class' closing `_sql_constraints` or the next field block:
```python
# ===== Sub 12d — per-recipe configurability =============================
collect = fields.Boolean(
string='Collect This Measurement',
default=True,
help='Toggle off to skip this prompt at runtime without deleting it. '
'Recipe authors use this to opt out of library-seeded prompts '
'without affecting the library itself.',
)
template_input_id = fields.Many2one(
'fp.step.template.input',
string='Source Library Prompt',
ondelete='set null',
help='Set when this row was snapshot-copied from a library template '
'prompt. Powers "Reset to Library Defaults" — rows where this '
'is False are treated as recipe-only custom prompts and '
'survive the reset.',
)
```
- [ ] **Step 3: Add `collect_measurements` boolean on the parent node model**
Locate the main `fusion.plating.process.node` model class (search for `_name = 'fusion.plating.process.node'` — the node itself, not the input child). Find a sensible location near the existing `description` field (search for `description = fields.Html`) and add immediately after:
```python
collect_measurements = fields.Boolean(
string='Collect Measurements at Runtime',
default=True,
help='Master switch. When off, the operator wizard skips this step '
'entirely (no input prompts shown). Use for housekeeping steps '
'or when no measurement is needed for this recipe.',
)
```
- [ ] **Step 4: Verify Python parses**
Run: `python -c "import ast; ast.parse(open('K:/Github/Odoo-Modules/fusion_plating/fusion_plating/models/fp_process_node.py').read())"`
Expected: silent.
- [ ] **Step 5: Commit Phase A**
```bash
cd K:/Github/Odoo-Modules/fusion_plating
git add fusion_plating/models/fp_step_template_input.py fusion_plating/models/fp_process_node.py
git commit -m "model(step-library): add 4 new input types + per-recipe collect toggles"
```
---
## Phase B — Library DEFAULT_INPUTS_BY_KIND + 8 New Step Kinds
### Task B1: Add 8 new Step Kinds to default_kind Selection
**Files:**
- Modify: `fusion_plating/models/fp_step_template.py:77-94`
- [ ] **Step 1: Locate the `default_kind` Selection field (around line 77)**
- [ ] **Step 2: Replace it with the expanded list**
Replace:
```python
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'),
('contract_review', 'Contract Review (QA-005)'),
], string='Step Kind', help='Drives sane-default input seeding.')
```
With:
```python
default_kind = fields.Selection([
('receiving', 'Receiving / Incoming Inspection'),
('contract_review', 'Contract Review (QA-005)'),
('racking', 'Racking'),
('mask', 'Masking'),
('cleaning', 'Cleaning'),
('electroclean', 'Electroclean'),
('etch', 'Etch / Activation'),
('rinse', 'Rinse'),
('strike', 'Strike (Wood\'s Nickel / Activation)'),
('plate', 'Plating'),
('replenishment', 'Tank Replenishment'),
('wbf_test', 'Water Break Free Test'),
('dry', 'Drying'),
('bake', 'Bake (HE Relief / Stress Relief)'),
('demask', 'De-Masking'),
('derack', 'De-Racking'),
('inspect', 'Inspection'),
('hardness_test', 'Hardness Test (HV / HK / HRC)'),
('adhesion_test', 'Adhesion Test'),
('salt_spray', 'Salt Spray / Corrosion Test'),
('final_inspect', 'Final Inspection'),
('packaging', 'Packaging / Pre-Ship'),
('ship', 'Shipping'),
('gating', 'Gating'),
], string='Step Kind', help='Drives sane-default input seeding.')
```
The list is now ordered roughly by typical workflow phase to make it easier for shop authors to scan.
### Task B2: Expand DEFAULT_INPUTS_BY_KIND for all kinds
**Files:**
- Modify: `fusion_plating/models/fp_step_template.py:140-213`
- [ ] **Step 1: Replace the `DEFAULT_INPUTS_BY_KIND` dict with the full expanded version**
Replace the entire dict (lines 140 through the closing `}` around line 213) with:
```python
DEFAULT_INPUTS_BY_KIND = {
'receiving': [
{'name': 'Qty Received', 'input_type': 'number',
'target_unit': 'each', 'sequence': 10, 'required': True},
{'name': 'Qty Rejected', 'input_type': 'number',
'target_unit': 'each', 'sequence': 20},
{'name': 'Customer PO# Verified', 'input_type': 'boolean', 'sequence': 30},
{'name': 'Packing Slip #', 'input_type': 'text', 'sequence': 40},
{'name': 'Condition Notes', 'input_type': 'text', 'sequence': 50},
{'name': 'Damage Photo', 'input_type': 'photo', 'sequence': 60},
{'name': 'Inspector Initials', 'input_type': 'signature',
'sequence': 70, 'required': True},
],
'cleaning': [
{'name': 'Actual Time', 'input_type': 'time_seconds',
'target_unit': 's', 'sequence': 10},
{'name': 'Actual Temperature', 'input_type': 'temperature',
'target_unit': 'f', 'sequence': 20},
{'name': 'Bath ID', 'input_type': 'text', 'sequence': 30},
{'name': 'Ultrasonic On', 'input_type': 'boolean', 'sequence': 40},
{'name': 'Titration Done', 'input_type': 'boolean', 'sequence': 50},
],
'electroclean': [
{'name': 'Actual Time', 'input_type': 'time_seconds',
'target_unit': 's', 'sequence': 10},
{'name': 'Actual Temperature', 'input_type': 'temperature',
'target_unit': 'f', 'sequence': 20},
{'name': 'Amperage', 'input_type': 'number', 'sequence': 30,
'hint': 'A'},
{'name': 'Voltage', 'input_type': 'number', 'sequence': 40,
'hint': 'V'},
{'name': 'Current Density', 'input_type': 'number', 'sequence': 50,
'hint': 'ASF (A per sq ft)'},
{'name': 'Polarity', 'input_type': 'selection', 'sequence': 60,
'selection_options': 'anodic,cathodic,periodic'},
{'name': 'Bath ID', 'input_type': 'text', 'sequence': 70},
],
'etch': [
{'name': 'Actual Time', 'input_type': 'time_seconds',
'target_unit': 's', 'sequence': 10},
{'name': 'Actual Temperature', 'input_type': 'temperature',
'target_unit': 'f', 'sequence': 20},
{'name': 'Acid Concentration', 'input_type': 'number', 'sequence': 30,
'hint': '% or g/L'},
{'name': 'Bath ID', 'input_type': 'text', 'sequence': 40},
{'name': 'HE Risk Flag', 'input_type': 'boolean', 'sequence': 50,
'hint': 'Hydrogen Embrittlement risk for high-strength steel'},
],
'rinse': [
{'name': 'Rinse Type', 'input_type': 'selection', 'sequence': 10,
'selection_options': 'cascade,spray,DI,city'},
{'name': 'Conductivity', 'input_type': 'number', 'sequence': 20,
'hint': 'µS/cm — required for DI rinses'},
{'name': 'Actual Time', 'input_type': 'time_seconds',
'target_unit': 's', 'sequence': 30},
],
'strike': [
{'name': 'Actual Time', 'input_type': 'time_seconds',
'target_unit': 's', 'sequence': 10},
{'name': 'Actual Temperature', 'input_type': 'temperature',
'target_unit': 'f', 'sequence': 20},
{'name': 'Amperage', 'input_type': 'number', 'sequence': 30,
'hint': 'A'},
{'name': 'Voltage', 'input_type': 'number', 'sequence': 40,
'hint': 'V'},
{'name': 'Current Density', 'input_type': 'number', 'sequence': 50,
'hint': 'ASF'},
{'name': 'Bath ID', 'input_type': 'text', 'sequence': 60},
],
'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': 'Bath ID', 'input_type': 'text', 'sequence': 30},
{'name': 'pH', 'input_type': 'ph', 'sequence': 40},
{'name': 'Bath Concentration', 'input_type': 'number', 'sequence': 50,
'hint': 'g/L'},
{'name': 'Current Density', 'input_type': 'number', 'sequence': 60,
'hint': 'ASF — electroplate only'},
{'name': 'Plating Thickness', 'input_type': 'multi_point_thickness',
'target_unit': 'in', 'sequence': 70},
],
'replenishment': [
{'name': 'Bath ID', 'input_type': 'text', 'sequence': 10,
'required': True},
{'name': 'Chemistry Added', 'input_type': 'text', 'sequence': 20,
'hint': 'name + amount, e.g. "Nickel sulfamate 500mL"'},
{'name': 'pH Before', 'input_type': 'ph', 'sequence': 30},
{'name': 'pH After', 'input_type': 'ph', 'sequence': 40},
{'name': 'Concentration Before', 'input_type': 'number', 'sequence': 50},
{'name': 'Concentration After', 'input_type': 'number', 'sequence': 60},
{'name': 'Operator Initials', 'input_type': 'signature',
'sequence': 70, 'required': True},
],
'wbf_test': [
{'name': 'Result', 'input_type': 'pass_fail', 'sequence': 10,
'required': True},
{'name': 'Retest Count', 'input_type': 'number', 'sequence': 20},
{'name': 'Photo on FAIL', 'input_type': 'photo', 'sequence': 30},
],
'dry': [
{'name': 'Dry Method', 'input_type': 'selection', 'sequence': 10,
'selection_options': 'hot air,oven,spin'},
{'name': 'Actual Time', 'input_type': 'time_seconds',
'target_unit': 's', 'sequence': 20},
{'name': 'Actual Temperature', 'input_type': 'temperature',
'target_unit': 'f', 'sequence': 30},
],
'bake': [
{'name': 'Time In', 'input_type': 'date', 'sequence': 10},
{'name': 'Time Out', 'input_type': 'date', 'sequence': 20},
{'name': 'Actual Temperature', 'input_type': 'temperature',
'target_unit': 'f', 'sequence': 30},
{'name': 'Oven ID', 'input_type': 'text', 'sequence': 40},
{'name': 'Chart Recorder File', 'input_type': 'photo', 'sequence': 50,
'hint': 'Attach AMS-2759 chart-recorder file'},
],
'racking': [
{'name': 'Actual Qty', 'input_type': 'number',
'target_unit': 'each', 'sequence': 10, 'required': True},
{'name': 'Rack ID', 'input_type': 'text', 'sequence': 20},
{'name': 'Masking Applied', 'input_type': 'boolean', 'sequence': 30},
{'name': 'Photo of Racked Load', 'input_type': 'photo', 'sequence': 40},
],
'derack': [
{'name': 'Actual Qty', 'input_type': 'number',
'target_unit': 'each', 'sequence': 10},
{'name': 'Mask Removal Method', 'input_type': 'selection', 'sequence': 20,
'selection_options': 'mechanical,solvent,thermal,not applicable'},
{'name': 'Residue Check', 'input_type': 'pass_fail', 'sequence': 30},
],
'mask': [
{'name': 'Actual Qty', 'input_type': 'number',
'target_unit': 'each', 'sequence': 10},
{'name': 'Mask Material', 'input_type': 'selection', 'sequence': 20,
'selection_options': 'Microshield,latex tape,vinyl plugs,wax,other'},
{'name': 'Photo of Masked Parts', 'input_type': 'photo', 'sequence': 30},
],
'demask': [
{'name': 'Residue Check', 'input_type': 'pass_fail', 'sequence': 10},
{'name': 'Surface Condition', 'input_type': 'selection', 'sequence': 20,
'selection_options': 'clean,marks,needs rework'},
],
'inspect': [
{'name': 'Result', 'input_type': 'pass_fail', 'sequence': 10,
'required': True},
{'name': 'Defect Type', 'input_type': 'selection', 'sequence': 20,
'selection_options': 'pitting,burn,blister,peel,missing coverage,none'},
{'name': 'Thickness Sample', 'input_type': 'thickness',
'target_unit': 'in', 'sequence': 30},
{'name': 'Photo', 'input_type': 'photo', 'sequence': 40},
{'name': 'Inspector Signature', 'input_type': 'signature', 'sequence': 50},
],
'hardness_test': [
{'name': 'Test Load', 'input_type': 'number', 'sequence': 10,
'hint': 'gf'},
{'name': 'Readings (HV/HK/HRC)', 'input_type': 'multi_point_thickness',
'sequence': 20, 'hint': 'Three indents minimum'},
{'name': 'Equipment ID', 'input_type': 'text', 'sequence': 30},
{'name': 'Last Calibration Date', 'input_type': 'date', 'sequence': 40},
],
'adhesion_test': [
{'name': 'Test Method', 'input_type': 'selection', 'sequence': 10,
'selection_options': 'bend,tape,burnish,file'},
{'name': 'Result', 'input_type': 'pass_fail', 'sequence': 20,
'required': True},
{'name': 'Photo of Coupon', 'input_type': 'photo', 'sequence': 30},
],
'salt_spray': [
{'name': 'Test Duration', 'input_type': 'number', 'sequence': 10,
'hint': 'hours'},
{'name': 'Result', 'input_type': 'pass_fail', 'sequence': 20,
'required': True},
{'name': 'Red Rust %', 'input_type': 'number', 'sequence': 30},
{'name': 'White Corrosion %', 'input_type': 'number', 'sequence': 40},
{'name': 'Lab Report', 'input_type': 'photo', 'sequence': 50,
'hint': 'Attach scanned lab report'},
],
'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': 'Defect Categorization', 'input_type': 'selection', 'sequence': 35,
'selection_options': 'pitting,burn,blister,peel,missing coverage,dimensional,none'},
{'name': 'Actual Coating Thickness',
'input_type': 'multi_point_thickness',
'target_unit': 'in', 'sequence': 40},
{'name': 'Dimensional Verification', 'input_type': 'pass_fail',
'sequence': 45},
{'name': 'Surface Finish (Ra)', 'input_type': 'number', 'sequence': 47,
'hint': 'µin'},
{'name': 'Pass/Fail', 'input_type': 'pass_fail', 'sequence': 50,
'required': True},
{'name': 'Inspector Signature', 'input_type': 'signature', 'sequence': 60},
],
'packaging': [
{'name': 'Packaging Type', 'input_type': 'selection', 'sequence': 10,
'selection_options': 'VCI bag,bubble wrap,separator paper,custom crate,other'},
{'name': 'Qty Per Package', 'input_type': 'number',
'target_unit': 'each', 'sequence': 20},
{'name': 'Package Count', 'input_type': 'number', 'sequence': 30},
{'name': 'Cert Package Included', 'input_type': 'boolean', 'sequence': 40},
{'name': 'Customer-Supplied Packaging', 'input_type': 'boolean',
'sequence': 50},
],
'ship': [
{'name': 'Outgoing Qty', 'input_type': 'number',
'target_unit': 'each', 'sequence': 10, 'required': True},
{'name': 'Carrier', 'input_type': 'selection', 'sequence': 20,
'selection_options': 'UPS,FedEx,Purolator,Customer Pickup,Other'},
{'name': 'Tracking #', 'input_type': 'text', 'sequence': 30},
{'name': 'BoL #', 'input_type': 'text', 'sequence': 40},
{'name': 'Photo of Sealed Shipment', 'input_type': 'photo',
'sequence': 50},
],
'gating': [],
'contract_review': [
{'name': 'Reviewer Initials', 'input_type': 'signature', 'sequence': 10},
{'name': 'Date Reviewed', 'input_type': 'date', 'sequence': 20},
{'name': 'QA-005 Approved', 'input_type': 'pass_fail', 'sequence': 30},
],
}
```
### Task B3: Add `action_add_common_audit_fields` method
**Files:**
- Modify: `fusion_plating/models/fp_step_template.py` (after `action_seed_default_inputs`, around line 234)
- [ ] **Step 1: Append after `action_seed_default_inputs`**
```python
COMMON_AUDIT_FIELDS = [
{'name': 'Operator Initials', 'input_type': 'signature',
'required': True, 'sequence': 800},
{'name': 'Bath ID', 'input_type': 'text', 'sequence': 810},
{'name': 'Photo on Failure', 'input_type': 'photo', 'sequence': 820,
'hint': 'upload only if failure observed'},
{'name': 'Equipment ID', 'input_type': 'text', 'sequence': 830},
]
def action_add_common_audit_fields(self):
"""Idempotently append the common audit fields to this template.
Skips rows whose name already exists. Logs to chatter.
"""
Input = self.env['fp.step.template.input']
for tpl in self:
existing_names = set(tpl.input_template_ids.mapped('name'))
added = []
for spec in self.COMMON_AUDIT_FIELDS:
if spec['name'] in existing_names:
continue
Input.create({
'template_id': tpl.id,
**spec,
})
added.append(spec['name'])
if added:
tpl.message_post(
body=_('Added common audit fields: %s') % ', '.join(added),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
return True
```
- [ ] **Step 2: Verify Python parses**
Run: `python -c "import ast; ast.parse(open('K:/Github/Odoo-Modules/fusion_plating/fusion_plating/models/fp_step_template.py').read())"`
Expected: silent.
- [ ] **Step 3: Commit Phase B model changes**
```bash
git add fusion_plating/models/fp_step_template.py
git commit -m "model(step-library): add 8 new Step Kinds + expanded defaults + audit-fields one-click"
```
---
## Phase C — Library Views
### Task C1: Surface "Add Common Audit Fields" button + relabel description
**Files:**
- Modify: `fusion_plating/views/fp_step_template_views.xml`
- [ ] **Step 1: In the form view's `<header>` (around line 32), add a second button after `action_seed_default_inputs`**
Replace:
```xml
<header>
<button name="action_seed_default_inputs" type="object"
string="Seed Default Inputs" class="btn-secondary"
invisible="not default_kind"/>
</header>
```
With:
```xml
<header>
<button name="action_seed_default_inputs" type="object"
string="Seed Default Inputs" class="btn-secondary"
invisible="not default_kind"/>
<button name="action_add_common_audit_fields" type="object"
string="+ Common Audit Fields"
class="btn-secondary"
help="Append Operator Initials, Bath ID, Photo on Failure, Equipment ID"/>
</header>
```
- [ ] **Step 2: Update the Instructions tab label and the field placeholder**
Replace:
```xml
<page string="Instructions" name="instructions">
<field name="description"
placeholder="Rich-text instructions / WI reference."/>
</page>
```
With:
```xml
<page string="Default Operator Instructions" name="instructions">
<div class="alert alert-info" role="alert">
Standing instructions the office gives operators for this
step. Snapshot-copied onto every recipe that uses this
step. Recipe authors can override per recipe.
</div>
<field name="description"
placeholder="e.g. Mask threaded holes with vinyl plugs. Use Microshield for through-holes."/>
</page>
```
- [ ] **Step 3: Commit**
```bash
git add fusion_plating/views/fp_step_template_views.xml
git commit -m "view(step-library): audit-fields button + relabel as Default Operator Instructions"
```
---
## Phase D — Migration Script
### Task D1: Create migration directory and post-migrate script
**Files:**
- Create: `fusion_plating/migrations/19.0.18.7.0/post-migrate.py`
- [ ] **Step 1: Verify migration directory exists**
Run: `ls K:/Github/Odoo-Modules/fusion_plating/fusion_plating/migrations/`
If `19.0.18.7.0/` doesn't exist, create it: `mkdir -p K:/Github/Odoo-Modules/fusion_plating/fusion_plating/migrations/19.0.18.7.0/`
- [ ] **Step 2: Write the migration script**
Content:
```python
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Post-migration for 19.0.18.7.0 — Step Library audit expansion.
1. Default `collect=True` on all existing recipe-step inputs.
2. Default `collect_measurements=True` on all existing recipe steps.
3. Re-run action_seed_default_inputs on every existing template to
pull in the newly-added prompts (idempotent — skips rows whose
name is already present, so user edits survive).
4. Backfill template_input_id by name-matching against the linked
library template (best-effort).
"""
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
if not version:
return
from odoo.api import Environment, SUPERUSER_ID
env = Environment(cr, SUPERUSER_ID, {})
# 1. Default collect=True on all recipe-step inputs that have NULL collect
cr.execute("""
UPDATE fusion_plating_process_node_input
SET collect = TRUE
WHERE collect IS NULL
""")
_logger.info("Backfilled collect=True on %s recipe-step inputs", cr.rowcount)
# 2. Default collect_measurements=True on recipe steps with NULL
cr.execute("""
UPDATE fusion_plating_process_node
SET collect_measurements = TRUE
WHERE collect_measurements IS NULL
""")
_logger.info("Backfilled collect_measurements=True on %s recipe steps", cr.rowcount)
# 3. Re-seed defaults on every existing template (idempotent)
Template = env['fp.step.template']
templates = Template.search([('default_kind', '!=', False)])
for tpl in templates:
try:
tpl.action_seed_default_inputs()
except Exception as e:
_logger.warning(
"Failed to re-seed defaults on template %s: %s", tpl.id, e
)
_logger.info("Re-seeded defaults on %s templates", len(templates))
# 4. Backfill template_input_id — name-match recipe-node inputs against
# their parent recipe's first-level library-template snapshot. Best
# effort: nodes without a clear library link stay with template_input_id=False.
cr.execute("""
SELECT ni.id, ni.name, ni.node_id
FROM fusion_plating_process_node_input ni
WHERE ni.template_input_id IS NULL
""")
rows = cr.fetchall()
matched = 0
for ni_id, name, node_id in rows:
# Find the library template via the recipe node's process_type or by
# tank match — best-effort, no guarantees. If the node has a
# template_id field, use that directly.
cr.execute("""
SELECT template_id FROM fusion_plating_process_node WHERE id = %s
""", (node_id,))
row = cr.fetchone()
if not row or not row[0]:
continue
tpl_id = row[0]
# Find the matching input by name in the library template
cr.execute("""
SELECT id FROM fp_step_template_input
WHERE template_id = %s AND name = %s LIMIT 1
""", (tpl_id, name))
match = cr.fetchone()
if match:
cr.execute("""
UPDATE fusion_plating_process_node_input
SET template_input_id = %s WHERE id = %s
""", (match[0], ni_id))
matched += 1
_logger.info("Backfilled template_input_id on %s recipe-step inputs", matched)
```
NOTE: the SQL queries use the actual Odoo table names. The recipe-step model is `fusion.plating.process.node` → table `fusion_plating_process_node`. The inline input model is `fusion.plating.process.node.input` → table `fusion_plating_process_node_input`. **Verify these table names** before running the migration by running:
```bash
ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -s /bin/bash -c \"psql -d admin -c \\\"\\\\dt | grep -i process_node\\\"\"'"
```
If the actual table names differ, update the SQL accordingly.
- [ ] **Step 3: Verify migration directory has __init__ if needed**
Odoo migrations don't strictly need an `__init__.py` in the version directory — they're picked up by file naming. Skip unless your repo convention says otherwise.
- [ ] **Step 4: Commit**
```bash
git add fusion_plating/migrations/19.0.18.7.0/post-migrate.py
git commit -m "migration(step-library): backfill collect/collect_measurements + re-seed defaults"
```
---
## Phase E — Runtime Wizard Filtering
### Task E1: Filter input wizard to `collect=True` only + skip when master is off
**Files:**
- Modify: `fusion_plating_jobs/wizards/fp_job_step_input_wizard.py:64-92`
- [ ] **Step 1: Locate the `default_get` method (around line 64)**
- [ ] **Step 2: Update the filter logic**
Replace:
```python
@api.model
def default_get(self, fields_list):
defaults = super().default_get(fields_list)
ctx = self.env.context
step_id = ctx.get('default_step_id') or ctx.get('active_id')
if not step_id:
return defaults
step = self.env['fp.job.step'].browse(step_id)
if not step.exists() or not step.recipe_node_id:
return defaults
defaults['step_id'] = step.id
node = step.recipe_node_id
# Filter to step_input prompts only — transition inputs go on the
# Move wizard, not here.
inputs = node.input_ids
if 'kind' in inputs._fields:
inputs = inputs.filtered(lambda i: i.kind == 'step_input')
defaults['line_ids'] = [(0, 0, {
'node_input_id': inp.id,
'name': inp.name,
'input_type': inp.input_type,
'target_min': getattr(inp, 'target_min', 0.0) or 0.0,
'target_max': getattr(inp, 'target_max', 0.0) or 0.0,
'target_unit': getattr(inp, 'target_unit', False) or False,
}) for inp in inputs]
return defaults
```
With:
```python
@api.model
def default_get(self, fields_list):
defaults = super().default_get(fields_list)
ctx = self.env.context
step_id = ctx.get('default_step_id') or ctx.get('active_id')
if not step_id:
return defaults
step = self.env['fp.job.step'].browse(step_id)
if not step.exists() or not step.recipe_node_id:
return defaults
defaults['step_id'] = step.id
node = step.recipe_node_id
# Master switch — when off, skip the wizard entirely.
if hasattr(node, 'collect_measurements') and not node.collect_measurements:
return defaults
# Filter to step_input prompts only — transition inputs go on the
# Move wizard, not here. Also filter to collect=True (per-recipe
# opt-out, default True).
inputs = node.input_ids
if 'kind' in inputs._fields:
inputs = inputs.filtered(lambda i: i.kind == 'step_input')
if 'collect' in inputs._fields:
inputs = inputs.filtered(lambda i: i.collect)
defaults['line_ids'] = [(0, 0, {
'node_input_id': inp.id,
'name': inp.name,
'input_type': inp.input_type,
'target_min': getattr(inp, 'target_min', 0.0) or 0.0,
'target_max': getattr(inp, 'target_max', 0.0) or 0.0,
'target_unit': getattr(inp, 'target_unit', False) or False,
}) for inp in inputs]
return defaults
```
### Task E2: Mirror new input types on wizard line model
**Files:**
- Modify: `fusion_plating_jobs/wizards/fp_job_step_input_wizard.py` (top of file, around line 30)
- [ ] **Step 1: Update `_FP_INPUT_TYPE_SELECTION` to include the 4 new types**
Replace:
```python
_FP_INPUT_TYPE_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'),
]
```
With:
```python
_FP_INPUT_TYPE_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'),
('photo', 'Photo'),
('multi_point_thickness', 'Multi-Point Thickness (avg)'),
('bath_chemistry_panel', 'Bath Chemistry Panel'),
('ph', 'pH'),
]
```
- [ ] **Step 2: Find the wizard line model class (likely later in the same file or in `fp_job_step_input_wizard_line.py`)**
If the line model uses `_FP_INPUT_TYPE_SELECTION` directly, no further code change needed. Confirm by searching:
```bash
grep -n "_FP_INPUT_TYPE_SELECTION\|class FpJobStepInputWizardLine" K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/wizards/fp_job_step_input_wizard.py
```
- [ ] **Step 3: Add Binary field for photo storage and Char fields for composite types on the line model**
Find the line model class (`FpJobStepInputWizardLine` or similar) and append after the existing value fields:
```python
# Sub 12d — composite + photo input types.
# Photo: stored as ir.attachment via Binary; we capture the bytes
# then on commit write to attachment + null this field.
photo_value = fields.Binary(string='Photo', attachment=True)
photo_filename = fields.Char(string='Photo Filename')
# Multi-point thickness: 5 readings + computed average.
point_1 = fields.Float(string='R1')
point_2 = fields.Float(string='R2')
point_3 = fields.Float(string='R3')
point_4 = fields.Float(string='R4')
point_5 = fields.Float(string='R5')
point_avg = fields.Float(
string='Average',
compute='_compute_point_avg',
store=False,
)
# Bath chemistry panel: 4 fields bundled.
panel_ph = fields.Float(string='Panel pH')
panel_concentration = fields.Float(string='Panel Concentration')
panel_temperature = fields.Float(string='Panel Temperature')
panel_bath_id = fields.Char(string='Panel Bath ID')
@api.depends('point_1', 'point_2', 'point_3', 'point_4', 'point_5')
def _compute_point_avg(self):
for rec in self:
pts = [
p for p in (rec.point_1, rec.point_2, rec.point_3,
rec.point_4, rec.point_5)
if p
]
rec.point_avg = sum(pts) / len(pts) if pts else 0.0
```
- [ ] **Step 4: Update `action_commit` to serialize composite values into the move's input_data JSON**
Find the `action_commit` method on the wizard. Update it to handle the new types — when serializing each line's value, branch on `input_type`:
```python
def _serialize_line_value(self, line):
"""Convert a wizard line into a JSON-serializable value for storage
on fp.job.step.move.input_data."""
t = line.input_type
if t == 'photo':
# Photo is stored as ir.attachment; the move row carries the ID.
if line.photo_value:
att = self.env['ir.attachment'].create({
'name': line.photo_filename or 'photo.jpg',
'datas': line.photo_value,
'res_model': 'fp.job.step.move',
'res_id': 0, # patched after move is created
})
return {'attachment_id': att.id, 'filename': att.name}
return None
if t == 'multi_point_thickness':
pts = [line.point_1, line.point_2, line.point_3,
line.point_4, line.point_5]
non_empty = [p for p in pts if p]
return {
'readings': pts,
'avg': sum(non_empty) / len(non_empty) if non_empty else 0.0,
}
if t == 'bath_chemistry_panel':
return {
'ph': line.panel_ph,
'concentration': line.panel_concentration,
'temperature': line.panel_temperature,
'bath_id': line.panel_bath_id,
}
if t == 'ph':
return line.value_number
# Fallback for existing types — use the existing value getter
return getattr(line, 'value_text', None) or getattr(line, 'value_number', 0.0)
```
Then call `self._serialize_line_value(line)` from within `action_commit` instead of inline value extraction. The exact integration point depends on the existing commit logic — read it first, then thread the new helper in.
- [ ] **Step 5: Verify Python parses**
```bash
python -c "import ast; ast.parse(open('K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/wizards/fp_job_step_input_wizard.py').read())"
```
- [ ] **Step 6: Commit**
```bash
git add fusion_plating_jobs/wizards/fp_job_step_input_wizard.py
git commit -m "wizard(step-input): filter to collect=True + new input types serialization"
```
### Task E3: Wizard form view — conditional widgets per input type
**Files:**
- Modify: `fusion_plating_jobs/wizards/fp_job_step_input_wizard_views.xml`
- [ ] **Step 1: Read the existing form to find the line list/form**
```bash
cat K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/wizards/fp_job_step_input_wizard_views.xml
```
- [ ] **Step 2: Add conditional render for each new type in the per-row form**
Inside the `<form>` block of `line_ids`, add per-type conditional fields. Each branch is wrapped in `invisible="input_type != '<type>'"`:
```xml
<group invisible="input_type != 'photo'">
<field name="photo_value" widget="image" options="{'preview_image': 'photo_value'}"/>
<field name="photo_filename" invisible="1"/>
</group>
<group invisible="input_type != 'multi_point_thickness'">
<field name="point_1"/>
<field name="point_2"/>
<field name="point_3"/>
<field name="point_4"/>
<field name="point_5"/>
<field name="point_avg" readonly="1"/>
</group>
<group invisible="input_type != 'bath_chemistry_panel'">
<field name="panel_ph"/>
<field name="panel_concentration"/>
<field name="panel_temperature"/>
<field name="panel_bath_id"/>
</group>
<group invisible="input_type != 'ph'">
<field name="value_number" string="pH (014)"/>
</group>
```
(The `value_number` reuse for pH is intentional — pH is just a constrained number.)
- [ ] **Step 3: Commit**
```bash
git add fusion_plating_jobs/wizards/fp_job_step_input_wizard_views.xml
git commit -m "view(step-input-wizard): conditional widgets per input type"
```
---
## Phase F — Recipe Editor UI (Simple Editor)
### Task F1: Add backend controllers for the new editor actions
**Files:**
- Modify: `fusion_plating/controllers/simple_recipe_controller.py`
- [ ] **Step 1: Read the existing controller to understand patterns**
```bash
grep -n "def\|@http.route" K:/Github/Odoo-Modules/fusion_plating/fusion_plating/controllers/simple_recipe_controller.py | head -30
```
- [ ] **Step 2: Append the four new endpoints**
```python
@http.route('/fp/simple_recipe/step/toggle_collect', type='jsonrpc', auth='user')
def toggle_collect(self, node_id, collect):
"""Master switch — toggle collect_measurements on a recipe step."""
node = request.env['fusion.plating.process.node'].browse(int(node_id))
node.collect_measurements = bool(collect)
return {'ok': True, 'collect_measurements': node.collect_measurements}
@http.route('/fp/simple_recipe/step/edit_input', type='jsonrpc', auth='user')
def edit_input(self, input_id, payload):
"""Edit a single recipe-step input. payload is a dict with any of:
collect, name, input_type, target_min, target_max, required, sequence,
selection_options.
"""
Input = request.env['fusion.plating.process.node.input']
rec = Input.browse(int(input_id))
if not rec.exists():
return {'ok': False, 'error': 'not_found'}
allowed = {
'collect', 'name', 'input_type', 'target_min', 'target_max',
'target_unit', 'required', 'sequence', 'selection_options', 'hint',
}
vals = {k: v for k, v in (payload or {}).items() if k in allowed}
if vals:
rec.write(vals)
return {'ok': True}
@http.route('/fp/simple_recipe/step/edit_instructions', type='jsonrpc', auth='user')
def edit_instructions(self, node_id, description):
"""Set the recipe step's per-recipe instructions override.
Pass empty string / None to revert to library default."""
node = request.env['fusion.plating.process.node'].browse(int(node_id))
node.description = description or False
return {'ok': True}
@http.route('/fp/simple_recipe/step/reset_to_library', type='jsonrpc', auth='user')
def reset_to_library(self, node_id):
"""Re-sync the recipe step's input_ids + description from the linked
library template. Preserves rows where template_input_id=False."""
Node = request.env['fusion.plating.process.node']
Input = request.env['fusion.plating.process.node.input']
node = Node.browse(int(node_id))
if not node.exists() or not node.template_id:
return {'ok': False, 'error': 'no_library_template'}
tpl = node.template_id
# Drop existing rows that came from the library (template_input_id set)
node.input_ids.filtered(lambda i: i.template_input_id).unlink()
# Re-snapshot from library
for src in tpl.input_template_ids:
Input.create({
'node_id': node.id,
'template_input_id': src.id,
'name': src.name,
'input_type': src.input_type,
'target_min': src.target_min,
'target_max': src.target_max,
'target_unit': src.target_unit,
'required': src.required,
'hint': src.hint,
'sequence': src.sequence,
'selection_options': src.selection_options,
'kind': 'step_input',
'collect': True,
})
# Reset description override
node.description = tpl.description or False
node.message_post(
body='Reset to library defaults from template "%s"' % tpl.name,
message_type='notification',
)
return {'ok': True}
```
NOTE: the field/relation names (`template_id`, `node_id`, `input_template_ids`, `kind`) need to match what's actually in the model. **Verify by reading `fp_process_node.py` carefully before pasting** — adjust if names differ.
- [ ] **Step 3: Commit**
```bash
git add fusion_plating/controllers/simple_recipe_controller.py
git commit -m "controller(simple-recipe): toggle/edit/reset endpoints for per-recipe configurability"
```
### Task F2: OWL editor — render Instructions + Measurements expansions
**Files:**
- Modify: `fusion_plating/static/src/js/simple_recipe_editor.js`
- Modify: `fusion_plating/static/src/xml/simple_recipe_editor.xml`
- Modify: `fusion_plating/static/src/scss/simple_recipe_editor.scss`
- [ ] **Step 1: Read the existing OWL component to understand state shape and step rendering**
```bash
grep -n "expanded\|step\|library" K:/Github/Odoo-Modules/fusion_plating/fusion_plating/static/src/js/simple_recipe_editor.js | head -30
```
- [ ] **Step 2: Add state for which step's expansions are open**
In the `state = useState({ ... })` block, add:
```js
expandedInstructions: null, // node_id whose instructions panel is open
expandedMeasurements: null, // node_id whose measurements panel is open
```
- [ ] **Step 3: Add toggle methods**
In the component class, add:
```js
toggleInstructions(nodeId) {
this.state.expandedInstructions =
this.state.expandedInstructions === nodeId ? null : nodeId;
this.state.expandedMeasurements = null;
}
toggleMeasurements(nodeId) {
this.state.expandedMeasurements =
this.state.expandedMeasurements === nodeId ? null : nodeId;
this.state.expandedInstructions = null;
}
async toggleCollectMeasurements(nodeId, collect) {
await rpc("/fp/simple_recipe/step/toggle_collect", {
node_id: nodeId, collect,
});
// Refresh the step from server
await this.loadAll();
}
async toggleInputCollect(inputId, collect) {
await rpc("/fp/simple_recipe/step/edit_input", {
input_id: inputId,
payload: { collect },
});
await this.loadAll();
}
async saveInstructions(nodeId, html) {
await rpc("/fp/simple_recipe/step/edit_instructions", {
node_id: nodeId, description: html,
});
await this.loadAll();
}
async resetToLibrary(nodeId) {
await rpc("/fp/simple_recipe/step/reset_to_library", { node_id: nodeId });
await this.loadAll();
}
```
- [ ] **Step 4: Update the OWL template to render the expansion affordances**
In `simple_recipe_editor.xml`, find the per-step rendering (look for the `t-foreach` over selected steps). After the step header div, add:
```xml
<div class="o_fp_step_expansions">
<button class="btn btn-link btn-sm"
t-on-click="() => this.toggleInstructions(step.id)">
▸ 📋 Instructions
<span t-if="step.has_custom_instructions" class="badge bg-info ms-1">custom</span>
<span t-else="" class="badge bg-secondary ms-1">default</span>
</button>
<button class="btn btn-link btn-sm ms-2"
t-on-click="() => this.toggleMeasurements(step.id)">
▸ ⚙ Measurements
<span class="badge ms-1"
t-att-class="step.measurements_badge_class">
<t t-esc="step.measurements_badge_text"/>
</span>
</button>
</div>
<!-- Instructions expansion -->
<div t-if="state.expandedInstructions === step.id"
class="o_fp_step_instructions_panel">
<textarea class="form-control" rows="4"
t-on-blur="(ev) => this.saveInstructions(step.id, ev.target.value)"
t-att-value="step.description_effective"/>
<div class="text-muted small mt-1">
Source: <t t-esc="step.has_custom_instructions ? 'this recipe' : 'library default'"/>
</div>
</div>
<!-- Measurements expansion -->
<div t-if="state.expandedMeasurements === step.id"
class="o_fp_step_measurements_panel">
<label>
<input type="checkbox"
t-att-checked="step.collect_measurements"
t-on-change="(ev) => this.toggleCollectMeasurements(step.id, ev.target.checked)"/>
Collect measurements at this step
</label>
<table class="table table-sm" t-if="step.collect_measurements">
<thead>
<tr><th>Collect</th><th>Name</th><th>Type</th><th>Range</th></tr>
</thead>
<tbody>
<tr t-foreach="step.input_ids" t-as="inp" t-key="inp.id">
<td><input type="checkbox" t-att-checked="inp.collect"
t-on-change="(ev) => this.toggleInputCollect(inp.id, ev.target.checked)"/></td>
<td><t t-esc="inp.name"/></td>
<td><t t-esc="inp.input_type"/></td>
<td>
<t t-if="inp.target_min || inp.target_max">
<t t-esc="inp.target_min"/><t t-esc="inp.target_max"/>
<t t-esc="inp.target_unit"/>
</t>
</td>
</tr>
</tbody>
</table>
<button class="btn btn-link btn-sm" t-on-click="() => this.resetToLibrary(step.id)">
Reset to Library Defaults
</button>
</div>
```
- [ ] **Step 5: Add SCSS for the expansion panels**
In `simple_recipe_editor.scss`:
```scss
.o_fp_step_expansions {
display: flex;
gap: 4px;
padding: 4px 8px;
border-top: 1px solid #d8dadd;
}
.o_fp_step_instructions_panel,
.o_fp_step_measurements_panel {
padding: 8px 12px;
background: #f8f9fa;
border-top: 1px solid #d8dadd;
table {
margin-bottom: 8px;
}
}
```
- [ ] **Step 6: Update the controller's `recipe/get` endpoint (or wherever step data is loaded) to include the new fields**
In `simple_recipe_controller.py`, find the function that returns the recipe data (likely `recipe_get` or similar). Ensure each step in the response includes:
```python
{
'id': node.id,
'name': node.name,
# ...existing fields...
'description': node.description or '',
'description_library': node.template_id.description if node.template_id else '',
'description_effective': node.description or (node.template_id.description if node.template_id else ''),
'has_custom_instructions': bool(node.description),
'collect_measurements': node.collect_measurements,
'input_ids': [
{
'id': i.id,
'name': i.name,
'input_type': i.input_type,
'collect': i.collect,
'target_min': i.target_min,
'target_max': i.target_max,
'target_unit': i.target_unit,
'required': i.required,
'sequence': i.sequence,
}
for i in node.input_ids.sorted('sequence')
],
'measurements_badge_text': '%d/%d collected' % (
sum(1 for i in node.input_ids if i.collect),
len(node.input_ids),
),
'measurements_badge_class': (
'bg-success' if all(i.collect for i in node.input_ids) and node.collect_measurements
else 'bg-warning' if any(i.collect for i in node.input_ids) and node.collect_measurements
else 'bg-secondary'
),
}
```
- [ ] **Step 7: Commit**
```bash
git add fusion_plating/static/src/ fusion_plating/controllers/
git commit -m "owl(simple-editor): instructions + measurements expansions per step"
```
---
## Phase G — Seed Data + CoC Report
### Task G1: Seed example templates for new Step Kinds
**Files:**
- Create: `fusion_plating/data/fp_step_template_data.xml` (if doesn't exist)
- Modify: `fusion_plating/__manifest__.py` (register data file)
- [ ] **Step 1: Check whether the file exists**
```bash
ls K:/Github/Odoo-Modules/fusion_plating/fusion_plating/data/fp_step_template_data.xml 2>/dev/null || echo "Does not exist — create it"
```
- [ ] **Step 2: Create / append seed templates**
Content (append if file exists, create if not):
```xml
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="fp_step_template_receiving_std" model="fp.step.template">
<field name="name">Incoming Inspection (Standard)</field>
<field name="code">RECV_STD</field>
<field name="default_kind">receiving</field>
<field name="icon">fa-inbox</field>
<field name="description"><![CDATA[
<p>Verify quantity received against packing slip. Visually inspect
for damage, corrosion, oil residue. Photo any damage. Record
inspector initials.</p>
]]></field>
</record>
<record id="fp_step_template_electroclean_std" model="fp.step.template">
<field name="name">Electroclean (Standard)</field>
<field name="code">ELEC_CLEAN_STD</field>
<field name="default_kind">electroclean</field>
<field name="icon">fa-bolt</field>
<field name="description"><![CDATA[
<p>Submerge rack and energize. Record actual amperage, voltage,
and current density. Verify polarity per recipe spec.</p>
]]></field>
</record>
<record id="fp_step_template_strike_std" model="fp.step.template">
<field name="name">Wood's Nickel Strike (Standard)</field>
<field name="code">STRIKE_STD</field>
<field name="default_kind">strike</field>
<field name="icon">fa-flash</field>
<field name="description"><![CDATA[
<p>Apply thin nickel strike to ensure adhesion before main plate.
Record bath ID, time, temperature, electrical readings.</p>
]]></field>
</record>
<record id="fp_step_template_salt_spray_std" model="fp.step.template">
<field name="name">Salt Spray Test (ASTM B117)</field>
<field name="code">SALT_SPRAY_STD</field>
<field name="default_kind">salt_spray</field>
<field name="icon">fa-tint</field>
<field name="description"><![CDATA[
<p>Submit test panel to salt spray cabinet for the specified
duration. Record red rust % and white corrosion %. Attach lab
report on completion.</p>
]]></field>
</record>
<record id="fp_step_template_adhesion_std" model="fp.step.template">
<field name="name">Adhesion Test (Bend / Tape)</field>
<field name="code">ADHESION_STD</field>
<field name="default_kind">adhesion_test</field>
<field name="icon">fa-link</field>
<field name="description"><![CDATA[
<p>Perform adhesion test per spec (bend, tape, burnish, or file).
Photo coupon. Record PASS/FAIL.</p>
]]></field>
</record>
<record id="fp_step_template_hardness_std" model="fp.step.template">
<field name="name">Microhardness Test</field>
<field name="code">HARDNESS_STD</field>
<field name="default_kind">hardness_test</field>
<field name="icon">fa-cube</field>
<field name="description"><![CDATA[
<p>Take three indentations minimum on the test coupon. Record
test load, individual readings, and the computed average.
Confirm equipment calibration is current.</p>
]]></field>
</record>
<record id="fp_step_template_packaging_std" model="fp.step.template">
<field name="name">Packaging (Standard)</field>
<field name="code">PKG_STD</field>
<field name="default_kind">packaging</field>
<field name="icon">fa-archive</field>
<field name="description"><![CDATA[
<p>Wrap parts per customer spec (VCI bag, bubble wrap, separator
paper). Verify cert package included if required. Record quantity
per package and total package count.</p>
]]></field>
</record>
<record id="fp_step_template_replenishment_std" model="fp.step.template">
<field name="name">Tank Replenishment</field>
<field name="code">REPL_STD</field>
<field name="default_kind">replenishment</field>
<field name="icon">fa-flask</field>
<field name="description"><![CDATA[
<p>Mid-shift bath top-up. Record bath ID, chemistry added (name
and amount), pH and concentration before/after. Operator must
sign.</p>
]]></field>
</record>
</odoo>
```
- [ ] **Step 3: Register the data file in the manifest**
Open `fusion_plating/__manifest__.py`. Locate the `data:` list. Add `'data/fp_step_template_data.xml'` after the existing step-template-related entries (search for `step_template` first to find the right location).
- [ ] **Step 4: Commit**
```bash
git add fusion_plating/data/fp_step_template_data.xml fusion_plating/__manifest__.py
git commit -m "data(step-library): seed example templates for new Step Kinds"
```
### Task G2: CoC report — render new input types + filter to collect=True
**Files:**
- Modify: `fusion_plating_reports/views/report_coc_chronological.xml`
- [ ] **Step 1: Read the existing chronological CoC report**
```bash
grep -n "input_ids\|input_data\|t-foreach.*input" K:/Github/Odoo-Modules/fusion_plating/fusion_plating_reports/views/report_coc_chronological.xml | head -20
```
- [ ] **Step 2: Find where step inputs are iterated and add the `collect` filter**
The exact xpath depends on the existing report. Find the `t-foreach` over `move.input_data` or equivalent. Wrap the row rendering in:
```xml
<t t-set="recipe_inputs"
t-value="move.step_id.recipe_node_id.input_ids.filtered(lambda i: i.collect)"/>
<t t-foreach="recipe_inputs" t-as="prompt">
<t t-set="value" t-value="move.input_data.get(str(prompt.id))"/>
<!-- per-type rendering branches... -->
</t>
```
- [ ] **Step 3: Add render branches for the new input types**
```xml
<t t-if="prompt.input_type == 'photo' and value and value.get('attachment_id')">
<img t-att-src="'/web/image/%s' % value['attachment_id']"
style="max-width: 200px; max-height: 150px;"/>
</t>
<t t-elif="prompt.input_type == 'multi_point_thickness' and value">
R1: <t t-esc="value.get('readings', [])[0] or '—'"/>,
R2: <t t-esc="value.get('readings', [])[1] or '—'"/>,
R3: <t t-esc="value.get('readings', [])[2] or '—'"/>,
R4: <t t-esc="value.get('readings', [])[3] or '—'"/>,
R5: <t t-esc="value.get('readings', [])[4] or '—'"/>
→ avg <t t-esc="round(value.get('avg', 0), 4)"/>
</t>
<t t-elif="prompt.input_type == 'bath_chemistry_panel' and value">
pH: <t t-esc="value.get('ph')"/>,
Conc: <t t-esc="value.get('concentration')"/>,
Temp: <t t-esc="value.get('temperature')"/>,
Bath: <t t-esc="value.get('bath_id')"/>
</t>
<t t-elif="prompt.input_type == 'ph' and value">
pH <t t-esc="round(float(value), 2)"/>
</t>
<t t-else="">
<t t-esc="value"/>
</t>
```
- [ ] **Step 4: Commit**
```bash
git add fusion_plating_reports/views/report_coc_chronological.xml
git commit -m "report(coc): render new input types + filter to collect=True"
```
---
## Phase H — Battle Test Script
### Task H1: Create `bt_step_library_audit.py`
**Files:**
- Create: `fusion_plating/scripts/bt_step_library_audit.py`
- [ ] **Step 1: Look at an existing battle-test script for the pattern**
```bash
head -40 K:/Github/Odoo-Modules/fusion_plating/fusion_plating_quality/scripts/bt_s18_cert_flow.py
```
- [ ] **Step 2: Write the battle test**
```python
# -*- coding: utf-8 -*-
"""Battle test — Step Library audit expansion (Sub 12d).
Run via odoo-shell on entech:
ssh pve-worker5 "pct exec 111 -- bash -c 'su - odoo -s /bin/bash -c \"
/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http
\"' < bt_step_library_audit.py"
Asserts 18 properties of the new architecture and prints PASS/FAIL/SKIP.
"""
import logging
_logger = logging.getLogger('bt_step_library_audit')
NEW_KINDS = [
'receiving', 'electroclean', 'strike', 'salt_spray',
'adhesion_test', 'hardness_test', 'packaging', 'replenishment',
]
NEW_INPUT_TYPES = [
'photo', 'multi_point_thickness', 'bath_chemistry_panel', 'ph',
]
results = [] # list of (idx, name, status, detail)
def check(idx, name, condition, detail=''):
status = 'PASS' if condition else 'FAIL'
results.append((idx, name, status, detail))
print('[%s] #%-2d %s%s' % (status, idx, name, detail))
Template = env['fp.step.template']
Node = env['fusion.plating.process.node']
# 1. Every new Step Kind has at least 1 seed template
for kind in NEW_KINDS:
cnt = Template.search_count([('default_kind', '=', kind)])
check(1, 'Seed template for kind %s' % kind, cnt >= 1,
'%d templates found' % cnt)
# 2. Every Step Kind yields expected default inputs after seeding
for kind in NEW_KINDS:
expected = set(spec['name'] for spec in
Template.DEFAULT_INPUTS_BY_KIND.get(kind, []))
if not expected:
continue
tpl = Template.search([('default_kind', '=', kind)], limit=1)
if tpl:
actual = set(tpl.input_template_ids.mapped('name'))
missing = expected - actual
check(2, 'Defaults seeded for %s' % kind, not missing,
'missing: %s' % missing if missing else 'all %d present' % len(expected))
# 3-4. Library → recipe drag preserves all fields including new types AND sets template_input_id
tpl = Template.search([('default_kind', '=', 'plate')], limit=1)
if tpl and tpl.input_template_ids:
src = tpl.input_template_ids[0]
node = Node.create({
'name': 'BT-Snapshot-Test',
'node_type': 'step',
'template_id': tpl.id,
})
inp = env['fusion.plating.process.node.input'].create({
'node_id': node.id,
'template_input_id': src.id,
'name': src.name,
'input_type': src.input_type,
'kind': 'step_input',
'collect': True,
})
check(3, 'Library→recipe input copy', inp.name == src.name and inp.input_type == src.input_type,
'name=%s type=%s' % (inp.name, inp.input_type))
check(4, 'template_input_id set on snapshot', inp.template_input_id == src,
'link=%s' % inp.template_input_id.id)
node.unlink() # cleanup
# 6-7. Wizard filter respects collect=True and master switch
node = Node.create({
'name': 'BT-Wizard-Filter',
'node_type': 'step',
'collect_measurements': True,
})
i1 = env['fusion.plating.process.node.input'].create({
'node_id': node.id, 'name': 'On', 'input_type': 'text',
'kind': 'step_input', 'collect': True,
})
i2 = env['fusion.plating.process.node.input'].create({
'node_id': node.id, 'name': 'Off', 'input_type': 'text',
'kind': 'step_input', 'collect': False,
})
visible = node.input_ids.filtered(lambda i: i.kind == 'step_input' and i.collect)
check(6, 'Wizard filter excludes collect=False', i2 not in visible,
'%d visible of %d' % (len(visible), len(node.input_ids)))
node.collect_measurements = False
# In the wizard's default_get, master=False returns no lines. Simulate:
empty_path = (not node.collect_measurements)
check(7, 'Master collect_measurements=False skips wizard', empty_path,
'master=False short-circuits')
node.unlink()
# 11. Multi-point thickness average computation
class _Stub:
def __init__(self, *vals):
self.point_1, self.point_2, self.point_3, self.point_4, self.point_5 = vals
non_empty = [v for v in vals if v]
self.point_avg = sum(non_empty)/len(non_empty) if non_empty else 0
s = _Stub(0.001, 0.0012, 0.0011, 0, 0) # 3 readings
check(11, 'Multi-point avg (skip empties)', round(s.point_avg, 5) == 0.0011,
'avg=%s' % s.point_avg)
# 16. Common audit fields idempotent
tpl = Template.create({'name': 'BT-Audit-Idempotent', 'default_kind': 'plate'})
tpl.action_add_common_audit_fields()
n1 = len(tpl.input_template_ids)
tpl.action_add_common_audit_fields()
n2 = len(tpl.input_template_ids)
check(16, 'Common audit fields idempotent', n1 == n2,
'first=%d second=%d' % (n1, n2))
tpl.unlink()
# 17. action_seed_default_inputs idempotent across edits
tpl = Template.create({'name': 'BT-Seed-Idempotent', 'default_kind': 'plate'})
tpl.action_seed_default_inputs()
n1 = len(tpl.input_template_ids)
# User edit
tpl.input_template_ids[0].name = 'EDITED-DO-NOT-CLOBBER'
tpl.action_seed_default_inputs()
n2 = len(tpl.input_template_ids)
edited = tpl.input_template_ids.filtered(lambda i: i.name == 'EDITED-DO-NOT-CLOBBER')
check(17, 'Seed idempotent + preserves edits', n1 <= n2 and len(edited) == 1,
'before=%d after=%d edit_preserved=%s' % (n1, n2, bool(edited)))
tpl.unlink()
# Summary
total = len(results)
passed = sum(1 for r in results if r[2] == 'PASS')
failed = sum(1 for r in results if r[2] == 'FAIL')
print('\n=== %d / %d PASSED · %d FAILED ===' % (passed, total, failed))
env.cr.commit()
```
NOTE: This is an abridged battle test covering the most critical 8 of the 18 spec assertions. The remaining (CoC report rendering, photo attachment, custom prompt survival across reset, etc.) require either fixture data or a running tablet UI and are best validated manually after the script runs. Document this in the final spec review pass.
- [ ] **Step 3: Commit**
```bash
git add fusion_plating/scripts/bt_step_library_audit.py
git commit -m "test(step-library): battle-test script for audit expansion"
```
---
## Phase I — Deploy + Verify
### Task I1: Bump manifests
**Files:**
- Modify: `fusion_plating/__manifest__.py`
- Modify: `fusion_plating_jobs/__manifest__.py`
- Modify: `fusion_plating_reports/__manifest__.py`
- [ ] **Step 1: Bump `fusion_plating` to `19.0.18.7.0`**
Run: `grep -n "version" K:/Github/Odoo-Modules/fusion_plating/fusion_plating/__manifest__.py | head -1`
Edit the `'version': '19.0.x.y.z'` line to `'19.0.18.7.0'`.
- [ ] **Step 2: Bump `fusion_plating_jobs` to `19.0.18.7.0`**
Same file pattern in `fusion_plating_jobs/__manifest__.py`.
- [ ] **Step 3: Bump `fusion_plating_reports` to `19.0.18.7.0`**
Same file pattern in `fusion_plating_reports/__manifest__.py`.
- [ ] **Step 4: Commit**
```bash
git add fusion_plating/__manifest__.py fusion_plating_jobs/__manifest__.py fusion_plating_reports/__manifest__.py
git commit -m "version: bump fusion_plating + jobs + reports to 19.0.18.7.0"
```
### Task I2: Push to entech and run upgrade
- [ ] **Step 1: Tar all touched files and push via SSH**
```bash
cd K:/Github/Odoo-Modules/fusion_plating && tar czf - \
fusion_plating/__manifest__.py \
fusion_plating/models/fp_step_template.py \
fusion_plating/models/fp_step_template_input.py \
fusion_plating/models/fp_process_node.py \
fusion_plating/views/fp_step_template_views.xml \
fusion_plating/data/fp_step_template_data.xml \
fusion_plating/migrations/19.0.18.7.0/post-migrate.py \
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 \
fusion_plating/controllers/simple_recipe_controller.py \
fusion_plating/scripts/bt_step_library_audit.py \
fusion_plating_jobs/__manifest__.py \
fusion_plating_jobs/wizards/fp_job_step_input_wizard.py \
fusion_plating_jobs/wizards/fp_job_step_input_wizard_views.xml \
fusion_plating_reports/__manifest__.py \
fusion_plating_reports/views/report_coc_chronological.xml \
| ssh pve-worker5 "pct exec 111 -- bash -c 'cd /mnt/extra-addons/custom && tar --no-same-owner -xzf - && systemctl stop odoo && su - postgres -s /bin/bash -c \"psql -d admin -c \\\"DELETE FROM ir_attachment WHERE url LIKE '\''/web/assets/%'\'';\\\"\" && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating,fusion_plating_jobs,fusion_plating_reports --stop-after-init\" 2>&1 | tail -25 && systemctl start odoo && systemctl is-active odoo'"
```
- [ ] **Step 2: Look for errors in the tail output**
Expected: `Modules loaded`, `Registry loaded`, `active`. Any `ERROR` or `Traceback` is a fail; debug and re-deploy.
### Task I3: Run the battle test on entech
- [ ] **Step 1: Pipe the script into odoo-shell**
```bash
cat K:/Github/Odoo-Modules/fusion_plating/fusion_plating/scripts/bt_step_library_audit.py | ssh pve-worker5 "pct exec 111 -- bash -c 'su - odoo -s /bin/bash -c \"/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http\"'" 2>&1 | tail -40
```
- [ ] **Step 2: Confirm all assertions PASS**
Look for `=== N / N PASSED · 0 FAILED ===` at the bottom. If any FAIL, capture the failing assertion + the actual vs expected and fix in source before re-running.
### Task I4: Manual smoke test on the live UI
- [ ] **Step 1: Open entech in browser, hard-refresh (Ctrl+F5)**
- [ ] **Step 2: Navigate to Plating → Configuration → Step Library**
Verify:
- 8 new seed templates appear (one per new kind)
- Form view shows "+ Common Audit Fields" button in header
- Description tab labelled "Default Operator Instructions"
- [ ] **Step 3: Open the Simple Recipe Editor on any existing recipe**
Verify:
- Each step shows ▸ 📋 Instructions and ▸ ⚙ Measurements affordances
- Clicking ⚙ Measurements opens the prompts list with collect checkboxes
- Toggling a checkbox triggers an HTTP POST and persists on reload
- [ ] **Step 4: Run a job through Mark Done**
Find a draft `fp.job` with at least one recipe step. Hit Mark Done on a step. Verify:
- Wizard fires showing only `collect=True` prompts
- New input types render their per-type widgets (photo, multi-point, etc.)
- After commit, the recorded values appear on the step's chatter / move record
- [ ] **Step 5: Generate the chronological CoC for that job**
Verify the report includes the new input types (photo thumbnail, multi-point readings + avg, bath chemistry panel) and excludes prompts where `collect=False`.
### Task I5: Final commit + cleanup
- [ ] **Step 1: Final commit (all phases combined into a clean history)**
If history is messy, optionally squash via interactive rebase. If clean, leave alone.
- [ ] **Step 2: Update `MEMORY.md` with a one-liner if appropriate**
If the deploy revealed an interesting Odoo gotcha (e.g. JSON serialization quirk, OWL state pattern), add a line to user memory under `feedback_*.md` so future sessions remember.
---
## Self-Review Notes
**Spec coverage check:**
- ✅ 8 new Step Kinds → Task B1
- ✅ Beefed-up defaults on existing kinds → Task B2 (full DEFAULT_INPUTS_BY_KIND rewrite)
- ✅ 4 new input types → Tasks A1, A2, E2
- ✅ Per-recipe `collect` + `collect_measurements` → Task A3
- ✅ "Add Common Audit Fields" → Task B3
- ✅ Office instructions relabel + library/recipe override → Task C1, F2
- ✅ Migration → Task D1
- ✅ Runtime wizard filter → Task E1
- ✅ New-type widgets → Task E2, E3
- ✅ Simple editor expansions → Task F1, F2
- ✅ Seed data → Task G1
- ✅ CoC report rendering + filter → Task G2
- ✅ Battle test → Task H1
- ✅ Deploy + verify → Tasks I1I5
**Type consistency:**
- `collect` (Boolean, default True) used consistently
- `collect_measurements` (Boolean, default True) used consistently
- `template_input_id` (Many2one to fp.step.template.input) used consistently
- New input type codes (`photo`, `multi_point_thickness`, `bath_chemistry_panel`, `ph`) used identically across A1, A2, E2, G2
**Known caveats requiring inline judgment:**
- Migration SQL table names (Task D1) — verify before running
- Wizard line model location (Task E2 step 2) — read first to decide whether to extend or create new file
- CoC report exact xpath (Task G2) — depends on existing report structure
- Some battle-test assertions (CoC rendering, photo attachment integrity, custom-prompt survival) require manual UI verification — script covers 10 of 18; remainder noted in Task I4 manual smoke test