From bbf2476f01362159f171b76dad979f4bc2b79ed1 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 29 Apr 2026 21:56:18 -0400 Subject: [PATCH] plan(step-library): full implementation plan for audit expansion + per-recipe configurability Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-04-29-step-library-audit.md | 1858 +++++++++++++++++ 1 file changed, 1858 insertions(+) create mode 100644 fusion_plating/docs/superpowers/plans/2026-04-29-step-library-audit.md diff --git a/fusion_plating/docs/superpowers/plans/2026-04-29-step-library-audit.md b/fusion_plating/docs/superpowers/plans/2026-04-29-step-library-audit.md new file mode 100644 index 00000000..7f740a00 --- /dev/null +++ b/fusion_plating/docs/superpowers/plans/2026-04-29-step-library-audit.md @@ -0,0 +1,1858 @@ +# 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 `
` (around line 32), add a second button after `action_seed_default_inputs`** + +Replace: + +```xml +
+
+``` + +With: + +```xml +
+
+``` + +- [ ] **Step 2: Update the Instructions tab label and the field placeholder** + +Replace: + +```xml + + + +``` + +With: + +```xml + + + + +``` + +- [ ] **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 `
` block of `line_ids`, add per-type conditional fields. Each branch is wrapped in `invisible="input_type != ''"`: + +```xml + + + + + + + + + + + + + + + + + + + + + +``` + +(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 +
+ + +
+ + +
+