From f4c41de91c1d9e179aa2aec263b3b71b16d30b9f Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Mon, 11 May 2026 23:07:24 -0400 Subject: [PATCH] docs: step qty gate + partial-qty + display rename design spec Three coupled shop-floor corrections: 1. Job display rename: WH/JOB/00011 -> Work Order # 00011 via display_name compute (name stays stable for DB refs) 2. Quantity gate on button_finish: refuses if qty_at_step > 0 AND there is a downstream pending/ready step (last step exempt) 3. Partial-qty UX: new action_complete_one_to_next per-row button for streaming flow; auto-move shim on Finish for 1-of-1; Move wizard unchanged (already has zero-qty + over-qty guards) Spec covers architecture, state transitions, test plan, files-touched matrix, and explicit Out of Scope (qty_done auto-tick, per-step scrap, cert PDF display). Co-Authored-By: Claude Opus 4.7 (1M context) --- ...step-qty-gate-and-display-rename-design.md | 294 ++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 fusion-plating/docs/superpowers/specs/2026-05-12-step-qty-gate-and-display-rename-design.md 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 + +