diff --git a/fusion-plating/docs/superpowers/plans/2026-05-12-step-qty-gate-and-display-rename.md b/fusion-plating/docs/superpowers/plans/2026-05-12-step-qty-gate-and-display-rename.md
new file mode 100644
index 00000000..0e3510de
--- /dev/null
+++ b/fusion-plating/docs/superpowers/plans/2026-05-12-step-qty-gate-and-display-rename.md
@@ -0,0 +1,969 @@
+# Step Qty Gate, Partial-Qty Handling, and Job Display Rename — 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:** Add a quantity gate on `fp.job.step.button_finish` (with last-step exemption), introduce a per-row `Complete 1 → Next` action for streaming flow, add an auto-move shim on Finish & Next for the 1-of-1 case, and override `fp.job.display_name` so jobs render as `Work Order # 00011` instead of `WH/JOB/00011`.
+
+**Architecture:** Five small Python changes (one compute + one gate + one action + one helper + manager-bypass keys) on `fp.job` and `fp.job.step`, plus two view edits (form `
` and embedded step list row button). Move wizard's existing zero-qty + over-qty guards stay; one regression test added for them. All changes deploy on entech, sync back to the local repo as the final task.
+
+**Tech Stack:** Odoo 19, PostgreSQL. No new dependencies.
+
+**Spec:** [`docs/superpowers/specs/2026-05-12-step-qty-gate-and-display-rename-design.md`](../specs/2026-05-12-step-qty-gate-and-display-rename-design.md)
+
+---
+
+## Deployment conventions
+
+Same pattern as the milestone-cascade plan that just shipped:
+
+- File paths are **entech container paths** (`/mnt/extra-addons/custom/...`).
+- Edits go via base64-encoded Python patch scripts:
+ ```bash
+ B64=$(base64 -w0 path/to/_patch.py)
+ ssh pve-worker5 "pct exec 111 -- bash -c \"echo $B64 | base64 -d > /tmp/_patch.py && python3 /tmp/_patch.py\""
+ ```
+- After each Python change: manifest version bump, then upgrade module:
+ ```bash
+ ssh pve-worker5 "pct exec 111 -- bash -c \"systemctl stop odoo && \
+ su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u --stop-after-init' 2>&1 | tail -5 && \
+ systemctl start odoo && systemctl is-active odoo\""
+ ```
+- Tests via:
+ ```bash
+ ssh pve-worker5 "pct exec 111 -- bash -c \"systemctl stop odoo && \
+ su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | grep -E 'TestQtyGate.*(FAIL|ERROR|Starting)' | head -30 && \
+ systemctl start odoo\""
+ ```
+- Backups: `cp /tmp/.bak` before the first patch of any file.
+- No git commits during tasks. Final task (Task 7) syncs touched files back to `K:/Github/Odoo-Modules/` and commits there.
+
+---
+
+## File structure
+
+| File | Type | Responsibility |
+|---|---|---|
+| `fusion_plating_jobs/models/fp_job.py` | modify | Add `_compute_display_name` override (renames `WH/JOB/00011` → `Work Order # 00011`). |
+| `fusion_plating/models/fp_job_step.py` | modify | Quantity gate in `button_finish`; new `action_complete_one_to_next`; new helper `_fp_record_one_piece_auto_move`; wire the helper into `action_finish_and_advance`. |
+| `fusion_plating_jobs/views/fp_job_form_inherit.xml` | modify | `
` binds `display_name`; per-row "Complete 1 → Next" button. |
+| `fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py` | modify | Append `TestQtyGate` class with 14 tests. |
+| `fusion_plating/__manifest__.py` | modify | Version bump. |
+| `fusion_plating_jobs/__manifest__.py` | modify | Version bump. |
+
+---
+
+## Task 1: Display rename — `Work Order # 00011`
+
+**Files:**
+- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py`
+- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/views/fp_job_form_inherit.xml`
+
+- [ ] **Step 1: Backup files**
+
+```bash
+ssh pve-worker5 "pct exec 111 -- bash -c 'cp /mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py /tmp/fp_job_t1.py.bak && cp /mnt/extra-addons/custom/fusion_plating_jobs/views/fp_job_form_inherit.xml /tmp/fp_job_form_inherit_t1.xml.bak'"
+```
+
+- [ ] **Step 2: Add `_compute_display_name` to `fp.job`**
+
+Locate the existing class declaration in `fp_job.py` (around the first `class FpJob(models.Model)` line, then the `_inherit = 'fp.job'` block). Find the existing `name` field declaration (around line 62 — `name = fields.Char(...)`). Add the new compute method immediately after the existing field declarations on the class (any spot inside the class body before existing `@api.depends` methods is fine; convention is to put it near the field it depends on).
+
+Insert:
+
+```python
+ @api.depends('name')
+ def _compute_display_name(self):
+ """Reformat 'WH/JOB/00011' → 'Work Order # 00011' for every
+ human-facing surface (form header, breadcrumbs, M2O dropdowns,
+ smart-button titles, error messages). The DB `name` is unchanged
+ so existing certs / deliveries / chatter references don't break.
+ """
+ for job in self:
+ if job.name and '/' in job.name:
+ suffix = job.name.rsplit('/', 1)[-1]
+ job.display_name = _('Work Order # %s') % suffix
+ else:
+ job.display_name = job.name or ''
+```
+
+Use a patch script with anchor-based string replacement. The anchor should be unique enough to find exactly one insertion site — pick a stable nearby field declaration (e.g. the `state` field's closing `)` if it's unique).
+
+- [ ] **Step 3: Bind `display_name` in the form header**
+
+In `fp_job_form_inherit.xml`, find the `
` block in the sheet header that currently binds `name`:
+
+Search anchor:
+```xml
+
+```
+
+Replace with:
+```xml
+
+```
+
+If the file uses a slightly different markup (e.g. with extra attributes like `class=...` or `readonly=...`), keep those attributes and just change `name="name"` to `name="display_name"`.
+
+- [ ] **Step 4: Bump fusion_plating_jobs manifest version**
+
+```bash
+ssh pve-worker5 "pct exec 111 -- bash -c \"CUR=\\\$(grep \\\"'version':\\\" /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py | head -1 | grep -oP '\\\\d+\\\\.\\\\d+\\\\.\\\\d+\\\\.\\\\d+\\\\.\\\\d+') && echo \\\"current: \\\$CUR\\\"\""
+```
+
+Bump the last component (`19.0.8.19.6` → `19.0.8.19.7`):
+
+```bash
+ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.8.19.6'/'version': '19.0.8.19.7'/\\\" /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py\""
+```
+
+(If the current version is different from `19.0.8.19.6` because Phase 1 work iterated more, substitute the actual current version.)
+
+- [ ] **Step 5: Validate Python + XML syntax**
+
+```bash
+ssh pve-worker5 "pct exec 111 -- bash -c \"python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py\\\").read()); print(\\\"py OK\\\")' && python3 -c 'import xml.etree.ElementTree as ET; ET.parse(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/views/fp_job_form_inherit.xml\\\"); print(\\\"xml OK\\\")'\""
+```
+
+Expected: `py OK` and `xml OK`.
+
+- [ ] **Step 6: Upgrade fusion_plating_jobs**
+
+```bash
+ssh pve-worker5 "pct exec 111 -- bash -c \"systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_jobs --stop-after-init' 2>&1 | tail -5 && systemctl start odoo && systemctl is-active odoo\""
+```
+
+Expected: `Modules loaded`, `Registry loaded`, then `active`.
+
+- [ ] **Step 7: Verify display_name renders correctly via odoo shell**
+
+```bash
+SCRIPT='job = env["fp.job"].search([("name", "like", "WH/JOB/")], limit=1)
+print(">>> name=", job.name)
+print(">>> display_name=", job.display_name)'
+B64=$(echo -n "$SCRIPT" | base64 -w0)
+ssh pve-worker5 "pct exec 111 -- bash -c \"echo $B64 | base64 -d > /tmp/check.py && su - odoo -s /bin/bash -c '/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http < /tmp/check.py' 2>&1 | grep '>>>'\""
+```
+
+Expected:
+```
+>>> name= WH/JOB/00011
+>>> display_name= Work Order # 00011
+```
+
+---
+
+## Task 2: Quantity gate on `button_finish`
+
+**Files:**
+- Modify: `/mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py`
+- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`
+
+- [ ] **Step 1: Backup**
+
+```bash
+ssh pve-worker5 "pct exec 111 -- bash -c 'cp /mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py /tmp/fp_job_step_t2.py.bak && cp /mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py /tmp/test_fp_job_milestone_cascade_t2.py.bak'"
+```
+
+- [ ] **Step 2: Add quantity gate to `button_finish`**
+
+Find the existing method in `fp_job_step.py` (around line 385). The current opening looks like:
+
+```python
+def button_finish(self):
+ for step in self:
+ if step.state != 'in_progress':
+ raise UserError(_(
+ "Step '%s' is in state '%s' — only in-progress steps can finish."
+ ) % (step.name, step.state))
+ now = fields.Datetime.now()
+ # Close the open timelog (the one with no date_finished)
+ open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
+```
+
+Use a patch script to inject the quantity gate immediately after the existing `state != 'in_progress'` check. New text:
+
+```python
+def button_finish(self):
+ skip_qty_gate = self.env.context.get('fp_skip_qty_gate')
+ for step in self:
+ if step.state != 'in_progress':
+ raise UserError(_(
+ "Step '%s' is in state '%s' — only in-progress steps can finish."
+ ) % (step.name, step.state))
+ # Quantity gate: refuses if parts still parked AND there's a
+ # downstream step to move them to. Last runnable step is
+ # exempt — parts finishing there complete in place.
+ if not skip_qty_gate and step.qty_at_step > 0:
+ has_downstream = step.job_id.step_ids.filtered(
+ lambda s: s.sequence > step.sequence
+ and s.state in ('pending', 'ready')
+ )
+ if has_downstream:
+ raise UserError(_(
+ "Step '%(name)s' still has %(n)d part(s) parked "
+ "— move them to the next step before finishing. "
+ "Use the row's 'Complete 1 → Next' or 'Move…' "
+ "button."
+ ) % {'name': step.name, 'n': step.qty_at_step})
+ now = fields.Datetime.now()
+ # Close the open timelog (the one with no date_finished)
+ open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
+```
+
+Patch script uses the existing method-opening anchor (`def button_finish(self):\n for step in self:\n if step.state != 'in_progress':`) and replaces with the new opening.
+
+- [ ] **Step 3: Add `TestQtyGate` test class skeleton + 3 gate tests**
+
+Append to `test_fp_job_milestone_cascade.py`:
+
+```python
+
+
+class TestQtyGate(TransactionCase):
+ """Step-level quantity gate + partial-qty handling.
+
+ Covers:
+ - button_finish blocks when qty_at_step > 0 AND downstream
+ steps exist (mid-recipe)
+ - manager bypass via fp_skip_qty_gate=True
+ - last-runnable-step exemption (qty_at_step > 0 allowed)
+ - action_complete_one_to_next (Task 3)
+ - auto-move shim on action_finish_and_advance (Task 4)
+ - display_name rename (Task 1)
+ """
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.partner = cls.env['res.partner'].create({'name': 'QtyCust'})
+ cls.product = cls.env['product.product'].create({
+ 'name': 'QtyWidget',
+ })
+
+ def _make_job(self, qty=3, **kw):
+ vals = {
+ 'partner_id': self.partner.id,
+ 'product_id': self.product.id,
+ 'qty': qty,
+ }
+ vals.update(kw)
+ return self.env['fp.job'].create(vals)
+
+ def _make_step(self, job, name='Step', sequence=10, state='pending'):
+ return self.env['fp.job.step'].create({
+ 'job_id': job.id,
+ 'name': name,
+ 'sequence': sequence,
+ 'state': state,
+ })
+
+ def _make_two_step_chain(self, qty=3):
+ """Create a job with two steps; the first is in_progress
+ with `qty` parts parked, the second is ready. Returns
+ (job, step1, step2)."""
+ job = self._make_job(qty=qty)
+ step1 = self._make_step(
+ job, name='Plate', sequence=10, state='in_progress',
+ )
+ step2 = self._make_step(
+ job, name='Bake', sequence=20, state='ready',
+ )
+ # date_started required by button_finish's timelog close
+ step1.date_started = fields.Datetime.now()
+ return job, step1, step2
+
+ # ---------------- button_finish gate ----------------------------
+
+ def test_button_finish_blocks_when_qty_at_step(self):
+ from odoo.exceptions import UserError
+ job, step1, step2 = self._make_two_step_chain(qty=3)
+ # First-step seed gives step1 qty_at_step = job.qty = 3
+ step1.invalidate_recordset(['qty_at_step'])
+ self.assertEqual(step1.qty_at_step, 3)
+ with self.assertRaises(UserError) as exc:
+ step1.button_finish()
+ self.assertIn('parts parked', str(exc.exception))
+
+ def test_button_finish_bypass(self):
+ job, step1, step2 = self._make_two_step_chain(qty=3)
+ step1.invalidate_recordset(['qty_at_step'])
+ step1.with_context(fp_skip_qty_gate=True).button_finish()
+ self.assertEqual(step1.state, 'done')
+
+ def test_button_finish_allows_last_step_with_qty(self):
+ """Last runnable step is exempt — parts complete in place."""
+ job = self._make_job(qty=5)
+ last = self._make_step(
+ job, name='FinalInspect', sequence=10, state='in_progress',
+ )
+ last.date_started = fields.Datetime.now()
+ last.invalidate_recordset(['qty_at_step'])
+ self.assertEqual(last.qty_at_step, 5) # first-step seed
+ # No downstream step → gate exempt
+ last.button_finish()
+ self.assertEqual(last.state, 'done')
+
+ def test_button_finish_passes_when_qty_zero(self):
+ """qty_at_step==0 (already moved out manually) → no gate fires."""
+ job, step1, step2 = self._make_two_step_chain(qty=2)
+ # Move all parts out so step1.qty_at_step = 0
+ self.env['fp.job.step.move'].create({
+ 'job_id': job.id,
+ 'from_step_id': step1.id,
+ 'to_step_id': step2.id,
+ 'transfer_type': 'step',
+ 'qty_moved': 2,
+ 'moved_by_user_id': self.env.user.id,
+ })
+ step1.invalidate_recordset(['qty_at_step'])
+ self.assertEqual(step1.qty_at_step, 0)
+ step1.button_finish()
+ self.assertEqual(step1.state, 'done')
+```
+
+- [ ] **Step 4: Bump fusion_plating manifest version**
+
+Find current version, bump the last component:
+
+```bash
+ssh pve-worker5 "pct exec 111 -- bash -c \"grep \\\"'version':\\\" /mnt/extra-addons/custom/fusion_plating/__manifest__.py | head -1\""
+```
+
+Then bump (assuming current is `19.0.18.14.12`):
+
+```bash
+ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.18.14.12'/'version': '19.0.18.14.13'/\\\" /mnt/extra-addons/custom/fusion_plating/__manifest__.py\""
+```
+
+- [ ] **Step 5: Validate Python**
+
+```bash
+ssh pve-worker5 "pct exec 111 -- bash -c \"python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py\\\").read()); ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py\\\").read()); print(\\\"OK\\\")'\""
+```
+
+Expected: `OK`.
+
+- [ ] **Step 6: Upgrade fusion_plating + fusion_plating_jobs with tests**
+
+```bash
+ssh pve-worker5 "pct exec 111 -- bash -c \"systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating,fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | grep -E 'TestQtyGate.*(FAIL|ERROR|Starting)' | head -30 && systemctl start odoo\""
+```
+
+Expected: 4 `Starting TestQtyGate.test_button_finish_*` lines, no FAIL or ERROR lines for TestQtyGate.
+
+---
+
+## Task 3: `action_complete_one_to_next`
+
+**Files:**
+- Modify: `/mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py`
+- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`
+
+- [ ] **Step 1: Add `action_complete_one_to_next` method**
+
+Append the new method to `fp_job_step.py` at the end of the `FpJobStep` class (after `button_manager_reset_to_ready` from the milestone-cascade Phase 1 work, since both are recent additions and group together). Patch via append-or-anchor-replace.
+
+Code:
+
+```python
+
+ def action_complete_one_to_next(self):
+ """One-piece flow shortcut: records move(qty=1) from this step
+ to the next pending/ready step, drains qty_at_step by 1. If
+ the drain takes qty_at_step to 0, auto-finishes the source
+ and starts the destination step (delegates to
+ action_finish_and_advance)."""
+ self.ensure_one()
+ if self.state != 'in_progress':
+ raise UserError(_(
+ "Step '%s' must be in progress to complete a part."
+ ) % self.name)
+ if self.qty_at_step < 1:
+ raise UserError(_(
+ "No parts parked at step '%s' — nothing to complete."
+ ) % self.name)
+ next_step = self.job_id.step_ids.filtered(
+ lambda s: s.sequence > self.sequence
+ and s.state in ('pending', 'ready')
+ ).sorted('sequence')[:1]
+ if not next_step:
+ raise UserError(_(
+ "Step '%s' is the last runnable step on the job — "
+ "no downstream step to move into. Finish the step "
+ "instead (it will close out the job)."
+ ) % self.name)
+ self.env['fp.job.step.move'].create({
+ 'job_id': self.job_id.id,
+ 'from_step_id': self.id,
+ 'to_step_id': next_step.id,
+ 'transfer_type': 'step',
+ 'qty_moved': 1,
+ 'moved_by_user_id': self.env.user.id,
+ })
+ # qty_at_step is computed from moves; force re-read before
+ # checking whether this was the last part. Without invalidate
+ # the cache still says "still 1 parked" and auto-finish never
+ # fires.
+ self.invalidate_recordset(['qty_at_step'])
+ if self.qty_at_step == 0:
+ return self.action_finish_and_advance()
+ return True
+```
+
+- [ ] **Step 2: Add 4 tests for `action_complete_one_to_next`**
+
+Append to `TestQtyGate` class:
+
+```python
+
+ # ---------------- action_complete_one_to_next -------------------
+
+ def test_complete_one_to_next_records_move(self):
+ job, step1, step2 = self._make_two_step_chain(qty=3)
+ step1.invalidate_recordset(['qty_at_step'])
+ self.assertEqual(step1.qty_at_step, 3)
+ step1.action_complete_one_to_next()
+ # One move(qty=1) created
+ moves = self.env['fp.job.step.move'].search([
+ ('from_step_id', '=', step1.id),
+ ])
+ self.assertEqual(len(moves), 1)
+ self.assertEqual(moves.qty_moved, 1)
+ # step1 still in progress, 2 parts left
+ step1.invalidate_recordset(['qty_at_step'])
+ self.assertEqual(step1.state, 'in_progress')
+ self.assertEqual(step1.qty_at_step, 2)
+
+ def test_complete_one_to_next_auto_finishes_on_last(self):
+ job, step1, step2 = self._make_two_step_chain(qty=1)
+ step1.invalidate_recordset(['qty_at_step'])
+ self.assertEqual(step1.qty_at_step, 1)
+ step1.action_complete_one_to_next()
+ # Source step done; next step started
+ self.assertEqual(step1.state, 'done')
+ self.assertEqual(step2.state, 'in_progress')
+
+ def test_complete_one_to_next_blocks_when_empty(self):
+ from odoo.exceptions import UserError
+ job, step1, step2 = self._make_two_step_chain(qty=2)
+ # Move all out first → qty_at_step = 0
+ self.env['fp.job.step.move'].create({
+ 'job_id': job.id,
+ 'from_step_id': step1.id,
+ 'to_step_id': step2.id,
+ 'transfer_type': 'step',
+ 'qty_moved': 2,
+ 'moved_by_user_id': self.env.user.id,
+ })
+ step1.invalidate_recordset(['qty_at_step'])
+ with self.assertRaises(UserError) as exc:
+ step1.action_complete_one_to_next()
+ self.assertIn('nothing to complete', str(exc.exception))
+
+ def test_complete_one_to_next_blocks_when_no_next_step(self):
+ from odoo.exceptions import UserError
+ job = self._make_job(qty=3)
+ last = self._make_step(
+ job, name='Inspect', sequence=10, state='in_progress',
+ )
+ last.date_started = fields.Datetime.now()
+ last.invalidate_recordset(['qty_at_step'])
+ with self.assertRaises(UserError) as exc:
+ last.action_complete_one_to_next()
+ self.assertIn('last runnable step', str(exc.exception))
+
+ def test_complete_one_to_next_blocks_when_not_in_progress(self):
+ from odoo.exceptions import UserError
+ job, step1, step2 = self._make_two_step_chain(qty=3)
+ step1.state = 'pending' # not in_progress
+ with self.assertRaises(UserError) as exc:
+ step1.action_complete_one_to_next()
+ self.assertIn('must be in progress', str(exc.exception))
+```
+
+- [ ] **Step 3: Bump fusion_plating manifest version**
+
+```bash
+ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.18.14.13'/'version': '19.0.18.14.14'/\\\" /mnt/extra-addons/custom/fusion_plating/__manifest__.py\""
+```
+
+- [ ] **Step 4: Validate + run tests**
+
+```bash
+ssh pve-worker5 "pct exec 111 -- bash -c \"python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py\\\").read()); ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py\\\").read()); print(\\\"OK\\\")' && systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating,fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | grep -E 'TestQtyGate.*test_complete_one_to_next.*(FAIL|ERROR|Starting)' | head -15 && systemctl start odoo\""
+```
+
+Expected: 5 `Starting` lines (the test from Step 2 plus 4 here), zero FAIL/ERROR.
+
+---
+
+## Task 4: Auto-move shim on Finish & Next
+
+**Files:**
+- Modify: `/mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py`
+- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`
+
+- [ ] **Step 1: Add `_fp_record_one_piece_auto_move` helper**
+
+Find the existing `action_finish_and_advance` method on `fp.job.step` (search for `def action_finish_and_advance`). It probably looks like:
+
+```python
+def action_finish_and_advance(self):
+ """Finish this step and auto-start the next pending/ready
+ step (Steelhead-style per-row button)."""
+ self.ensure_one()
+ if self.state == 'in_progress':
+ self.button_finish()
+ # ...rest: pick next step + button_start
+```
+
+Add the helper as a sibling method, then wire it in. New code:
+
+```python
+
+ def _fp_record_one_piece_auto_move(self):
+ """Decide whether to silently record a move(qty=1) before
+ the step finishes. Five cases:
+ - qty_at_step == 0: nothing to do (parts already moved).
+ - last runnable step: parts complete in place; no move.
+ - qty_at_step == 1 + downstream: record move(1).
+ - qty_at_step > 1 + downstream: raise.
+ - qty_at_step > 1 + last step: allow (parts complete in
+ place; qty_done auto-tick is Phase 2).
+ Called from action_finish_and_advance just before
+ button_finish.
+ """
+ self.ensure_one()
+ qty = self.qty_at_step
+ if qty <= 0:
+ return False
+ next_step = self.job_id.step_ids.filtered(
+ lambda s: s.sequence > self.sequence
+ and s.state in ('pending', 'ready')
+ ).sorted('sequence')[:1]
+ if not next_step:
+ # Last runnable step: parts complete in place.
+ return False
+ if qty > 1:
+ raise UserError(_(
+ "Step '%s' still has %d parts here — use the row's "
+ "'Complete 1 → Next' button (for one-by-one flow) "
+ "or the 'Move…' wizard (for batched flow) to drain "
+ "the step before finishing."
+ ) % (self.name, qty))
+ # qty == 1 + next_step exists → record move silently.
+ self.env['fp.job.step.move'].create({
+ 'job_id': self.job_id.id,
+ 'from_step_id': self.id,
+ 'to_step_id': next_step.id,
+ 'transfer_type': 'step',
+ 'qty_moved': 1,
+ 'moved_by_user_id': self.env.user.id,
+ })
+ return True
+```
+
+- [ ] **Step 2: Wire the helper into `action_finish_and_advance`**
+
+Find `action_finish_and_advance`. The current code likely starts:
+
+```python
+def action_finish_and_advance(self):
+ self.ensure_one()
+ if self.state == 'in_progress':
+ self.button_finish()
+```
+
+Insert the helper call before `button_finish`:
+
+```python
+def action_finish_and_advance(self):
+ self.ensure_one()
+ if self.state == 'in_progress':
+ # Auto-move shim: for qty_at_step==1 + downstream, record a
+ # move(qty=1) so the qty gate in button_finish passes. Raises
+ # for qty>1 with a friendly pointer to Complete 1 → Next.
+ self._fp_record_one_piece_auto_move()
+ self.button_finish()
+```
+
+The patch script uses the existing method's `self.ensure_one()\n if self.state == 'in_progress':\n self.button_finish()` as the anchor.
+
+- [ ] **Step 3: Add 4 auto-move shim tests**
+
+Append to `TestQtyGate`:
+
+```python
+
+ # ---------------- auto-move shim on Finish & Next ---------------
+
+ def test_finish_and_advance_auto_move_for_qty_1(self):
+ job, step1, step2 = self._make_two_step_chain(qty=1)
+ step1.invalidate_recordset(['qty_at_step'])
+ self.assertEqual(step1.qty_at_step, 1)
+ step1.action_finish_and_advance()
+ # Move(qty=1) recorded silently
+ moves = self.env['fp.job.step.move'].search([
+ ('from_step_id', '=', step1.id),
+ ])
+ self.assertEqual(len(moves), 1)
+ self.assertEqual(moves.qty_moved, 1)
+ self.assertEqual(step1.state, 'done')
+ self.assertEqual(step2.state, 'in_progress')
+
+ def test_finish_and_advance_blocks_for_qty_gt_1(self):
+ from odoo.exceptions import UserError
+ job, step1, step2 = self._make_two_step_chain(qty=3)
+ step1.invalidate_recordset(['qty_at_step'])
+ self.assertEqual(step1.qty_at_step, 3)
+ with self.assertRaises(UserError) as exc:
+ step1.action_finish_and_advance()
+ self.assertIn("Complete 1", str(exc.exception))
+ # State unchanged
+ self.assertEqual(step1.state, 'in_progress')
+
+ def test_finish_and_advance_passes_for_qty_0(self):
+ job, step1, step2 = self._make_two_step_chain(qty=2)
+ # Move all out first
+ self.env['fp.job.step.move'].create({
+ 'job_id': job.id,
+ 'from_step_id': step1.id,
+ 'to_step_id': step2.id,
+ 'transfer_type': 'step',
+ 'qty_moved': 2,
+ 'moved_by_user_id': self.env.user.id,
+ })
+ step1.invalidate_recordset(['qty_at_step'])
+ before = self.env['fp.job.step.move'].search_count([
+ ('from_step_id', '=', step1.id),
+ ])
+ step1.action_finish_and_advance()
+ after = self.env['fp.job.step.move'].search_count([
+ ('from_step_id', '=', step1.id),
+ ])
+ self.assertEqual(after, before) # no extra move
+ self.assertEqual(step1.state, 'done')
+
+ def test_finish_and_advance_allows_last_step_with_qty_gt_1(self):
+ """Last runnable step: parts complete in place; no auto-move,
+ no UserError, no qty gate."""
+ job = self._make_job(qty=5)
+ last = self._make_step(
+ job, name='FinalInspect', sequence=10, state='in_progress',
+ )
+ last.date_started = fields.Datetime.now()
+ last.invalidate_recordset(['qty_at_step'])
+ self.assertEqual(last.qty_at_step, 5)
+ before = self.env['fp.job.step.move'].search_count([])
+ last.action_finish_and_advance()
+ after = self.env['fp.job.step.move'].search_count([])
+ self.assertEqual(after, before) # no move recorded
+ self.assertEqual(last.state, 'done')
+```
+
+- [ ] **Step 4: Bump fusion_plating manifest version**
+
+```bash
+ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.18.14.14'/'version': '19.0.18.14.15'/\\\" /mnt/extra-addons/custom/fusion_plating/__manifest__.py\""
+```
+
+- [ ] **Step 5: Validate + run tests**
+
+```bash
+ssh pve-worker5 "pct exec 111 -- bash -c \"python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py\\\").read()); ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py\\\").read()); print(\\\"OK\\\")' && systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating,fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | grep -E 'TestQtyGate.*test_finish_and_advance.*(FAIL|ERROR|Starting)' | head -10 && systemctl start odoo\""
+```
+
+Expected: 4 `Starting` lines for `test_finish_and_advance_*`, zero FAIL/ERROR.
+
+---
+
+## Task 5: Per-row "Complete 1 → Next" button + display_name tests
+
+**Files:**
+- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/views/fp_job_form_inherit.xml`
+- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`
+
+- [ ] **Step 1: Add the per-row button**
+
+In `fp_job_form_inherit.xml`, find the embedded step list's button block. The existing per-row buttons include `button_pause`, `action_open_input_wizard`, `button_skip`, `action_open_move_wizard`. We're adding "Complete 1 → Next" after `button_pause` and before `action_open_input_wizard` (so it sits with the primary-action buttons).
+
+Anchor — the existing Pause button:
+```xml
+
+```
+
+Insert immediately after Pause's closing `/>`:
+
+```xml
+
+
+```
+
+- [ ] **Step 2: Add display_name + Move wizard regression tests**
+
+Append to `TestQtyGate`:
+
+```python
+
+ # ---------------- display_name rename ----------------------------
+
+ def test_display_name_format(self):
+ job = self._make_job(qty=1)
+ # The default ir.sequence creates name='WH/JOB/NNNNN'.
+ self.assertTrue(job.name.startswith('WH/JOB/'))
+ self.assertTrue(job.display_name.startswith('Work Order # '))
+ # Suffix matches.
+ suffix = job.name.rsplit('/', 1)[-1]
+ self.assertEqual(job.display_name, 'Work Order # %s' % suffix)
+
+ def test_display_name_no_slash_passthrough(self):
+ """Manually-named jobs without the sequence prefix display
+ as-is (no rewrite)."""
+ job = self._make_job(qty=1)
+ # Override name to something without a slash
+ job.name = 'SmokeJob42'
+ job.invalidate_recordset(['display_name'])
+ self.assertEqual(job.display_name, 'SmokeJob42')
+
+ # ---------------- Move wizard zero-qty regression ----------------
+
+ def test_move_wizard_blocks_zero_qty(self):
+ from odoo.exceptions import UserError
+ job, step1, step2 = self._make_two_step_chain(qty=2)
+ wiz = self.env['fp.job.step.move.wizard'].create({
+ 'job_id': job.id,
+ 'from_step_id': step1.id,
+ 'to_step_id': step2.id,
+ 'qty_moved': 0,
+ 'transfer_type':'step',
+ })
+ with self.assertRaises(UserError) as exc:
+ wiz.action_commit()
+ self.assertIn('at least 1', str(exc.exception))
+```
+
+- [ ] **Step 3: Bump fusion_plating_jobs manifest version**
+
+```bash
+ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.8.19.7'/'version': '19.0.8.19.8'/\\\" /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py\""
+```
+
+- [ ] **Step 4: Validate XML + Python**
+
+```bash
+ssh pve-worker5 "pct exec 111 -- bash -c \"python3 -c 'import xml.etree.ElementTree as ET; ET.parse(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/views/fp_job_form_inherit.xml\\\"); print(\\\"xml OK\\\")' && python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py\\\").read()); print(\\\"py OK\\\")'\""
+```
+
+Expected: `xml OK`, `py OK`.
+
+- [ ] **Step 5: Upgrade fusion_plating_jobs + run tests**
+
+```bash
+ssh pve-worker5 "pct exec 111 -- bash -c \"systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | grep -E 'TestQtyGate.*(FAIL|ERROR)' | head -10 && systemctl start odoo\""
+```
+
+Expected: 0 lines (no failures in `TestQtyGate`).
+
+---
+
+## Task 6: End-to-end smoke test on entech
+
+**Files:** none (verification via odoo shell + browser).
+
+- [ ] **Step 1: Create a 3-step recipe job with qty=2**
+
+```bash
+SCRIPT='partner = env["res.partner"].create({"name": "QtyGate Smoke"})
+prod = env["product.product"].create({"name": "QtyGateProd"})
+job = env["fp.job"].create({"partner_id": partner.id, "product_id": prod.id, "qty": 2})
+step1 = env["fp.job.step"].create({"job_id": job.id, "name": "S1-Plate", "sequence": 10, "state": "in_progress"})
+step1.date_started = fields.Datetime.now()
+step2 = env["fp.job.step"].create({"job_id": job.id, "name": "S2-Bake", "sequence": 20, "state": "ready"})
+step3 = env["fp.job.step"].create({"job_id": job.id, "name": "S3-Inspect", "sequence": 30, "state": "ready"})
+job.invalidate_recordset()
+print(">>> JOB_ID=", job.id)
+print(">>> JOB_NAME=", job.name)
+print(">>> DISPLAY_NAME=", job.display_name)
+print(">>> step1.qty_at_step=", step1.qty_at_step)
+env.cr.commit()'
+B64=$(echo -n "$SCRIPT" | base64 -w0)
+ssh pve-worker5 "pct exec 111 -- bash -c \"echo $B64 | base64 -d > /tmp/smoke_qty.py && su - odoo -s /bin/bash -c '/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http < /tmp/smoke_qty.py' 2>&1 | grep '>>>'\""
+```
+
+Expected:
+```
+>>> JOB_ID=
+>>> JOB_NAME= WH/JOB/00xxx
+>>> DISPLAY_NAME= Work Order # 00xxx
+>>> step1.qty_at_step= 2
+```
+
+Note JOB_ID for later steps.
+
+- [ ] **Step 2: Try to finish step1 — must be blocked**
+
+```bash
+SCRIPT='from odoo.exceptions import UserError
+step1 = env["fp.job"].browse().step_ids.filtered(lambda s: s.name == "S1-Plate")
+try:
+ step1.button_finish()
+ print(">>> RESULT: no error (unexpected)")
+except UserError as e:
+ print(">>> RESULT: blocked,", str(e)[:120])'
+```
+
+Run the script (substituting JOB_ID). Expected:
+```
+>>> RESULT: blocked, Step 'S1-Plate' still has 2 part(s) parked — move them to the next step before finishing...
+```
+
+- [ ] **Step 3: Use action_complete_one_to_next to drain step1**
+
+```bash
+SCRIPT='step1 = env["fp.job"].browse().step_ids.filtered(lambda s: s.name == "S1-Plate")
+step1.action_complete_one_to_next()
+step1.invalidate_recordset(["qty_at_step"])
+print(">>> step1.state=", step1.state, "qty_at_step=", step1.qty_at_step)
+step2 = env["fp.job"].browse().step_ids.filtered(lambda s: s.name == "S2-Bake")
+step2.invalidate_recordset(["qty_at_step"])
+print(">>> step2.state=", step2.state, "qty_at_step=", step2.qty_at_step)
+env.cr.commit()'
+```
+
+Expected after first call:
+```
+>>> step1.state= in_progress qty_at_step= 1
+>>> step2.state= ready qty_at_step= 0
+```
+
+(Step2 stays `ready` because step1 still has 1 part — step1 isn't done yet.)
+
+- [ ] **Step 4: Complete the second part — auto-finish**
+
+```bash
+SCRIPT='step1 = env["fp.job"].browse().step_ids.filtered(lambda s: s.name == "S1-Plate")
+step1.action_complete_one_to_next()
+step1.invalidate_recordset()
+step2 = env["fp.job"].browse().step_ids.filtered(lambda s: s.name == "S2-Bake")
+step2.invalidate_recordset()
+print(">>> step1.state=", step1.state)
+print(">>> step2.state=", step2.state, "qty_at_step=", step2.qty_at_step)
+env.cr.commit()'
+```
+
+Expected:
+```
+>>> step1.state= done
+>>> step2.state= in_progress qty_at_step= 2
+```
+
+(step2 now has both parts; auto-finish + auto-start fired on the last `Complete 1 → Next` call.)
+
+- [ ] **Step 5: Open the job in browser, verify the header label**
+
+Navigate to `https://enplating.com/odoo` → open the smoke job. Verify:
+- Form header reads **"Work Order # 00xxx"** (not WH/JOB/00xxx).
+- Step1 row no longer shows the "Complete 1 → Next" button (state=done).
+- Step2 row DOES show "Complete 1 → Next" (state=in_progress, qty_at_step > 0).
+
+- [ ] **Step 6: Clean up smoke data**
+
+```bash
+SCRIPT='job = env["fp.job"].browse()
+if job.exists():
+ env["fp.job.step.move"].search([("job_id", "=", job.id)]).sudo().unlink()
+ job.step_ids.sudo().unlink()
+ job.sudo().unlink()
+env["res.partner"].search([("name", "=", "QtyGate Smoke")]).sudo().unlink()
+env["product.product"].search([("name", "=", "QtyGateProd")]).sudo().unlink()
+env.cr.commit()
+print(">>> cleanup done")'
+```
+
+---
+
+## Task 7: Sync touched files back to local repo + commit
+
+**Files:**
+- `K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/models/fp_job.py`
+- `K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml`
+- `K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`
+- `K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/__manifest__.py`
+- `K:/Github/Odoo-Modules/fusion_plating/fusion_plating/models/fp_job_step.py`
+- `K:/Github/Odoo-Modules/fusion_plating/fusion_plating/__manifest__.py`
+
+- [ ] **Step 1: Pull each touched file from entech to local repo**
+
+```bash
+ssh pve-worker5 "pct exec 111 -- bash -c 'cat /mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py'" > K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/models/fp_job.py
+ssh pve-worker5 "pct exec 111 -- bash -c 'cat /mnt/extra-addons/custom/fusion_plating_jobs/views/fp_job_form_inherit.xml'" > K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml
+ssh pve-worker5 "pct exec 111 -- bash -c 'cat /mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py'" > K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py
+ssh pve-worker5 "pct exec 111 -- bash -c 'cat /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py'" > K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/__manifest__.py
+ssh pve-worker5 "pct exec 111 -- bash -c 'cat /mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py'" > K:/Github/Odoo-Modules/fusion_plating/fusion_plating/models/fp_job_step.py
+ssh pve-worker5 "pct exec 111 -- bash -c 'cat /mnt/extra-addons/custom/fusion_plating/__manifest__.py'" > K:/Github/Odoo-Modules/fusion_plating/fusion_plating/__manifest__.py
+```
+
+- [ ] **Step 2: Review diff**
+
+```bash
+cd K:/Github/Odoo-Modules && git diff --stat fusion_plating/fusion_plating_jobs/ fusion_plating/fusion_plating/
+```
+
+Expected: ~6 files changed, additions concentrated in `fp_job_step.py` (button_finish gate + action_complete_one_to_next + _fp_record_one_piece_auto_move + wiring), `fp_job.py` (_compute_display_name), and `test_fp_job_milestone_cascade.py` (14 new tests).
+
+- [ ] **Step 3: Stage + commit**
+
+```bash
+cd K:/Github/Odoo-Modules && git add fusion_plating/fusion_plating_jobs/ fusion_plating/fusion_plating/ && git commit -m "$(cat <<'EOF'
+feat(jobs): step qty gate + partial-qty + display rename
+
+Three coupled shop-floor corrections:
+- fp.job.step.button_finish: refuses if qty_at_step > 0 AND a
+ downstream pending/ready step exists. Last runnable step is
+ exempt (parts complete in place). Manager bypass via
+ fp_skip_qty_gate=True context key.
+- fp.job.step.action_complete_one_to_next: per-row "Complete
+ 1 -> Next" button. Records move(qty=1) to next step; if that
+ drains qty_at_step to 0, auto-finishes source + auto-starts
+ destination via existing action_finish_and_advance.
+- fp.job.step._fp_record_one_piece_auto_move: auto-move shim
+ wired into action_finish_and_advance. qty=1 + downstream =>
+ silently record move(1). qty>1 + downstream => raise pointing
+ at Complete 1 -> Next. Last step always allowed.
+- fp.job._compute_display_name: renders "Work Order # 00011"
+ in form header, breadcrumbs, M2O dropdowns, error messages.
+ DB name stays as WH/JOB/00011 - existing refs unchanged.
+- 14 new TestQtyGate tests covering gate / shim / auto-finish /
+ last-step exemption / display rename / Move wizard zero-qty.
+
+Spec: docs/superpowers/specs/2026-05-12-step-qty-gate-and-display-rename-design.md
+Plan: docs/superpowers/plans/2026-05-12-step-qty-gate-and-display-rename.md
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+- [ ] **Step 4: Push (optional)**
+
+```bash
+cd K:/Github/Odoo-Modules && git push origin main
+```
+
+---
+
+## Self-review notes
+
+- **Spec coverage:** Architecture sections 1–5 map to Tasks 1, 2, 3, 4, 5. State diagram entries are each covered by a dedicated test. Out-of-scope items (qty_done auto-tick, per-step scrap, cert PDF audit) are explicitly NOT in any task.
+- **Placeholder scan:** Two `` placeholders in Task 6 are cross-step substitutions (the engineer reads the value from Step 1's output). All code blocks are complete; no "TBD" or "...similar to..." references.
+- **Type consistency:** `action_complete_one_to_next` / `_fp_record_one_piece_auto_move` / `button_finish` all reference the same field names (`qty_at_step`, `state`, `sequence`, `job_id`, `step_ids`). The auto-move-shim's call site in `action_finish_and_advance` matches the helper's signature (no arguments, returns bool that the caller ignores). Test `TestQtyGate.setUpClass` matches the test method's `self.partner`, `self.product` references.
+- **Field invalidation:** Every test that creates a Move and then checks `qty_at_step` calls `invalidate_recordset(['qty_at_step'])` first. Inside `action_complete_one_to_next` itself, the same invalidate is performed before the auto-finish check. The spec's "implementation notes" callout matches the tests.