Files
Odoo-Modules/fusion_plating/docs/superpowers/plans/2026-05-23-shopfloor-plant-view-plan.md
gsinghpal d6ebcb6233 docs(shopfloor): implementation plan for plant-view kanban redesign
20 tasks across 5 phases:
  1. Data model foundation (area_kind, last_activity_at, paired
     work centres, feature flag) — 5 tasks
  2. Card state computation + mini-timeline (precedence helpers,
     card_state compute, mini_timeline_json) — 3 tasks
  3. Backend endpoint + landing dispatch — 2 tasks
  4. Frontend components bottom-up (tokens, mini-timeline, card,
     column header, KPI tile, filter chip, top-level action) —
     7 tasks
  5. QA + flip default — 3 tasks

Each task has TDD-style steps (write failing test → run → implement
→ run → commit) with full code blocks and exact file paths. Bakes
in project-specific patterns from CLAUDE.md (OWL template scope
rule 20, t-out markup wrap, no SCSS @import, dark-mode compile-
time branch).

Self-review pass confirms 1-to-1 coverage of every spec section
and explicit deferral of every §13 Phase 2 item.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:34:42 -04:00

121 KiB

Shop Floor Plant View — 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: Replace the per-step-grouped Shop Floor kanban with a 9-column department-grouped kanban (one card per job, fixed sequence, 13 distinct card states) per the spec at docs/superpowers/specs/2026-05-23-shopfloor-plant-view-design.md.

Architecture: New area_kind taxonomy on fp.work.centre drives column placement. Server-computed card_state on fp.job classifies each job into 1 of 13 mutually-exclusive visual states with explicit precedence. New endpoint /fp/landing/plant_kanban returns columns + denormalized cards in one payload. New OWL component tree (fp_plant_kanban) replaces shopfloor_landing behind a feature flag. Dev system, sample data only — no production migration concerns.

Tech Stack: Odoo 19 (Python 3.11, OWL 2), PostgreSQL, native odoo on entech (LXC 111 on pve-worker5). Tests use odoo.tests.common.TransactionCase. SCSS compiled by Odoo's bundle pipeline with dark-mode branch via $o-webclient-color-scheme.


Critical patterns to follow

These are project rules already documented in K:/Github/Odoo-Modules/fusion_plating/CLAUDE.md — re-stating here so the implementer doesn't have to context-switch:

  • OWL template scope: only Math is exposed as a JS global. NEVER write String(x), Number(x), parseInt(x), JSON.stringify(x), etc. inside t-on-click, t-att-*, or t-out — they throw Uncaught TypeError: v2 is not a function at click time and the handler silently dies. Use string literals in arrays, do all coercion in JS-side handler methods, or use operators (+x works as a number cast because + is an operator).
  • OWL t-out HTML escape: plain strings get escaped. To render HTML from RPC, wrap with markup() from @odoo/owl in the JS before assigning to state.
  • SCSS @import forbidden: every SCSS file (including _tokens.scss partials) must be registered as a separate entry in the manifest's web.assets_backend. Tokens first. Concatenation is sequential.
  • Dark mode: NOT via class selector or media query. Use $o-webclient-color-scheme == dark @if branch at SCSS compile time. Same SCSS file compiles into both light and dark bundles automatically.
  • Existing field naming: new custom fields on standard Odoo models use x_fc_* prefix. New custom models use fp.*. New non-prefixed fields on FP models (e.g. card_state, area_kind, last_activity_at) are fine because the model is custom.
  • End every git commit message with: Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
  • Existing single-station pairing UX stays for MVP. The new paired_work_centre_ids M2M holds exactly one record on day 1 (the existing selected station). Multi-station picker is Phase 2.

File structure map

Backend — modify

File What changes
fusion_plating/models/fp_work_centre.py Add area_kind Selection field
fusion_plating/__manifest__.py Bump version to 19.0.21.0.0
fusion_plating_jobs/models/fp_job.py Add card_state, mini_timeline_json computes + 6 _fp_* precedence helpers
fusion_plating_jobs/models/fp_job_step.py Add area_kind related-with-fallback + last_activity_at + _fp_is_idle helper
fusion_plating_jobs/__manifest__.py Bump version to 19.0.10.24.0
fusion_plating_shopfloor/models/res_users.py Add paired_work_centre_ids M2M
fusion_plating_shopfloor/models/res_config_settings.py (may not exist; if not, create) Add x_fc_shopfloor_layout Selection
fusion_plating_shopfloor/__manifest__.py Bump version to 19.0.31.0.0, register new assets in correct order
fusion_plating_shopfloor/views/res_config_settings_views.xml Surface the layout setting

Backend — create

File Responsibility
fusion_plating/migrations/19.0.21.0.0/post-migrate.py Backfill area_kind on existing fp.work.centre rows
fusion_plating_shopfloor/controllers/plant_kanban.py /fp/landing/plant_kanban endpoint
fusion_plating_jobs/tests/test_card_state.py Tests for all 13 states + precedence
fusion_plating_jobs/tests/test_area_kind_routing.py Tests for step-kind → column mapping
fusion_plating_jobs/tests/test_mini_timeline.py Tests for 9-element timeline output
fusion_plating_shopfloor/tests/test_plant_kanban_endpoint.py Endpoint integration tests

Frontend — create

All under fusion_plating_shopfloor/static/src/:

File Responsibility
scss/_plant_tokens.scss Design tokens (colors per card state, sizes) — loads first
scss/plant_kanban.scss Board layout, sticky header, polling spinner
scss/components/_plant_card.scss All 13 card-state styles
scss/components/_mini_timeline.scss 9-step horizontal bar
scss/components/_column_header.scss "📍 You're here" badge + count chip
scss/components/_kpi_tile.scss Clickable KPI button
scss/components/_filter_chip.scss Toggleable chip
js/plant_kanban.js Top-level OWL action fp_plant_kanban
js/components/plant_card.js Variant C card
js/components/mini_timeline.js 9-step bar
js/components/column_header.js Header + you're-here badge
js/components/kpi_tile.js KPI button + click-to-filter
js/components/filter_chip.js Filter chip toggle
xml/plant_kanban.xml Top-level template
xml/components/plant_card.xml Card template
xml/components/mini_timeline.xml Timeline template
xml/components/column_header.xml Header template
xml/components/kpi_tile.xml KPI template
xml/components/filter_chip.xml Chip template

Phase 1 — Data model foundation

Task 1: Add area_kind to fp.work.centre

Files:

  • Modify: fusion_plating/models/fp_work_centre.py

  • Modify: fusion_plating/__manifest__.py (bump version)

  • Create: fusion_plating/migrations/19.0.21.0.0/post-migrate.py

  • Test: existing tests still pass — no new test required (data-layer-only change)

  • Step 1: Read the existing file and confirm structure

grep -n "kind = fields\.Selection" K:/Github/Odoo-Modules/fusion_plating/fusion_plating/models/fp_work_centre.py

Locate the existing kind field. The new field goes right after it.

  • Step 2: Add the area_kind Selection

In fusion_plating/models/fp_work_centre.py, immediately after the existing kind field, add:

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='Floor Column',
    help='Which Shop Floor column this work centre belongs to. '
         'Drives the plant-view kanban grouping. Set this to one of '
         'the nine departments; the plant view will route any job '
         'whose active step uses this work centre into that column.',
    index=True,
)
  • Step 3: Bump module version

In fusion_plating/__manifest__.py, change 'version': '19.0.20.9.0''version': '19.0.21.0.0'.

  • Step 4: Create the migration directory + post-migrate script
mkdir -p K:/Github/Odoo-Modules/fusion_plating/fusion_plating/migrations/19.0.21.0.0

Create fusion_plating/migrations/19.0.21.0.0/post-migrate.py:

# -*- coding: utf-8 -*-
# Backfill fp.work.centre.area_kind from the existing kind taxonomy.
# Runs on -u fusion_plating after the new column is added.

import logging
_logger = logging.getLogger(__name__)


def migrate(cr, version):
    """Map existing kind values onto the new 9-area taxonomy.

    Unmapped centres get 'plating' as a safe catch-all (wet shop default)
    plus a warning so the admin knows to review them. The kanban can
    still render those jobs; they'll just appear in the Plating column
    until the admin sets the correct area_kind.
    """
    cr.execute("""
        UPDATE fp_work_centre
           SET area_kind = CASE kind
               WHEN 'wet_line' THEN 'plating'
               WHEN 'bake'     THEN 'baking'
               WHEN 'mask'     THEN 'masking'
               WHEN 'rack'     THEN 'racking'
               WHEN 'inspect'  THEN 'inspection'
               ELSE 'plating'
           END
         WHERE area_kind IS NULL
    """)
    cr.execute("""
        SELECT id, name, kind
          FROM fp_work_centre
         WHERE area_kind = 'plating' AND kind NOT IN ('wet_line', NULL)
    """)
    rows = cr.fetchall()
    if rows:
        _logger.warning(
            "%d fp.work.centre rows defaulted to area_kind='plating'; "
            "review and adjust if needed: %s",
            len(rows),
            ', '.join('%s (id=%s, kind=%s)' % (r[1], r[0], r[2]) for r in rows[:10]),
        )
  • Step 5: Apply locally + verify
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating --stop-after-init\" 2>&1 | tail -20 && systemctl start odoo'"

Then:

ssh pve-worker5 "pct exec 111 -- su - postgres -c \"psql -d admin -c \\\"SELECT kind, area_kind, count(*) FROM fp_work_centre GROUP BY kind, area_kind ORDER BY kind;\\\"\""

Expected: every row has area_kind set. No NULLs.

  • Step 6: Commit
cd K:/Github/Odoo-Modules/fusion_plating && git add fusion_plating/models/fp_work_centre.py fusion_plating/__manifest__.py fusion_plating/migrations/19.0.21.0.0/post-migrate.py && git commit -m "feat(plating): add fp.work.centre.area_kind for plant-view kanban

Adds a 9-value Selection field routing each work centre to one of
the Shop Floor columns (Receiving / Masking / Blasting / Racking /
Plating / Baking / De-Racking / Final inspection / Shipping).

Migration 19.0.21.0.0 backfills existing rows from their kind value:
wet_line→plating, bake→baking, mask→masking, rack→racking,
inspect→inspection. Unmapped centres default to 'plating' and are
logged for admin review.

Part of the 2026-05-23 Shop Floor plant-view redesign (spec at
docs/superpowers/specs/2026-05-23-shopfloor-plant-view-design.md).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 2: Add area_kind to fp.job.step with step-kind fallback

Files:

  • Modify: fusion_plating_jobs/models/fp_job_step.py
  • Test: fusion_plating_jobs/tests/test_area_kind_routing.py (create)

The field is stored computed (not pure related) because we need the fallback to recipe-step default_kind when the work_centre is missing or its area_kind is unset.

  • Step 1: Write the failing test first

Create fusion_plating_jobs/tests/test_area_kind_routing.py:

# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase


class TestAreaKindRouting(TransactionCase):
    """Verifies that fp.job.step.area_kind routes correctly for every
    recipe step kind in the project's step library, including when
    work_centre is absent and the fallback dispatch kicks in."""

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.Job = cls.env['fp.job']
        cls.Step = cls.env['fp.job.step']
        cls.Node = cls.env['fusion.plating.process.node']

    def _make_step_with_kind(self, default_kind, work_centre=None):
        """Build a minimal fp.job + fp.job.step pair with a recipe node
        of the given default_kind. work_centre optional — if absent, the
        fallback dispatch should still route the step to the right area."""
        partner = self.env['res.partner'].create({'name': 'Test Co'})
        job = self.Job.create({
            'name': 'TEST-WO',
            'partner_id': partner.id,
            'qty': 1,
        })
        node = self.Node.create({
            'name': 'Test step',
            'node_type': 'step',
            'default_kind': default_kind,
        })
        step = self.Step.create({
            'job_id': job.id,
            'name': node.name,
            'recipe_node_id': node.id,
            'sequence': 10,
            'work_centre_id': work_centre.id if work_centre else False,
        })
        return step

    def test_racking_step_routes_to_racking_column(self):
        step = self._make_step_with_kind('racking')
        self.assertEqual(step.area_kind, 'racking')

    def test_masking_step_routes_to_masking(self):
        step = self._make_step_with_kind('masking')
        self.assertEqual(step.area_kind, 'masking')

    def test_blasting_step_routes_to_blasting(self):
        step = self._make_step_with_kind('blasting')
        self.assertEqual(step.area_kind, 'blasting')

    def test_de_rack_routes_to_de_racking(self):
        step = self._make_step_with_kind('de_rack')
        self.assertEqual(step.area_kind, 'de_racking')

    def test_de_mask_also_routes_to_de_racking(self):
        # Spec D4: de-masking folds into de-racking.
        step = self._make_step_with_kind('de_mask')
        self.assertEqual(step.area_kind, 'de_racking')

    def test_wet_steps_all_route_to_plating(self):
        for kind in ('soak_clean', 'electroclean', 'acid_dip', 'etch',
                     'desmut', 'zincate', 'rinse', 'water_break_test',
                     'e_nickel_plate', 'chrome', 'anodize', 'black_oxide',
                     'drying'):
            step = self._make_step_with_kind(kind)
            self.assertEqual(
                step.area_kind, 'plating',
                "expected '%s' → plating, got %r" % (kind, step.area_kind),
            )

    def test_bake_routes_to_baking(self):
        step = self._make_step_with_kind('bake')
        self.assertEqual(step.area_kind, 'baking')

    def test_inspection_steps_route_to_inspection(self):
        for kind in ('final_inspection', 'post_plate_inspection',
                     'thickness_qc', 'fair'):
            step = self._make_step_with_kind(kind)
            self.assertEqual(step.area_kind, 'inspection')

    def test_contract_review_routes_to_receiving(self):
        # Spec D5: contract review cards live in Receiving with a chip.
        step = self._make_step_with_kind('contract_review')
        self.assertEqual(step.area_kind, 'receiving')

    def test_incoming_inspection_routes_to_receiving(self):
        step = self._make_step_with_kind('incoming_inspection')
        self.assertEqual(step.area_kind, 'receiving')

    def test_shipping_routes_to_shipping(self):
        step = self._make_step_with_kind('shipping')
        self.assertEqual(step.area_kind, 'shipping')

    def test_unknown_kind_defaults_to_plating(self):
        step = self._make_step_with_kind('some_made_up_kind')
        self.assertEqual(step.area_kind, 'plating')

    def test_work_centre_area_kind_wins_over_step_kind(self):
        # When the work_centre carries an explicit area_kind, it
        # overrides the recipe-step fallback (more specific data).
        wc = self.env['fp.work.centre'].create({
            'name': 'Test Inspect Bench',
            'code': 'INSP-TEST',
            'kind': 'inspect',
            'area_kind': 'inspection',
        })
        # default_kind says racking, but work_centre says inspection
        step = self._make_step_with_kind('racking', work_centre=wc)
        self.assertEqual(step.area_kind, 'inspection')
  • Step 2: Run the failing test
ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_jobs/tests/test_area_kind_routing.py' < K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/tests/test_area_kind_routing.py && ssh pve-worker5 \"pct exec 111 -- su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin --test-enable --stop-after-init --log-level=test -i fusion_plating_jobs --test-tags /fusion_plating_jobs:TestAreaKindRouting' 2>&1 | tail -30\""

