Files
Odoo-Modules/fusion_plating/docs/superpowers/plans/2026-06-03-racking-multi-rack-phase1.md
gsinghpal acd1fc9f8f docs(fusion_plating): racking multi-rack + WO grouping design spec & Phase 1 plan
Approved design for splitting a WO's parts across multiple racks + grouping
multiple WOs on one rack, plus the Phase 1 implementation plan (split +
independent movement). Phases 2 (grouping + Station screen) and 3 (Plant
Kanban rollup) are noted for follow-up plans.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 08:37:18 -04:00

28 KiB
Raw Blame History

Multi-Rack Splitting at Racking — Phase 1 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: Let an operator split a single work order's parts across multiple physical racks at the Racking step (default 1 rack with all parts; "+ Add Rack" divides equally; manual qty override), and move each rack independently through Plating → Baking → De-Racking, reusing the existing move log.

Architecture: A new first-class fp.rack.load record (+ fp.rack.load.line per work order) represents "parts on one rack." It carries its own workflow position and moves via the existing fp.job.step.move chain-of-custody log (one move row per line). Phase 1 is single-WO (one line per load); grouping is Phase 2. The UI is a Racking panel on the Job Workspace (mirrors the existing Receiving card).

Tech Stack: Odoo 19 (Python models + TransactionCase tests), OWL 2 (JS/XML/SCSS), JSONRPC controllers. Spec: docs/superpowers/specs/2026-06-03-racking-multi-rack-wo-grouping-design.md.

Test command (local dev, Community):

docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_plating \
    -u fusion_plating --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60

Task 0: Confirm integration field names (no code; grep only)

The plan references fields on existing models. Confirm exact names before writing code so later tasks reference real symbols.

  • Step 1: Confirm the job's recipe field + step/move/rack fields
cd /Users/gurpreet/Github/Odoo-Modules/fusion_plating
grep -nE "recipe.*fields\.|_name = ['\"]fp\.job['\"]" fusion_plating_jobs/models/fp_job.py | head
grep -nE "area_kind|qty_at_step|qty_at_step_start|qty_at_step_finish|rack_id|requires_rack_assignment" fusion_plating/models/fp_job_step.py | head
grep -nE "qty_moved|transfer_type|to_step_id|from_step_id|rack_id|move_datetime|moved_by_user_id" fusion_plating/models/fp_job_step_move.py | head
grep -nE "capacity|capacity_count|racking_state" fusion_plating/models/fp_rack.py | head
grep -nE "area_kind" fusion_plating_jobs/models/fp_job_step.py | head

Record the confirmed names. If fp.job has no direct recipe_id, use the field that resolves the recipe (check fp_job.py for recipe_id / x_fc_recipe_id / a compute). The plan below assumes:

  • fp.job.recipe_id (Many2one to the recipe node/header) — substitute the real name everywhere if different.

  • fp.job.step.area_kind, fp.job.step.qty_at_step, fp.job.step.qty_at_step_start/finish, fp.job.step.rack_id.

  • fp.job.step.move: job_id, from_step_id, to_step_id, qty_moved, rack_id, transfer_type, moved_by_user_id, move_datetime.

  • fusion.plating.rack: capacity, capacity_count, racking_state.

  • Step 2: Confirm the area_kind column sequence (for "least-advanced" later)

grep -n "_COLUMN_LABELS\|_COLUMN_SEQUENCE" fusion_plating_shopfloor/controllers/plant_kanban.py fusion_plating_jobs/models/fp_job.py

Record the ordered list [receiving, masking, blasting, racking, plating, baking, de_racking, inspection, shipping].


Task 1: fp.rack.load + fp.rack.load.line models + sequence + ACL

Files:

  • Create: fusion_plating/models/fp_rack_load.py

  • Modify: fusion_plating/models/__init__.py (add from . import fp_rack_load)

  • Create: fusion_plating/data/fp_rack_load_sequence.xml

  • Modify: fusion_plating/security/ir.model.access.csv (add rows)

  • Modify: fusion_plating/__manifest__.py (add data file, bump version)

  • Test: fusion_plating/tests/test_rack_load.py (+ register in tests/__init__.py)

  • Step 1: Write the failing test (model exists + qty_total compute + sequence)

