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>
28 KiB
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(addfrom . 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 intests/__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: Commit —
git 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_jobundefined). -
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_forcalls_fp_is_racking_step()(exists onfp.job.stepinfusion_plating_jobs).fp.rack.loadlives infusion_plating, which loads beforefusion_plating_jobs; guard withif hasattr(step, '_fp_is_racking_step')or move these helpers to a thin model extension infusion_plating_jobs. Decide at Task 0: if_fp_is_racking_stepisn't importable from core, put Task 2's_fp_racking_step_for/_fp_racking_totalon anfp.rack.loadextension infusion_plating_jobs/models/instead.
- Step 4: Run tests — expect PASS.
- Step 5: Commit —
git 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 tofp_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_rackedundefined). - 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 onfp.rack.loadvia_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_tois 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 abutton_finishhook — 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: pyflakes —
docker 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; passjobId) -
Modify:
fusion_plating_shopfloor/__manifest__.py(register the 3 asset files; bump version) -
Step 1: Implement the OWL component (standalone,
rpcfrom@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_totalused consistently across Tasks 2–6; controller calls match.
Notes for entech deployment (after local green)
- New models →
-u fusion_plating,fusion_plating_jobs,fusion_plating_shopflooron entech (creates tables, no destructive migration). - Existing single
fp.job.step.rack_idflow is untouched (back-compat).