Expected: tests fail with AttributeError: 'fp.job.step' object has no attribute 'area_kind'.

  • Step 3: Add the kind-mapping module-level constant + the field

In fusion_plating_jobs/models/fp_job_step.py, near the top imports area, add:

# Mapping from fp.process.node.default_kind → fp.work.centre.area_kind.
# Used as fallback when the step's work_centre is unset OR its work_centre
# has no area_kind. Authoritative source per spec §4.2.
_STEP_KIND_TO_AREA = {
    # Receiving
    'receiving':           'receiving',
    'incoming_inspection': 'receiving',
    'contract_review':     'receiving',
    'gating':              'receiving',
    'ready_for_processing':'receiving',
    # Masking
    'masking':             'masking',
    # Blasting
    'blasting':            'blasting',
    'bead_blast':          'blasting',
    'media_blast':         'blasting',
    # Racking
    'racking':             'racking',
    # Plating (everything wet)
    'soak_clean':          'plating',
    'electroclean':        'plating',
    'acid_dip':            'plating',
    'etch':                'plating',
    'desmut':              'plating',
    'zincate':             'plating',
    'rinse':               'plating',
    'water_break_test':    'plating',
    'activation':          'plating',
    'e_nickel_plate':      'plating',
    'chrome':              'plating',
    'anodize':             'plating',
    'black_oxide':         'plating',
    'drying':              'plating',
    # Baking
    'bake':                'baking',
    'oven_bake':           'baking',
    'post_bake_relief':    'baking',
    # De-Racking (folds in de-masking per spec D4)
    'de_rack':             'de_racking',
    'de_mask':             'de_racking',
    'unrack':              'de_racking',
    # Inspection
    'inspection':          'inspection',
    'final_inspection':    'inspection',
    'post_plate_inspection':'inspection',
    'thickness_qc':        'inspection',
    'fair':                'inspection',
    'dimensional_check':   'inspection',
    # Shipping
    'shipping':            'shipping',
    'pack_ship':           'shipping',
}

Then inside the FpJobStep class, after the existing recipe_node_id field, add:

area_kind = fields.Selection(
    selection=[
        ('receiving',  'Receiving'),
        ('masking',    'Masking'),
        ('blasting',   'Blasting'),
        ('racking',    'Racking'),
        ('plating',    'Plating'),
        ('baking',     'Baking'),
        ('de_racking', 'De-Racking'),
        ('inspection', 'Final inspection'),
        ('shipping',   'Shipping'),
    ],
    string='Floor Column',
    compute='_compute_area_kind',
    store=True,
    index=True,
    help='Which Shop Floor column this step belongs to. Resolved '
         'as: work_centre.area_kind if set; else fallback to recipe '
         'step kind via _STEP_KIND_TO_AREA; else "plating" as a safe '
         'catch-all.',
)

And the compute method:

@api.depends('work_centre_id.area_kind', 'recipe_node_id.default_kind')
def _compute_area_kind(self):
    for step in self:
        # 1. Explicit work-centre area_kind wins
        if step.work_centre_id and step.work_centre_id.area_kind:
            step.area_kind = step.work_centre_id.area_kind
            continue
        # 2. Fallback to step-kind mapping
        kind = step.recipe_node_id.default_kind if step.recipe_node_id else False
        if kind and kind in _STEP_KIND_TO_AREA:
            step.area_kind = _STEP_KIND_TO_AREA[kind]
            continue
        # 3. Safe catch-all
        step.area_kind = 'plating'
  • Step 4: Run the tests — should now pass
ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job_step.py' < K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/models/fp_job_step.py && pct exec 111 -- su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin --test-enable --stop-after-init --log-level=test -u fusion_plating_jobs --test-tags /fusion_plating_jobs:TestAreaKindRouting' 2>&1 | tail -40"

Expected: all 13 tests pass.

  • Step 5: Add the test file to fusion_plating_jobs/tests/__init__.py if it has an __init__.py that imports tests. Check first:
grep -n "test_" K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/tests/__init__.py 2>/dev/null

If the file has explicit imports of test modules, add from . import test_area_kind_routing. If it uses a glob, no change needed.

  • Step 6: Bump fusion_plating_jobs manifest to 19.0.10.24.0

  • Step 7: Commit

cd K:/Github/Odoo-Modules/fusion_plating && git add fusion_plating_jobs/models/fp_job_step.py fusion_plating_jobs/__manifest__.py fusion_plating_jobs/tests/test_area_kind_routing.py fusion_plating_jobs/tests/__init__.py && git commit -m "feat(jobs): add fp.job.step.area_kind for plant-view kanban routing

Stored computed field that routes each step to one of the 9 Shop
Floor columns. Resolution order:
  1. step.work_centre_id.area_kind (most specific)
  2. _STEP_KIND_TO_AREA[recipe_node.default_kind] (fallback table)
  3. 'plating' (safe catch-all for unmapped kinds)

The fallback table covers all 30+ step kinds in the project's
step library, including the spec D4 rule (de_mask routes to
de_racking) and D5 rule (contract_review routes to receiving).

Tested with 13 dispatch scenarios.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 3: Add last_activity_at to fp.job.step

Used by the idle-warning state computation (S16). Needs to update on every state transition / move / chatter post.

Files:

  • Modify: fusion_plating_jobs/models/fp_job_step.py

  • Step 1: Add the field

After the area_kind field, add:

last_activity_at = fields.Datetime(
    string='Last Activity',
    index=True,
    help='Updated on any state transition, move-out, or chatter post. '
         'Drives the idle-warning card state (S16 — step in_progress '
         'with no activity for 8+ hours).',
)
  • Step 2: Hook the write override

Find the existing write() method on FpJobStep (around line 265 per the earlier grep). Add a stamp to last_activity_at whenever state changes:

def write(self, vals):
    # Existing logic preserved...
    if 'state' in vals:
        vals.setdefault('last_activity_at', fields.Datetime.now())
    return super().write(vals)

If the existing write is more complex, splice the timestamp update in just before the super().write() call.

  • Step 3: Hook move creation

In fusion_plating/models/fp_job_step_move.py, add a small create override (or add to existing) that stamps last_activity_at on the from_step and to_step:

@api.model_create_multi
def create(self, vals_list):
    moves = super().create(vals_list)
    Step = self.env['fp.job.step']
    now = fields.Datetime.now()
    step_ids = set()
    for m in moves:
        if m.from_step_id:
            step_ids.add(m.from_step_id.id)
        if m.to_step_id:
            step_ids.add(m.to_step_id.id)
    if step_ids:
        Step.browse(list(step_ids)).sudo().write({'last_activity_at': now})
    return moves
  • Step 4: Hook chatter — message_post override

In fp.job.step, override message_post to bump the timestamp:

def message_post(self, **kwargs):
    res = super().message_post(**kwargs)
    self.sudo().write({'last_activity_at': fields.Datetime.now()})
    return res
  • Step 5: Add _fp_is_idle helper
def _fp_is_idle(self, threshold_hours=8):
    """True when state=in_progress and last_activity_at is older than
    the threshold. Drives the S16 idle-warning card state."""
    self.ensure_one()
    if self.state != 'in_progress':
        return False
    if not self.last_activity_at:
        return False
    delta = fields.Datetime.now() - self.last_activity_at
    return delta.total_seconds() > threshold_hours * 3600
  • Step 6: Backfill existing rows

