Files
Odoo-Modules/fusion_plating/docs/superpowers/plans/2026-05-24-shopfloor-live-step-fix-plan.md
gsinghpal 2285c9def1 docs(plating): spec + plan for Shop Floor live-step + library cleanup
Spec documents the 4 code defects + structural vocabulary mismatch
between fp.step.kind taxonomy and the legacy _STEP_KIND_TO_AREA dict,
plus the 30 library templates missing metadata. Plan breaks the work
into 15 bite-sized tasks across 2 phases.

Implementation shipped in:
- c75d2bde (Odoo 19 session.authenticate signature fix — separate)
- 7b90f210 (Phase 1: fusion_plating)
- b06d28e7 (Phase 2: jobs + shopfloor)
- 6afc9e3c (follow-up tracking + pattern anchor)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:13:58 -04:00

42 KiB

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

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, inside the FpStepKind class, add the field right after the description field (around line 31):

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, find the form view's <sheet> and add area_kind to the main field group:

<group>
    <field name="code"/>
    <field name="name"/>
    <field name="area_kind" widget="badge"
           help="Cards whose active step uses this kind appear in this column on the Shop Floor board."/>
    <field name="sequence"/>
    <field name="active"/>
</group>
  • Step 2: Update list view

In the same file, find the list view. Add area_kind after name:

<field name="area_kind" decoration-info="True"/>
  • 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, inside the "ACTIVE KINDS" block. After the step_kind_mask record (sequence=40), add:

<record id="step_kind_blast" model="fp.step.kind">
    <field name="code">blast</field>
    <field name="name">Blasting / Media Blast</field>
    <field name="sequence">35</field>
    <field name="icon">fa-bullseye</field>
    <field name="area_kind">blasting</field>
</record>
  • Step 2: Add <field name="area_kind"> 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:

<record id="step_kind_other" model="fp.step.kind">
    <field name="code">other</field>
    <field name="name">Other</field>
    <field name="sequence">5</field>
    <field name="icon">fa-circle-o</field>
    <field name="area_kind">plating</field>   <!-- NEW LINE -->
</record>

Note: the file is wrapped in <data noupdate="1">, 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, inside the existing <odoo> block:

<record id="fp_step_template_hwp_a15" model="fp.step.template">
    <field name="name">Hot Water Porosity Test (A-15)</field>
    <field name="code">HWP_A15</field>
    <field name="sequence">10</field>
    <field name="icon">fa-tint</field>
    <field name="kind_id" ref="step_kind_inspect"/>
    <field name="description" type="html">
        &lt;p&gt;Hot-water porosity test for plated samples. Verify continuity
        of the deposit across the test panel; record any porosity sites.&lt;/p&gt;
    </field>
</record>
  • Step 2: Add Final Inspection / Packaging template

Right after, add:

<record id="fp_step_template_final_pkg_std" model="fp.step.template">
    <field name="name">Final Inspection / Packaging</field>
    <field name="code">FINAL_PKG_STD</field>
    <field name="sequence">10</field>
    <field name="icon">fa-check-circle</field>
    <field name="kind_id" ref="step_kind_final_inspect"/>
    <field name="description" type="html">
        &lt;p&gt;Combined final visual + dimensional inspection followed by
        packaging into the customer&amp;rsquo;s original boxes for shipment.&lt;/p&gt;
    </field>
</record>
  • 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. 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:

{
    '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 around line 510, change:

<option t-att-value="k.code" t-att-selected="k.code === state.libraryEditor.default_kind">
    <t t-esc="k.name"/>
</option>

to:

<option t-att-value="k.code" t-att-selected="k.code === state.libraryEditor.default_kind">
    <t t-esc="k.name"/>
    <t t-if="k.area_kind_label"><t t-esc="k.area_kind_label"/> column</t>
</option>
  • 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

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:

# -*- 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, change:

'version': '19.0.21.1.3',

to:

'version': '19.0.21.2.0',
  • Step 4: Local dev test — upgrade fusion_plating only
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
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
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) <noreply@anthropic.com>
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, find _compute_active_step_id (around line 386). Replace the entire method with:

@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:

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, 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:

@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
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, 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:

# 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:

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:

'version': '19.0.33.1.2',

to:

'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

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:

# -*- 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 ('', '<p><br></p>', '<p></p>'):
            vals['description'] = '<p>%s</p>' % 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:

'version': '19.0.10.23.0',

to:

'version': '19.0.10.24.0',
  • Step 4: Write the battle test

Create fusion_plating_quality/scripts/bt_s24_between_steps.py:

# -*- 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
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) <noreply@anthropic.com>
EOF
)"

Task 13: Local dev test — end-to-end

  • Step 1: Upgrade all 3 modules in dev
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
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.

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).

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
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
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 ...:

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
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
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.

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
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
git fetch origin
git status

If origin/main has moved → rebase first.

  • Step 2: Push
git push origin main

Rollback

If anything goes sideways on entech:

# 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.)