# fusion_plating/tests/test_rack_load.py
from odoo.tests import TransactionCase, tagged

@tagged('post_install', '-at_install')
class TestRackLoad(TransactionCase):
    def setUp(self):
        super().setUp()
        self.Load = self.env['fp.rack.load']
        self.rack = self.env['fusion.plating.rack'].create({'name': 'TST-RACK-1', 'capacity': 60})
        # A minimal job + racking step. Use existing helpers if present;
        # otherwise create a bare job. Adjust required fields per fp.job.
        self.job = self.env['fp.job'].create({'name': 'WO-TEST-1', 'qty': 100})

    def test_create_and_qty_total(self):
        load = self.Load.create({
            'rack_id': self.rack.id,
            'line_ids': [(0, 0, {'job_id': self.job.id, 'qty': 40})],
        })
        self.assertTrue(load.name.startswith('RACKLOAD/'))
        self.assertEqual(load.qty_total, 40)
        self.assertEqual(load.state, 'loading')
  • Step 2: Run it — expect FAIL (KeyError: 'fp.rack.load'). Command: the Test command above, --test-tags /fusion_plating:TestRackLoad.

  • Step 3: Implement the models

# fusion_plating/models/fp_rack_load.py
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError, UserError


class FpRackLoad(models.Model):
    _name = 'fp.rack.load'
    _description = 'Rack Load (parts on one physical rack)'
    _inherit = ['mail.thread']
    _order = 'id desc'

    name = fields.Char(string='Reference', required=True, copy=False,
                       default=lambda self: _('New'))
    rack_id = fields.Many2one('fusion.plating.rack', string='Rack',
                              required=True, tracking=True)
    line_ids = fields.One2many('fp.rack.load.line', 'load_id', string='Work Orders')
    qty_total = fields.Integer(string='Total Parts', compute='_compute_qty_total',
                               store=True)
    current_step_id = fields.Many2one('fp.job.step', string='Current Step', tracking=True)
    current_area_kind = fields.Char(string='Current Area',
                                    compute='_compute_current_area_kind', store=True)
    state = fields.Selection([
        ('loading', 'Loading'), ('loaded', 'Loaded'),
        ('running', 'Running'), ('unracked', 'Unracked'),
        ('cancelled', 'Cancelled'),
    ], default='loading', required=True, tracking=True)
    tag_ids = fields.Many2many('fp.rack.tag', string='Tags')
    company_id = fields.Many2one('res.company', default=lambda s: s.env.company)

    @api.depends('line_ids.qty')
    def _compute_qty_total(self):
        for load in self:
            load.qty_total = sum(load.line_ids.mapped('qty'))

    @api.depends('current_step_id.area_kind')
    def _compute_current_area_kind(self):
        for load in self:
            load.current_area_kind = load.current_step_id.area_kind or False

    @api.model_create_multi
    def create(self, vals_list):
        for vals in vals_list:
            if vals.get('name', _('New')) == _('New'):
                vals['name'] = self.env['ir.sequence'].next_by_code('fp.rack.load') or _('New')
        return super().create(vals_list)

    _qty_total_positive = models.Constraint(
        'CHECK (qty_total >= 0)', 'Rack load quantity cannot be negative.')


class FpRackLoadLine(models.Model):
    _name = 'fp.rack.load.line'
    _description = 'Rack Load Line (one work order on a rack)'

    load_id = fields.Many2one('fp.rack.load', required=True, ondelete='cascade')
    job_id = fields.Many2one('fp.job', string='Work Order', required=True)
    qty = fields.Integer(string='Parts', required=True, default=0)
    part_catalog_id = fields.Many2one(related='job_id.part_catalog_id', store=True)

    _qty_positive = models.Constraint(
        'CHECK (qty >= 0)', 'Line quantity cannot be negative.')

Sequence:

<!-- fusion_plating/data/fp_rack_load_sequence.xml -->
<odoo>
  <record id="seq_fp_rack_load" model="ir.sequence">
    <field name="name">Rack Load</field>
    <field name="code">fp.rack.load</field>
    <field name="prefix">RACKLOAD/%(year)s/</field>
    <field name="padding">4</field>
  </record>
