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) <noreply@anthropic.com>
17 KiB
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:
- Display rename: show
Work Order # 00011everywhere a job appears to humans, while keepingname = "WH/JOB/00011"as the stable DB identifier. - 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. - 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.
@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 <h1> 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.)
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
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 == 1AND there's a next pending/ready step → record amove(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.
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:
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):
<!-- Complete 1 part and advance — streaming flow (large parts
going one-by-one through the same step). Hidden when there's
nothing parked or the step isn't actively running. -->
<button name="action_complete_one_to_next" type="object"
string="Complete 1 → Next" icon="fa-forward"
class="btn-link text-success"
invisible="state != 'in_progress' or qty_at_step < 1"/>
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 <sheet> block, change the <h1> to bind display_name:
<h1><field name="display_name"/></h1>
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 <h1> → 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_donewhen last step finishes. Currentlyqty_doneis operator-entered before the job-level "Mark Job Done" button. A future improvement: when the last runnable step finishes withqty_at_step > 0, automatically bumpjob.qty_doneby that count. Skipped from Phase 1 because (a) the existing job-level qty-reconciliation gate already catches mismatches and (b) it requires capturing pre-finishqty_at_stepinto the existing-but-unusedqty_at_step_finishfield, 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_stepto 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_nameautomatically threads through Odoo's M2O rendering, but the CoC PDF template may hardcodenamein 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_stepiscompute=False, store=False. After creating a Move inaction_complete_one_to_next, the in-memory cache still holds the pre-move value. Always callinvalidate_recordset(['qty_at_step'])before reading it to decide auto-finish.- The Move wizard's existing zero-qty guard lives in
action_commit(raisesUserError). The newaction_complete_one_to_nextdoesn't go through the wizard, so it has its ownqty_at_step < 1check (gates differently — refuses when nothing to move, vs. refusing when qty entered is 0). Both surfaces are now protected. display_nameis a magic field in Odoo — overriding its compute is the supported pattern. Odoo's M2O widget, breadcrumb, andname_getAPI all route through it. No additional wiring needed.