Add to the 19.0.21.0.0/post-migrate.py (we're already touching this version):

def migrate(cr, version):
    # ... existing area_kind backfill ...

    # Backfill last_activity_at from write_date for steps that don't have one yet
    cr.execute("""
        UPDATE fp_job_step
           SET last_activity_at = write_date
         WHERE last_activity_at IS NULL
    """)

Wait — last_activity_at is on fp_job_step which is in fusion_plating_jobs, but the migration lives in fusion_plating. Move this backfill to fusion_plating_jobs/migrations/19.0.10.24.0/post-migrate.py instead.

mkdir -p K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/migrations/19.0.10.24.0

Create fusion_plating_jobs/migrations/19.0.10.24.0/post-migrate.py:

# -*- coding: utf-8 -*-
import logging
_logger = logging.getLogger(__name__)

def migrate(cr, version):
    """Backfill fp.job.step.last_activity_at from write_date so existing
    in-progress steps don't immediately trigger the idle-warning gate
    on first compute."""
    cr.execute("""
        UPDATE fp_job_step
           SET last_activity_at = write_date
         WHERE last_activity_at IS NULL
    """)
    _logger.info("Backfilled last_activity_at on existing fp.job.step rows")
  • Step 7: Manual smoke test on entech
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_jobs --stop-after-init\" 2>&1 | tail -10 && systemctl start odoo'"
ssh pve-worker5 "pct exec 111 -- su - postgres -c \"psql -d admin -c \\\"SELECT count(*), count(last_activity_at) FROM fp_job_step;\\\"\""

Expected: both counts equal — every row has last_activity_at populated.

  • Step 8: Commit
git add fusion_plating_jobs/models/fp_job_step.py fusion_plating/models/fp_job_step_move.py fusion_plating_jobs/migrations/19.0.10.24.0/post-migrate.py && git commit -m "feat(jobs): add fp.job.step.last_activity_at for idle detection

Tracks the last meaningful activity on a step (state transition,
move-out, chatter post). Drives the S16 idle-warning card state
(in_progress with no activity for 8+ hours).

Backfilled from write_date on existing rows so jobs that were
in-progress before this deploy don't immediately trip the gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 4: Add paired_work_centre_ids to res.users

Files:

  • Modify: fusion_plating_shopfloor/models/res_users.py

  • Step 1: Read the existing res_users.py

cat K:/Github/Odoo-Modules/fusion_plating/fusion_plating_shopfloor/models/res_users.py

Confirm the inherit pattern and existing fields. The new field goes alongside the existing x_fc_tablet_pin_* fields.

  • Step 2: Add the M2M
paired_work_centre_ids = fields.Many2many(
    'fp.work.centre',
    'res_users_fp_work_centre_paired_rel',
    'user_id',
    'work_centre_id',
    string='Paired Work Centres',
    help='Stations the operator is currently paired to via the tablet. '
         'MVP holds exactly one record on day 1 (the dropdown-selected '
         'station). Phase 2 multi-station picker can populate multiple. '
         'Drives the "is this card mine" check on the plant-view kanban.',
)
  • Step 3: Wire to the existing tablet pairing flow

Find the existing pairing code. Look for where currentStationId (in JS) is sent to the server. It's likely in fp_shopfloor_tech_store.js calling an endpoint. Trace it to the controller that handles unlock/pair:

grep -rn "paired_station\|currentStationId\|fp_shopfloor_tech_store" K:/Github/Odoo-Modules/fusion_plating/fusion_plating_shopfloor/controllers/ K:/Github/Odoo-Modules/fusion_plating/fusion_plating_shopfloor/models/ 2>/dev/null

In the controller that handles set_tech / unlock, after the existing pairing write add:

# Mirror the single selected station into paired_work_centre_ids
# (M2M) so the plant-view kanban can resolve "mine" cards. Forward-
# compatible: a future multi-station picker writes the same M2M with
# multiple records.
station = request.env['fusion.plating.shopfloor.station'].browse(station_id)
work_centre = station.work_centre_id if station else False
user = request.env['res.users'].browse(user_id)
if work_centre:
    user.sudo().paired_work_centre_ids = [(6, 0, [work_centre.id])]
else:
    user.sudo().paired_work_centre_ids = [(5, 0, 0)]  # clear

(Adjust the path from station → work_centre based on the actual schema. If fp.shopfloor.station has a different relation to fp.work.centre, use that.)

  • Step 4: Smoke test

Restart odoo, pair to a station via the tablet, then check:

ssh pve-worker5 "pct exec 111 -- su - postgres -c \"psql -d admin -c \\\"SELECT u.login, array_agg(wc.name) FROM res_users u LEFT JOIN res_users_fp_work_centre_paired_rel rel ON rel.user_id = u.id LEFT JOIN fp_work_centre wc ON wc.id = rel.work_centre_id WHERE u.login NOT LIKE '%@%' OR u.login LIKE '%enplating%' GROUP BY u.login;\\\"\""

Expected: paired users have their station in the array.

  • Step 5: Commit
git add fusion_plating_shopfloor/models/res_users.py fusion_plating_shopfloor/controllers/ && git commit -m "feat(shopfloor): add res.users.paired_work_centre_ids M2M

M2M from res.users to fp.work.centre, populated on tablet pairing.
MVP holds exactly one row (mirrors the single-station dropdown);
forward-compatible for the Phase 2 multi-station picker.

Drives the 'is this card mine' check on the plant-view kanban —
without it the new view can't compute which cards belong to the
paired operator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 5: Add feature flag x_fc_shopfloor_layout

Files:

  • Modify or create: fusion_plating_shopfloor/models/res_config_settings.py

  • Modify: fusion_plating_shopfloor/views/res_config_settings_views.xml

  • Step 1: Check if the file exists

ls K:/Github/Odoo-Modules/fusion_plating/fusion_plating_shopfloor/models/res_config_settings.py 2>/dev/null

If not present, create it. If present, add the field.

  • Step 2: Add the Selection field on res.config.settings
# -*- coding: utf-8 -*-
from odoo import fields, models


class ResConfigSettings(models.TransientModel):
    _inherit = 'res.config.settings'

    x_fc_shopfloor_layout = fields.Selection(
        [
            ('legacy', 'Legacy (per-step kanban)'),
            ('v2',     'Plant View (one card per job, 9 columns)'),
        ],
        string='Shop Floor Layout',
        default='legacy',
        config_parameter='fusion_plating_shopfloor.layout',
        help='Switches the Shop Floor client action between the legacy '
             'per-step kanban and the v2 plant view. Stays on legacy '
             'during the parallel rollout; flip to v2 once validated.',
    )

config_parameter makes Odoo persist the value to ir.config_parameter (key fusion_plating_shopfloor.layout) so the resolver can read it without a res.config.settings recordset.

  • Step 3: Surface in Settings UI

In fusion_plating_shopfloor/views/res_config_settings_views.xml, inside the existing Fusion Plating settings block, add:

<setting id="fp_shopfloor_layout_setting"
         string="Shop Floor Layout"
         help="Switch between legacy and the v2 plant view.">
    <field name="x_fc_shopfloor_layout" widget="radio"/>
</setting>

If the XML file doesn't exist yet, create it following the project pattern in other res_config_settings_views.xml files.

  • Step 4: Register the file in the manifest

In fusion_plating_shopfloor/__manifest__.py, add the views file to 'data': if not already present.

  • Step 5: Verify the setting saves

Restart odoo, open Settings → Fusion Plating, toggle the setting, save. Then:

ssh pve-worker5 "pct exec 111 -- su - postgres -c \"psql -d admin -c \\\"SELECT key, value FROM ir_config_parameter WHERE key LIKE '%shopfloor%';\\\"\""

Expected: fusion_plating_shopfloor.layout row with the selected value.

  • Step 6: Commit
git add fusion_plating_shopfloor/models/res_config_settings.py fusion_plating_shopfloor/views/res_config_settings_views.xml fusion_plating_shopfloor/__manifest__.py && git commit -m "feat(shopfloor): add x_fc_shopfloor_layout feature flag

Selection on res.config.settings (legacy / v2), backed by
ir.config_parameter so the landing-action resolver can read it
cheaply on every action open.

Defaults to 'legacy' during parallel rollout. The new plant-view
client action ships in subsequent commits; only the resolver
needs to flip when we're ready to switch defaults.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Phase 2 — Card state computation

Task 6: Add the 6 precedence helpers to fp.job

Files:

  • Modify: fusion_plating_jobs/models/fp_job.py

Each helper is a tiny pure function returning a bool. They're called by the card_state compute in the next task. Putting them first means the test in Task 7 can mock them cleanly.

  • Step 1: Add helper methods on fp.job
# === Card-state precedence helpers (spec §6.2 + §9.4) =================
# Each method returns a bool; the card_state compute calls them in
# precedence order and the first truthy one wins. Add new states by
# (a) adding a helper here, (b) adding the rule to _compute_card_state.

def _fp_inbound_not_received(self):
    """True when the job is confirmed but the inbound fp.receiving
    record is still draft (parts haven't arrived). Card state =
    'no_parts'."""
    self.ensure_one()
    if self.state != 'confirmed':
        return False
    so = self.sale_order_id
    if not so or 'x_fc_receiving_ids' not in so._fields:
        return False
    return any(r.state == 'draft' for r in so.x_fc_receiving_ids)

def _fp_has_open_hold(self):
    """True when an open fusion.plating.quality.hold exists for this
    job. Card state = 'on_hold'."""
    self.ensure_one()
    if 'fusion.plating.quality.hold' not in self.env:
        return False
    Hold = self.env['fusion.plating.quality.hold']
    return bool(Hold.search_count([
        ('job_id', '=', self.id),
        ('state', '=', 'open'),
    ]))

def _fp_has_pending_qc(self):
    """True when a quality check is in draft or in_progress on this job.
    Card state = 'awaiting_qc'."""
    self.ensure_one()
    if 'fusion.plating.quality.check' not in self.env:
        return False
    QC = self.env['fusion.plating.quality.check']
    return bool(QC.search_count([
        ('job_id', '=', self.id),
        ('state', 'in', ('draft', 'in_progress')),
    ]))

def _fp_bake_window_due_soon(self, threshold_hours=1):
    """True when a bake window is open AND its required-by deadline
    is within `threshold_hours`. Card state = 'bake_due'."""
    self.ensure_one()
    if 'fusion.plating.bake.window' not in self.env:
        return False
    Window = self.env['fusion.plating.bake.window']
    cutoff = fields.Datetime.now() + datetime.timedelta(hours=threshold_hours)
    # bake.window doesn't have a direct fp.job link in all installs;
    # find via the job's part_ref or sale_order_id depending on the
    # bake.window schema. Adjust the domain to match your install.
    domain = [
        ('state', '=', 'awaiting_bake'),
        ('bake_required_by', '<=', cutoff),
    ]
    if 'job_id' in Window._fields:
        domain.append(('job_id', '=', self.id))
    elif self.sale_order_id and 'sale_order_id' in Window._fields:
        domain.append(('sale_order_id', '=', self.sale_order_id.id))
    return bool(Window.search_count(domain))

def _fp_is_mine(self, user=None):
    """True when the active step's work centre is in the operator's
    paired stations. Drives ready_mine / running_mine state."""
    self.ensure_one()
    user = user or self.env.user
    if not self.active_step_id or not self.active_step_id.work_centre_id:
        return False
    paired = user.paired_work_centre_ids.ids if 'paired_work_centre_ids' in user._fields else []
    return self.active_step_id.work_centre_id.id in paired

Add import datetime at the top of the file if not already present.

Add a corresponding helper on fp.job.step:

def _fp_has_unfinished_predecessors(self):
    """True when an earlier-sequence step on the same job is not yet
    terminal (done/skipped/cancelled). Drives predecessor_locked."""
    self.ensure_one()
    return bool(self.job_id.step_ids.filtered(
        lambda s: s.sequence < self.sequence
        and s.state not in ('done', 'skipped', 'cancelled')
    ))
  • Step 2: Smoke check — restart odoo, no errors on load
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_jobs --stop-after-init\" 2>&1 | grep -E \"(ERROR|loaded)\" | tail -10 && systemctl start odoo'"
  • Step 3: Commit
git add fusion_plating_jobs/models/fp_job.py && git commit -m "feat(jobs): add precedence helpers for plant-view card_state

Six small _fp_* helpers on fp.job (plus one on fp.job.step) that
each test one of the precedence conditions for the 13-state
classifier in the next commit.

Helpers are intentionally tiny and pure so they're cheap to call
in the card_state compute and easy to mock in tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 7: Add card_state compute on fp.job (TDD)

Files:

  • Modify: fusion_plating_jobs/models/fp_job.py

  • Test: fusion_plating_jobs/tests/test_card_state.py (create)

  • Step 1: Write the failing tests

Create fusion_plating_jobs/tests/test_card_state.py:

# -*- coding: utf-8 -*-
from unittest.mock import patch
from odoo.tests.common import TransactionCase


class TestCardState(TransactionCase):
    """One test per card state + one test per precedence pair."""

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.Job = cls.env['fp.job']
        cls.Step = cls.env['fp.job.step']
        cls.Node = cls.env['fusion.plating.process.node']
        cls.partner = cls.env['res.partner'].create({'name': 'Test'})

    def _make_job_with_active_step(self, step_kind='racking', step_state='ready'):
        job = self.Job.create({
            'name': 'WO-TEST',
            'partner_id': self.partner.id,
            'qty': 1,
            'state': 'in_progress',
        })
        node = self.Node.create({
            'name': 'Step',
            'node_type': 'step',
            'default_kind': step_kind,
        })
        self.Step.create({
            'job_id': job.id,
            'name': node.name,
            'recipe_node_id': node.id,
            'sequence': 10,
            'state': step_state,
        })
        # Force active_step recompute
        job.invalidate_recordset(['active_step_id'])
        return job

    # ---- one test per state -------------------------------------------

    def test_ready_state(self):
        job = self._make_job_with_active_step(step_state='ready')
        self.assertEqual(job.card_state, 'ready')

    def test_running_state(self):
        job = self._make_job_with_active_step(step_state='in_progress')
        self.assertEqual(job.card_state, 'running')

    def test_done_state(self):
        # All steps done, job state=done, on shipping
        job = self._make_job_with_active_step('shipping', 'in_progress')
        job.state = 'done'
        self.assertEqual(job.card_state, 'done')

    def test_contract_review_state(self):
        job = self._make_job_with_active_step('contract_review', 'in_progress')
        self.assertEqual(job.card_state, 'contract_review')

    def test_on_hold_wins_over_running(self):
        job = self._make_job_with_active_step(step_state='in_progress')
        with patch.object(type(job), '_fp_has_open_hold', return_value=True):
            self.assertEqual(job.card_state, 'on_hold')

    def test_awaiting_signoff(self):
        job = self._make_job_with_active_step(step_state='in_progress')
        step = job.active_step_id
        step.requires_signoff = True  # if related field, set on recipe_node
        if not step.requires_signoff:
            step.recipe_node_id.requires_signoff = True
            step.invalidate_recordset(['requires_signoff'])
        step.state = 'done'
        step.signoff_user_id = False
        job.invalidate_recordset(['card_state'])
        self.assertEqual(job.card_state, 'awaiting_signoff')

    def test_awaiting_qc(self):
        job = self._make_job_with_active_step(step_state='in_progress')
        with patch.object(type(job), '_fp_has_pending_qc', return_value=True):
            self.assertEqual(job.card_state, 'awaiting_qc')

    def test_bake_due(self):
        job = self._make_job_with_active_step(step_state='in_progress')
        with patch.object(type(job), '_fp_bake_window_due_soon', return_value=True):
            self.assertEqual(job.card_state, 'bake_due')

    def test_predecessor_locked(self):
        job = self._make_job_with_active_step(step_state='ready')
        step = job.active_step_id
        # Insert an unfinished predecessor
        prior = self.Step.create({
            'job_id': job.id,
            'name': 'Earlier',
            'sequence': 5,
            'state': 'pending',
        })
        with patch.object(type(step), '_fp_should_block_predecessors', return_value=True):
            job.invalidate_recordset(['card_state'])
            self.assertEqual(job.card_state, 'predecessor_locked')

    def test_idle_warning(self):
        job = self._make_job_with_active_step(step_state='in_progress')
        step = job.active_step_id
        with patch.object(type(step), '_fp_is_idle', return_value=True):
            job.invalidate_recordset(['card_state'])
            self.assertEqual(job.card_state, 'idle_warning')

    def test_no_parts(self):
        # confirmed job with no started steps; receiving still draft
        job = self.Job.create({
            'name': 'WO-PARTS',
            'partner_id': self.partner.id,
            'qty': 1,
            'state': 'confirmed',
        })
        with patch.object(type(job), '_fp_inbound_not_received', return_value=True):
            self.assertEqual(job.card_state, 'no_parts')

    # ---- precedence pairs ---------------------------------------------

    def test_on_hold_beats_awaiting_signoff(self):
        # Both true; on_hold (rule 2) must win.
        job = self._make_job_with_active_step(step_state='in_progress')
        with patch.object(type(job), '_fp_has_open_hold', return_value=True), \
             patch.object(type(job), '_fp_has_pending_qc', return_value=True):
            self.assertEqual(job.card_state, 'on_hold')

    def test_bake_due_beats_running(self):
        job = self._make_job_with_active_step(step_state='in_progress')
        with patch.object(type(job), '_fp_bake_window_due_soon', return_value=True):
            self.assertEqual(job.card_state, 'bake_due')

    def test_idle_beats_running(self):
        job = self._make_job_with_active_step(step_state='in_progress')
        step = job.active_step_id
        with patch.object(type(step), '_fp_is_idle', return_value=True):
            job.invalidate_recordset(['card_state'])
            self.assertEqual(job.card_state, 'idle_warning')
  • Step 2: Run the failing test
# Copy test to entech and run
cat K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/tests/test_card_state.py | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_jobs/tests/test_card_state.py'"
ssh pve-worker5 "pct exec 111 -- su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin --test-enable --stop-after-init --log-level=test -u fusion_plating_jobs --test-tags /fusion_plating_jobs:TestCardState' 2>&1 | tail -40"

Expected: failures with "no attribute 'card_state'".

  • Step 3: Implement the compute

In fusion_plating_jobs/models/fp_job.py, add the field:

card_state = fields.Char(
    string='Card State (plant view)',
    compute='_compute_card_state',
    store=True,
    index=True,
    help='One of 13 mutually-exclusive states driving the plant-view '
         'kanban card chrome. See spec §6 for the catalog and '
         'precedence rules.',
)

And the compute method (matches spec §9.3 / §6.2 precedence order EXACTLY):

@api.depends(
    'state',
    'active_step_id',
    'active_step_id.state',
    'active_step_id.requires_signoff',
    'active_step_id.signoff_user_id',
    'active_step_id.last_activity_at',
    'active_step_id.area_kind',
    'active_step_id.recipe_node_id.default_kind',
    # Indirect dependencies — recomputed manually when these change
    # via the relevant model's invalidate_recordset calls.
)
def _compute_card_state(self):
    for job in self:
        # Edge: no active step at all
        if not job.active_step_id:
            if job.state == 'confirmed' and job._fp_inbound_not_received():
                job.card_state = 'no_parts'
            else:
                job.card_state = 'contract_review'
            continue

        step = job.active_step_id

        # Rule 1 — no_parts
        if job._fp_inbound_not_received():
            job.card_state = 'no_parts'
            continue
        # Rule 2 — on_hold
        if job._fp_has_open_hold():
            job.card_state = 'on_hold'
            continue
        # Rule 3 — awaiting_signoff (S22)
        if (step.requires_signoff and step.state == 'done'
                and not step.signoff_user_id):
            job.card_state = 'awaiting_signoff'
            continue
        # Rule 4 — awaiting_qc
        if job._fp_has_pending_qc():
            job.card_state = 'awaiting_qc'
            continue
        # Rule 5 — bake_due
        if job._fp_bake_window_due_soon():
            job.card_state = 'bake_due'
            continue
        # Rule 6 — predecessor_locked
        if (step._fp_should_block_predecessors()
                and step._fp_has_unfinished_predecessors()):
            job.card_state = 'predecessor_locked'
            continue
        # Rule 7 — idle_warning (S16)
        if step.state == 'in_progress' and step._fp_is_idle(threshold_hours=8):
            job.card_state = 'idle_warning'
            continue
        # Rule 8 — done
        if step.area_kind == 'shipping' and job.state == 'done':
            job.card_state = 'done'
            continue
        # Rule 9 — contract_review
        if step.recipe_node_id.default_kind == 'contract_review':
            job.card_state = 'contract_review'
            continue
        # Rules 10/12 — running (mine vs not)
        if step.state == 'in_progress':
            job.card_state = 'running_mine' if job._fp_is_mine() else 'running'
            continue
        # Rules 11/13 — ready (mine vs not)
        if step.state == 'ready':
            job.card_state = 'ready_mine' if job._fp_is_mine() else 'ready'
            continue
        # Safe default
        job.card_state = 'ready'
  • Step 4: Run tests — should now pass
cat K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/models/fp_job.py | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py'"
ssh pve-worker5 "pct exec 111 -- su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin --test-enable --stop-after-init --log-level=test -u fusion_plating_jobs --test-tags /fusion_plating_jobs:TestCardState' 2>&1 | tail -40"

Expected: 13 of 13 tests pass.

  • Step 5: Commit
git add fusion_plating_jobs/models/fp_job.py fusion_plating_jobs/tests/test_card_state.py fusion_plating_jobs/tests/__init__.py && git commit -m "feat(jobs): add fp.job.card_state for plant-view kanban

13-state classifier with explicit precedence dispatch matching
spec §6.2/§9.3 exactly. Stored computed field so the kanban
endpoint can index/filter on it cheaply.

Tested with one assertion per state + 3 precedence-pair tests
(on_hold beats awaiting_signoff, bake_due beats running, idle
beats running).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 8: Add mini_timeline_json compute

Files:

  • Modify: fusion_plating_jobs/models/fp_job.py

  • Test: fusion_plating_jobs/tests/test_mini_timeline.py (create)

  • Step 1: Write failing test

Create fusion_plating_jobs/tests/test_mini_timeline.py:

# -*- coding: utf-8 -*-
import json
from odoo.tests.common import TransactionCase


class TestMiniTimeline(TransactionCase):

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.Job = cls.env['fp.job']
        cls.Step = cls.env['fp.job.step']
        cls.Node = cls.env['fusion.plating.process.node']
        cls.partner = cls.env['res.partner'].create({'name': 'T'})

    def _build_recipe_job(self, step_areas):
        """step_areas: list of (area_kind, state). Returns the job."""
        job = self.Job.create({
            'name': 'WO-TIMELINE',
            'partner_id': self.partner.id,
            'qty': 1,
        })
        for i, (area, state) in enumerate(step_areas):
            # Build a recipe node whose default_kind routes to that area
            kind_map = {
                'receiving': 'incoming_inspection',
                'masking': 'masking', 'blasting': 'blasting',
                'racking': 'racking', 'plating': 'soak_clean',
                'baking': 'bake', 'de_racking': 'de_rack',
                'inspection': 'final_inspection', 'shipping': 'shipping',
            }
            node = self.Node.create({
                'name': area, 'node_type': 'step',
                'default_kind': kind_map[area],
            })
            self.Step.create({
                'job_id': job.id, 'name': area,
                'recipe_node_id': node.id,
                'sequence': (i + 1) * 10, 'state': state,
            })
        job.invalidate_recordset()
        return job

    def test_timeline_returns_9_elements(self):
        job = self._build_recipe_job([
            ('receiving', 'done'),
            ('racking', 'in_progress'),
            ('plating', 'pending'),
        ])
        timeline = json.loads(job.mini_timeline_json)
        self.assertEqual(len(timeline), 9)

    def test_timeline_order_matches_column_sequence(self):
        job = self._build_recipe_job([('receiving', 'done')])
        timeline = json.loads(job.mini_timeline_json)
        areas = [t['area'] for t in timeline]
        self.assertEqual(areas, [
            'receiving', 'masking', 'blasting', 'racking', 'plating',
            'baking', 'de_racking', 'inspection', 'shipping',
        ])

    def test_done_areas_marked_done(self):
        job = self._build_recipe_job([
            ('receiving', 'done'),
            ('masking', 'done'),
            ('racking', 'in_progress'),
        ])
        timeline = json.loads(job.mini_timeline_json)
        by_area = {t['area']: t['state'] for t in timeline}
        self.assertEqual(by_area['receiving'], 'done')
        self.assertEqual(by_area['masking'], 'done')
        self.assertEqual(by_area['racking'], 'current')
        self.assertEqual(by_area['plating'], 'upcoming')

    def test_skipped_area_renders_as_upcoming(self):
        # Recipe has no Masking step — column still shows as upcoming
        job = self._build_recipe_job([
            ('receiving', 'done'),
            ('blasting', 'done'),
            ('racking', 'in_progress'),
        ])
        timeline = json.loads(job.mini_timeline_json)
        by_area = {t['area']: t['state'] for t in timeline}
        self.assertEqual(by_area['masking'], 'upcoming')
  • Step 2: Run the test (expected fail)
ssh pve-worker5 "pct exec 111 -- su - odoo -s /bin/bash -c '/usr/bin/odoo ... --test-tags /fusion_plating_jobs:TestMiniTimeline'"

Expected: AttributeError.

  • Step 3: Implement the compute

Add to fp.job:

mini_timeline_json = fields.Text(
    string='Mini-Timeline (JSON)',
    compute='_compute_mini_timeline_json',
    help='Serialized 9-element array, one per Shop Floor column, '
         'with state in {done, current, upcoming}. Card UI reads '
         'this to render the bottom timeline strip.',
)

_COLUMN_SEQUENCE = [
    'receiving', 'masking', 'blasting', 'racking', 'plating',
    'baking', 'de_racking', 'inspection', 'shipping',
]

@api.depends(
    'step_ids.state',
    'step_ids.area_kind',
    'active_step_id',
    'card_state',
)
def _compute_mini_timeline_json(self):
    import json as _json
    for job in self:
        active_area = job.active_step_id.area_kind if job.active_step_id else None
        timeline = []
        for area in self._COLUMN_SEQUENCE:
            steps_in_area = job.step_ids.filtered(lambda s: s.area_kind == area)
            if not steps_in_area:
                # Recipe doesn't visit this area
                timeline.append({'area': area, 'state': 'upcoming'})
                continue
            if all(s.state in ('done', 'skipped') for s in steps_in_area):
                timeline.append({'area': area, 'state': 'done'})
            elif area == active_area:
                timeline.append({
                    'area': area,
                    'state': 'current',
                    'variant': job.card_state,
                })
            else:
                timeline.append({'area': area, 'state': 'upcoming'})
        job.mini_timeline_json = _json.dumps(timeline)
  • Step 4: Run the test

Expected: all 4 tests pass.

  • Step 5: Commit
git add fusion_plating_jobs/models/fp_job.py fusion_plating_jobs/tests/test_mini_timeline.py && git commit -m "feat(jobs): add fp.job.mini_timeline_json for plant-view card

9-element JSON array, one per Shop Floor column, with state in
{done, current, upcoming}. Powers the bottom timeline strip on
each card without the frontend needing to know recipe shape.

The 'current' entry carries the card's variant (e.g. 'on_hold',
'bake_due') so the renderer picks the right color for the
highlighted column marker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Phase 3 — Backend endpoint