</odoo>

ACL rows (append to fusion_plating/security/ir.model.access.csv) — Technician r/w/c, Manager full:

access_fp_rack_load_tech,fp.rack.load.tech,model_fp_rack_load,fusion_plating.group_fp_technician,1,1,1,0
access_fp_rack_load_mgr,fp.rack.load.mgr,model_fp_rack_load,fusion_plating.group_fp_manager,1,1,1,1
access_fp_rack_load_line_tech,fp.rack.load.line.tech,model_fp_rack_load_line,fusion_plating.group_fp_technician,1,1,1,1
access_fp_rack_load_line_mgr,fp.rack.load.line.mgr,model_fp_rack_load_line,fusion_plating.group_fp_manager,1,1,1,1

Add 'data/fp_rack_load_sequence.xml' to __manifest__.py data, bump version. Register the test in tests/__init__.py.

  • Step 4: Run the test — expect PASS.
  • Step 5: Commitgit add fusion_plating/models/fp_rack_load.py fusion_plating/data/fp_rack_load_sequence.xml fusion_plating/security/ir.model.access.csv fusion_plating/tests/test_rack_load.py fusion_plating/models/__init__.py fusion_plating/tests/__init__.py fusion_plating/__manifest__.py && git commit -m "feat(fusion_plating): add fp.rack.load + line models (racking phase 1)"

Task 2: Division API — add_rack / divide_equally / set_qty / remove_rack

Pure quantity math operating on a job's set of rack-loads at the racking step. This is the heart of the feature; full code + tests.

Files:

  • Modify: fusion_plating/models/fp_rack_load.py (add class methods)

  • Test: fusion_plating/tests/test_rack_load.py (add cases)

  • Step 1: Write failing tests for the division math (D4: remainder to first racks)

    def _mk_loads(self, n, total):
        """Helper: split `total` parts of self.job across n loads equally."""
        return self.env['fp.rack.load']._fp_split_job(self.job, total, n)

    def test_divide_two_is_50_50(self):
        loads = self._mk_loads(2, 100)
        self.assertEqual(sorted(loads.mapped('qty_total')), [50, 50])

    def test_divide_three_remainder_to_first(self):
        loads = self._mk_loads(3, 100)
        self.assertEqual(loads.mapped('qty_total'), [34, 33, 33])

    def test_divide_four_equal(self):
        loads = self._mk_loads(4, 100)
        self.assertEqual(loads.mapped('qty_total'), [25, 25, 25, 25])

    def test_add_rack_redivides(self):
        loads = self._mk_loads(1, 100)
        self.assertEqual(loads.mapped('qty_total'), [100])
        loads2 = self.env['fp.rack.load']._fp_add_rack(self.job)
        self.assertEqual(sorted(loads2.mapped('qty_total')), [50, 50])

    def test_set_qty_manual_and_unassigned(self):
        loads = self._mk_loads(2, 100)  # 50/50
        loads[0]._fp_set_qty(70)
        # one load now 70; total assigned 120 must be rejected (> available)
        with self.assertRaises(UserError):
            loads[1]._fp_set_qty(50)  # 70+50 > 100
  • Step 2: Run — expect FAIL (_fp_split_job undefined).

  • Step 3: Implement the division API

    @api.model
    def _fp_equal_split(self, total, n):
        """Return a list of n ints summing to total; remainder to the first racks (D4)."""
        if n < 1:
            return []
        base, rem = divmod(int(total), n)
        return [base + 1 if i < rem else base for i in range(n)]

    @api.model
    def _fp_racking_step_for(self, job):
        """The job's Racking step (the parts source). Adjust the lookup to the
        real racking-step detection (_fp_is_racking_step)."""
        steps = job.step_ids if 'step_ids' in job._fields else \
            self.env['fp.job.step'].search([('job_id', '=', job.id)])
        return steps.filtered(lambda s: s._fp_is_racking_step())[:1]

    @api.model
    def _fp_racking_total(self, job):
        """Total parts available to rack for this job."""
        step = self._fp_racking_step_for(job)
        return int(step.qty_at_step) if step else int(job.qty)

    @api.model
    def _fp_job_loads(self, job):
        return self.search([
            ('line_ids.job_id', '=', job.id),
            ('state', 'in', ('loading', 'loaded')),
        ])

    @api.model
    def _fp_split_job(self, job, total, n):
        """Create n fresh loads for `job` summing to `total`, equal split."""
        existing = self._fp_job_loads(job)
        existing.filtered(lambda l: not l.current_step_id).unlink()
        qtys = self._fp_equal_split(total, n)
        loads = self.env['fp.rack.load']
        for q in qtys:
            loads |= self.create({'line_ids': [(0, 0, {'job_id': job.id, 'qty': q})]})
        return loads

    @api.model
    def _fp_add_rack(self, job):
        """Add one rack and re-divide equally across all of the job's loads."""
        total = self._fp_racking_total(job)
        n = len(self._fp_job_loads(job)) + 1
        return self._fp_split_job(job, total, max(n, 1))

    @api.model
    def _fp_divide_equally(self, job):
        total = self._fp_racking_total(job)
        n = max(len(self._fp_job_loads(job)), 1)
        return self._fp_split_job(job, total, n)

    def _fp_set_qty(self, qty):
        """Manual override of a single load's qty. Reject if it pushes the job's
        total assigned over the available parts."""
        self.ensure_one()
        line = self.line_ids[:1]
        if not line:
            raise UserError(_('This rack has no work order line.'))
        job = line.job_id
        total = self.env['fp.rack.load']._fp_racking_total(job)
        other = sum(self.env['fp.rack.load']._fp_job_loads(job).filtered(
            lambda l: l != self).mapped('qty_total'))
        if other + int(qty) > total:
            raise UserError(_('Assigned %(a)s exceeds available %(t)s parts.')
                            % {'a': other + int(qty), 't': total})
        line.qty = int(qty)

    def _fp_remove_rack(self):
        self.ensure_one()
        if self.current_step_id:
            raise UserError(_('Cannot remove a rack that has already moved.'))
        self.unlink()

