diff --git a/fusion_plating/docs/superpowers/plans/2026-05-24-shopfloor-live-step-fix-plan.md b/fusion_plating/docs/superpowers/plans/2026-05-24-shopfloor-live-step-fix-plan.md new file mode 100644 index 00000000..9605f7ea --- /dev/null +++ b/fusion_plating/docs/superpowers/plans/2026-05-24-shopfloor-live-step-fix-plan.md @@ -0,0 +1,1125 @@ +# Shop Floor — Live Step + Kind/Library Cleanup 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:** Fix the Shop Floor plant kanban so cards land in the correct column based on each step's `area_kind`, drop done jobs from the board, expand the step-kind taxonomy with `blast`/`derack`/`demask`/`gating`, and backfill missing metadata on 30 library templates. + +**Architecture:** Make `fp.step.kind` authoritative for column routing by adding a required `area_kind` Selection field. Delete the hardcoded `_STEP_KIND_TO_AREA` dict. Pre-migrate seeds existing kinds; post-migrate backfills template metadata + repoints recipe nodes using unambiguous name patterns. All migrations are idempotent (re-running `-u` is safe). + +**Spec:** [docs/superpowers/specs/2026-05-24-shopfloor-live-step-fix-design.md](../specs/2026-05-24-shopfloor-live-step-fix-design.md) + +**Tech Stack:** Odoo 19, Python (api/orm/migrations), PostgreSQL, QWeb XML, OWL components. + +--- + +## File Inventory (what each task touches) + +| Path | Responsibility | +|---|---| +| `fusion_plating/models/fp_step_kind.py` | Add `area_kind` Selection field | +| `fusion_plating/views/fp_step_kind_views.xml` | Form + list views surface `area_kind` | +| `fusion_plating/data/fp_step_kind_data.xml` | Add new `step_kind_blast` record | +| `fusion_plating/data/fp_step_template_data.xml` | Add Hot Water Porosity + Final Inspection / Packaging templates | +| `fusion_plating/controllers/simple_recipe_controller.py` | Include `area_kind` in `kindOptions` payload | +| `fusion_plating/static/src/xml/simple_recipe_editor.xml` | Kind picker shows "→ Column" suffix | +| `fusion_plating/migrations/19.0.21.2.0/pre-migrate.py` | NEW — seed `area_kind`, activate `derack`/`demask`/`gating` | +| `fusion_plating/__manifest__.py` | Version bump to `19.0.21.2.0` | +| `fusion_plating_jobs/models/fp_job.py` | `_compute_active_step_id` priority chain + `_compute_card_state` edge case | +| `fusion_plating_jobs/models/fp_job_step.py` | Simplify `_compute_area_kind`; delete `_STEP_KIND_TO_AREA` | +| `fusion_plating_jobs/migrations/19.0.10.24.0/post-migrate.py` | NEW — template backfill + node repointing + recomputes | +| `fusion_plating_jobs/__manifest__.py` | Version bump to `19.0.10.24.0` | +| `fusion_plating_shopfloor/controllers/plant_kanban.py` | State filter + comment update | +| `fusion_plating_shopfloor/__manifest__.py` | Version bump to `19.0.33.1.3` | +| `fusion_plating_quality/scripts/bt_s24_between_steps.py` | NEW — battle test | + +--- + +## Task 1: Add `area_kind` field on `fp.step.kind` + +**Files:** +- Modify: `fusion_plating/models/fp_step_kind.py` + +- [ ] **Step 1: Add the field definition** + +In [`fusion_plating/models/fp_step_kind.py`](../../fusion_plating/models/fp_step_kind.py), inside the `FpStepKind` class, add the field right after the `description` field (around line 31): + +```python +area_kind = fields.Selection( + [ + ('receiving', 'Receiving'), + ('masking', 'Masking'), + ('blasting', 'Blasting'), + ('racking', 'Racking'), + ('plating', 'Plating'), + ('baking', 'Baking'), + ('de_racking', 'De-Racking'), + ('inspection', 'Final Inspection'), + ('shipping', 'Shipping'), + ], + string='Shop Floor Column', + required=True, + index=True, + tracking=True, + help='Determines which column on the Shop Floor plant kanban shows ' + 'cards whose active step uses this kind. Step kinds drive ' + 'routing automatically — picking a kind tells the system both ' + 'what gates fire AND where the card lives.', +) +``` + +- [ ] **Step 2: Load test deferred to Task 7** + +The `required=True` will fail on existing rows until pre-migrate seeds them. Don't try to load fusion_plating yet — the pre-migrate (Task 7) must land first. + +--- + +## Task 2: Update Step Kind form + list views + +**Files:** +- Modify: `fusion_plating/views/fp_step_kind_views.xml` + +- [ ] **Step 1: Update form view** + +In [`fusion_plating/views/fp_step_kind_views.xml`](../../fusion_plating/views/fp_step_kind_views.xml), find the form view's `` and add `area_kind` to the main field group: + +```xml + + + + + + + +``` + +- [ ] **Step 2: Update list view** + +In the same file, find the list view. Add `area_kind` after `name`: + +```xml + +``` + +- [ ] **Step 3: No commit yet — grouped with Tasks 1, 3, 4, 5, 7 under one fusion_plating commit.** + +--- + +## Task 3: Add `step_kind_blast` data record + seed area_kind on existing kinds + +**Files:** +- Modify: `fusion_plating/data/fp_step_kind_data.xml` + +- [ ] **Step 1: Add the new blast kind** + +In [`fusion_plating/data/fp_step_kind_data.xml`](../../fusion_plating/data/fp_step_kind_data.xml), inside the "ACTIVE KINDS" block. After the `step_kind_mask` record (sequence=40), add: + +```xml + + blast + Blasting / Media Blast + 35 + fa-bullseye + blasting + +``` + +- [ ] **Step 2: Add `` to each existing active kind record** + +For each active kind record in the file, add the area_kind line. The mapping (from spec Change 10 `KIND_TO_AREA`): + +| code | area_kind | +|---|---| +| other | plating | +| wet_process | plating | +| receiving | receiving | +| contract_review | receiving | +| racking | racking | +| mask | masking | +| cleaning | plating | +| electroclean | plating | +| etch | plating | +| rinse | plating | +| strike | plating | +| plate | plating | +| replenishment | plating | +| wbf_test | plating | +| dry | plating | +| bake | baking | +| demask | de_racking | +| derack | de_racking | +| inspect | inspection | +| hardness_test | inspection | +| adhesion_test | inspection | +| salt_spray | inspection | +| final_inspect | inspection | +| packaging | shipping | +| ship | shipping | +| gating | receiving | + +Example for `step_kind_other`: + +```xml + + other + Other + 5 + fa-circle-o + plating + +``` + +Note: the file is wrapped in ``, so this only affects FRESH installs. Existing installs (like entech) are handled by the pre-migrate in Task 7. + +- [ ] **Step 3: No commit yet — grouped with Task 7.** + +--- + +## Task 4: Add new templates to data file + +**Files:** +- Modify: `fusion_plating/data/fp_step_template_data.xml` + +- [ ] **Step 1: Add Hot Water Porosity Test template** + +At the bottom of [`fusion_plating/data/fp_step_template_data.xml`](../../fusion_plating/data/fp_step_template_data.xml), inside the existing `` block: + +```xml + + Hot Water Porosity Test (A-15) + HWP_A15 + 10 + fa-tint + + + <p>Hot-water porosity test for plated samples. Verify continuity + of the deposit across the test panel; record any porosity sites.</p> + + +``` + +- [ ] **Step 2: Add Final Inspection / Packaging template** + +Right after, add: + +```xml + + Final Inspection / Packaging + FINAL_PKG_STD + 10 + fa-check-circle + + + <p>Combined final visual + dimensional inspection followed by + packaging into the customer&rsquo;s original boxes for shipment.</p> + + +``` + +- [ ] **Step 3: No commit yet — grouped with Task 7.** + +--- + +## Task 5: Update Simple Editor kind picker UI + +**Files:** +- Modify: `fusion_plating/controllers/simple_recipe_controller.py` +- Modify: `fusion_plating/static/src/xml/simple_recipe_editor.xml` + +- [ ] **Step 1: Include `area_kind` + label in the kindOptions payload** + +Open [`fusion_plating/controllers/simple_recipe_controller.py`](../../fusion_plating/controllers/simple_recipe_controller.py). Search for `kindOptions` or the function that builds the kind list. Look for a list comprehension over `env['fp.step.kind'].search([])`. In the per-kind dict, add two new keys: + +```python +{ + 'id': k.id, + 'code': k.code, + 'name': k.name, + 'icon': k.icon, + 'sequence': k.sequence, + # NEW — for the picker UI hint (spec Change 7) + 'area_kind': k.area_kind, + 'area_kind_label': dict(k._fields['area_kind'].selection).get(k.area_kind, ''), +} +``` + +(The existing payload shape may differ — add the two new keys to whatever the actual dict is.) + +- [ ] **Step 2: Update the picker option in XML** + +In [`fusion_plating/static/src/xml/simple_recipe_editor.xml`](../../fusion_plating/static/src/xml/simple_recipe_editor.xml) around line 510, change: + +```xml + +``` + +to: + +```xml + +``` + +- [ ] **Step 3: No commit yet — grouped with Task 7.** + +--- + +## Task 6: (Removed — covered by Task 2) + +The form-level Step Kind catalog UI is already handled by Task 2. Skipping. + +--- + +## Task 7: Write `fusion_plating` pre-migrate + version bump + commit Phase 1 + +**Files:** +- Create: `fusion_plating/migrations/19.0.21.2.0/pre-migrate.py` +- Modify: `fusion_plating/__manifest__.py` + +- [ ] **Step 1: Create the migration directory** + +```bash +mkdir -p fusion_plating/migrations/19.0.21.2.0 +``` + +- [ ] **Step 2: Write the pre-migrate file** + +Create [`fusion_plating/migrations/19.0.21.2.0/pre-migrate.py`](../../fusion_plating/migrations/19.0.21.2.0/pre-migrate.py): + +```python +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +"""19.0.21.2.0 — Shop Floor live-step + kind taxonomy. + +Seeds fp.step.kind.area_kind on existing kinds BEFORE the NOT NULL +constraint added by Change 5 hits the schema. Also activates the three +inactive kinds (derack/demask/gating) needed for the full area_kind +taxonomy. + +Idempotent: only fills NULLs / inactive rows. +""" +import logging + +_logger = logging.getLogger(__name__) + +KIND_TO_AREA = { + 'other': 'plating', + 'wet_process': 'plating', + 'receiving': 'receiving', + 'contract_review': 'receiving', + 'gating': 'receiving', + 'racking': 'racking', + 'derack': 'de_racking', + 'mask': 'masking', + 'demask': 'de_racking', + 'cleaning': 'plating', + 'electroclean': 'plating', + 'etch': 'plating', + 'rinse': 'plating', + 'strike': 'plating', + 'plate': 'plating', + 'replenishment': 'plating', + 'wbf_test': 'plating', + 'dry': 'plating', + 'bake': 'baking', + 'inspect': 'inspection', + 'final_inspect': 'inspection', + 'hardness_test': 'inspection', + 'adhesion_test': 'inspection', + 'salt_spray': 'inspection', + 'packaging': 'shipping', + 'ship': 'shipping', + 'blast': 'blasting', + 'bead_blast': 'blasting', + 'media_blast': 'blasting', +} + + +def migrate(cr, version): + # Phase 1 — Pre-create the column (NULL-permitting) so we can + # seed it BEFORE Odoo's schema sync tries to enforce NOT NULL. + cr.execute(""" + ALTER TABLE fp_step_kind + ADD COLUMN IF NOT EXISTS area_kind VARCHAR + """) + + # Phase 2 — Seed area_kind on existing kinds where it's NULL. + for code, area in KIND_TO_AREA.items(): + cr.execute( + "UPDATE fp_step_kind SET area_kind = %s " + "WHERE code = %s " + "AND (area_kind IS NULL OR area_kind = '')", + (area, code), + ) + _logger.info('[live-step-fix] kind.area_kind seeded for known codes') + + # Phase 3 — Fallback: user-created kinds not in our seed map → + # 'plating'. Clears the NOT NULL constraint for any leftover row. + cr.execute( + "UPDATE fp_step_kind SET area_kind = 'plating' " + "WHERE area_kind IS NULL OR area_kind = ''" + ) + _logger.info( + '[live-step-fix] %s unknown kinds defaulted to plating', + cr.rowcount, + ) + + # Phase 4 — Activate kinds we need (Cat A). + for code in ('derack', 'demask', 'gating'): + cr.execute( + "UPDATE fp_step_kind SET active = TRUE " + "WHERE code = %s AND active = FALSE", + (code,), + ) + _logger.info('[live-step-fix] derack/demask/gating activated') +``` + +- [ ] **Step 3: Bump manifest version** + +In [`fusion_plating/__manifest__.py`](../../fusion_plating/__manifest__.py), change: + +```python +'version': '19.0.21.1.3', +``` + +to: + +```python +'version': '19.0.21.2.0', +``` + +- [ ] **Step 4: Local dev test — upgrade fusion_plating only** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating --stop-after-init 2>&1 | tail -40 +``` + +Expected: clean upgrade with log line `[live-step-fix] kind.area_kind seeded for known codes`, no tracebacks. + +- [ ] **Step 5: Verify the field landed** + +```bash +docker exec odoo-dev-db psql -U odoo -d fusion-dev -c \ + "SELECT code, area_kind, active FROM fp_step_kind ORDER BY sequence, id;" +``` + +Expected: every row has `area_kind` set; `derack`, `demask`, `gating` rows show `active=t`. A `blast` row exists with `area_kind='blasting'`. + +- [ ] **Step 6: Commit Phase 1** + +```bash +git add fusion_plating/models/fp_step_kind.py \ + fusion_plating/views/fp_step_kind_views.xml \ + fusion_plating/data/fp_step_kind_data.xml \ + fusion_plating/data/fp_step_template_data.xml \ + fusion_plating/controllers/simple_recipe_controller.py \ + fusion_plating/static/src/xml/simple_recipe_editor.xml \ + fusion_plating/migrations/19.0.21.2.0/pre-migrate.py \ + fusion_plating/__manifest__.py +git commit -m "$(cat <<'EOF' +feat(fusion_plating): kind.area_kind drives Shop Floor column routing + +Add required area_kind Selection to fp.step.kind so each kind +self-declares which plant-view column its steps belong in. Replaces +the hardcoded _STEP_KIND_TO_AREA dict (removed in fp_job_step.py +in a follow-up commit). + +- New `blast` kind for the Blasting column +- Activate `derack` / `demask` / `gating` (were dropped in 19.0.20.6.0) +- Step Kind form + list views surface area_kind +- Simple Editor kind picker shows "→ Column" suffix +- Add Hot Water Porosity Test + Final Inspection/Packaging templates +- Pre-migrate seeds area_kind on existing kinds before NOT NULL hits + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 8: Rewrite `_compute_active_step_id` priority chain + +**Files:** +- Modify: `fusion_plating_jobs/models/fp_job.py:385-391` + +- [ ] **Step 1: Replace the compute** + +In [`fusion_plating_jobs/models/fp_job.py`](../../fusion_plating_jobs/models/fp_job.py), find `_compute_active_step_id` (around line 386). Replace the entire method with: + +```python +@api.depends('step_ids.state', 'step_ids.sequence') +def _compute_active_step_id(self): + """Pick the "live" step — first match by priority then sequence. + + Priority order: + in_progress > paused > ready > first pending + + in_progress is the most informative (someone is actively working + on it). paused means someone was working and stopped — the card + belongs at that station so the next operator can pick it up. ready + is the next-up step waiting for an operator. The first pending + after a done step is the "next gate" — where the card visually + waits between steps. + + Returns False only when every step is `done` (job finished) or + when there are no steps at all (recipe not assigned). + + See spec 2026-05-24-shopfloor-live-step-fix-design.md Change 1. + """ + PRIORITY_STATES = ('in_progress', 'paused', 'ready', 'pending') + for job in self: + ordered = job.step_ids.sorted('sequence') + live = job.env['fp.job.step'] + for state in PRIORITY_STATES: + live = ordered.filtered(lambda s: s.state == state) + if live: + break + job.active_step_id = live[:1].id if live else False +``` + +- [ ] **Step 2: No commit yet — grouped with Task 12.** + +--- + +## Task 9: Fix `_compute_card_state` edge case + +**Files:** +- Modify: `fusion_plating_jobs/models/fp_job.py:261-267` + +- [ ] **Step 1: Replace the edge-case branch** + +In the same file, find `_compute_card_state` (around line 257). Replace the first `if not job.active_step_id:` block with: + +```python +if not job.active_step_id: + # Edge: no live step. + # - job.state='done' → 'done' (defensive — done jobs are filtered + # off the board upstream, but the field still needs a value). + # - confirmed + parts not yet received → 'no_parts'. + # - else → 'ready' (job awaiting work, no steps yet OR recipe + # not assigned). + if job.state == 'done': + job.card_state = 'done' + elif (job.state == 'confirmed' + and job._fp_inbound_not_received()): + job.card_state = 'no_parts' + else: + job.card_state = 'ready' + continue +``` + +- [ ] **Step 2: No commit yet — grouped with Task 12.** + +--- + +## Task 10: Simplify `_compute_area_kind`; delete `_STEP_KIND_TO_AREA` + +**Files:** +- Modify: `fusion_plating_jobs/models/fp_job_step.py` + +- [ ] **Step 1: Delete the `_STEP_KIND_TO_AREA` module-level dict** + +In [`fusion_plating_jobs/models/fp_job_step.py`](../../fusion_plating_jobs/models/fp_job_step.py), remove the block from line 20 (`# Mapping from fp.process.node.default_kind → fp.work.centre.area_kind.`) through line 73 (the closing `}` of `_STEP_KIND_TO_AREA`). Total ~54 lines deleted. + +- [ ] **Step 2: Replace the compute** + +Find `_compute_area_kind` (around line 178). Replace with: + +```python +@api.depends( + 'work_centre_id.area_kind', + 'recipe_node_id.kind_id.area_kind', +) +def _compute_area_kind(self): + """Resolve the plant-view column this step belongs in. + + Priority chain: + 1. work_centre.area_kind (explicit operator setup wins) + 2. recipe_node.kind_id.area_kind (kind taxonomy authoritative) + 3. catch-all 'plating' (data integrity issue if we land here) + + The legacy _STEP_KIND_TO_AREA dict was removed — fp.step.kind now + self-declares its area_kind, so the kind taxonomy IS the source + of truth. See spec 2026-05-24-shopfloor-live-step-fix-design.md. + """ + for step in self: + # 1. Explicit work_centre wins + if step.work_centre_id and step.work_centre_id.area_kind: + step.area_kind = step.work_centre_id.area_kind + continue + # 2. Kind taxonomy + node = step.recipe_node_id + if node and node.kind_id and node.kind_id.area_kind: + step.area_kind = node.kind_id.area_kind + continue + # 3. Catch-all — only reached for orphaned steps (no work_centre + # AND no recipe_node). + step.area_kind = 'plating' +``` + +- [ ] **Step 3: Search for any leftover `_STEP_KIND_TO_AREA` references** + +```bash +grep -rn "_STEP_KIND_TO_AREA" --include='*.py' . +``` + +Expected: zero results. If any show up (tests, scripts), update them — most likely they just import the dict and check membership, which can be removed. + +- [ ] **Step 4: No commit yet — grouped with Task 12.** + +--- + +## Task 11: Add board state filter + comment update + +**Files:** +- Modify: `fusion_plating_shopfloor/controllers/plant_kanban.py` +- Modify: `fusion_plating_shopfloor/__manifest__.py` + +- [ ] **Step 1: Add state filter to the fp.job search** + +In [`fusion_plating_shopfloor/controllers/plant_kanban.py`](../../fusion_plating_shopfloor/controllers/plant_kanban.py), find where the controller searches `fp.job`. Look for `env['fp.job'].search(` in the main payload-building function (around the `_render_card` / column-building section). + +Update the search domain so done + cancelled jobs drop off: + +```python +# Defect 4 fix: done + cancelled jobs drop off the live board. +# They stay reachable via smart buttons, history views, and the +# backend Plating Jobs list. See spec 2026-05-24-shopfloor-live-step-fix-design.md. +jobs = env['fp.job'].search([ + ('state', 'in', ('confirmed', 'in_progress')), + # ...keep any existing domain conditions... +]) +``` + +(Adapt the exact patch to the actual function — there may already be a domain you need to extend with the state filter.) + +- [ ] **Step 2: Update `_resolve_card_area` docstring** + +In the same file, find `_resolve_card_area` (around line 161). Replace the docstring with: + +```python +def _resolve_card_area(job): + """Pick the column a card lives in. + + Active-step area_kind wins. With the live-step priority chain + (see fp.job._compute_active_step_id), active_step_id is False + only when the job has NO steps at all (recipe not assigned) + OR every step is done. Done jobs are filtered off the board + upstream, so this fallback fires only for truly orphaned cards. + """ + if job.active_step_id and job.active_step_id.area_kind: + return job.active_step_id.area_kind + # Orphan fallback — represents a data integrity issue, not a + # normal state. Cards here have NO steps assigned at all. + return 'receiving' +``` + +- [ ] **Step 3: Bump fusion_plating_shopfloor manifest version** + +In [`fusion_plating_shopfloor/__manifest__.py`](../../fusion_plating_shopfloor/__manifest__.py): + +```python +'version': '19.0.33.1.2', +``` + +to: + +```python +'version': '19.0.33.1.3', +``` + +- [ ] **Step 4: No commit yet — grouped with Task 12.** + +--- + +## Task 12: Write post-migrate + battle test + commit Phase 2 + +**Files:** +- Create: `fusion_plating_jobs/migrations/19.0.10.24.0/post-migrate.py` +- Modify: `fusion_plating_jobs/__manifest__.py` +- Create: `fusion_plating_quality/scripts/bt_s24_between_steps.py` + +- [ ] **Step 1: Create the migration directory** + +```bash +mkdir -p fusion_plating_jobs/migrations/19.0.10.24.0 +``` + +- [ ] **Step 2: Write the post-migrate** + +Create [`fusion_plating_jobs/migrations/19.0.10.24.0/post-migrate.py`](../../fusion_plating_jobs/migrations/19.0.10.24.0/post-migrate.py): + +```python +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +"""19.0.10.24.0 — Template metadata backfill + recipe-node repointing. + +Runs AFTER fusion_plating's pre-migrate (which seeds kind.area_kind +and activates derack/demask/gating). At this point: + - All kinds have area_kind set. + - blast / derack / demask / gating exist and are active. + - XML data files have loaded (new templates exist). + +This migration: + 1. Backfills code / description / icon / kind_id on the 30 + library templates seeded without metadata. + 2. Repoints existing recipe nodes from wrong kinds to correct + ones using unambiguous name patterns. + 3. Recomputes area_kind on all fp.job.step rows. + 4. Recomputes active_step_id + card_state on in-flight jobs. + +All phases idempotent — re-running -u is safe. +""" +import logging + +from odoo.api import Environment, SUPERUSER_ID + +_logger = logging.getLogger(__name__) + +# (name : (code, icon, kind_code, description_snippet)) +TEMPLATE_BACKFILL = { + 'Acid Dip': ('ACID_DIP_STD', 'fa-flask', 'wet_process', 'Short acid immersion to activate the substrate before plating.'), + 'Air Dry': ('AIR_DRY_STD', 'fa-sun-o', 'wet_process', 'Air drying step between wet-line operations.'), + 'Bake': ('BAKE_STD', 'fa-fire', 'bake', 'Post-plate bake for hydrogen embrittlement relief.'), + 'Blasting': ('BLAST_STD', 'fa-bullseye', 'blast', 'Media or bead blasting to prepare the substrate.'), + 'Check Sulfamate Nickel Area': ('CHECK_SN_AREA', 'fa-search', 'inspect', 'Quick visual area check on the sulfamate nickel line.'), + 'Contract Review': ('CR_STD', 'fa-file-text-o', 'contract_review', 'QA-005 contract review gate. Required when the customer flag is on.'), + 'De-Masking': ('DEMASK_STD', 'fa-eraser', 'demask', 'Remove masking material after plating. Folds into De-Racking column.'), + 'DeRacking': ('DERACK_STD', 'fa-th', 'derack', 'Remove parts from racks for inspection / packaging.'), + 'Desmut': ('DESMUT_STD', 'fa-flask', 'wet_process', 'Remove smut from aluminium surfaces after etching.'), + 'Drying': ('DRYING_STD', 'fa-sun-o', 'wet_process', 'Drying step (oven or air) at the end of the wet line.'), + 'E-Nickel Plating': ('ENP_STD', 'fa-diamond', 'plate', 'Electroless nickel plate operation. Time and temp per recipe.'), + 'Electroclean': ('ECLEAN_STD', 'fa-bolt', 'wet_process', 'Anodic / cathodic electrocleaning step on the cleaning line.'), + 'Etch': ('ETCH_STD', 'fa-flask', 'wet_process', 'Chemical etching to prepare the substrate.'), + 'Final Inspection': ('FINAL_INSP_STD', 'fa-check-circle', 'final_inspect', 'Final visual + dimensional QA before packing.'), + 'HCl Activation': ('HCL_ACT_STD', 'fa-flask', 'wet_process', 'HCl activation dip prior to strike or plate.'), + 'Inspection': ('INSP_STD', 'fa-search', 'inspect', 'In-process inspection step.'), + 'Masking': ('MASK_STD', 'fa-paint-brush', 'mask', 'Apply masking to areas that should not be plated.'), + 'Nickel Strip (S-1)': ('NI_STRIP_S1', 'fa-undo', 'wet_process', 'Chemical strip of prior nickel deposit (rework path).'), + 'Nickel Strip - Steel Line': ('NI_STRIP_SL', 'fa-undo', 'wet_process', 'Chemical strip on the steel line (rework path).'), + 'Post-plate Inspection': ('POST_INSP_STD', 'fa-check-circle', 'inspect', 'Post-plate inspection — thickness sample + visual.'), + 'Pre-Measurements': ('PRE_MEAS_STD', 'fa-tachometer', 'inspect', 'Pre-process dimensional measurements (FAIR start point).'), + 'Racking': ('RACK_STD', 'fa-th', 'racking', 'Load parts onto racks for plating.'), + 'Ready for Plating': ('GATE_PLATE', 'fa-flag', 'gating', 'Gating step — parts staged ready for the plating line.'), + 'Ready for processing': ('GATE_PROC', 'fa-flag', 'gating', 'Generic gating step — parts staged ready for the next operation.'), + 'Rinse': ('RINSE_STD', 'fa-tint', 'wet_process', 'Rinse step between wet-line operations.'), + 'Shipping': ('SHIP_STD', 'fa-paper-plane', 'ship', 'Final shipping / hand-off to logistics.'), + 'Soak Clean': ('SOAK_CLEAN_STD', 'fa-bathtub', 'wet_process', 'Soak cleaning step at the start of the wet line.'), + 'Surface Activation': ('SURF_ACT_STD', 'fa-flask', 'wet_process', 'Surface activation dip prior to plate.'), + 'Water Break Test': ('WBF_TEST_STD', 'fa-tint', 'wet_process', 'Water-break test for surface cleanliness.'), + 'Zincate': ('ZINCATE_STD', 'fa-flask', 'wet_process', 'Zincate immersion on aluminium prior to plate.'), +} + +# (filter_sql, current_kind_code, new_kind_code, description) +# current_kind_code=None means "any kind that isn't the target" +NODE_REPOINTING = [ + ("n.name = 'Blasting'", 'other', 'blast', 'Blasting -> blast'), + ("n.name ILIKE 'Ready %%'", None, 'gating', 'Ready For X -> gating'), + ("n.name ILIKE '%%De-Masking%%' OR n.name ILIKE '%%DeMasking%%'", 'mask', 'demask', 'De-Masking -> demask'), + ("n.name = 'Scheduling'", 'other', 'gating', 'Scheduling -> gating'), + ("n.name ILIKE '%%Nickel Strip%%'", 'plate', 'wet_process', 'Nickel Strip -> wet_process'), + ("n.name ILIKE '%%Pre-Measurement%%' OR n.name ILIKE '%%Check Sulfamate%%'", 'other', 'inspect', 'Pre-Meas/Check Sulfamate -> inspect'), +] + + +def migrate(cr, version): + env = Environment(cr, SUPERUSER_ID, {}) + + # Phase 1 — Template metadata backfill. Idempotent: only fills + # NULL/empty fields, doesn't overwrite admin edits. + Tpl = env['fp.step.template'] + Kind = env['fp.step.kind'] + fixed_tpl = 0 + for name, (code, icon, kind_code, desc) in TEMPLATE_BACKFILL.items(): + tpl = Tpl.search([('name', '=', name)], limit=1) + if not tpl: + continue + vals = {} + if not tpl.code: + vals['code'] = code + cur_desc = (tpl.description or '').strip() + if cur_desc in ('', '