Task 9: Create /fp/landing/plant_kanban controller

Files:

  • Create: fusion_plating_shopfloor/controllers/plant_kanban.py

  • Modify: fusion_plating_shopfloor/controllers/__init__.py

  • Test: fusion_plating_shopfloor/tests/test_plant_kanban_endpoint.py (create)

  • Step 1: Add to controllers package init

In fusion_plating_shopfloor/controllers/__init__.py:

from . import plant_kanban

(Add this line; keep existing imports.)

  • Step 2: Implement the endpoint

Create fusion_plating_shopfloor/controllers/plant_kanban.py:

# -*- coding: utf-8 -*-
# Plant-view kanban endpoint. Returns columns + denormalized cards in
# one payload so the OWL component doesn't need to fan out RPCs.

from odoo import http, _
from odoo.http import request
import json
import logging

_logger = logging.getLogger(__name__)

_COLUMN_LABELS = [
    ('receiving',  _('Receiving')),
    ('masking',    _('Masking')),
    ('blasting',   _('Blasting')),
    ('racking',    _('Racking')),
    ('plating',    _('Plating')),
    ('baking',     _('Baking')),
    ('de_racking', _('De-Racking')),
    ('inspection', _('Final inspection')),
    ('shipping',   _('Shipping')),
]


class PlantKanbanController(http.Controller):

    @http.route('/fp/landing/plant_kanban', type='jsonrpc', auth='user')
    def plant_kanban(self, mode='station', filters=None):
        """Returns the assembled board payload.

        mode: 'station' | 'all_plant' | 'manager'
        filters: optional dict, e.g. {'overdue': True, 'fair': True}
        """
        user = request.env.user
        Job = request.env['fp.job']

        # Find paired station (for 'mine' resolution + column highlight)
        paired = user.paired_work_centre_ids[:1] if (
            'paired_work_centre_ids' in user._fields
        ) else request.env['fp.work.centre']
        paired_area = paired.area_kind if paired else None

        # Build base domain — every job with at least one non-terminal step
        domain = [
            ('state', 'in', ('confirmed', 'in_progress')),
            ('active_step_id', '!=', False),
        ]
        # Apply optional filters
        filters = filters or {}
        if filters.get('overdue'):
            from datetime import date
            domain.append(('commitment_date', '<', date.today()))
        if filters.get('on_hold'):
            domain.append(('card_state', '=', 'on_hold'))
        if filters.get('running'):
            domain.append(('active_step_id.state', '=', 'in_progress'))
        if filters.get('blocked'):
            domain.append(('card_state', 'in', (
                'on_hold', 'predecessor_locked', 'awaiting_signoff',
                'awaiting_qc', 'no_parts',
            )))
        if filters.get('mine') and paired:
            domain.append(('card_state', 'in', ('ready_mine', 'running_mine')))
        if filters.get('fair'):
            # Wire to whatever flag denotes FAIR — partner or customer_spec
            # Adjust to your data model:
            domain.append(('customer_spec_id.x_fc_requires_first_article', '=', True))

        jobs = Job.search(domain, limit=500)

        # Group by area_kind
        cards = {}
        cards_by_area = {area: [] for area, _label in _COLUMN_LABELS}
        for job in jobs:
            area = job.active_step_id.area_kind or 'plating'
            cards_by_area.setdefault(area, []).append(job.id)
            cards[str(job.id)] = self._render_card(job, user, paired)

        columns = []
        for area, label in _COLUMN_LABELS:
            columns.append({
                'area_kind': area,
                'label': label,
                'is_mine': (area == paired_area),
                'card_ids': sorted(
                    cards_by_area.get(area, []),
                    key=lambda jid: self._sort_key(cards[str(jid)]),
                ),
            })

        # KPIs
        kpis = self._compute_kpis(jobs, paired)

        return {
            'ok': True,
            'mode': mode,
            'paired_station': {
                'id': paired.id, 'name': paired.name,
                'area_kind': paired_area,
            } if paired else None,
            'kpis': kpis,
            'columns': columns,
            'cards': cards,
        }

    def _render_card(self, job, user, paired):
        """Build the full card payload for a single fp.job."""
        step = job.active_step_id
        timeline = json.loads(job.mini_timeline_json or '[]')
        return {
            'wo_name': job.display_wo_name or job.name,
            'is_mine': job.card_state in ('ready_mine', 'running_mine'),
            'card_state': job.card_state,
            'due_date': fields_isoformat(job.commitment_date),
            'due_label': self._due_label(job.commitment_date),
            'is_overdue': self._is_overdue(job.commitment_date),
            'customer': job.partner_id.name,
            'part_number': (
                job.part_catalog_id.part_number
                if 'part_catalog_id' in job._fields and job.part_catalog_id
                else ''
            ),
            'part_revision': (
                job.part_catalog_id.revision
                if 'part_catalog_id' in job._fields and job.part_catalog_id
                else ''
            ),
            'qty': job.qty,
            'po_number': (
                job.sale_order_id.x_fc_po_number
                if job.sale_order_id and 'x_fc_po_number' in job.sale_order_id._fields
                else ''
            ),
            'recipe_name': job.recipe_id.name if job.recipe_id else '',
            'spec_code': (
                job.customer_spec_id.code
                if 'customer_spec_id' in job._fields and job.customer_spec_id
                else ''
            ),
            'tags': self._compute_tags(job),
            'step_name': step.name if step else '',
            'step_seq': step.sequence if step else 0,
            'step_total': len(job.step_ids),
            'tank_label': (
                step.work_centre_id.name
                if step and step.work_centre_id else ''
            ),
            'state_chip': self._state_chip(job.card_state, step),
            'operator': self._operator_payload(step),
            'duration_label': self._duration_label(step),
            'icons': self._icons(job, step),
            'mini_timeline': timeline,
        }

    def _compute_tags(self, job):
        tags = []
        partner = job.partner_id
        if 'x_fc_rush' in partner._fields and partner.x_fc_rush:
            tags.append('rush')
        if 'x_fc_vip' in partner._fields and partner.x_fc_vip:
            tags.append('vip')
        spec = job.customer_spec_id if 'customer_spec_id' in job._fields else None
        if spec and 'x_fc_requires_first_article' in spec._fields \
                and spec.x_fc_requires_first_article:
            tags.append('fair')
        return tags

    def _state_chip(self, card_state, step):
        """Returns {'label': ..., 'kind': ...} for the state chip."""
        # ... implement per spec §6.1; one map keyed by card_state.
        chips = {
            'ready': {'label': _('● Ready'), 'kind': 'ready'},
            'ready_mine': {'label': _('● Ready to start'), 'kind': 'ready'},
            'running': self._running_chip(step),
            'running_mine': self._running_chip(step),
            'on_hold': {'label': _('🔴 Quality Hold'), 'kind': 'hold'},
            'awaiting_signoff': {'label': _('🔏 Awaiting QA sign-off'), 'kind': 'signoff'},
            'awaiting_qc': self._qc_chip(step),
            'bake_due': self._bake_chip(step),
            'predecessor_locked': self._lock_chip(step),
            'idle_warning': self._idle_chip(step),
            'no_parts': self._no_parts_chip(step),
            'contract_review': {'label': _('📋 QA-005 Awaiting'), 'kind': 'paperwork'},
            'done': {'label': _('✓ Ready for pickup'), 'kind': 'done'},
        }
        return chips.get(card_state, {'label': '', 'kind': ''})

    def _running_chip(self, step):
        elapsed = self._elapsed_label(step)
        return {'label': _('▶ Running %s') % elapsed, 'kind': 'running'}

    def _idle_chip(self, step):
        elapsed = self._elapsed_hours(step)
        # Find last operator from timelogs or step.assigned_user_id
        op_name = step.assigned_user_id.name.split()[0] if step.assigned_user_id else _('Operator')
        return {
            'label': _('⏸ Idle %dh · %s') % (elapsed, op_name),
            'kind': 'idle',
        }

    # ... additional small helpers ...

    def _sort_key(self, card):
        """Sort within column: overdue → bake_due → ready → running → idle → locked → done."""
        priority = {
            'on_hold': 0,
            'no_parts': 1,
            'bake_due': 2,
            'awaiting_signoff': 3,
            'awaiting_qc': 4,
            'ready_mine': 5,
            'running_mine': 6,
            'ready': 7,
            'running': 8,
            'idle_warning': 9,
            'predecessor_locked': 10,
            'contract_review': 11,
            'done': 12,
        }
        return (
            0 if card['is_overdue'] else 1,
            priority.get(card['card_state'], 99),
            card['due_date'] or '9999-12-31',
        )

    def _compute_kpis(self, jobs, paired):
        return {
            'active_jobs': len(jobs),
            'at_my_station': sum(1 for j in jobs if j.card_state in ('ready_mine', 'running_mine')),
            'bakes_due_soon': sum(1 for j in jobs if j.card_state == 'bake_due'),
            'on_hold': sum(1 for j in jobs if j.card_state == 'on_hold'),
            'overdue': sum(1 for j in jobs if self._is_overdue(j.commitment_date)),
        }