Note: _fp_racking_step_for calls _fp_is_racking_step() (exists on fp.job.step in fusion_plating_jobs). fp.rack.load lives in fusion_plating, which loads before fusion_plating_jobs; guard with if hasattr(step, '_fp_is_racking_step') or move these helpers to a thin model extension in fusion_plating_jobs. Decide at Task 0: if _fp_is_racking_step isn't importable from core, put Task 2's _fp_racking_step_for/_fp_racking_total on an fp.rack.load extension in fusion_plating_jobs/models/ instead.

  • Step 4: Run tests — expect PASS.
  • Step 5: Commitgit commit -am "feat(fusion_plating): rack-load division API (equal split + manual override)"

Task 3: fp.job integration — qty_racked / qty_unracked

Files:

  • Create: fusion_plating_jobs/models/fp_job_rack.py (or add to fp_job.py)

  • Modify: fusion_plating_jobs/models/__init__.py

  • Test: fusion_plating_jobs/tests/test_job_rack.py

  • Step 1: Failing test

@tagged('post_install', '-at_install')
class TestJobRack(TransactionCase):
    def test_qty_racked_unracked(self):
        rack = self.env['fusion.plating.rack'].create({'name': 'R1', 'capacity': 60})
        job = self.env['fp.job'].create({'name': 'WO-X', 'qty': 100})
        self.env['fp.rack.load']._fp_split_job(job, 100, 2)  # 50/50
        self.assertEqual(job.qty_racked, 100)
        self.assertEqual(job.qty_unracked, 0)
  • Step 2: Run — FAIL (qty_racked undefined).
  • Step 3: Implement
# fusion_plating_jobs/models/fp_job_rack.py
from odoo import api, fields, models

class FpJob(models.Model):
    _inherit = 'fp.job'

    rack_load_line_ids = fields.One2many('fp.rack.load.line', 'job_id',
                                         string='Rack Loads')
    qty_racked = fields.Integer(compute='_compute_qty_racked')
    qty_unracked = fields.Integer(compute='_compute_qty_racked')

    @api.depends('rack_load_line_ids.qty', 'rack_load_line_ids.load_id.state')
    def _compute_qty_racked(self):
        for job in self:
            active = job.rack_load_line_ids.filtered(
                lambda l: l.load_id.state in ('loading', 'loaded', 'running'))
            job.qty_racked = sum(active.mapped('qty'))
            total = self.env['fp.rack.load']._fp_racking_total(job)
            job.qty_unracked = max(total - job.qty_racked, 0)
  • Step 4: Run — PASS. Step 5: Commit.

