diff --git a/fusion_plating/docs/superpowers/plans/2026-06-03-racking-multi-rack-phase1.md b/fusion_plating/docs/superpowers/plans/2026-06-03-racking-multi-rack-phase1.md
new file mode 100644
index 00000000..7f58b913
--- /dev/null
+++ b/fusion_plating/docs/superpowers/plans/2026-06-03-racking-multi-rack-phase1.md
@@ -0,0 +1,626 @@
+# 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).
diff --git a/fusion_plating/docs/superpowers/specs/2026-06-03-racking-multi-rack-wo-grouping-design.md b/fusion_plating/docs/superpowers/specs/2026-06-03-racking-multi-rack-wo-grouping-design.md
new file mode 100644
index 00000000..4585bef5
--- /dev/null
+++ b/fusion_plating/docs/superpowers/specs/2026-06-03-racking-multi-rack-wo-grouping-design.md
@@ -0,0 +1,133 @@
+# Multi-Rack Splitting + Work-Order Grouping at Racking — Design
+
+**Date:** 2026-06-03
+**Status:** Approved (design sign-off 2026-06-03)
+**Modules touched:** `fusion_plating` (core: rack-load models), `fusion_plating_jobs` (movement / partial-order integration), `fusion_plating_shopfloor` (UI surfaces + controllers), `fusion_plating_reports` (rack travel ticket reuse)
+
+## 1. Problem / Goal
+
+At the **Racking** step, operators load a job's parts onto physical racks before plating. Today a step links to exactly **one** rack (`fp.job.step.rack_id`, single Many2one) and there is **no model for partial parts-per-rack** or **multiple work orders sharing a rack**. Operators need to:
+
+1. **Split a job across multiple racks.** Default: all parts on one rack. An **"+ Add Rack"** button divides the quantity equally (100 → 50/50 → 34/33/33 → 25×4…). The operator can then **manually override** any individual rack's quantity.
+2. **Move racks independently** through the rest of the line (Plating → Baking → De-Racking) — partial-order flow, but rack-aware. The operator chooses which rack(s) advance.
+3. **Group multiple work orders on one rack** when they run the **identical recipe + spec** (any customer), for line efficiency — e.g. WO-A (20 ENP parts) + WO-B (10 ENP parts) on one rack, processed together, then separated at De-Racking.
+
+## 2. Locked Decisions (from brainstorm 2026-06-03)
+
+| # | Decision |
+|---|----------|
+| D1 | **Rack movement = independent, operator's choice.** Each rack is its own trackable unit; it can move ahead on its own, or the operator can move several at once. |
+| D2 | **Grouping eligibility = identical process + spec.** Only WOs with the same resolved recipe AND same coating spec / thickness target may share a rack. Different customers are allowed. Mismatched recipe/spec is **blocked**. |
+| D3 | **Two UI surfaces.** (a) A per-WO **Racking panel** on the Job Workspace (the split case). (b) A dedicated **Racking Station** shop-floor screen listing all WOs at Racking, with split controls *and* cross-WO grouping. Both drive the same model + endpoints. |
+| D4 | **Division remainder** goes to the first rack(s): `base = total // N`, the first `total % N` racks get `base + 1`. Total always equals the parts available. |
+| D5 | **Capacity = soft warning.** Each rack shows `assigned / capacity`; over-capacity is an amber warning, never a hard block. |
+| D6 | **Plant Kanban = one card per job** with a small **rack rollup** ("3 racks · 1 Baking, 2 Plating"). The job card sits in the column of its **least-advanced** rack-load (a WO isn't "done" until every rack clears). Per-rack detail lives on the Racking screen / a card drill-down — NOT as separate board cards. |
+
+## 3. Data Model
+
+### 3.1 `fp.rack.load` (new, in `fusion_plating`)
+"Parts loaded on one physical rack." First-class, moves through the workflow independently.
+
+| Field | Type | Notes |
+|---|---|---|
+| `name` | Char | Sequence `RACKLOAD/YYYY/NNNN` |
+| `rack_id` | Many2one `fusion.plating.rack` | The physical rack |
+| `line_ids` | One2many `fp.rack.load.line` (inverse `load_id`) | Per-WO allocation (1 line = single WO; 2+ = grouped) |
+| `qty_total` | Integer (compute, stored) | `sum(line_ids.qty)` |
+| `recipe_id` | Many2one (recipe ref) | The shared recipe (all lines must match) — for grouping eligibility + display |
+| `spec_key` | Char (compute, stored) | Normalised spec/thickness signature used to enforce D2 grouping |
+| `current_step_id` | Many2one `fp.job.step` | The step the rack-load is parked at (drives independent position) |
+| `current_area_kind` | Char (compute, stored) | From `current_step_id.area_kind` — for the Plant Kanban column |
+| `state` | Selection | `loading` → `loaded` → `running` → `unracked` (→ `cancelled`) |
+| `tag_ids` | Many2many `fp.rack.tag` | Reuse existing rack tags (Rush / Hold for QC) |
+| `company_id` | Many2one | Standard |
+| chatter | mail.thread | Audit |
+
+Constraints: a rack-load's `line_ids` must all share `recipe_id` + `spec_key` (D2); `qty_total` must be ≥ 1; `rack_id` unique among non-unracked loads (a physical rack holds one active load at a time).
+
+### 3.2 `fp.rack.load.line` (new, in `fusion_plating`)
+| Field | Type | Notes |
+|---|---|---|
+| `load_id` | Many2one `fp.rack.load`, required, ondelete cascade | |
+| `job_id` | Many2one `fp.job`, required | The work order whose parts are on this rack |
+| `qty` | Integer, required | Parts of this job on this rack |
+| `part_catalog_id` | Many2one (related from job) | Display |
+| `recipe_id` / `spec_key` | related/compute from job | Used to enforce D2 |
+
+### 3.3 Job ↔ rack-load relationships (on `fp.job`, in `fusion_plating_jobs`)
+- `rack_load_line_ids` (One2many to `fp.rack.load.line`) — all loads carrying this job's parts.
+- `qty_racked` (compute) = sum of this job's load-line qtys — how many of the job's parts are on racks.
+- `qty_unracked` (compute) = `qty_at_racking_step − qty_racked` — parts not yet assigned to a rack (the "Unassigned" counter).
+
+## 4. Division Math (the "+ Add Rack" behaviour)
+
+- Default state: **1 rack-load, line.qty = full racking quantity**.
+- **+ Add Rack** → create one more rack-load and **re-divide equally** across all current loads (D4): `base = total // N`; first `total % N` loads get `base + 1`. This overwrites all line qtys (the simple behaviour: "add 4th rack → divide by 4").
+- **Divide Equally** button → same as above without adding a rack (re-balance current N).
+- **Manual qty edit** on a rack → updates that load's line qty; the **Unassigned: N** counter recomputes (`total − Σ assigned`). Manual edits persist until the next *Add Rack* / *Divide Equally*. Sum may not exceed `total` (validation). Sum < total is allowed (operator may rack in waves) and shown as Unassigned.
+- **Remove Rack** → only when its load hasn't moved past Racking; its qty returns to Unassigned.
+
+## 5. Independent Movement + Partial-Order Integration
+
+- Movement reuses the existing **move log** `fp.job.step.move`. When a rack-load advances from step A → B, create **one move row per line** (per job): `from_step_id`, `to_step_id`, `qty_moved = line.qty`, `rack_id = load.rack_id`, `transfer_type = 'step'`. This keeps the existing `qty_at_step` partial-order compute correct and rack-aware.
+- The rack-load's `current_step_id` is set to the destination on commit (explicit position for the independent-movement UI), and `state` flips `loaded → running`.
+- The operator can move **one** load or **select several** to move together (D1). Reuse / extend the existing **Move Rack** tablet dialog (`move_rack_dialog.js` + `/fp/tablet/move_rack/*`) so a rack-load moves as a unit; the multi-select batch move is a thin wrapper.
+- **De-Racking** = unrack. When a rack-load reaches the De-Racking step and is unracked: set `state = unracked`, free the physical rack (`rack.racking_state = 'empty'`), and each line's `qty` returns to **its own** job's downstream flow (inspection → cert → shipping). Grouped WOs separate cleanly here — each job continues with its own parts/qty.
+
+## 6. Work-Order Grouping (D2)
+
+- On the Racking Station screen, eligible WOs at Racking (same `recipe_id` + `spec_key`, any customer) can be **pulled onto a shared rack-load** → adds a `fp.rack.load.line` for the second job.
+- Eligibility is enforced server-side: adding a line whose job's recipe/spec differs from the load's is rejected with a clear message.
+- A grouped rack-load moves as one unit (§5); at De-Racking each line returns to its job (§5).
+
+## 7. UI Surfaces
+
+### 7.1 Job Workspace → Racking panel (per-WO) — `fusion_plating_shopfloor`
+- Appears on the Job Workspace when the WO is at the Racking step (mirrors the existing Receiving card pattern).
+- Shows: total parts, **Unassigned: N**, a list of rack-loads each with `[rack picker] [qty input] [assigned/capacity bar] [remove]`, **+ Add Rack** and **Divide Equally** buttons.
+- Split / qty-edit only (single WO). Grouping is not done here.
+
+### 7.2 Racking Station screen (new) — `fusion_plating_shopfloor`
+- New OWL client action + menu under Shop Floor.
+- Lists all WOs currently at the Racking step (grouped by recipe/spec for grouping visibility).
+- Per-WO split controls (same as 7.1) **plus** "Combine onto rack" to pull an eligible WO onto another's rack-load.
+- Shows rack capacity bars + over-capacity warnings.
+
+### 7.3 Shared controller endpoints — `fusion_plating_shopfloor/controllers`
+- `/fp/racking/load` (GET context for a WO or the station)
+- `/fp/racking/add_rack` / `divide_equally` / `set_qty` / `remove_rack`
+- `/fp/racking/assign_rack` (pick/scan the physical rack for a load — reuse `/rack/list_empty` + `/rack/scan_qr`)
+- `/fp/racking/group` (add an eligible WO's line to a load) / `ungroup`
+- `/fp/racking/move` (advance one or more rack-loads to the next step — wraps the move-log writes)
+All run as `request.env.user` (the technician) reusing existing rack/move ACLs.
+
+## 8. Plant Kanban Representation (D6)
+- One card per job. Card column = area of the job's **least-advanced** rack-load (`min` over `rack_load_line_ids.load_id.current_area_kind` by column sequence), falling back to today's `active_step_id.area_kind` when the job has no rack-loads.
+- Card shows a compact **rack rollup** chip ("3 racks · 1 Baking, 2 Plating"). Tapping the chip / card opens a per-rack drill-down (or routes to the Racking screen).
+- No new board columns; no per-rack board cards.
+
+## 9. Phasing (single spec, built in order)
+1. **Phase 1 — Split + independent movement.** `fp.rack.load` + `fp.rack.load.line`, division math, move-log integration, De-Racking unrack, Job Workspace Racking panel. Single-WO only (one line per load).
+2. **Phase 2 — WO grouping + Racking Station screen.** Multi-line loads, eligibility enforcement, the dedicated cross-WO surface.
+3. **Phase 3 — Plant Kanban rollup + drill-down.**
+
+## 10. Integration Points / Reuse
+- `fusion.plating.rack` (capacity, racking_state, tags) — reused; rack-load references it.
+- `fp.job.step.move` / `qty_at_step` partial-order compute — reused, now rack-aware.
+- `move_rack_dialog.js` + `/fp/tablet/move_rack/*` + `/rack/list_empty` + `/rack/scan_qr` — reused/extended.
+- Rack Travel Ticket PDF (`report_fp_rack_travel`) — reused (print a load's ticket).
+- `_fp_is_racking_step` / racking inspection gate — unchanged; rack-loads are created at the racking step.
+
+## 11. Edge Cases / Rules
+- Sum of load qtys may be **< total** (rack in waves); the remainder shows as Unassigned and can be racked later.
+- A load can't be removed/edited once it has moved past Racking.
+- One physical rack = one active (non-unracked) load at a time.
+- Over-capacity = soft amber warning only.
+- Cancelling a job cascades its load lines; a load with no remaining lines is cancelled.
+- Migration: existing single `fp.job.step.rack_id` assignments are left as-is (legacy); new flow uses rack-loads. No destructive backfill.
+
+## 12. Out of Scope (this spec)
+- Auto-suggesting which WOs to group (operator-driven only).
+- Rack capacity *planning*/optimisation.
+- Changing the De-Racking inspection model.
+- Reworking the legacy `rack_id`-on-step flow (kept for back-compat).