', '

'): + vals['description'] = '

%s

' % desc + if tpl.icon == 'fa-cog': + vals['icon'] = icon + kind = Kind.search([('code', '=', kind_code)], limit=1) + if kind and tpl.kind_id.code != kind_code: + vals['kind_id'] = kind.id + if vals: + tpl.write(vals) + fixed_tpl += 1 + _logger.info( + '[live-step-fix] template metadata backfilled: %s templates updated', + fixed_tpl, + ) + + # Phase 2 — Recipe node repointing. Idempotent: AND k.code != %s + # ensures already-correct rows are skipped. + for filter_sql, cur_code, new_code, desc in NODE_REPOINTING: + params = [new_code] + sql = ( + "UPDATE fusion_plating_process_node n " + "SET kind_id = (SELECT id FROM fp_step_kind WHERE code = %s LIMIT 1) " + "FROM fp_step_kind k " + "WHERE n.kind_id = k.id " + "AND (" + filter_sql + ")" + ) + if cur_code is not None: + sql += " AND k.code = %s" + params.append(cur_code) + sql += " AND k.code != %s" + params.append(new_code) + cr.execute(sql, params) + _logger.info( + '[live-step-fix] repointed %s nodes: %s', + cr.rowcount, desc, + ) + + # Phase 3 — Recompute area_kind on every fp.job.step row. + steps = env['fp.job.step'].search([]) + if steps: + steps._compute_area_kind() + steps.flush_recordset(['area_kind']) + _logger.info( + '[live-step-fix] recomputed area_kind on %s steps', len(steps), + ) + + # Phase 4 — Recompute active_step_id + card_state on in-flight jobs. + jobs = env['fp.job'].search([ + ('state', 'in', ('confirmed', 'in_progress')), + ]) + if jobs: + jobs._compute_active_step_id() + jobs._compute_card_state() + jobs.flush_recordset(['active_step_id', 'card_state']) + _logger.info( + '[live-step-fix] recomputed active_step_id + card_state on %s jobs', + len(jobs), + ) +``` + +- [ ] **Step 3: Bump fusion_plating_jobs manifest version** + +In [`fusion_plating_jobs/__manifest__.py`](../../fusion_plating_jobs/__manifest__.py): + +```python +'version': '19.0.10.23.0', +``` + +to: + +```python +'version': '19.0.10.24.0', +``` + +- [ ] **Step 4: Write the battle test** + +Create [`fusion_plating_quality/scripts/bt_s24_between_steps.py`](../../fusion_plating_quality/scripts/bt_s24_between_steps.py): + +```python +# -*- coding: utf-8 -*- +"""Battle test S24 — Live step priority chain + board state filter. + +Run end-to-end via odoo shell with stdin redirection: + + 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\" \\ + < /mnt/extra-addons/custom/fusion_plating_quality/scripts/bt_s24_between_steps.py'" + +Asserts: + 1. Job between steps (one done, next pending) has live active_step_id + pointing at the next pending step, NOT False. + 2. Card column resolves to that pending step's area_kind, NOT receiving. + 3. Paused steps still count as active. + 4. state='done' jobs are excluded from the live-board search domain. +""" +import logging + +_logger = logging.getLogger(__name__) + + +def _resolve_card_area(job): + """Mirror of plant_kanban._resolve_card_area for test purposes.""" + if job.active_step_id and job.active_step_id.area_kind: + return job.active_step_id.area_kind + return 'receiving' + + +def run(): + partner = env['res.partner'].search([('customer_rank', '>', 0)], limit=1) + if not partner: + raise AssertionError('No customer partner found — seed test data first') + + recipe = env['fusion.plating.process.node'].search([ + ('node_type', '=', 'recipe'), + ('child_ids', '!=', False), + ], limit=1) + if not recipe: + raise AssertionError('No recipe found — seed test data first') + + job = env['fp.job'].create({ + 'partner_id': partner.id, + 'recipe_id': recipe.id, + 'qty': 1, + }) + job._fp_generate_steps_from_recipe() + steps = job.step_ids.sorted('sequence') + assert len(steps) >= 3, 'Need at least 3 steps for the test' + + # === Phase A — between-step assertion === + s1 = steps[0] + s2 = steps[1] + s1.button_start() + s1.button_finish() + job.invalidate_recordset(['active_step_id', 'card_state']) + assert job.active_step_id.id == s2.id, ( + 'Expected active_step_id = %s (next pending), got %s' % (s2.id, job.active_step_id.id) + ) + assert _resolve_card_area(job) == s2.area_kind, ( + 'Card column should match s2.area_kind=%s, got %s' % ( + s2.area_kind, _resolve_card_area(job), + ) + ) + _logger.info('[bt_s24] Phase A OK — between-step routing correct') + + # === Phase B — paused step assertion === + s2.button_start() + s2.button_pause('lunch break') + job.invalidate_recordset(['active_step_id', 'card_state']) + assert job.active_step_id.id == s2.id, ( + 'Paused step should remain the live step, got %s' % job.active_step_id.id + ) + _logger.info('[bt_s24] Phase B OK — paused step stays live') + + # === Phase C — done job filter === + for s in steps: + if s.state != 'done': + if s.state == 'paused': + s.button_resume() + if s.state != 'in_progress': + s.button_start() + s.button_finish() + job.with_context( + fp_skip_step_gate=True, + fp_skip_qty_reconcile=True, + fp_skip_bake_gate=True, + ).button_mark_done() + assert job.state == 'done' + + jobs_on_board = env['fp.job'].search([ + ('state', 'in', ('confirmed', 'in_progress')), + ]) + assert job.id not in jobs_on_board.ids, ( + 'Done job %s should be filtered off board' % job.id + ) + _logger.info('[bt_s24] Phase C OK — done jobs filtered off board') + + _logger.info('[bt_s24] ALL ASSERTIONS PASSED') + + +run() +``` + +- [ ] **Step 5: Commit Phase 2** + +```bash +git add fusion_plating_jobs/models/fp_job.py \ + fusion_plating_jobs/models/fp_job_step.py \ + fusion_plating_jobs/migrations/19.0.10.24.0/post-migrate.py \ + fusion_plating_jobs/__manifest__.py \ + fusion_plating_shopfloor/controllers/plant_kanban.py \ + fusion_plating_shopfloor/__manifest__.py \ + fusion_plating_quality/scripts/bt_s24_between_steps.py +git commit -m "$(cat <<'EOF' +feat(jobs+shopfloor): live-step priority chain + done-job filter + +Fix the Shop Floor plant kanban so cards land in the right column: +- fp.job._compute_active_step_id walks priority chain + (in_progress > paused > ready > pending), not just in_progress +- fp.job._compute_card_state edge case respects job.state='done' +- fp.job.step._compute_area_kind reads kind.area_kind directly; + legacy _STEP_KIND_TO_AREA dict removed +- /fp/landing/plant_kanban filters out done/cancelled jobs + +Migration backfills template metadata (codes, descriptions, icons, +kind_id) on 30 unfinished library templates and repoints recipe +nodes for 6 unambiguous name patterns (Blasting -> blast, +Ready For X -> gating, De-Masking -> demask, etc.). + +Battle test bt_s24_between_steps.py covers between-step routing, +paused step lifecycle, and done-job board filter. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 13: Local dev test — end-to-end + +- [ ] **Step 1: Upgrade all 3 modules in dev** + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating,fusion_plating_jobs,fusion_plating_shopfloor --stop-after-init 2>&1 | tail -50 +``` + +Expected log lines: +- `[live-step-fix] kind.area_kind seeded for known codes` +- `[live-step-fix] derack/demask/gating activated` +- `[live-step-fix] template metadata backfilled: N templates updated` +- `[live-step-fix] repointed M nodes: ...` +- `[live-step-fix] recomputed area_kind on X steps` +- `[live-step-fix] recomputed active_step_id + card_state on Y jobs` + +No tracebacks. + +- [ ] **Step 2: SQL spot-checks** + +```bash +docker exec odoo-dev-db psql -U odoo -d fusion-dev -c \ + "SELECT code, area_kind, active FROM fp_step_kind WHERE active = TRUE ORDER BY sequence;" +``` +Expected: every active kind has area_kind set; blast/derack/demask/gating all active. + +```bash +docker exec odoo-dev-db psql -U odoo -d fusion-dev -c \ + "SELECT COUNT(*) FROM fp_step_template WHERE active = TRUE AND (code IS NULL OR code = '');" +``` +Expected: 0 (every active template has a code). + +```bash +docker exec odoo-dev-db psql -U odoo -d fusion-dev -c \ + "SELECT k.code, COUNT(*) FROM fusion_plating_process_node n JOIN fp_step_kind k ON k.id = n.kind_id WHERE n.name = 'Blasting' GROUP BY k.code;" +``` +Expected: rows with `k.code = 'blast'`. + +- [ ] **Step 3: Run battle test on dev DB** + +```bash +docker exec -i odoo-dev-app odoo shell -d fusion-dev --no-http < fusion_plating_quality/scripts/bt_s24_between_steps.py 2>&1 | tail -30 +``` + +Expected: `[bt_s24] ALL ASSERTIONS PASSED` at the end. + +If any assertion fires, fix the underlying bug (don't loosen the assertion) and re-run. + +--- + +## Task 14: Deploy to entech + +- [ ] **Step 1: Fetch + check for concurrent Cursor commits** + +```bash +git fetch origin +git status +git log origin/main..HEAD --oneline +git log HEAD..origin/main --oneline +``` + +If origin/main has commits we don't have → STOP. Rebase or pull first. (Per concurrent-cursor memory.) + +- [ ] **Step 2: Copy files to entech** + +Loop over each touched file and `cat | ssh pve-worker5 ...`: + +```bash +for f in \ + fusion_plating/models/fp_step_kind.py \ + fusion_plating/views/fp_step_kind_views.xml \ + fusion_plating/data/fp_step_kind_data.xml \ + fusion_plating/data/fp_step_template_data.xml \ + fusion_plating/controllers/simple_recipe_controller.py \ + fusion_plating/static/src/xml/simple_recipe_editor.xml \ + fusion_plating/migrations/19.0.21.2.0/pre-migrate.py \ + fusion_plating/__manifest__.py \ + fusion_plating_jobs/models/fp_job.py \ + fusion_plating_jobs/models/fp_job_step.py \ + fusion_plating_jobs/migrations/19.0.10.24.0/post-migrate.py \ + fusion_plating_jobs/__manifest__.py \ + fusion_plating_shopfloor/controllers/plant_kanban.py \ + fusion_plating_shopfloor/__manifest__.py \ + fusion_plating_quality/scripts/bt_s24_between_steps.py; do + cat "$f" | ssh pve-worker5 "pct exec 111 -- bash -c \"mkdir -p \\\$(dirname /mnt/extra-addons/custom/$f) && cat > /mnt/extra-addons/custom/$f\"" +done +``` + +- [ ] **Step 3: Upgrade modules + restart** + +```bash +ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating,fusion_plating_jobs,fusion_plating_shopfloor --stop-after-init\" 2>&1 | tail -50 && systemctl start odoo && sleep 2 && systemctl is-active odoo'" +``` + +Expected: same log lines as Task 13 Step 1 + `active` at the end. + +- [ ] **Step 4: SQL spot-checks on entech** + +```bash +ssh pve-worker5 "pct exec 111 -- bash -c 'echo \"SELECT code, area_kind, active FROM fp_step_kind WHERE active = TRUE ORDER BY sequence;\" | sudo -u postgres psql -d admin'" +``` +Expected: 14+ active kinds, all with area_kind. + +```bash +ssh pve-worker5 "pct exec 111 -- bash -c 'echo \"SELECT k.code, COUNT(*) FROM fp_job_step s JOIN fusion_plating_process_node n ON n.id = s.recipe_node_id JOIN fp_step_kind k ON k.id = n.kind_id WHERE n.name = '\\''Blasting'\\'' GROUP BY k.code;\" | sudo -u postgres psql -d admin'" +``` +Expected: rows with `k.code = 'blast'`. + +- [ ] **Step 5: Manual smoke on the live UI** + +Open the Shop Floor in a browser. Verify: + +1. The 7 done jobs are GONE from the board (the original symptom). +2. Plating → Configuration → Recipes & Steps → Step Kind catalog — every kind has a "Shop Floor Column" value. +3. Step Library — every active template has a code, description, meaningful icon. +4. Simple Editor → drop into a recipe → kind picker shows "Masking — Masking column" etc. + +- [ ] **Step 6: Run battle test on entech** + +```bash +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\" < /mnt/extra-addons/custom/fusion_plating_quality/scripts/bt_s24_between_steps.py'" 2>&1 | tail -30 +``` + +Expected: `[bt_s24] ALL ASSERTIONS PASSED`. + +--- + +## Task 15: Push commits to remote + +- [ ] **Step 1: Final fetch before push** + +```bash +git fetch origin +git status +``` + +If origin/main has moved → rebase first. + +- [ ] **Step 2: Push** + +```bash +git push origin main +``` + +--- + +## Rollback + +If anything goes sideways on entech: + +```bash +# Restore previous file content from a known-good commit +ssh pve-worker5 "pct exec 111 -- bash -c 'cd /mnt/extra-addons/custom && find . -path \"*/fusion_plating*\" -newer /tmp/marker -print'" +# Then revert via re-copy of the prior commit's files using the same loop as Task 14 Step 2 but from an earlier git checkout. +# Force the modules into to-upgrade state and restart: +ssh pve-worker5 "pct exec 111 -- bash -c 'echo \"UPDATE ir_module_module SET state = '\\''to upgrade'\\'' WHERE name IN ('\\''fusion_plating'\\'','\\''fusion_plating_jobs'\\'','\\''fusion_plating_shopfloor'\\'');\" | sudo -u postgres psql -d admin'" +ssh pve-worker5 "pct exec 111 -- systemctl restart odoo" +``` + +(Pre-/post-migrate scripts are idempotent so partial rollback + retry is safe.) diff --git a/fusion_plating/docs/superpowers/specs/2026-05-24-shopfloor-live-step-fix-design.md b/fusion_plating/docs/superpowers/specs/2026-05-24-shopfloor-live-step-fix-design.md new file mode 100644 index 00000000..1823c772 --- /dev/null +++ b/fusion_plating/docs/superpowers/specs/2026-05-24-shopfloor-live-step-fix-design.md @@ -0,0 +1,635 @@ +# Shop Floor — Live Step + Kind/Library Cleanup + +**Date:** 2026-05-24 +**Modules:** `fusion_plating`, `fusion_plating_jobs`, `fusion_plating_shopfloor` +**Status:** Revised after step-library audit. Awaiting implementation plan. + +--- + +## Problem + +All 7 jobs on entech are stuck in the **Receiving** column of the Shop Floor +plant kanban, each tagged with a purple "📋 QA-005 review" chip, even though +every step on every one of them is `done`. The board doesn't reflect shop +state. + +Investigation surfaced **four code defects**, a **structural vocabulary +mismatch** between the user-extensible step kind taxonomy and the hardcoded +`area_kind` mapping, **gaps in the kind taxonomy** (no `blast` kind, three +relevant kinds inactive), and **30 step-library templates missing codes, +descriptions, and meaningful icons**. + +### Defect 1 — `_compute_card_state` edge case mislabels done jobs + +[`fusion_plating_jobs/models/fp_job.py:261-267`](../../../fusion_plating_jobs/models/fp_job.py) + +A job whose `active_step_id` is False (all steps done OR no steps at all) +defaults to `'contract_review'` regardless of `job.state`. Done jobs get a +QA-005 chip they don't deserve. + +### Defect 2 — `_compute_active_step_id` is too narrow + +[`fusion_plating_jobs/models/fp_job.py:386-391`](../../../fusion_plating_jobs/models/fp_job.py) + +Only matches `state == 'in_progress'`. Between-step / paused / ready jobs +have `active_step_id = False`. Combined with Defect 3, these teleport to +Receiving. + +### Defect 3 — column-resolve fallback is `'receiving'` + +[`fusion_plating_shopfloor/controllers/plant_kanban.py:161-170`](../../../fusion_plating_shopfloor/controllers/plant_kanban.py) + +When `active_step_id` is False this fallback fires for every non-running +job. Receiving becomes a parking lot. + +### Defect 4 — done jobs aren't filtered off the board + +Done + cancelled jobs stay visible forever. The 7 stuck cards on entech are +all `state='done'` jobs that shipped weeks ago. + +### Defect 5 (structural) — kind→area_kind vocabulary mismatch + +`fp.step.kind` is a user-extensible taxonomy (28 records, 12 active in the +dropdown post 2026-05-24 dedup). `kind_id` is `required=True` on both +`fp.step.template` and `fusion.plating.process.node`, defaulting to +`code='other'`. + +`fp.job.step._compute_area_kind` reads `recipe_node.default_kind` (the kind +code) through the hardcoded `_STEP_KIND_TO_AREA` dict in +[`fp_job_step.py:25-73`](../../../fusion_plating_jobs/models/fp_job_step.py). + +The two vocabularies overlap on **7 of 28 codes**. Adoption on entech: + +| `kind.code` | Nodes | Mapping exists? | Falls to | +|---|---|---|---| +| `other` | 240 | ❌ | `'plating'` | +| `racking` | 122 | ✅ | `'racking'` ✓ | +| `wet_process` | 105 | ❌ | `'plating'` (lucky — wet line IS plating) | +| `bake` | 103 | ✅ | `'baking'` ✓ | +| `mask` | 92 | ❌ (dict has `'masking'`) | `'plating'` (wrong) | +| `inspect` | 52 | ❌ (dict has `'inspection'`) | `'plating'` (wrong) | +| `plate` | 35 | ❌ (dict has `'e_nickel_plate'`) | `'plating'` (lucky) | +| `final_inspect` | 31 | ❌ (dict has `'final_inspection'`) | `'plating'` (wrong) | +| `contract_review` | 17 | ✅ | `'receiving'` ✓ | +| `receiving` | 16 | ✅ | `'receiving'` ✓ | +| `ship` | 3 | ❌ (dict has `'shipping'`) | `'plating'` (wrong) | + +The structural fix: make `area_kind` a required field on `fp.step.kind` +itself so each kind self-declares its column. + +### Defect 6 (taxonomy) — kinds that should exist but don't / are inactive + +| Kind | Currently | Needed because | +|---|---|---| +| `blast` | Does not exist | 11 recipe nodes named "Blasting" can't be classified correctly. There's no kind that maps to the Blasting column. | +| `derack` | Exists but `active=False` | 23+ recipe nodes named "De-racking" / "DeRacking" need their own kind for tablet routing clarity (`area_kind='de_racking'`). | +| `demask` | Exists but `active=False` | 33 recipe nodes named "De-Masking" are misclassified as `mask` → land in Masking column. Per spec §D4 De-Masking folds into De-Racking. | +| `gating` | Exists but `active=False` | 50+ "Ready For X" recipe nodes are unclassified gates. Without `gating` they fall back to `other` → catch-all. | + +### Defect 7 (library) — 30 step-library templates missing metadata + +Step Library audit (38 active templates): + +| Field | Has it | Missing | +|---|---|---| +| `code` | 8 | 30 | +| `description` | 8 | 30 | +| Meaningful icon (not `fa-cog`) | 13 | 25 | +| `material_callout` | 0 | 38 | +| `process_type_id` | 0 | 38 | + +The 8 well-formed templates (`RECV_STD`, `ELEC_CLEAN_STD`, `STRIKE_STD`, etc.) +came from the XML data file. The remaining 30 came from +`_seed_step_library_if_empty()` (programmatic seed from ENP-ALUM-BASIC recipe) +without their library-management metadata. + +Several library templates are also classified to the wrong kind. Examples: + +| Template | Currently `kind` | Should be `kind` | +|---|---|---| +| Blasting | `other` | `blast` (kind we're creating) | +| De-Masking | `mask` | `demask` (per spec §D4) | +| Ready for Plating / Ready for processing | `plate` / `other` | `gating` | +| Pre-Measurements / Check Sulfamate Nickel Area | `other` | `inspect` | +| Nickel Strip / Nickel Strip - Steel Line | `plate` | `wet_process` (it's a strip, not plating) | + +### Defect 8 (recipe nodes) — in-the-wild misclassifications + +Once kinds are fixed and library is corrected, the EXISTING ~880 recipe +nodes still point at the wrong kind in well-defined patterns: + +| Pattern | Affected nodes | Re-point to | +|---|---|---| +| `name = 'Blasting'` AND `kind = other` | 11 | `kind = blast` | +| `name ILIKE 'Ready %'` AND `kind != gating` | ~50+ | `kind = gating` | +| `name ILIKE '%De-Masking%' OR '%DeMasking%'` AND `kind = mask` | 33 | `kind = demask` | +| `name = 'Scheduling'` AND `kind = other` | 5 | `kind = gating` | +| `name ILIKE '%Nickel Strip%'` AND `kind = plate` | ~10 | `kind = wet_process` | +| `name ILIKE '%Pre-Measurement%' OR '%Check Sulfamate%'` AND `kind = other` | ~10 | `kind = inspect` | + +These are auto-migratable because the patterns are unambiguous. The harder +calls (e.g. "Post Plate Inspection" — `inspect` or `final_inspect`?) stay +manual. + +--- + +## Approved fix + +### Change 1 — `_compute_active_step_id` priority chain + +Replace the single-state filter with a priority lookup over `step_ids` +sorted by sequence. First match wins: + +``` +in_progress > paused > ready > first pending +``` + +If every step is `done` (or no steps exist), returns False — handled by +Change 2. + +**Why this order:** + +- `in_progress` is the most informative. +- `paused` means someone was working and stopped; the card belongs at that station so the next operator can pick it up. +- `ready` is the next-up step waiting on an operator. +- The first `pending` after a `done` is the "next gate" — where the card visually waits. + +**File:** [`fusion_plating_jobs/models/fp_job.py`](../../../fusion_plating_jobs/models/fp_job.py) + +### Change 2 — `_compute_card_state` edge case + +Replace the buggy "no active step → contract_review" fallback with: + +```python +if not job.active_step_id: + if job.state == 'done': + job.card_state = 'done' + elif job._fp_inbound_not_received(): + job.card_state = 'no_parts' + else: + job.card_state = 'ready' # no steps yet — recipe not assigned + continue +``` + +**File:** [`fusion_plating_jobs/models/fp_job.py`](../../../fusion_plating_jobs/models/fp_job.py) + +### Change 3 — Board state filter + +Add `('state', 'in', ('confirmed', 'in_progress'))` to the `fp.job` search +domain in `/fp/landing/plant_kanban`. Done + cancelled jobs disappear from +the board; they remain reachable elsewhere. + +**File:** [`fusion_plating_shopfloor/controllers/plant_kanban.py`](../../../fusion_plating_shopfloor/controllers/plant_kanban.py) + +### Change 4 — Column-resolve fallback (comment only) + +`_resolve_card_area`'s `'receiving'` fallback stays but updates inline +comment to explain the new semantics (truly orphaned cards only). + +**File:** [`fusion_plating_shopfloor/controllers/plant_kanban.py`](../../../fusion_plating_shopfloor/controllers/plant_kanban.py) + +### Change 5 — `fp.step.kind.area_kind` field (structural) + +Add a required Selection field to `fp.step.kind`. Each kind self-declares +which plant-view column its steps belong in. + +```python +area_kind = fields.Selection( + [ + ('receiving', 'Receiving'), + ('masking', 'Masking'), + ('blasting', 'Blasting'), + ('racking', 'Racking'), + ('plating', 'Plating'), + ('baking', 'Baking'), + ('de_racking', 'De-Racking'), + ('inspection', 'Final Inspection'), + ('shipping', 'Shipping'), + ], + string='Shop Floor Column', + required=True, + index=True, + tracking=True, + help='Determines which column on the Shop Floor plant kanban shows ' + 'cards whose active step uses this kind.', +) +``` + +**File:** [`fusion_plating/models/fp_step_kind.py`](../../../fusion_plating/models/fp_step_kind.py) + +### Change 6 — `_compute_area_kind` priority chain + +Simplify `fp.job.step._compute_area_kind`: + +```python +@api.depends( + 'work_centre_id.area_kind', + 'recipe_node_id.kind_id.area_kind', +) +def _compute_area_kind(self): + for step in self: + # 1. work_centre.area_kind (explicit operator setup) + if step.work_centre_id and step.work_centre_id.area_kind: + step.area_kind = step.work_centre_id.area_kind + continue + # 2. recipe_node.kind_id.area_kind (kind taxonomy is authoritative) + node = step.recipe_node_id + if node and node.kind_id and node.kind_id.area_kind: + step.area_kind = node.kind_id.area_kind + continue + # 3. Catch-all — data integrity issue if we land here + step.area_kind = 'plating' +``` + +The legacy `_STEP_KIND_TO_AREA` dict is deleted. + +**File:** [`fusion_plating_jobs/models/fp_job_step.py`](../../../fusion_plating_jobs/models/fp_job_step.py) + +### Change 7 — Step Kind UI surfaces `area_kind` + +- **Form view** ([`fp_step_kind_views.xml`](../../../fusion_plating/views/fp_step_kind_views.xml)) — add `area_kind` as a prominent picker next to `code` + `name`, with a help-text inline ("Cards whose active step uses this kind appear in this column on the Shop Floor board"). +- **List view** — add `area_kind` as a chip column. +- **Simple Editor kind picker** ([`simple_recipe_editor.xml:506-522`](../../../fusion_plating/static/src/xml/simple_recipe_editor.xml)) — option label becomes "Masking — Masking column" so authors see the routing at pick time. Requires updating `kindOptions` payload in [`simple_recipe_controller.py`](../../../fusion_plating/controllers/simple_recipe_controller.py) to include `area_kind` + a human-readable column label per kind. + +### Change 8 — Step Kind taxonomy expansion (Cat A) + +XML data file additions / updates in +[`fusion_plating/data/fp_step_kind_data.xml`](../../../fusion_plating/data/fp_step_kind_data.xml): + +```xml + + + blast + Blasting / Media Blast + 35 + fa-bullseye + blasting + + + +``` + +Migration (Change 10) handles the flip on existing installs since the data +file has `noupdate="1"`: + +```python +# Activate kinds that were dropped in 19.0.20.6.0 but are needed +# for the area_kind taxonomy to be complete. +for code, area in ( + ('derack', 'de_racking'), + ('demask', 'de_racking'), + ('gating', 'receiving'), +): + cr.execute(""" + UPDATE fp_step_kind + SET active = TRUE, area_kind = %s + WHERE code = %s AND active = FALSE + """, (area, code)) +``` + +### Change 9 — Step Template metadata backfill + additions (Cat B) + +Migration backfills metadata on the 30 templates seeded without it. +Idempotent — only fills NULL/empty fields, doesn't overwrite human edits. + +```python +TEMPLATE_BACKFILL = { + # name : (code, icon, kind_code, description_snippet) + 'Acid Dip': ('ACID_DIP_STD', 'fa-flask', 'wet_process', 'Short acid immersion to activate the substrate before plating.'), + 'Air Dry': ('AIR_DRY_STD', 'fa-sun-o', 'wet_process', 'Air drying step between wet-line operations.'), + 'Bake': ('BAKE_STD', 'fa-fire', 'bake', 'Post-plate bake for hydrogen embrittlement relief.'), + 'Blasting': ('BLAST_STD', 'fa-bullseye', 'blast', 'Media or bead blasting to prepare the substrate.'), + 'Check Sulfamate Nickel Area': ('CHECK_SN_AREA', 'fa-search', 'inspect', 'Quick visual area check on the sulfamate nickel line.'), + 'Contract Review': ('CR_STD', 'fa-file-text-o', 'contract_review', 'QA-005 contract review gate. Required when the customer flag is on.'), + 'De-Masking': ('DEMASK_STD', 'fa-eraser', 'demask', 'Remove masking material after plating. Folds into De-Racking column.'), + 'DeRacking': ('DERACK_STD', 'fa-th', 'derack', 'Remove parts from racks for inspection / packaging.'), + 'Desmut': ('DESMUT_STD', 'fa-flask', 'wet_process', 'Remove smut from aluminium surfaces after etching.'), + 'Drying': ('DRYING_STD', 'fa-sun-o', 'wet_process', 'Drying step (oven or air) at the end of the wet line.'), + 'E-Nickel Plating': ('ENP_STD', 'fa-diamond', 'plate', 'Electroless nickel plate operation. Time and temp per recipe.'), + 'Electroclean': ('ECLEAN_STD', 'fa-bolt', 'wet_process', 'Anodic / cathodic electrocleaning step on the cleaning line.'), + 'Etch': ('ETCH_STD', 'fa-flask', 'wet_process', 'Chemical etching to prepare the substrate.'), + 'Final Inspection': ('FINAL_INSP_STD','fa-check-circle','final_inspect','Final visual + dimensional QA before packing.'), + 'HCl Activation': ('HCL_ACT_STD', 'fa-flask', 'wet_process', 'HCl activation dip prior to strike or plate.'), + 'Inspection': ('INSP_STD', 'fa-search', 'inspect', 'In-process inspection step.'), + 'Masking': ('MASK_STD', 'fa-paint-brush', 'mask', 'Apply masking to areas that should not be plated.'), + 'Nickel Strip (S-1)': ('NI_STRIP_S1', 'fa-undo', 'wet_process', 'Chemical strip of prior nickel deposit (rework path).'), + 'Nickel Strip - Steel Line': ('NI_STRIP_SL','fa-undo', 'wet_process', 'Chemical strip on the steel line (rework path).'), + 'Post-plate Inspection': ('POST_INSP_STD', 'fa-check-circle','inspect', 'Post-plate inspection — thickness sample + visual.'), + 'Pre-Measurements': ('PRE_MEAS_STD', 'fa-tachometer', 'inspect', 'Pre-process dimensional measurements (FAIR start point).'), + 'Racking': ('RACK_STD', 'fa-th', 'racking', 'Load parts onto racks for plating.'), + 'Ready for Plating': ('GATE_PLATE', 'fa-flag', 'gating', 'Gating step — parts staged ready for the plating line.'), + 'Ready for processing': ('GATE_PROC', 'fa-flag', 'gating', 'Generic gating step — parts staged ready for the next operation.'), + 'Rinse': ('RINSE_STD', 'fa-tint', 'wet_process', 'Rinse step between wet-line operations.'), + 'Shipping': ('SHIP_STD', 'fa-paper-plane', 'ship', 'Final shipping / hand-off to logistics.'), + 'Soak Clean': ('SOAK_CLEAN_STD','fa-bathtub', 'wet_process', 'Soak cleaning step at the start of the wet line.'), + 'Surface Activation': ('SURF_ACT_STD', 'fa-flask', 'wet_process', 'Surface activation dip prior to plate.'), + 'Water Break Test': ('WBF_TEST_STD', 'fa-tint', 'wet_process', 'Water-break test for surface cleanliness.'), + 'Zincate': ('ZINCATE_STD', 'fa-flask', 'wet_process', 'Zincate immersion on aluminium prior to plate.'), +} +``` + +New templates (XML data file additions, `noupdate="1"`): + +| Name | Code | Kind | Why add | +|---|---|---|---| +| `Hot Water Porosity Test (A-15)` | `HWP_A15` | `inspect` | 7 recipe nodes use it — should be in the library | +| `Final Inspection / Packaging` | `FINAL_PKG_STD` | `final_inspect` | 3 recipe nodes use it; library has separate inspection + packaging but not the combined one | + +**Files:** +- [`fusion_plating/data/fp_step_template_data.xml`](../../../fusion_plating/data/fp_step_template_data.xml) — 2 new template records +- Migration (Change 10) — TEMPLATE_BACKFILL loop, idempotent + +### Change 10 — Unified migration + +New file: [`fusion_plating/migrations/19.0.21.2.0/pre-migrate.py`](../../../fusion_plating/migrations/19.0.21.2.0/pre-migrate.py) + +Pre-migrate runs BEFORE the `area_kind NOT NULL` constraint hits the +schema, so it fills values first. + +```python +import logging +_logger = logging.getLogger(__name__) + +KIND_TO_AREA = { + 'other': 'plating', # catch-all default + 'wet_process': 'plating', + 'receiving': 'receiving', + 'contract_review':'receiving', + 'gating': 'receiving', + 'racking': 'racking', + 'derack': 'de_racking', + 'mask': 'masking', + 'demask': 'de_racking', # spec §D4 + 'cleaning': 'plating', + 'electroclean': 'plating', + 'etch': 'plating', + 'rinse': 'plating', + 'strike': 'plating', + 'plate': 'plating', + 'replenishment': 'plating', + 'wbf_test': 'plating', + 'dry': 'plating', + 'bake': 'baking', + 'inspect': 'inspection', + 'final_inspect': 'inspection', + 'hardness_test': 'inspection', + 'adhesion_test': 'inspection', + 'salt_spray': 'inspection', + 'packaging': 'shipping', + 'ship': 'shipping', + 'blast': 'blasting', + 'bead_blast': 'blasting', + 'media_blast': 'blasting', +} + +def migrate(cr, version): + # Phase 1 — seed area_kind on existing kinds BEFORE NOT NULL hits. + for code, area in KIND_TO_AREA.items(): + cr.execute(""" + UPDATE fp_step_kind SET area_kind = %s + WHERE code = %s AND (area_kind IS NULL OR area_kind = '') + """, (area, code)) + # Anything still NULL: default to 'plating' to clear the constraint. + cr.execute(""" + UPDATE fp_step_kind SET area_kind = 'plating' + WHERE area_kind IS NULL OR area_kind = '' + """) + _logger.info('[live-step-fix] kind.area_kind seeded') + + # Phase 2 — activate the three inactive kinds we need (Cat A). + for code in ('derack', 'demask', 'gating'): + cr.execute(""" + UPDATE fp_step_kind SET active = TRUE + WHERE code = %s AND active = FALSE + """, (code,)) + _logger.info('[live-step-fix] derack/demask/gating activated') +``` + +New file: [`fusion_plating_jobs/migrations/19.0.10.24.0/post-migrate.py`](../../../fusion_plating_jobs/migrations/19.0.10.24.0/post-migrate.py) + +Post-migrate runs AFTER schema sync, so all fields exist with values. + +```python +import logging +_logger = logging.getLogger(__name__) + +# Library template metadata backfill — copied from spec Change 9. +TEMPLATE_BACKFILL = { ... } # full dict per Change 9 + +# Recipe node patterns to repoint (Cat C). +NODE_REPOINTING = [ + # (name_filter_sql, current_kind_code, new_kind_code, description) + ("name = 'Blasting'", 'other', 'blast', 'Blasting → blast'), + ("name ILIKE 'Ready %%'", None, 'gating', 'Ready For X → gating'), + ("name ILIKE '%%De-Masking%%' OR name ILIKE '%%DeMasking%%'", 'mask', 'demask', 'De-Masking → demask'), + ("name = 'Scheduling'", 'other', 'gating', 'Scheduling → gating'), + ("name ILIKE '%%Nickel Strip%%'", 'plate', 'wet_process', 'Nickel Strip → wet_process'), + ("name ILIKE '%%Pre-Measurement%%' OR name ILIKE '%%Check Sulfamate%%'", 'other', 'inspect', 'Pre-Meas/Check Sulfamate → inspect'), +] + +def migrate(cr, version): + from odoo.api import Environment, SUPERUSER_ID + env = Environment(cr, SUPERUSER_ID, {}) + + # Phase 1 — template metadata backfill (Cat B). Idempotent. + Tpl = env['fp.step.template'] + Kind = env['fp.step.kind'] + fixed = 0 + for name, (code, icon, kind_code, desc) in TEMPLATE_BACKFILL.items(): + tpl = Tpl.search([('name', '=', name)], limit=1) + if not tpl: + continue + vals = {} + if not tpl.code: + vals['code'] = code + if not tpl.description or tpl.description in ('', '