Task 4: Independent movement + De-Racking unrack

Files:

  • Modify: fusion_plating_jobs/models/fp_job_rack.py (movement methods on fp.rack.load via _inherit)

  • Test: fusion_plating_jobs/tests/test_job_rack.py (add cases)

  • Step 1: Failing test (advance a load → creates per-line moves + sets position)

    def test_advance_load_creates_move(self):
        job = self.env['fp.job'].create({'name': 'WO-Y', 'qty': 60})
        # need two steps: racking + plating. Build via the job's recipe/steps;
        # for the unit test, create two fp.job.step rows directly.
        Step = self.env['fp.job.step']
        s_rack = Step.create({'job_id': job.id, 'name': 'Racking', 'sequence': 30})
        s_plate = Step.create({'job_id': job.id, 'name': 'Plating', 'sequence': 40})
        load = self.env['fp.rack.load']._fp_split_job(job, 60, 1)
        load.current_step_id = s_rack
        load._fp_advance_to(s_plate)
        self.assertEqual(load.current_step_id, s_plate)
        self.assertEqual(load.state, 'running')
        mv = self.env['fp.job.step.move'].search([('rack_id', '=', load.rack_id.id)])
        self.assertTrue(mv)
        self.assertEqual(mv[0].qty_moved, 60)
  • Step 2: Run — FAIL.
  • Step 3: Implement movement on fp.rack.load
class FpRackLoad(models.Model):
    _inherit = 'fp.rack.load'

    def _fp_advance_to(self, to_step):
        """Move this rack-load to `to_step`, writing one move row per line."""
        Move = self.env['fp.job.step.move']
        for load in self:
            from_step = load.current_step_id
            for line in load.line_ids:
                Move.create({
                    'job_id': line.job_id.id,
                    'from_step_id': from_step.id if from_step else False,
                    'to_step_id': to_step.id,
                    'qty_moved': line.qty,
                    'rack_id': load.rack_id.id,
                    'transfer_type': 'step',
                    'moved_by_user_id': self.env.user.id,
                })
            load.current_step_id = to_step
            load.state = 'running'

    def _fp_unrack(self):
        """De-Racking: free the rack, mark unracked. Each line's parts continue
        in their own job's flow (the moves already attributed qty per job)."""
        for load in self:
            load.state = 'unracked'
            if load.rack_id:
                load.rack_id.racking_state = 'empty'
  • Step 4: Run — PASS. Step 5: Commit.

Reuse the existing Move Rack tablet dialog for the operator-facing single/multi move; _fp_advance_to is the model API those endpoints call. The de-racking trigger: call _fp_unrack() from the De-Racking step's finish (wire in Task 6 controller or a button_finish hook — keep it in the controller for Phase 1).