The skeleton above shows the structure. Fill in the remaining tiny helpers (_due_label, _is_overdue, _elapsed_label, _elapsed_hours, _operator_payload, _duration_label, _icons, _qc_chip, _bake_chip, _lock_chip, _no_parts_chip) — each is 3-8 lines, all string formatting.

  • Step 3: Add a basic integration test

Create fusion_plating_shopfloor/tests/test_plant_kanban_endpoint.py:

# -*- coding: utf-8 -*-
from odoo.tests.common import HttpCase, tagged


@tagged('-at_install', 'post_install')
class TestPlantKanbanEndpoint(HttpCase):

    def test_endpoint_returns_9_columns(self):
        self.authenticate('admin', 'admin')
        result = self.url_open(
            '/fp/landing/plant_kanban',
            data='{"jsonrpc":"2.0","method":"call","params":{}}',
            headers={'Content-Type': 'application/json'},
        )
        body = result.json()['result']
        self.assertTrue(body['ok'])
        self.assertEqual(len(body['columns']), 9)
        areas = [c['area_kind'] for c in body['columns']]
        self.assertEqual(areas, [
            'receiving', 'masking', 'blasting', 'racking', 'plating',
            'baking', 'de_racking', 'inspection', 'shipping',
        ])

    def test_card_payload_has_required_fields(self):
        # Set up a job, then verify card payload structure
        # ... (full setup with partner, recipe, etc.)
        pass
  • Step 4: Run the endpoint test
ssh pve-worker5 "pct exec 111 -- su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin --test-enable --stop-after-init -u fusion_plating_shopfloor --test-tags /fusion_plating_shopfloor:TestPlantKanbanEndpoint' 2>&1 | tail -30"

Expected: tests pass; 9 columns in correct order.

  • Step 5: Commit
git add fusion_plating_shopfloor/controllers/ fusion_plating_shopfloor/tests/test_plant_kanban_endpoint.py && git commit -m "feat(shopfloor): /fp/landing/plant_kanban endpoint

JSONRPC endpoint returning {kpis, columns, cards} for the plant-
view kanban. Columns are always the same 9 in process sequence;
cards are denormalized per spec §9.2 so the OWL component
doesn't fan out per-card RPCs.

Includes within-column sort (overdue → bake_due → ready → running
→ idle → locked → done) and basic filter handling (overdue,
on_hold, running, blocked, mine, fair).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 10: Update landing action resolver for v2 dispatch

Files:

  • Modify: fusion_plating/data/fp_landing_data.xml (the ir.actions.server action_fp_resolve_plating_landing) OR wherever the resolver lives. Confirm via:
grep -rn "action_fp_resolve_plating_landing\|fp_shopfloor_landing\|fp_plant_kanban" K:/Github/Odoo-Modules/fusion_plating/fusion_plating/ K:/Github/Odoo-Modules/fusion_plating/fusion_plating_shopfloor/ 2>/dev/null | head -10
  • Step 1: Find the existing client action registration for fp_shopfloor_landing
grep -rn "fp_shopfloor_landing\|fp.shopfloor.landing" K:/Github/Odoo-Modules/fusion_plating/fusion_plating_shopfloor/ 2>/dev/null | head

The action probably lives in a data XML file. We'll add a new client action fp_plant_kanban and have the resolver pick between them.

  • Step 2: Add the new client action data

In the same XML file where fp_shopfloor_landing is registered, add:

<record id="action_fp_plant_kanban" model="ir.actions.client">
    <field name="name">Shop Floor</field>
    <field name="tag">fp_plant_kanban</field>
    <field name="target">main</field>
</record>
  • Step 3: Update the resolver to read the feature flag

If the resolver is an ir.actions.server with Python code, update it:

# ir.actions.server.code — runs when user opens the landing action
ICP = env['ir.config_parameter'].sudo()
layout = ICP.get_param('fusion_plating_shopfloor.layout', default='legacy')

# User override still wins if set (existing logic):
if env.user.x_fc_plating_landing_action_id:
    action = env.user.x_fc_plating_landing_action_id.read()[0]
elif env.company.x_fc_default_landing_action_id:
    action = env.company.x_fc_default_landing_action_id.read()[0]
else:
    # Default: dispatch by feature flag
    if layout == 'v2':
        action = env.ref('fusion_plating_shopfloor.action_fp_plant_kanban').read()[0]
    else:
        action = env.ref('fusion_plating_shopfloor.action_fp_shopfloor_landing').read()[0]

(Locate the exact existing resolver code and merge the dispatch logic in.)

  • Step 4: Smoke test

Set the config parameter to v2:

ssh pve-worker5 "pct exec 111 -- su - postgres -c \"psql -d admin -c \\\"INSERT INTO ir_config_parameter (key, value, write_uid, create_uid, write_date, create_date) VALUES ('fusion_plating_shopfloor.layout', 'v2', 1, 1, now(), now()) ON CONFLICT (key) DO UPDATE SET value = 'v2';\\\"\""

Open the Plating app. Confirm: clicking the root menu hits the resolver and returns the new client action. Since the frontend isn't built yet, you'll see an "Action not found" error — that's expected. Set the param back to legacy to use the existing UI while we build v2.

  • Step 5: Commit
git add fusion_plating_shopfloor/data/ fusion_plating/data/fp_landing_data.xml && git commit -m "feat(shopfloor): dispatch landing action by x_fc_shopfloor_layout

Registers the new fp_plant_kanban client action and teaches the
landing resolver to pick between legacy and v2 based on the
ir.config_parameter set by the new feature-flag setting.

User-level and company-level overrides still win (existing
behaviour). Default to legacy until the v2 frontend ships.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Phase 4 — Frontend components (bottom-up)

Task 11: SCSS design tokens

Files:

  • Create: fusion_plating_shopfloor/static/src/scss/_plant_tokens.scss

Critical: Register this FIRST in the manifest's web.assets_backend (before any consumer SCSS). Per CLAUDE.md rule 8, SCSS files are concatenated in manifest order; tokens must precede consumers.

  • Step 1: Create the tokens file
// =====================================================================
// Plant-view kanban — design tokens
// MUST load before _plant_card.scss, _mini_timeline.scss, etc.
// Per the project rule: @import in custom SCSS is forbidden in Odoo 19.
// =====================================================================

$o-webclient-color-scheme: bright !default;

// === Card-state colors (light mode defaults) ===
$_plant-bg-hex:          #f8f9fa;
$_plant-card-bg-hex:     #ffffff;
$_plant-card-border-hex: #d8dadd;
$_plant-text-hex:        #1d1f1e;
$_plant-muted-hex:       #777;

$_plant-mine-bg-hex:     #fffaeb;
$_plant-mine-border-hex: #f0a500;
$_plant-hold-bg-hex:     #fff5f5;
$_plant-hold-border-hex: #dc3545;
$_plant-bake-bg-hex:     #fff8e1;
$_plant-bake-border-hex: #ff9800;
$_plant-signoff-bg-hex:  #f5f0ff;
$_plant-signoff-border-hex: #6f42c1;
$_plant-idle-bg-hex:     #fef9e7;
$_plant-idle-border-hex: #e6a800;
$_plant-qc-bg-hex:       #e7f5fc;
$_plant-qc-border-hex:   #17a2b8;
$_plant-locked-bg-hex:   #f8f9fa;
$_plant-noparts-bg-hex:  #f5f5f5;
$_plant-noparts-border-hex: #6c757d;
$_plant-done-bg-hex:     #f0f9f4;
$_plant-done-border-hex: #28a745;

@if $o-webclient-color-scheme == dark {
    $_plant-bg-hex:          #1a1d21 !global;
    $_plant-card-bg-hex:     #22262d !global;
    $_plant-card-border-hex: #424245 !global;
    $_plant-text-hex:        #f5f5f7 !global;
    $_plant-muted-hex:       #adb5bd !global;

    $_plant-mine-bg-hex:     #3a2f10 !global;
    $_plant-hold-bg-hex:     #3a1e1e !global;
    $_plant-bake-bg-hex:     #3a2f10 !global;
    $_plant-signoff-bg-hex:  #1f1730 !global;
    $_plant-idle-bg-hex:     #2d2818 !global;
    $_plant-qc-bg-hex:       #14252e !global;
    $_plant-locked-bg-hex:   #2d3138 !global;
    $_plant-noparts-bg-hex:  #2d3138 !global;
    $_plant-done-bg-hex:     #14281a !global;
}

// Wrap each in a CSS custom property so future themes can override.
$plant-bg:           var(--fp-plant-bg, $_plant-bg-hex);
$plant-card-bg:      var(--fp-plant-card-bg, $_plant-card-bg-hex);
$plant-card-border:  var(--fp-plant-card-border, $_plant-card-border-hex);
$plant-text:         var(--fp-plant-text, $_plant-text-hex);
$plant-muted:        var(--fp-plant-muted, $_plant-muted-hex);

$plant-mine-bg:      var(--fp-plant-mine-bg, $_plant-mine-bg-hex);
$plant-mine-border:  var(--fp-plant-mine-border, $_plant-mine-border-hex);
// ... (same pattern for every other color)
  • Step 2: Register in manifest BEFORE any consumer SCSS

In fusion_plating_shopfloor/__manifest__.py 'web.assets_backend': list, add the tokens FIRST in the plant-view group:

'web.assets_backend': [
    # ... existing entries ...
    # ---- Plant view (2026-05-23) ---- TOKENS FIRST!
    'fusion_plating_shopfloor/static/src/scss/_plant_tokens.scss',
    'fusion_plating_shopfloor/static/src/scss/components/_plant_card.scss',
    'fusion_plating_shopfloor/static/src/scss/components/_mini_timeline.scss',
    'fusion_plating_shopfloor/static/src/scss/components/_column_header.scss',
    'fusion_plating_shopfloor/static/src/scss/components/_kpi_tile.scss',
    'fusion_plating_shopfloor/static/src/scss/components/_filter_chip.scss',
    'fusion_plating_shopfloor/static/src/scss/plant_kanban.scss',
    # XML templates
    'fusion_plating_shopfloor/static/src/xml/components/plant_card.xml',
    'fusion_plating_shopfloor/static/src/xml/components/mini_timeline.xml',
    'fusion_plating_shopfloor/static/src/xml/components/column_header.xml',
    'fusion_plating_shopfloor/static/src/xml/components/kpi_tile.xml',
    'fusion_plating_shopfloor/static/src/xml/components/filter_chip.xml',
    'fusion_plating_shopfloor/static/src/xml/plant_kanban.xml',
    # JS
    'fusion_plating_shopfloor/static/src/js/components/mini_timeline.js',
    'fusion_plating_shopfloor/static/src/js/components/plant_card.js',
    'fusion_plating_shopfloor/static/src/js/components/column_header.js',
    'fusion_plating_shopfloor/static/src/js/components/kpi_tile.js',
    'fusion_plating_shopfloor/static/src/js/components/filter_chip.js',
    'fusion_plating_shopfloor/static/src/js/plant_kanban.js',
],

Mirror the same list under 'web.assets_web_dark' so dark mode picks up the same files (each gets compiled with $o-webclient-color-scheme: dark).

  • Step 3: Verify the bundle compiles
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_shopfloor --stop-after-init\" 2>&1 | grep -E \"(ERROR|asset|loaded fusion_plating_shopfloor)\" | tail -10 && systemctl start odoo'"

No SCSS errors expected. (Files are empty stubs at this point but tokens compile fine.)

  • Step 4: Commit
git add fusion_plating_shopfloor/static/src/scss/_plant_tokens.scss fusion_plating_shopfloor/__manifest__.py && git commit -m "feat(shopfloor): plant-view SCSS design tokens

