diff --git a/fusion-plating/docs/superpowers/specs/2026-05-12-step-qty-gate-and-display-rename-design.md b/fusion-plating/docs/superpowers/specs/2026-05-12-step-qty-gate-and-display-rename-design.md
new file mode 100644
index 00000000..0c5a798f
--- /dev/null
+++ b/fusion-plating/docs/superpowers/specs/2026-05-12-step-qty-gate-and-display-rename-design.md
@@ -0,0 +1,294 @@
+# Step Quantity Gate, Partial-Qty Handling, and Job Display Rename
+
+**Date:** 2026-05-12
+**Status:** Approved for implementation
+**Scope:** `fusion_plating`, `fusion_plating_jobs` (on entech)
+
+## Goal
+
+Three coupled shop-floor corrections on `fp.job` / `fp.job.step`:
+
+1. **Display rename:** show `Work Order # 00011` everywhere a job appears to humans, while keeping `name = "WH/JOB/00011"` as the stable DB identifier.
+2. **Quantity gate on `button_finish`:** prevent a step from being marked Done while parts are still parked at it. The current implementation has no quantity check, which is how an operator can produce the "all steps Done, qty_done=0" state visible in production.
+3. **Partial-quantity flow:** add a per-row "Complete 1 → Next" action so streaming (large parts moving one-by-one through the same step) is a single click per part. Keep the Move wizard for batched (sub-batch) flow. Keep "Finish & Next" working for the 1-of-1 case via a transparent auto-move shim.
+
+## Motivation
+
+The current state observed in production (job `WH/JOB/00011`, `qty=1`, `qty_done=0`, 11 steps all `Done`) shows the data integrity problem: `fp.job.step.button_finish()` checks only `state == 'in_progress'`. No quantity validation. The user can click Finish on every step regardless of whether parts physically moved through. The job-level `button_mark_done` catches the qty discrepancy at the very end, but by then the per-step audit trail is already a fiction.
+
+Real shop floors run three flows on the same job model:
+
+| Flow | Example | Operator UX needed |
+|---|---|---|
+| **1-of-1** | One large valve body, qty=1 | One click: Finish & Next (auto-moves the 1 part) |
+| **Streaming** | 10 large parts going one-by-one through the same plating tank | One click per part: Complete 1 → Next |
+| **Batched** | 50 small parts going through in groups of 10 | Move wizard for each chunk, then Finish |
+
+The data model (`fp.job.step.move` records, `qty_at_step` compute) already supports all three. What's missing is the gate plus a first-class shortcut for streaming.
+
+## Decisions
+
+| Decision | Choice | Rationale |
+|---|---|---|
+| Job rename mechanism | Override `display_name` via compute; leave `name` untouched | DB identifier stable; old references in chatter/certs/deliveries don't break; rollback is one line |
+| Quantity gate scope | `qty_at_step > 0` blocks `button_finish` | Catches the bug at the right layer; manager bypass via context |
+| Partial qty UX | Move-driven (Option A from brainstorming) | Maps cleanly to all three flows with one click per natural unit of work |
+| Streaming shortcut | New `action_complete_one_to_next` row button | First-class action for the one-by-one case; no wizard ceremony |
+| 1-of-1 shortcut | Auto-move shim on existing `action_finish_current_step` + `action_finish_and_advance` | Keeps the single-click UX; transparently records the move |
+| Move wizard zero-qty | Already guarded (`qty_moved <= 0` raises) | Verify with a test; no code change needed |
+| Manager force-complete | Stays bypass-by-design (already skips `button_finish`) | Manager use-case is "this step was done outside ERP" — no qty in ERP to validate |
+
+## Architecture
+
+### 1. `fp.job.display_name` compute
+
+Single override on `fp.job`. No model change beyond adding a computed method.
+
+```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 ''
+```
+
+View change: the form `
` binds `display_name` instead of `name`. Everywhere else Odoo uses `display_name` automatically — M2O widgets, kanban titles, list views, breadcrumbs.
+
+### 2. Quantity gate on `fp.job.step.button_finish`
+
+The gate only fires when there's a *downstream* step parts could move into. The **last runnable step** of a recipe is allowed to finish with parts here — they complete the recipe in place. (`qty_done` reconciliation at job close is unchanged for Phase 1; see Out of Scope.)
+
+```python
+def button_finish(self):
+ """[existing docstring extended]
+
+ Quantity gate (new): refuses if qty_at_step > 0 AND there is at
+ least one downstream pending/ready step. The last runnable step
+ is exempt — parts finishing in place are valid. Manager bypass
+ via context key fp_skip_qty_gate=True.
+ """
+ skip_qty_gate = self.env.context.get('fp_skip_qty_gate')
+ for step in self:
+ if step.state != 'in_progress':
+ raise UserError(...) # existing
+ 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})
+ # No downstream step: this is the last runnable step.
+ # Parts finishing here become "done" with the recipe.
+ # ...remainder unchanged
+```
+
+### 3. New `fp.job.step.action_complete_one_to_next`
+
+```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 step and
+ starts the destination step (delegates to action_finish_and_advance,
+ which already handles auto-start)."""
+ 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 deciding
+ # whether this was the last part. Without invalidate the cache says
+ # "still 1 parked" and the auto-finish never fires.
+ self.invalidate_recordset(['qty_at_step'])
+ if self.qty_at_step == 0:
+ return self.action_finish_and_advance()
+ return True
+```
+
+### 4. Auto-move shim on `action_finish_current_step` + `action_finish_and_advance`
+
+Both methods finish "the current step" and (for the former) "auto-start the next". The shim adds:
+
+- **Before finishing:** if `qty_at_step == 1` AND there's a next pending/ready step → record a `move(qty=1)` to the next step, then proceed.
+- **If `qty_at_step > 1`:** raise with a friendly message pointing at "Complete 1 → Next" or "Move…".
+- **If `qty_at_step == 0`:** proceed as today (the parts already moved via Move wizard or Complete 1 → Next).
+
+The shim lives in `action_finish_and_advance` (on `fp.job.step`); `action_finish_current_step` (on `fp.job`) calls it, so it inherits the shim. Single point of behaviour.
+
+```python
+def _fp_record_one_piece_auto_move(self):
+ """Helper called from action_finish_and_advance. Decides whether
+ to silently record a move(qty=1) before the step finishes. Three
+ cases:
+ - qty_at_step == 0: nothing to do (parts already moved manually).
+ - qty_at_step == 1 + downstream step exists: record move(1).
+ - qty_at_step == 1 + no downstream (last step): no move; parts
+ complete in place.
+ - qty_at_step > 1 + downstream exists: raise (operator must use
+ Complete 1 → Next or Move… to drain the step).
+ - qty_at_step > 1 + no downstream (last step): allow; parts
+ all complete in place. (qty_done auto-tick is Phase 2.)
+ """
+ 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 here complete in place. The
+ # button_finish gate already permits this case; just allow.
+ 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 and next_step exists → record the 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
+```
+
+Wired into `action_finish_and_advance` immediately before the existing finish logic:
+
+```python
+def action_finish_and_advance(self):
+ self.ensure_one()
+ if self.state == 'in_progress':
+ self._fp_record_one_piece_auto_move() # may raise on qty>1
+ # ...rest unchanged (button_finish + auto-start next)
+```
+
+### 5. View additions
+
+In `fp_job_form_inherit.xml` (embedded step list):
+
+```xml
+
+
+```
+
+Placed in the row's button column, after "Pause" and before "Move…". The header `Finish & Next` button is unchanged in markup — the auto-move/qty-gate logic is entirely behind the existing button.
+
+In the form header `` block, change the `
` to bind `display_name`:
+
+```xml
+
+```
+
+`qty_at_step` is already a list column on the embedded step list (visible as "Qty Here"). No change needed for visibility — the existing field declaration is sufficient for the `invisible=` expression.
+
+## State transition diagram
+
+```
+Before this work:
+ in_progress ──button_finish──> done (no qty check)
+
+After:
+ any step, qty_at_step==0 ──button_finish──> done
+ mid-recipe step, qty_at_step==1 ──Finish & Next──> [auto-move(1)] ──> done
+ mid-recipe step, qty_at_step==1 ──Complete 1→Next──> [move(1)] ──> done + start_next
+ mid-recipe step, qty_at_step>1 ──Complete 1→Next──> [move(1)] (stays in_progress)
+ mid-recipe step, qty_at_step>1 ──Finish & Next──> ❌ UserError (use shortcuts)
+ LAST recipe step, qty_at_step>0 ──Finish & Next──> done (no move; parts complete in place)
+```
+
+"Mid-recipe step" = at least one downstream step is pending/ready. "LAST recipe step" = no downstream step in pending/ready state (either truly last, or all later steps are skipped/cancelled).
+
+## Test plan
+
+New class `TestQtyGate` in `tests/test_fp_job_milestone_cascade.py`:
+
+| Test | Scenario | Expected |
+|---|---|---|
+| `test_button_finish_blocks_when_qty_at_step` | qty_at_step=3, click Finish | `UserError("still 3 parts parked")` |
+| `test_button_finish_bypass` | `fp_skip_qty_gate=True` context | state→done |
+| `test_complete_one_to_next_records_move` | qty=3 → click | move(qty=1) created, qty_at_step=2, state still in_progress |
+| `test_complete_one_to_next_auto_finishes_on_last` | qty=1 → click | move(qty=1), source state→done, next step started |
+| `test_complete_one_to_next_blocks_when_empty` | qty=0 | `UserError("nothing to complete")` |
+| `test_complete_one_to_next_blocks_when_no_next_step` | last step | `UserError("last runnable step")` |
+| `test_complete_one_to_next_blocks_when_not_in_progress` | state=pending | `UserError("must be in progress")` |
+| `test_finish_and_advance_auto_move_for_qty_1` | running step, qty_at_step=1 | move(qty=1) recorded, then finish + auto-start next |
+| `test_finish_and_advance_blocks_for_qty_gt_1` | running step, qty_at_step=3 | `UserError("use Complete 1 → Next or Move")` |
+| `test_finish_and_advance_passes_for_qty_0` | qty=0 (already moved) | finish proceeds, no extra move |
+| `test_button_finish_allows_last_step_with_qty` | last runnable step, qty_at_step=3, click Finish | state→done; no UserError; no move recorded |
+| `test_finish_and_advance_allows_last_step_with_qty_gt_1` | last runnable step, qty_at_step=5 | state→done; no auto-move; no UserError |
+| `test_display_name_format` | name=`WH/JOB/00099` | display_name=`Work Order # 00099` |
+| `test_display_name_no_slash_passthrough` | name=`SmokeJob` | display_name=`SmokeJob` |
+| `test_move_wizard_blocks_zero_qty` | wizard.qty_moved=0 → commit | `UserError("at least 1")` |
+
+## Files touched
+
+| File | Change |
+|---|---|
+| `fusion_plating_jobs/models/fp_job.py` | Add `_compute_display_name` override. |
+| `fusion_plating/models/fp_job_step.py` | Quantity gate in `button_finish`; new `action_complete_one_to_next`; new helper `_fp_record_one_piece_auto_move` invoked from `action_finish_and_advance`. |
+| `fusion_plating_jobs/views/fp_job_form_inherit.xml` | Header `
` → `display_name`; per-row "Complete 1 → Next" button. |
+| `fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py` | New `TestQtyGate` class with the 13 tests above. |
+| `fusion_plating_jobs/__manifest__.py` | Version bump. |
+| `fusion_plating/__manifest__.py` | Version bump (touches `fp_job_step.py`). |
+
+## Out of scope
+
+- **Auto-tick `job.qty_done` when last step finishes.** Currently `qty_done` is operator-entered before the job-level "Mark Job Done" button. A future improvement: when the last runnable step finishes with `qty_at_step > 0`, automatically bump `job.qty_done` by that count. Skipped from Phase 1 because (a) the existing job-level qty-reconciliation gate already catches mismatches and (b) it requires capturing pre-finish `qty_at_step` into the existing-but-unused `qty_at_step_finish` field, which expands scope.
+- **Per-step scrap tracking** — currently scrap is captured at the *job* level (`qty_scrapped`). Per-step scrap (which step did each scrap event happen at?) is a real shop-floor desire but a bigger data-model change; future spec.
+- **Auto-finish on Move wizard's last move** — when the Move wizard records a move that drops `qty_at_step` to 0, it could optionally auto-finish the source step. Skipped because the Move wizard is already explicit (operator chose a qty); an extra confirmation step adds value. Can reconsider if the manual Finish click after a manual Move becomes a friction complaint.
+- **Display name in CoC / cert PDFs** — `display_name` automatically threads through Odoo's M2O rendering, but the CoC PDF template may hardcode `name` in places. Audit pass in a follow-up if/when shop reports the new label needs to land on customer-facing paperwork.
+
+## Implementation notes / gotchas
+
+- `qty_at_step` is `compute=False, store=False`. After creating a Move in `action_complete_one_to_next`, the in-memory cache still holds the pre-move value. Always call `invalidate_recordset(['qty_at_step'])` before reading it to decide auto-finish.
+- The Move wizard's existing zero-qty guard lives in `action_commit` (raises `UserError`). The new `action_complete_one_to_next` doesn't go through the wizard, so it has its own `qty_at_step < 1` check (gates differently — refuses when nothing to move, vs. refusing when qty entered is 0). Both surfaces are now protected.
+- `display_name` is a magic field in Odoo — overriding its compute is the supported pattern. Odoo's M2O widget, breadcrumb, and `name_get` API all route through it. No additional wiring needed.