Task 5: Controllers /fp/racking/*

Files:

  • Create: fusion_plating_shopfloor/controllers/racking_controller.py

  • Modify: fusion_plating_shopfloor/controllers/__init__.py

  • Test: manual (controller smoke via the panel in Task 6); optional python smoke with pyflakes.

  • Step 1: Implement endpoints (JSONRPC, auth='user', run as the technician)

# fusion_plating_shopfloor/controllers/racking_controller.py
from odoo import http
from odoo.http import request
from odoo.exceptions import UserError

class FpRackingController(http.Controller):

    def _job(self, job_id):
        return request.env['fp.job'].browse(int(job_id))

    def _load_payload(self, job):
        Load = request.env['fp.rack.load']
        loads = Load._fp_job_loads(job)
        total = Load._fp_racking_total(job)
        return {
            'ok': True,
            'job_id': job.id,
            'wo_name': job.display_wo_name,
            'total': total,
            'unassigned': max(total - sum(loads.mapped('qty_total')), 0),
            'loads': [{
                'id': l.id, 'name': l.name,
                'rack_id': l.rack_id.id, 'rack_name': l.rack_id.name or '',
                'rack_capacity': l.rack_id.capacity or 0,
                'qty': l.qty_total,
                'over_capacity': bool(l.rack_id.capacity and l.qty_total > l.rack_id.capacity),
                'moved': bool(l.current_step_id),
            } for l in loads],
        }

    @http.route('/fp/racking/load', type='jsonrpc', auth='user')
    def load(self, job_id):
        return self._load_payload(self._job(job_id))

    @http.route('/fp/racking/add_rack', type='jsonrpc', auth='user')
    def add_rack(self, job_id):
        job = self._job(job_id)
        try:
            request.env['fp.rack.load']._fp_add_rack(job)
        except UserError as e:
            return {'ok': False, 'error': str(e.args[0])}
        return self._load_payload(job)

    @http.route('/fp/racking/divide_equally', type='jsonrpc', auth='user')
    def divide_equally(self, job_id):
        job = self._job(job_id)
        request.env['fp.rack.load']._fp_divide_equally(job)
        return self._load_payload(job)

    @http.route('/fp/racking/set_qty', type='jsonrpc', auth='user')
    def set_qty(self, load_id, qty):
        load = request.env['fp.rack.load'].browse(int(load_id))
        try:
            load._fp_set_qty(qty)
        except UserError as e:
            return {'ok': False, 'error': str(e.args[0])}
        return self._load_payload(load.line_ids[:1].job_id)

    @http.route('/fp/racking/remove_rack', type='jsonrpc', auth='user')
    def remove_rack(self, load_id):
        load = request.env['fp.rack.load'].browse(int(load_id))
        job = load.line_ids[:1].job_id
        try:
            load._fp_remove_rack()
        except UserError as e:
            return {'ok': False, 'error': str(e.args[0])}
        return self._load_payload(job)

    @http.route('/fp/racking/assign_rack', type='jsonrpc', auth='user')
    def assign_rack(self, load_id, rack_id):
        load = request.env['fp.rack.load'].browse(int(load_id))
        rack = request.env['fusion.plating.rack'].browse(int(rack_id))
        load.rack_id = rack.id
        rack.racking_state = 'loaded'
        return self._load_payload(load.line_ids[:1].job_id)
  • Step 2: pyflakesdocker exec odoo-modsdev-app python3 -m pyflakes <file> → no undefined names. Step 3: Commit.

Task 6: Job Workspace Racking panel (OWL)

Files:

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

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

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

  • Modify: fusion_plating_shopfloor/static/src/xml/job_workspace.xml (render <RackingPanel> when the WO is at the racking step)

  • Modify: fusion_plating_shopfloor/static/src/js/job_workspace.js (import + register the component; pass jobId)

  • Modify: fusion_plating_shopfloor/__manifest__.py (register the 3 asset files; bump version)

  • Step 1: Implement the OWL component (standalone, rpc from @web/core/network/rpc, static props)

/** @odoo-module **/
import { Component, useState, onWillStart } from "@odoo/owl";
import { rpc } from "@web/core/network/rpc";