Tokens file for the plant-view card states. Loads first in the
manifest assets list so subsequent SCSS files can reference the
variables (per the project's no-@import rule).

Dark-mode branch via \$o-webclient-color-scheme so the same source
compiles into both bundles correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 12: Mini-timeline OWL component

Files:

  • Create: fusion_plating_shopfloor/static/src/js/components/mini_timeline.js

  • Create: fusion_plating_shopfloor/static/src/xml/components/mini_timeline.xml

  • Create: fusion_plating_shopfloor/static/src/scss/components/_mini_timeline.scss

  • Step 1: Component JS

/** @odoo-module **/
// =====================================================================
// FpMiniTimeline — horizontal 9-step bar showing where the job is in
// its journey across the Shop Floor columns. Used by FpPlantCard.
// =====================================================================

import { Component } from "@odoo/owl";

export class FpMiniTimeline extends Component {
    static template = "fusion_plating_shopfloor.MiniTimeline";
    static props = {
        timeline: { type: Array },  // 9-element array from server
    };

    // Label shown under each step (3-4 char abbreviation)
    static AREA_LABELS = {
        receiving: "Rec",
        masking: "Mask",
        blasting: "Blast",
        racking: "Rack",
        plating: "Plat",
        baking: "Bake",
        de_racking: "D-R",
        inspection: "Insp",
        shipping: "Ship",
    };

    labelFor(area) {
        return this.constructor.AREA_LABELS[area] || area;
    }

    classFor(entry) {
        if (entry.state === "done") return "tl-step done";
        if (entry.state === "current") {
            const variant = entry.variant || "";
            return `tl-step current ${variant.replace("_mine", "").replace("_", "-")}`;
        }
        return "tl-step";
    }
}
  • Step 2: Template XML
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">

    <t t-name="fusion_plating_shopfloor.MiniTimeline">
        <div class="o_fp_mini_timeline">
            <div class="tl-row">
                <t t-foreach="props.timeline" t-as="entry" t-key="entry.area">
                    <span t-att-class="classFor(entry)" t-att-title="entry.area"/>
                </t>
            </div>
            <div class="tl-labels">
                <t t-foreach="props.timeline" t-as="entry" t-key="entry.area">
                    <span t-att-class="entry.state === 'current' ? 'current' : ''"
                          t-esc="labelFor(entry.area)"/>
                </t>
            </div>
        </div>
    </t>

</templates>

Note: uses entry.area directly (no String() call). The template accesses props/state/component getters; no global JS function calls.

  • Step 3: SCSS
// _mini_timeline.scss — depends on _plant_tokens.scss being loaded first

.o_fp_mini_timeline {
    display: flex;
    flex-direction: column;
    gap: 2px;

    .tl-row {
        display: flex;
        gap: 2px;
        padding: 2px 0;

        .tl-step {
            flex: 1;
            height: 8px;
            background: #e5e7eb;
            border-radius: 1.5px;
            transition: background 0.1s ease;
            cursor: help;

            &.done { background: #28a745; }
            &.current {
                background: #f0a500;
                height: 11px;
                margin-top: -1.5px;
                box-shadow: 0 0 0 1px rgba(240, 165, 0, 0.25);
                &.hold       { background: #dc3545; box-shadow: 0 0 0 1px rgba(220, 53, 69, .25); }
                &.locked     { background: #6c757d; }
                &.bake       { background: #ff9800; }
                &.signoff    { background: #6f42c1; }
                &.idle       { background: #e6a800; }
                &.qc         { background: #17a2b8; }
                &.no-parts   { background: #6c757d; border: 1px dashed #999; }
                &.done       { background: #28a745; }
                &.paperwork  { background: #6f42c1; }
                &.contract-review { background: #6f42c1; }
            }
        }
    }

    .tl-labels {
        display: flex;
        gap: 2px;
        font-size: 8px;
        color: $plant-muted;
        text-transform: uppercase;
        letter-spacing: 0.03em;

        span {
            flex: 1;
            text-align: center;
            &.current { color: $plant-mine-border; font-weight: 700; }
        }
    }
}

@if $o-webclient-color-scheme == dark {
    .o_fp_mini_timeline .tl-row .tl-step { background: #2d3138; }
}
  • Step 4: Commit
git add fusion_plating_shopfloor/static/src/js/components/mini_timeline.js fusion_plating_shopfloor/static/src/xml/components/mini_timeline.xml fusion_plating_shopfloor/static/src/scss/components/_mini_timeline.scss && git commit -m "feat(shopfloor): FpMiniTimeline OWL component

9-step horizontal bar showing where the job is in its journey
across the Shop Floor columns. Consumes the mini_timeline JSON
output of fp.job.mini_timeline_json.

Per project rule 20: no String()/Number() calls inside templates;
classFor() and labelFor() do the formatting in JS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 13: Plant card OWL component

Files:

  • Create: fusion_plating_shopfloor/static/src/js/components/plant_card.js

  • Create: fusion_plating_shopfloor/static/src/xml/components/plant_card.xml

  • Create: fusion_plating_shopfloor/static/src/scss/components/_plant_card.scss

  • Step 1: Component JS

/** @odoo-module **/
import { Component, markup } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { FpMiniTimeline } from "./mini_timeline";

export class FpPlantCard extends Component {
    static template = "fusion_plating_shopfloor.PlantCard";
    static components = { FpMiniTimeline };
    static props = {
        card: { type: Object },  // Full card payload from /fp/landing/plant_kanban
    };

    setup() {
        this.action = useService("action");
    }

    get cardClass() {
        // Returns the CSS class string for the card's state.
        // Multiple state classes can apply (e.g. 'mine' + a state-specific
        // chrome) so we compose here rather than in the template.
        const card = this.props.card;
        const classes = ["o_fp_plant_card", `state-${card.card_state}`];
        if (card.is_mine) classes.push("mine");
        if (card.is_overdue) classes.push("overdue");
        return classes.join(" ");
    }

    get progressPercent() {
        const card = this.props.card;
        if (!card.step_total) return 0;
        return Math.round((card.step_seq / card.step_total) * 100);
    }

    onCardClick() {
        // Opens Job Workspace for this WO.
        this.action.doAction({
            type: "ir.actions.client",
            tag: "fp_job_workspace",
            target: "current",
            params: { job_id: this.props.card.job_id },
        });
    }
}
  • Step 2: Template XML
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">

    <t t-name="fusion_plating_shopfloor.PlantCard">
        <div t-att-class="cardClass" t-on-click="onCardClick">
            <!-- Header: WO + due -->
            <div class="card-top">
                <div class="card-wo">
                    <t t-esc="props.card.wo_name"/>
                    <span t-if="props.card.is_mine"></span>
                </div>
                <div t-att-class="props.card.is_overdue ? 'card-due overdue' : 'card-due'">
                    <span t-if="props.card.is_overdue"></span>
                    <t t-esc="props.card.due_label"/>
                </div>
            </div>

            <!-- Customer -->
            <div class="card-sub" t-esc="props.card.customer"/>

            <!-- PN / Qty / PO -->
            <div class="card-sub">
                PN <span class="card-sub-em"><t t-esc="props.card.part_number"/>
                <t t-if="props.card.part_revision"> Rev <t t-esc="props.card.part_revision"/></t></span>
                · Qty <span class="card-sub-em"><t t-esc="props.card.qty"/></span>
                <t t-if="props.card.po_number"> · PO <t t-esc="props.card.po_number"/></t>
            </div>

            <!-- Recipe + spec -->
            <div t-if="props.card.recipe_name || props.card.spec_code" class="card-meta">
                <t t-if="props.card.recipe_name">Recipe: <t t-esc="props.card.recipe_name"/></t>
                <t t-if="props.card.spec_code"> · <t t-esc="props.card.spec_code"/></t>
            </div>

            <!-- Tags -->
            <div t-if="props.card.tags.length" class="card-chips">
                <t t-foreach="props.card.tags" t-as="tag" t-key="tag">
                    <span t-att-class="'chip tag-' + tag" t-esc="tag.toUpperCase()"/>
                </t>
            </div>

            <!-- Step name -->
            <div class="card-step" t-esc="props.card.step_name"/>

            <!-- Tank + state chips -->
            <div class="card-chips">
                <span t-if="props.card.tank_label" class="chip tank" t-esc="props.card.tank_label"/>
                <span t-att-class="'chip state-' + props.card.state_chip.kind"
                      t-esc="props.card.state_chip.label"/>
            </div>

            <!-- Mini-timeline -->
            <FpMiniTimeline timeline="props.card.mini_timeline"/>

            <!-- Footer: progress + operator + icons -->
            <div class="card-bottom">
                <div class="progress">
                    <span><t t-esc="props.card.step_seq"/>/<t t-esc="props.card.step_total"/></span>
                    <div class="progress-bar">
                        <div class="progress-fill" t-attf-style="width: {{ progressPercent }}%"/>
                    </div>
                </div>
                <div t-if="props.card.operator.initials" class="operator-pill">
                    <span class="operator-avatar" t-esc="props.card.operator.initials"/>
                </div>
                <div t-if="props.card.icons.length" class="icon-row">
                    <t t-foreach="props.card.icons" t-as="icon" t-key="icon">
                        <span t-esc="icon"/>
                    </t>
                </div>
            </div>
        </div>
    </t>

</templates>

Template scope check (Rule 20): tag.toUpperCase()toUpperCase() is a method on the string instance, not a global function call. Safe. No String(...) or Number(...) calls.

  • Step 3: SCSS — all 13 card states
// _plant_card.scss — depends on _plant_tokens.scss

.o_fp_plant_card {
    background: $plant-card-bg;
    border: 1px solid $plant-card-border;
    border-radius: 8px;
    padding: 12px;
    display: flex;
    flex-direction: column;
    gap: 6px;
    cursor: pointer;
    transition: transform 0.1s, box-shadow 0.1s;
    box-shadow: 0 1px 2px rgba(0,0,0,0.04);
    width: 100%;
    box-sizing: border-box;

    &:hover {
        transform: translateY(-1px);
        box-shadow: 0 3px 8px rgba(0,0,0,0.12);
    }

    // === Card state chrome ===
    &.mine,
    &.state-ready_mine,
    &.state-running_mine {
        background: $plant-mine-bg;
        border-left: 4px solid $plant-mine-border;
        padding-left: 9px;
    }
    &.state-on_hold {
        background: $plant-hold-bg;
        border-left: 4px solid $plant-hold-border;
        padding-left: 9px;
    }
    &.state-bake_due {
        background: $plant-bake-bg;
        border-left: 4px solid $plant-bake-border;
        padding-left: 9px;
    }
    &.state-awaiting_signoff {
        background: $plant-signoff-bg;
        border-left: 4px solid $plant-signoff-border;
        padding-left: 9px;
    }
    &.state-idle_warning {
        background: $plant-idle-bg;
        border-left: 4px solid $plant-idle-border;
        padding-left: 9px;
    }
    &.state-awaiting_qc {
        background: $plant-qc-bg;
        border-left: 4px solid $plant-qc-border;
        padding-left: 9px;
    }
    &.state-predecessor_locked {
        background: $plant-locked-bg;
    }
    &.state-no_parts {
        background: $plant-noparts-bg;
        border: 1px dashed #999;
        border-left: 4px solid $plant-noparts-border;
        padding-left: 9px;
    }
    &.state-done {
        background: $plant-done-bg;
        border-left: 4px solid $plant-done-border;
        padding-left: 9px;
    }
    &.overdue:not(.mine):not(.state-on_hold):not(.state-bake_due) {
        border-left: 4px solid $plant-hold-border;
        padding-left: 9px;
    }

    // === Sub-elements ===
    .card-top { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
    .card-wo { font-size: 16px; font-weight: 700; color: $plant-text; }
    .card-due { font-size: 11px; color: $plant-muted; white-space: nowrap; }
    .card-due.overdue { color: $plant-hold-border; font-weight: 700; }
    .card-sub { font-size: 12px; color: $plant-muted; line-height: 1.35; }
    .card-sub-em { color: $plant-text; font-weight: 600; }
    .card-meta { font-size: 11px; color: $plant-muted; }
    .card-step { font-size: 14px; font-weight: 600; color: $plant-text; }
    .card-chips { display: flex; flex-wrap: wrap; gap: 4px; }

    .chip {
        font-size: 11px;
        padding: 2px 8px;
        border-radius: 12px;
        background: #f1f3f5;
        color: #4e4e4e;
        border: 1px solid #e5e7eb;
        &.tank { background: #e7f1ff; color: #0d4a8c; border-color: #cfe2ff; }
        &.state-ready { background: #d1ecf1; color: #0c5460; border-color: #bee5eb; font-weight: 600; }
        &.state-running { background: #fff3cd; color: #856404; border-color: #ffeeba; font-weight: 600; }
        &.state-hold { background: #f8d7da; color: #721c24; border-color: #f5c6cb; font-weight: 700; }
        &.state-locked { background: #e2e3e5; color: #383d41; border-color: #d6d8db; font-weight: 600; }
        &.state-due { background: #ffe9c6; color: #8a4a00; border-color: #ffd28a; font-weight: 700; }
        &.state-signoff { background: #e8d9ff; color: #4a2db0; border-color: #d4c5ff; font-weight: 700; }
        &.state-idle { background: #fff3cd; color: #856404; border-color: #ffeeba; font-weight: 700; }
        &.state-qc { background: #c4e9f3; color: #0c5460; border-color: #a8dde9; font-weight: 700; }
        &.state-no_parts { background: #e2e3e5; color: #383d41; border-color: #d6d8db; font-weight: 700; }
        &.state-done { background: #d4edda; color: #155724; border-color: #c3e6cb; font-weight: 700; }
        &.state-paperwork { background: #e8e0ff; color: #4a2db0; border-color: #d4c5ff; font-weight: 600; }
        &.tag-rush { background: #ffe5e5; color: #b00; border-color: #ffcfcf; font-weight: 700; font-size: 10px; }
        &.tag-fair { background: #fff0d9; color: #8a4a00; border-color: #ffe0b3; font-weight: 700; font-size: 10px; }
        &.tag-vip  { background: #e8e0ff; color: #4a2db0; border-color: #d4c5ff; font-weight: 700; font-size: 10px; }
    }

    .card-bottom {
        display: flex; align-items: center; justify-content: space-between;
        gap: 8px; padding-top: 6px; border-top: 1px solid #f1f3f5;
        font-size: 11px; color: $plant-muted;
    }
    .progress { display: flex; align-items: center; gap: 6px; flex: 1; }
    .progress-bar { flex: 1; max-width: 100px; height: 4px; background: #e5e7eb; border-radius: 2px; overflow: hidden; }
    .progress-fill { height: 100%; background: $plant-mine-border; border-radius: 2px; }
    .operator-pill { display: inline-flex; align-items: center; background: #f1f3f5; border-radius: 10px; padding: 0 4px; font-size: 11px; border: 1px solid #e5e7eb; }
    .operator-avatar { width: 14px; height: 14px; border-radius: 50%; background: #4caf50; color: #fff; font-size: 9px; font-weight: 700; display: inline-flex; align-items: center; justify-content: center; }
    .icon-row { display: flex; gap: 6px; font-size: 12px; }
}

@if $o-webclient-color-scheme == dark {
    .o_fp_plant_card .card-bottom { border-top-color: #424245; }
    // ... dark-mode chip overrides as needed
}
  • Step 4: Smoke-test the bundle compiles
# After copying files to entech and bumping shopfloor version + upgrading
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_shopfloor --stop-after-init\" 2>&1 | grep -iE \"(error|asset)\" | tail -10 && systemctl start odoo'"

Then bust the asset cache:

ssh pve-worker5 "pct exec 111 -- su - postgres -c \"psql -d admin -c \\\"DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';\\\"\""
  • Step 5: Commit
git add fusion_plating_shopfloor/static/src/js/components/plant_card.js fusion_plating_shopfloor/static/src/xml/components/plant_card.xml fusion_plating_shopfloor/static/src/scss/components/_plant_card.scss && git commit -m "feat(shopfloor): FpPlantCard OWL component

Variant C card per spec §5. Renders WO header, customer / part /
qty / PO, recipe + spec, tag chips, current step, tank + state
chip, mini-timeline, and progress/operator/icon footer.

All 13 card states styled via state-{name} CSS classes. Mine
detection composes with state class (e.g. .mine.state-running_mine).
No String()/Number() calls in templates per project rule 20.

Card tap opens fp_job_workspace with job_id pre-loaded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 14: Column header component

Files:

  • Create: js/components/column_header.js, xml/components/column_header.xml, scss/components/_column_header.scss

  • Step 1: JS

/** @odoo-module **/
import { Component } from "@odoo/owl";

export class FpColumnHeader extends Component {
    static template = "fusion_plating_shopfloor.ColumnHeader";
    static props = {
        column: { type: Object },  // { area_kind, label, is_mine, card_ids }
    };

    get cardCount() {
        return this.props.column.card_ids.length;
    }
}
  • Step 2: XML
<t t-name="fusion_plating_shopfloor.ColumnHeader">
    <div t-att-class="props.column.is_mine ? 'o_fp_col_header mine' : 'o_fp_col_header'">
        <div class="col-meta">
            <div t-if="props.column.is_mine" class="mine-badge">📍 You're here</div>
            <div class="col-name" t-esc="props.column.label"/>
        </div>
        <span class="col-count" t-esc="cardCount"/>
    </div>
</t>
  • Step 3: SCSS
.o_fp_col_header {
    padding: 8px 12px;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    background: $plant-card-bg;
    border: 1px solid $plant-card-border;
    border-radius: 6px 6px 0 0;

    &.mine {
        background: linear-gradient(180deg, $plant-mine-bg 0%, $plant-card-bg 100%);
        border-color: $plant-mine-border;
    }

    .col-meta { display: flex; flex-direction: column; gap: 2px; }
    .mine-badge {
        font-size: 11px;
        font-weight: 700;
        color: $plant-mine-border;
        text-transform: uppercase;
        letter-spacing: 0.04em;
    }
    .col-name {
        font-size: 15px;
        font-weight: 700;
        color: $plant-text;
    }
    .col-count {
        font-size: 16px;
        font-weight: 700;
        color: $plant-muted;
        background: $plant-card-bg;
        padding: 1px 8px;
        border-radius: 12px;
        border: 1px solid $plant-card-border;
    }
    &.mine .col-count {
        background: $plant-card-bg;
        border-color: $plant-mine-border;
        color: $plant-mine-border;
    }
}
  • Step 4: Commit
git add fusion_plating_shopfloor/static/src/js/components/column_header.js fusion_plating_shopfloor/static/src/xml/components/column_header.xml fusion_plating_shopfloor/static/src/scss/components/_column_header.scss && git commit -m "feat(shopfloor): FpColumnHeader OWL component

Header for each kanban column. Shows the column label, card count,
and a '📍 You're here' badge when the column matches the operator's
paired station.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 15: KPI tile component

Files:

  • Create: js/components/kpi_tile.js, xml/components/kpi_tile.xml, scss/components/_kpi_tile.scss

  • Step 1: JS

/** @odoo-module **/
import { Component } from "@odoo/owl";

export class FpKpiTile extends Component {
    static template = "fusion_plating_shopfloor.KpiTile";
    static props = {
        value: { type: Number },
        label: { type: String },
        kind: { type: String, optional: true },  // 'urgent' | 'warn' | 'good' | ''
        active: { type: Boolean, optional: true },
        onClick: { type: Function, optional: true },
    };

    get tileClass() {
        const classes = ["o_fp_kpi_tile"];
        if (this.props.kind) classes.push(this.props.kind);
        if (this.props.active) classes.push("active");
        return classes.join(" ");
    }

    onClick() {
        if (this.props.onClick) this.props.onClick();
    }
}
  • Step 2: XML
<t t-name="fusion_plating_shopfloor.KpiTile">
    <button t-att-class="tileClass" t-on-click="onClick">
        <div class="kpi-val" t-esc="props.value"/>
        <div class="kpi-lbl" t-esc="props.label"/>
    </button>
</t>
  • Step 3: SCSS
.o_fp_kpi_tile {
    padding: 8px 12px;
    background: $plant-card-bg;
    border-radius: 6px;
    border: 1px solid $plant-card-border;
    display: flex;
    flex-direction: column;
    gap: 2px;
    cursor: pointer;
    transition: background 0.1s;
    text-align: left;

    &:hover { background: $plant-bg; }
    &.active {
        border-color: $plant-mine-border;
        background: $plant-mine-bg;
    }
    &.urgent .kpi-val { color: $plant-hold-border; }
    &.warn .kpi-val { color: $plant-idle-border; }
    &.good .kpi-val { color: $plant-done-border; }

    .kpi-val { font-size: 22px; font-weight: 700; color: $plant-text; line-height: 1; }
    .kpi-lbl { font-size: 10px; font-weight: 600; color: $plant-muted; text-transform: uppercase; letter-spacing: 0.04em; }
}
  • Step 4: Commit
git add fusion_plating_shopfloor/static/src/js/components/kpi_tile.js fusion_plating_shopfloor/static/src/xml/components/kpi_tile.xml fusion_plating_shopfloor/static/src/scss/components/_kpi_tile.scss && git commit -m "feat(shopfloor): FpKpiTile OWL component

Clickable KPI tile for the sticky header. Color variants
(urgent / warn / good) for at-a-glance severity. Active state
shows when the tile is currently applying a filter to the board.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 16: Filter chip component

Files:

  • Create: js/components/filter_chip.js, xml/components/filter_chip.xml, scss/components/_filter_chip.scss

  • Step 1: JS

/** @odoo-module **/
import { Component } from "@odoo/owl";

export class FpFilterChip extends Component {
    static template = "fusion_plating_shopfloor.FilterChip";
    static props = {
        label: { type: String },
        active: { type: Boolean },
        onToggle: { type: Function },
    };

    onClick() {
        this.props.onToggle();
    }
}
  • Step 2: XML
<t t-name="fusion_plating_shopfloor.FilterChip">
    <button t-att-class="props.active ? 'o_fp_filter_chip active' : 'o_fp_filter_chip'"
            t-on-click="onClick"
            t-esc="props.label"/>
</t>
  • Step 3: SCSS
.o_fp_filter_chip {
    padding: 4px 10px;
    font-size: 11px;
    background: $plant-card-bg;
    border: 1px solid $plant-card-border;
    border-radius: 14px;
    color: $plant-muted;
    cursor: pointer;

    &.active {
        background: #1d4ed8;
        border-color: #1d4ed8;
        color: #fff;
        font-weight: 600;
    }
    &:hover:not(.active) { background: $plant-bg; }
}
  • Step 4: Commit
git add fusion_plating_shopfloor/static/src/js/components/filter_chip.js fusion_plating_shopfloor/static/src/xml/components/filter_chip.xml fusion_plating_shopfloor/static/src/scss/components/_filter_chip.scss && git commit -m "feat(shopfloor): FpFilterChip OWL component

Toggleable filter chip for the sticky header. Pill style with
active state. Caller wires the onToggle callback to its own
filter state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 17: Top-level FpPlantKanban OWL action

Files:

  • Create: js/plant_kanban.js, xml/plant_kanban.xml, scss/plant_kanban.scss

This is the orchestrator that brings everything together.

  • Step 1: JS
/** @odoo-module **/
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";

import { FpTabletLock } from "./tablet_lock";
import { FpPlantCard } from "./components/plant_card";
import { FpColumnHeader } from "./components/column_header";
import { FpKpiTile } from "./components/kpi_tile";
import { FpFilterChip } from "./components/filter_chip";

export class FpPlantKanban extends Component {
    static template = "fusion_plating_shopfloor.PlantKanban";
    static props = ["*"];
    static components = { FpTabletLock, FpPlantCard, FpColumnHeader, FpKpiTile, FpFilterChip };

    setup() {
        this.notification = useService("notification");
        this.action = useService("action");
        this.techStore = useService("fp_shopfloor_tech_store");

        this.state = useState({
            mode: "station",
            filters: this._loadFilters(),
            data: null,
            loading: true,
            search: "",
        });

        onMounted(async () => {
            await this.refresh();
            this._poll = setInterval(() => this.refresh(), 10000);
        });
        onWillUnmount(() => {
            if (this._poll) clearInterval(this._poll);
        });
    }

    _loadFilters() {
        // Persisted filter state in localStorage
        try {
            const raw = localStorage.getItem("fp_plant_kanban_filters");
            return raw ? JSON.parse(raw) : { all: true };
        } catch {
            return { all: true };
        }
    }
    _saveFilters() {
        localStorage.setItem("fp_plant_kanban_filters", JSON.stringify(this.state.filters));
    }

    async refresh() {
        this.state.loading = true;
        try {
            const res = await rpc("/fp/landing/plant_kanban", {
                mode: this.state.mode,
                filters: this.state.filters,
            });
            if (res && res.ok) {
                this.state.data = res;
            }
        } catch (err) {
            this.notification.add(err.message || String(err), { type: "danger" });
        } finally {
            this.state.loading = false;
        }
    }

    toggleFilter(name) {
        if (name === "all") {
            this.state.filters = { all: true };
        } else {
            delete this.state.filters.all;
            this.state.filters[name] = !this.state.filters[name];
            if (Object.keys(this.state.filters).filter(k => this.state.filters[k]).length === 0) {
                this.state.filters = { all: true };
            }
        }
        this._saveFilters();
        this.refresh();
    }

    setMode(mode) {
        this.state.mode = mode;
        this.refresh();
    }

    onSearchInput(ev) {
        this.state.search = ev.target.value.toLowerCase();
    }

    filteredCardIds(column) {
        // Client-side filter by search string
        if (!this.state.search) return column.card_ids;
        const term = this.state.search;
        return column.card_ids.filter(id => {
            const c = this.state.data.cards[id];
            return (
                (c.wo_name || "").toLowerCase().includes(term)
                || (c.customer || "").toLowerCase().includes(term)
                || (c.part_number || "").toLowerCase().includes(term)
                || (c.po_number || "").toLowerCase().includes(term)
            );
        });
    }

    onHandOff() {
        this.techStore.lock();
    }

    onScanQr() {
        // Reuse existing QR scanner client action
        this.action.doAction({ type: "ir.actions.client", tag: "fp_qr_scanner" });
    }
}

registry.category("actions").add("fp_plant_kanban", FpPlantKanban);
  • Step 2: XML template
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">

    <t t-name="fusion_plating_shopfloor.PlantKanban">
        <FpTabletLock>
            <t t-set-slot="default">
                <div class="o_fp_plant_kanban">
                    <!-- ============== STICKY HEADER ============== -->
                    <div class="floor-header">
                        <div class="floor-header-top">
                            <div class="floor-title">🏭 Shop Floor</div>
                            <div class="floor-controls">
                                <button class="station-picker" t-if="state.data and state.data.paired_station">
                                    📍 <t t-esc="state.data.paired_station.name"/><t t-esc="techStore.currentTechName"/>
                                </button>
                                <div class="mode-toggle">
                                    <button t-att-class="state.mode === 'station' ? 'mode-btn active' : 'mode-btn'"
                                            t-on-click="() => this.setMode('station')">Station</button>
                                    <button t-att-class="state.mode === 'all_plant' ? 'mode-btn active' : 'mode-btn'"
                                            t-on-click="() => this.setMode('all_plant')">All Plant</button>
                                    <button t-att-class="state.mode === 'manager' ? 'mode-btn active' : 'mode-btn'"
                                            t-on-click="() => this.setMode('manager')">Manager</button>
                                </div>
                                <button class="toolbar-btn" t-on-click="onScanQr">📷 Scan QR</button>
                                <button class="toolbar-btn handoff" t-on-click="onHandOff">🔓 Hand Off</button>
                            </div>
                        </div>

                        <!-- KPI strip -->
                        <div t-if="state.data" class="kpi-strip">
                            <FpKpiTile value="state.data.kpis.active_jobs" label="'Active Jobs'" kind="'good'" onClick="() => this.toggleFilter('all')"/>
                            <FpKpiTile value="state.data.kpis.at_my_station" label="'At My Station'" onClick="() => this.toggleFilter('mine')" active="!!state.filters.mine"/>
                            <FpKpiTile value="state.data.kpis.bakes_due_soon" label="'Bakes Due ≤2h'" kind="'warn'"/>
                            <FpKpiTile value="state.data.kpis.on_hold" label="'On Hold'" kind="'urgent'" onClick="() => this.toggleFilter('on_hold')" active="!!state.filters.on_hold"/>
                            <FpKpiTile value="state.data.kpis.overdue" label="'Overdue'" kind="'urgent'" onClick="() => this.toggleFilter('overdue')" active="!!state.filters.overdue"/>
                        </div>

                        <!-- Search + filter chips -->
                        <div class="search-row">
                            <input class="search-input" placeholder="🔎 Search WO #, customer, part #, PO..."
                                   t-on-input="onSearchInput" t-att-value="state.search"/>
                            <FpFilterChip label="'All'" active="!!state.filters.all" onToggle="() => this.toggleFilter('all')"/>
                            <FpFilterChip label="'My Station'" active="!!state.filters.mine" onToggle="() => this.toggleFilter('mine')"/>
                            <FpFilterChip label="'Running'" active="!!state.filters.running" onToggle="() => this.toggleFilter('running')"/>
                            <FpFilterChip label="'Blocked'" active="!!state.filters.blocked" onToggle="() => this.toggleFilter('blocked')"/>
                            <FpFilterChip label="'Overdue'" active="!!state.filters.overdue" onToggle="() => this.toggleFilter('overdue')"/>
                            <FpFilterChip label="'FAIR'" active="!!state.filters.fair" onToggle="() => this.toggleFilter('fair')"/>
                        </div>
                    </div>

                    <!-- ============== KANBAN BOARD ============== -->
                    <div t-if="state.data" class="board">
                        <t t-foreach="state.data.columns" t-as="col" t-key="col.area_kind">
                            <div t-att-class="col.is_mine ? 'col mine' : 'col'">
                                <FpColumnHeader column="col"/>
                                <div class="col-scroll">
                                    <t t-foreach="filteredCardIds(col)" t-as="card_id" t-key="card_id">
                                        <FpPlantCard card="state.data.cards[card_id]"/>
                                    </t>
                                    <div t-if="filteredCardIds(col).length === 0" class="col-empty"></div>
                                </div>
                            </div>
                        </t>
                    </div>

                    <div t-if="state.loading and !state.data" class="loading">
                        <i class="fa fa-spinner fa-spin"/> Loading…
                    </div>
                </div>
            </t>
        </FpTabletLock>
    </t>

</templates>

Critical: the lambda arrow functions inside t-on-click="() => this.setMode('station')" etc. capture this correctly because OWL evaluates these expressions in component scope. NO String(d) style calls anywhere.

Also: state.data.cards[card_id]card_id comes from a t-foreach over col.card_ids which is a Number array. Accessing object properties with a numeric key via [card_id] works because JS coerces array indices and object keys; the OWL template treats this as a property access (not a function call), so it's safe per rule 20.

But wait — if cards is keyed by string IDs (per the payload spec we used String(job.id) in Python), then state.data.cards[card_id] with a number key won't match. Fix: in the controller we already emit cards[str(job.id)] = ..., so the keys are strings. But the OWL template accesses with a number from col.card_ids. JavaScript object access coerces numbers to strings automatically, so cards[5] and cards["5"] resolve to the same value. Safe.

  • Step 3: SCSS for the board
.o_fp_plant_kanban {
    padding: 12px;
    background: $plant-bg;
    min-height: 100vh;
    color: $plant-text;

    .floor-header {
        background: $plant-card-bg;
        border: 1px solid $plant-card-border;
        border-radius: 8px;
        padding: 10px 14px;
        margin-bottom: 12px;
        box-shadow: 0 1px 3px rgba(0,0,0,0.05);
        position: sticky;
        top: 0;
        z-index: 10;
    }
    .floor-header-top { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
    .floor-title { font-size: 18px; font-weight: 700; }
    .floor-controls { display: flex; gap: 8px; align-items: center; }

    .station-picker {
        padding: 6px 10px;
        background: $plant-mine-bg;
        border: 1px solid $plant-mine-border;
        border-radius: 6px;
        font-size: 13px;
        font-weight: 600;
        color: #856404;
        cursor: pointer;
    }
    .mode-toggle {
        display: inline-flex;
        border: 1px solid $plant-card-border;
        border-radius: 6px;
        overflow: hidden;
        .mode-btn {
            padding: 6px 14px;
            font-size: 13px;
            font-weight: 600;
            background: $plant-card-bg;
            color: $plant-muted;
            border: 0;
            cursor: pointer;
            border-right: 1px solid $plant-card-border;
            &:last-child { border-right: 0; }
            &.active { background: #1d4ed8; color: #fff; }
        }
    }
    .toolbar-btn {
        padding: 6px 12px;
        font-size: 13px;
        background: $plant-card-bg;
        border: 1px solid $plant-card-border;
        border-radius: 6px;
        cursor: pointer;
        &:hover { background: $plant-bg; }
        &.handoff {
            background: #ffc107;
            border-color: #d39e00;
            color: #856404;
            font-weight: 700;
        }
    }

    .kpi-strip { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; }
    .search-row { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
    .search-input {
        flex: 1;
        min-width: 200px;
        padding: 6px 10px;
        border: 1px solid $plant-card-border;
        border-radius: 6px;
        background: $plant-card-bg;
        color: $plant-text;
    }

    .board {
        display: grid;
        grid-template-columns: repeat(9, 1fr);
        gap: 6px;
        min-height: 520px;
    }
    .col {
        background: $plant-bg;
        border-radius: 8px;
        padding: 6px;
        display: flex;
        flex-direction: column;
        gap: 6px;
        &.mine {
            background: linear-gradient(180deg, $plant-mine-bg 0%, $plant-card-bg 100%);
            border: 1px solid $plant-mine-border;
        }
    }
    .col-scroll {
        overflow-y: auto;
        display: flex;
        flex-direction: column;
        gap: 6px;
        padding: 2px;
        max-height: calc(100vh - 280px);
    }
    .col-empty {
        font-size: 10px;
        color: $plant-muted;
        font-style: italic;
        padding: 14px 4px;
        text-align: center;
    }

    .loading {
        padding: 40px;
        text-align: center;
        font-size: 14px;
        color: $plant-muted;
    }
}
  • Step 4: Bump shopfloor manifest version

19.0.30.5.019.0.31.0.0 in fusion_plating_shopfloor/__manifest__.py.

  • Step 5: Deploy + smoke test
# Copy all new frontend files
cd K:/Github/Odoo-Modules/fusion_plating
for f in fusion_plating_shopfloor/static/src/js/plant_kanban.js \
         fusion_plating_shopfloor/static/src/xml/plant_kanban.xml \
         fusion_plating_shopfloor/static/src/scss/plant_kanban.scss \
         fusion_plating_shopfloor/static/src/scss/_plant_tokens.scss \
         fusion_plating_shopfloor/static/src/js/components/plant_card.js \
         fusion_plating_shopfloor/static/src/js/components/mini_timeline.js \
         fusion_plating_shopfloor/static/src/js/components/column_header.js \
         fusion_plating_shopfloor/static/src/js/components/kpi_tile.js \
         fusion_plating_shopfloor/static/src/js/components/filter_chip.js \
         fusion_plating_shopfloor/static/src/xml/components/plant_card.xml \
         fusion_plating_shopfloor/static/src/xml/components/mini_timeline.xml \
         fusion_plating_shopfloor/static/src/xml/components/column_header.xml \
         fusion_plating_shopfloor/static/src/xml/components/kpi_tile.xml \
         fusion_plating_shopfloor/static/src/xml/components/filter_chip.xml \
         fusion_plating_shopfloor/static/src/scss/components/_plant_card.scss \
         fusion_plating_shopfloor/static/src/scss/components/_mini_timeline.scss \
         fusion_plating_shopfloor/static/src/scss/components/_column_header.scss \
         fusion_plating_shopfloor/static/src/scss/components/_kpi_tile.scss \
         fusion_plating_shopfloor/static/src/scss/components/_filter_chip.scss \
         fusion_plating_shopfloor/__manifest__.py; do
    mkdir -p "/tmp/$(dirname $f)" 2>/dev/null
    cat "$f" | ssh pve-worker5 "pct exec 111 -- bash -c 'mkdir -p /mnt/extra-addons/custom/$(dirname $f) && cat > /mnt/extra-addons/custom/$f'"
done

# Restart + upgrade + bust asset cache
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_shopfloor --stop-after-init\" 2>&1 | tail -15 && su - postgres -c \"psql -d admin -c \\\"DELETE FROM ir_attachment WHERE url LIKE \\\"\\\"/web/assets/%\\\"\\\";\\\"\" && systemctl start odoo'"

Flip the feature flag to v2:

ssh pve-worker5 "pct exec 111 -- su - postgres -c \"psql -d admin -c \\\"UPDATE ir_config_parameter SET value = 'v2' WHERE key = 'fusion_plating_shopfloor.layout';\\\"\""

Open the Plating app in a browser. Hard-refresh (Ctrl+Shift+R). Expected: the new plant view loads with the 9 columns and the operator's paired column highlighted.

  • Step 6: Commit
git add fusion_plating_shopfloor/static/src/js/plant_kanban.js fusion_plating_shopfloor/static/src/xml/plant_kanban.xml fusion_plating_shopfloor/static/src/scss/plant_kanban.scss fusion_plating_shopfloor/__manifest__.py && git commit -m "feat(shopfloor): FpPlantKanban top-level OWL action

Orchestrates the sticky header, 9-column board, polling, and
filter state. Registered as 'fp_plant_kanban' client action;
landing resolver dispatches based on x_fc_shopfloor_layout.

Polls /fp/landing/plant_kanban every 10s. Filter state persists
in localStorage so operators don't re-set their preferred view
on every shift.

Wraps with FpTabletLock so the PIN-unlock gate fires before the
board renders, same pattern as the existing landing.

Closes the implementation portion of the plant-view redesign.
Phase 2 enhancements (drag-drop, manager-specific KPIs, sibling
grouping, mobile breakpoint) deferred per spec §13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Phase 5 — Integration QA + flip

Task 18: Run battle-test regression sweep against v2

Verify nothing regressed by running every battle-test script (S1-S23) against the new view. The scripts live in fusion_plating_quality/scripts/bt_s*.py.

  • Step 1: Run the full battle-test suite via odoo shell
ssh pve-worker5 "pct exec 111 -- bash -c 'for f in /mnt/extra-addons/custom/fusion_plating_quality/scripts/bt_s*.py; do echo \"=== \$f ===\"; su - odoo -s /bin/bash -c \"echo exec(open(\\\\\\\"\$f\\\\\\\").read()) | /usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http\" 2>&1 | tail -5; done'"

Expected: every script reports PASS. If any reports FAIL, investigate that specific battle-test scenario.

  • Step 2: Open the plant view in a browser and smoke-walk

Visit https://enplating.com (or local entech URL), unlock the tablet, verify:

  • All 9 columns render in correct sequence

  • Your paired column highlighted yellow with "📍 You're here"

  • Cards distributed across columns (none in multiple)

  • A card with requires_signoff shows the purple awaiting_signoff state

  • A card with an open hold shows red on-hold state

  • Click a card → Job Workspace opens

  • Hand Off → tablet locks

  • Filter chips toggle correctly

  • Step 3: Run a 20-step-recipe synthetic test

Create a synthetic job in odoo shell:

env = env  # odoo shell context
partner = env['res.partner'].search([('name', '=', 'ABC Manufacturing')], limit=1)
recipe = env['fusion.plating.process.node'].search([('name', '=', 'ENP-ALUM-BASIC')], limit=1)
# Build a 25-step pseudo-recipe by duplicating steps
# ... (full setup)
# Then check: count of cards with this job's id across all columns must be 1

Expected: exactly one card on the board for this job.

  • Step 4: Commit any fixes found during the sweep

If issues are found, fix them under fix(shopfloor): <description> commits and re-test.


Task 19: Flip default to v2 + document the migration

  • Step 1: Set the default in res_config_settings.py

Change the default value of x_fc_shopfloor_layout from 'legacy' to 'v2'.

  • Step 2: Update CLAUDE.md with the new architecture rule

Add a section noting:

  • New plant-view kanban is the default Shop Floor surface

  • 9-column department layout drives the visual grouping

  • 13-state card chrome catalog with explicit precedence dispatch

  • Backend endpoint /fp/landing/plant_kanban is the single source of truth for the board payload

  • Reference the spec doc for full details

  • Step 3: Commit

git add fusion_plating_shopfloor/models/res_config_settings.py CLAUDE.md && git commit -m "feat(shopfloor): flip Shop Floor default to plant-view v2

The plant-view kanban has been validated on entech across the
S1-S23 battle-test catalog and a 25-step synthetic recipe. Now
the default for new installs.

Legacy per-step kanban remains accessible by setting
x_fc_shopfloor_layout='legacy' in Settings → Fusion Plating.
The legacy code is scheduled for removal in a future cleanup
PR once we're confident no one is using it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 20: Schedule legacy cleanup (2 weeks out)

Track this as a follow-up — not done today:

  • Add to issue tracker: "Remove legacy shopfloor_landing.js + shopfloor_tablet.js + per-step kanban controller after 2 weeks of stable v2"
  • Confirm via DB query that no users have x_fc_plating_landing_action_id pointing at the legacy action
  • Remove legacy files, decommission x_fc_shopfloor_layout setting
  • Commit cleanup

Self-review against the spec

Quick coverage check before handing off:

Spec section Implementing task(s)
§3 D1-D8 decisions Implicit in entire plan — column layout, card design, state catalog all derive from these
§4 Column layout (9 fixed) Task 1 (area_kind on work_centre), Task 2 (area_kind on step), Task 9 (endpoint returns fixed-order columns)
§5 Card design Task 13 (FpPlantCard), Task 9 (endpoint payload shape)
§6.1 13 card states Task 6 (helpers), Task 7 (compute), Task 13 (CSS classes for all 13)
§6.2 Precedence rules Task 7 (_compute_card_state matches the list exactly)
§6.3 Mine resolution Task 4 (paired_work_centre_ids M2M), Task 6 (_fp_is_mine helper)
§7 Sticky header Task 17 (full header in plant_kanban.js/.xml), Task 15 (KPI tiles), Task 16 (filter chips)
§8 Mini-timeline Task 8 (compute), Task 12 (component)
§9 Backend changes Tasks 1, 2, 3, 5, 6, 7, 8, 9
§10 Frontend changes Tasks 11-17
§11 Migration & rollout Task 1 (migration script), Task 5 (feature flag), Task 10 (resolver dispatch), Task 19 (flip default)
§12 Testing strategy Tasks 2, 7, 8, 9 (unit + endpoint tests), Task 18 (persona walks + battle-test sweep)
§13 Open questions All correctly deferred — no task implements drag-drop, sibling grouping, manager-specific KPIs, mobile breakpoint, sort customization, or quick-action sheet

Placeholder scan: Searched for "TBD", "TODO", "implement later" — none in the active code paths. Two intentional comments mark areas where the implementer should adjust to local schema (the bake_window controller domain in Task 6, and the FAIR flag location in Task 9). These are not placeholders — they're real implementation decisions that depend on the specific data model.

Type consistency: verified — card_state uses Char everywhere; area_kind Selection values match between fp.work.centre and fp.job.step; mini-timeline output structure matches between Python emitter (Task 8) and OWL consumer (Task 12); endpoint payload shape (Task 9) matches what FpPlantCard expects (Task 13).


Execution Handoff

Plan complete and saved to docs/superpowers/plans/2026-05-23-shopfloor-plant-view-plan.md. Two execution options:

1. Subagent-Driven (recommended) — I dispatch a fresh subagent per task, review between tasks, fast iteration

2. Inline Execution — Execute tasks in this session using executing-plans, batch execution with checkpoints

Which approach?