# 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):** ```bash 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** ```bash 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) ```bash 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)** ```python # 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** ```python # 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: ```xml Rack Load fp.rack.load RACKLOAD/%(year)s/ 4 ``` ACL rows (append to `fusion_plating/security/ir.model.access.csv`) — Technician r/w/c, Manager full: ```csv 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)** ```python 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** ```python @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: 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 to `fp_job.py`) - Modify: `fusion_plating_jobs/models/__init__.py` - Test: `fusion_plating_jobs/tests/test_job_rack.py` - [ ] **Step 1: Failing test** ```python @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** ```python # 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)** ```python 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`** ```python 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) ```python # 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 ` → 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 `` 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`) ```javascript /** @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 || ""; } } ``` ```xml
🧰 Racking Unassigned: /
/
``` 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** ```bash 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** ```bash 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 2–6; 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).