export class RackingPanel extends Component {
    static template = "fusion_plating_shopfloor.RackingPanel";
    static props = ["jobId"];
    setup() {
        this.state = useState({ data: null, error: "" });
        onWillStart(() => this.reload());
    }
    async reload() {
        const d = await rpc("/fp/racking/load", { job_id: this.props.jobId });
        if (d.ok) this.state.data = d; else this.state.error = d.error || "";
    }
    async addRack() { this._apply(await rpc("/fp/racking/add_rack", { job_id: this.props.jobId })); }
    async divideEqually() { this._apply(await rpc("/fp/racking/divide_equally", { job_id: this.props.jobId })); }
    async setQty(load, ev) {
        const qty = parseInt(ev.target.value, 10) || 0;
        this._apply(await rpc("/fp/racking/set_qty", { load_id: load.id, qty }));
    }
    async removeRack(load) { this._apply(await rpc("/fp/racking/remove_rack", { load_id: load.id })); }
    _apply(d) { if (d.ok) this.state.data = d; else this.state.error = d.error || ""; }
}
<!-- racking_panel.xml -->
<templates xml:space="preserve">
  <t t-name="fusion_plating_shopfloor.RackingPanel">
    <div class="o_fp_racking_panel" t-if="state.data">
      <div class="o_fp_rkp_head">
        <span class="o_fp_rkp_title">🧰 Racking</span>
        <span class="o_fp_rkp_unassigned" t-att-class="state.data.unassigned ? 'has' : ''">
          Unassigned: <t t-esc="state.data.unassigned"/> / <t t-esc="state.data.total"/>
        </span>
      </div>
      <div t-if="state.error" class="o_fp_rkp_err" t-esc="state.error"/>
      <t t-foreach="state.data.loads" t-as="load" t-key="load.id">
        <div t-att-class="'o_fp_rkp_row' + (load.over_capacity ? ' over' : '')">
          <span class="o_fp_rkp_rack" t-esc="load.rack_name || 'No rack'"/>
          <input type="number" inputmode="numeric" class="form-control o_fp_rkp_qty"
                 t-att-value="load.qty" t-att-disabled="load.moved"
                 t-on-change="(ev) => this.setQty(load, ev)"/>
          <span class="o_fp_rkp_cap" t-if="load.rack_capacity">
            / <t t-esc="load.rack_capacity"/>
          </span>
          <button class="btn btn-sm btn-light" t-att-disabled="load.moved"
                  t-on-click="() => this.removeRack(load)"></button>
        </div>
      </t>
      <div class="o_fp_rkp_actions">
        <button class="btn btn-primary" t-on-click="addRack">+ Add Rack</button>
        <button class="btn btn-light" t-on-click="divideEqually">Divide Equally</button>
      </div>
    </div>
  </t>
</templates>

SCSS: card surface using existing $_ws-* tokens (mirror .o_fp_ws_rcv); over-capacity row gets an amber left border. Register RackingPanel in job_workspace.js components and render it in the steps area when state.data.job.is_at_racking (add that flag to the workspace /fp/workspace/load payload, or check the active step's area_kind === 'racking').

  • Step 2: Register assets + bump version. Step 3: Manual smoke (see Task 7). Step 4: Commit.

Task 7: Local deploy + manual smoke + verify

  • Step 1: Update + clear assets on local dev
docker exec odoo-modsdev-db psql -U odoo -d fusion-dev -c "DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';"
docker exec odoo-modsdev-app odoo -d fusion-dev -u fusion_plating,fusion_plating_jobs,fusion_plating_shopfloor --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -30

Expected: no ERROR/Traceback; "Modules loaded."

  • Step 2: Run the full test suite
docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_plating,/fusion_plating_jobs \
    -u fusion_plating,fusion_plating_jobs --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60

Expected: all green.

  • Step 3: Manual smoke (browser, http://localhost:8082): open a WO at Racking in the Job Workspace → Racking panel shows 1 rack with all parts → +Add Rack → 50/50 → +Add Rack → 34/33/33 → edit a qty → Unassigned updates → assign a rack → move (via existing Move Rack) and confirm the load advances independently.

  • Step 4: Commit any fixes. Do NOT deploy to entech yet — entech deploy is a separate, explicitly-confirmed step (new models + migration on a live DB).


Self-Review

  • Spec coverage: §3 model → Task 1; §4 division → Task 2; §3.3 job fields → Task 3; §5 movement + de-racking → Task 4; §7.3 endpoints → Task 5; §7.1 Job Workspace panel → Task 6. Phase-1 scope only (single WO / one line per load); §6 grouping + §7.2 station screen + §8 Plant Kanban are Phase 2/3 (separate plans) — intentionally deferred.
  • Placeholder scan: the only deferred specifics are the confirmed field names (Task 0) and the racking-step lookup location (flagged in Task 2). No "TODO/handle edge cases" hand-waving in code steps.
  • Type consistency: _fp_split_job, _fp_add_rack, _fp_divide_equally, _fp_set_qty, _fp_remove_rack, _fp_advance_to, _fp_unrack, _fp_job_loads, _fp_racking_total used consistently across Tasks 26; controller calls match.

Notes for entech deployment (after local green)

  • New models → -u fusion_plating,fusion_plating_jobs,fusion_plating_shopfloor on entech (creates tables, no destructive migration).
  • Existing single fp.job.step.rack_id flow is untouched (back-compat).