Files
Odoo-Modules/fusion-plating/docs/superpowers/specs/2026-05-12-step-qty-gate-and-display-rename-design.md
gsinghpal f4c41de91c 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) <noreply@anthropic.com>
2026-05-11 23:07:24 -04:00

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:

  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.

@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 == 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.

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 &lt; 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_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 PDFsdisplay_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.