'): + vals['description'] = f'

{desc}

' + if tpl.icon == 'fa-cog': + vals['icon'] = icon + kind = Kind.search([('code', '=', kind_code)], limit=1) + if kind and tpl.kind_id.code != kind_code: + vals['kind_id'] = kind.id + if vals: + tpl.write(vals) + fixed += 1 + _logger.info('[live-step-fix] template backfill: %s templates updated', fixed) + + # Phase 2 — recipe node repointing (Cat C). Pattern-driven SQL. + for filter_sql, cur_code, new_code, desc in NODE_REPOINTING: + params = [] + sql = f""" + UPDATE fusion_plating_process_node n + SET kind_id = (SELECT id FROM fp_step_kind WHERE code = %s LIMIT 1) + FROM fp_step_kind k + WHERE n.kind_id = k.id + AND ({filter_sql}) + """ + params.append(new_code) + if cur_code is not None: + sql += " AND k.code = %s" + params.append(cur_code) + sql += " AND k.code != %s" + params.append(new_code) + cr.execute(sql, params) + _logger.info('[live-step-fix] repointed %s nodes: %s', + cr.rowcount, desc) + + # Phase 3 — recompute area_kind on all fp.job.step rows. + steps = env['fp.job.step'].search([]) + steps._compute_area_kind() + steps.flush_recordset(['area_kind']) + _logger.info('[live-step-fix] recomputed area_kind on %s steps', len(steps)) + + # Phase 4 — recompute active_step_id + card_state on in-flight jobs. + jobs = env['fp.job'].search([('state', 'in', ('confirmed', 'in_progress'))]) + jobs._compute_active_step_id() + jobs._compute_card_state() + jobs.flush_recordset(['active_step_id', 'card_state']) + _logger.info('[live-step-fix] recomputed jobs: %s', len(jobs)) +``` + +Idempotent across the board: phase 1 only fills NULLs / fa-cog defaults; +phase 2 includes `AND k.code != %s` so re-running won't re-do already +correct rows; phases 3-4 are pure recomputes. + +### Change 11 — Version bumps + +| Module | From | To | +|---|---|---| +| `fusion_plating` | `19.0.21.1.3` | `19.0.21.2.0` (schema change on fp.step.kind + data file additions) | +| `fusion_plating_jobs` | `19.0.10.23.0` | `19.0.10.24.0` (compute change + migration) | +| `fusion_plating_shopfloor` | `19.0.33.1.2` | `19.0.33.1.3` (controller filter + comment) | + +--- + +## What this approach replaces + +| Dropped from the original (pre-restructure) spec | Why | +|---|---| +| `_RESOLVER_KIND_TO_AREA` translation dict | Kind self-declares its column — no translation needed | +| `_resolve_area_kind_from_name` helper | Kind taxonomy is authoritative; name resolution is unnecessary | +| `_STARTER_KIND_BY_NAME` extensions for column routing | The starter resolver is for `default_kind` seeding (Sub 12a library), not column routing — stays as-is for that purpose | +| Parenthetical stripping regex | Not needed when we read the kind directly | +| Backfill of `default_kind` on existing recipe nodes via name resolver | Recipe nodes already have `kind_id` populated by 19.0.20.6.0 pre-migrate | + +--- + +## Test plan + +### Manual smoke (on entech after deploy) + +1. Open Shop Floor tablet/desktop — confirm the 7 done jobs are GONE from the board. +2. Plating → Configuration → Recipes & Steps → **Step Kind catalog** — confirm: + - `blast` exists, active, area_kind=`blasting` + - `derack`, `demask`, `gating` are now `active=True`, area_kinds correct + - Every kind has area_kind set +3. Plating → Configuration → Recipes & Steps → **Step Library** — confirm: + - All 38 templates now have a code, description, meaningful icon + - `Hot Water Porosity Test (A-15)` and `Final Inspection / Packaging` are listed + - "Blasting" is `kind=blast`, "De-Masking" is `kind=demask`, "Ready for ..." are `kind=gating` +4. Open the Simple Recipe Editor; click "+ Add new kind" — confirm area_kind picker is visible/required in the inline-create flow. +5. Create a fresh test job from any recipe (e.g. ENP-ALUM-BASIC): + a. Confirm it lands in Receiving column with `card_state='ready'`. + b. Walk through all steps — confirm column transitions follow area_kind sequence. + c. Mark job done → confirm card drops off the board. +6. Verify a step with `state='paused'` keeps the card at its column. + +### Spot-check existing data + +```sql +-- Every node should have a kind with area_kind set. +SELECT n.id, n.name, k.code, k.area_kind + FROM fusion_plating_process_node n + JOIN fp_step_kind k ON k.id = n.kind_id + WHERE k.area_kind IS NULL OR k.area_kind = ''; +-- expected: 0 rows + +-- Blasting nodes should now use blast kind. +SELECT k.code, COUNT(*) FROM fusion_plating_process_node n + JOIN fp_step_kind k ON k.id = n.kind_id + WHERE n.name = 'Blasting' GROUP BY k.code; +-- expected: all rows have k.code = 'blast' + +-- Ready For X gating nodes. +SELECT k.code, COUNT(*) FROM fusion_plating_process_node n + JOIN fp_step_kind k ON k.id = n.kind_id + WHERE n.name ILIKE 'Ready %' GROUP BY k.code; +-- expected: all rows have k.code = 'gating' + +-- De-Masking nodes use demask. +SELECT k.code, COUNT(*) FROM fusion_plating_process_node n + JOIN fp_step_kind k ON k.id = n.kind_id + WHERE n.name ILIKE '%De-Masking%' OR n.name ILIKE '%DeMasking%' + GROUP BY k.code; +-- expected: all rows have k.code = 'demask' + +-- Template code coverage. +SELECT COUNT(*) FROM fp_step_template + WHERE active = TRUE AND (code IS NULL OR code = ''); +-- expected: 0 +``` + +### Automated battle test + +New script: `fusion_plating_quality/scripts/bt_s24_between_steps.py` covering +the live-step priority chain end-to-end (see prior version of the spec for +full pseudocode — unchanged). + +### Existing tests + +Existing tests in `fusion_plating_shopfloor/tests/` and +`fusion_plating_jobs/tests/` may need updates for: +- The new `state` filter in `/fp/landing/plant_kanban`. +- The new `active_step_id` priority chain. + +Re-run all `bt_s*.py` scripts to confirm no regressions in S1-S23. + +--- + +## Roll-out + +1. Implement Changes 1-11 in a single branch. +2. Local dev test (`docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating,fusion_plating_jobs,fusion_plating_shopfloor --stop-after-init`). +3. Deploy to entech using the standard `pct exec 111` flow. Pre-migrate seeds run automatically. +4. Verify on entech with manual smoke + SQL spot-checks. +5. Commit + push to GitHub. + +--- + +## Non-goals (explicit) + +- **Re-assigning historical steps to `work_centre_id`.** The 85+ steps with NULL `work_centre_id` stay that way. The kind→area_kind lookup gives them correct `area_kind` without needing a work_centre. +- **Recipe authoring UX changes** beyond the kind picker hint. Required-field enforcement on `kind_id` already exists. +- **Removing the "Other" kind.** Stays as a catch-all default mapped to `'plating'`. +- **Card_state precedence rework.** Rules 1-13 stay; only the edge-case fallback changes. +- **Mini-timeline rendering.** Separate compute (`mini_timeline_json`), out of scope. +- **Hidden-but-recent done jobs.** No "recent shipments" filter. +- **Subjective node re-classification.** "Post Plate Inspection" stays whatever the recipe author picked (`inspect` vs `final_inspect`). Only the unambiguous patterns in Change 10 phase 2 are auto-migrated. +- **process_type_id / material_callout backfill on templates.** Out of scope for this spec — those need recipe-author input per template. + +--- + +## Files touched (summary) + +| File | Change | +|---|---| +| `fusion_plating/models/fp_step_kind.py` | New `area_kind` Selection field (Change 5) | +| `fusion_plating/views/fp_step_kind_views.xml` | Add area_kind to form + list (Change 7) | +| `fusion_plating/controllers/simple_recipe_controller.py` | Include area_kind + label in kindOptions (Change 7) | +| `fusion_plating/static/src/xml/simple_recipe_editor.xml` | Kind picker shows "→ Column" suffix (Change 7) | +| `fusion_plating/data/fp_step_kind_data.xml` | New `step_kind_blast` record (Change 8) | +| `fusion_plating/data/fp_step_template_data.xml` | New `Hot Water Porosity Test` + `Final Inspection / Packaging` templates (Change 9) | +| `fusion_plating/migrations/19.0.21.2.0/pre-migrate.py` | NEW — seed area_kind, activate kinds (Change 10 phase 1-2) | +| `fusion_plating/__manifest__.py` | Version bump (Change 11) | +| `fusion_plating_jobs/models/fp_job.py` | Rewrite `_compute_active_step_id` (Change 1) + `_compute_card_state` edge case (Change 2) | +| `fusion_plating_jobs/models/fp_job_step.py` | Simplify `_compute_area_kind` (Change 6); drop `_STEP_KIND_TO_AREA` dict | +| `fusion_plating_jobs/migrations/19.0.10.24.0/post-migrate.py` | NEW — template backfill + node repointing + recomputes (Change 10 phase 1-4) | +| `fusion_plating_jobs/__manifest__.py` | Version bump (Change 11) | +| `fusion_plating_shopfloor/controllers/plant_kanban.py` | Add state filter (Change 3) + comment (Change 4) | +| `fusion_plating_shopfloor/__manifest__.py` | Version bump (Change 11) | +| `fusion_plating_quality/scripts/bt_s24_between_steps.py` | NEW — battle test | + +Estimated diff: ~400 lines added (most in the migration data tables), ~30 modified, ~50 deleted (the `_STEP_KIND_TO_AREA` dict goes away).