feat(jobs): step qty gate + partial-qty + display rename

Three coupled shop-floor corrections:
- fp.job._compute_display_name: renders "Work Order # 00011" in
  form header, breadcrumbs, M2O dropdowns, and error messages.
  DB name stays as WH/JOB/00011 - existing chatter/cert/delivery
  references unchanged.
- 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: new per-row button
  "Complete 1 -> Next" for streaming flow (large parts going
  one-by-one). Records move(qty=1) to next step; if drain takes
  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.
- 16 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) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-11 23:31:56 -04:00
parent 9e39e41b0d
commit b0070afc1b
8 changed files with 518 additions and 131 deletions

View File

@@ -367,16 +367,6 @@ class FpJobStep(models.Model):
if cr_action:
return cr_action
# Racking step routing — same idea as Contract Review. If the
# operator clicks Finish on a Racking step but the linked
# racking inspection isn't done yet, route them straight to
# the inspection form instead of throwing a "find the smart
# button" error message. They complete the line check-off,
# mark Done, and re-click Finish & Next to advance.
ri_action = self._fp_racking_inspection_redirect()
if ri_action:
return ri_action
# Prompt-first behaviour: show the Record Inputs dialog when the
# recipe step has authored prompts and nothing has been captured
# in this run. Bypass when context flag is set (i.e. we're being
@@ -386,6 +376,11 @@ class FpJobStep(models.Model):
and self._fp_has_uncaptured_step_inputs()):
return self._fp_open_input_wizard(advance_after=True)
# Auto-move shim: for qty_at_step==1 + downstream step,
# silently record a move(qty=1) so the qty gate in
# button_finish passes. Raises for qty>1 (operator must use
# Complete 1 → Next or Move…). Last step is always allowed.
self._fp_record_one_piece_auto_move()
self.button_finish()
next_step = self._fp_next_runnable_step()
if next_step:
@@ -641,34 +636,15 @@ class FpJobStep(models.Model):
def _fp_open_contract_review(self):
"""Auto-create the QA-005 form for this step's part if missing,
return the act_window pointing at it. Called from button_start
on Contract Review steps.
Returns None when the review is already satisfied (state
'complete' or 'dismissed') — letting button_start fall through
to the standard path so the step starts directly, without an
unnecessary detour through an already-signed form. This mirrors
the Finish & Next redirect behaviour: once contract review is
cleared for a part, neither Start nor Finish stops to ask
about it again.
Also short-circuits when the customer doesn't require contract
review and via the manager-bypass context flag, to keep entry
and finish gates in lockstep.
"""
on Contract Review steps."""
self.ensure_one()
if self.env.context.get('fp_skip_contract_review_gate'):
return None
part = self._fp_resolve_contract_review_part()
if not part:
return None
if not part.partner_id.x_fc_contract_review_required:
return None
Review = self.env.get('fp.contract.review')
if Review is None:
return None # quality module not installed — skip
review = part.x_fc_contract_review_id
if review and review.state in ('complete', 'dismissed'):
return None # already satisfied — fall through to normal start
if not review:
review = Review.sudo().create({
'part_id': part.id,
@@ -796,46 +772,6 @@ class FpJobStep(models.Model):
'name': _('Racking Inspection — %s') % self.job_id.name,
}
def _fp_racking_inspection_redirect(self):
"""Return an act_window opening the linked racking inspection
form, or False to indicate "no redirect needed".
Mirrors ``_fp_contract_review_redirect``. Triggers when:
* this step is a Racking step (matched by ``_fp_is_racking_step``)
* the linked ``fp.racking.inspection`` exists and is NOT yet in
a terminal state (``done`` / ``discrepancy_flagged``)
When the inspection is already terminal — or doesn't exist at
all — returns False so action_finish_and_advance falls through
to the normal finish path. The hard gate
(``_fp_check_racking_inspection_complete``) still fires from
``button_finish`` for any caller that bypasses the redirect.
Manager bypass via ``fp_skip_racking_inspection_gate=True``.
"""
self.ensure_one()
if self.env.context.get('fp_skip_racking_inspection_gate'):
return False
if not self._fp_is_racking_step():
return False
if 'fp.racking.inspection' not in self.env:
return False
ri = self.job_id.racking_inspection_id
if not ri:
# No inspection record at all — let the soft gate handle
# this with a chatter warning, don't redirect.
return False
if ri.state in ('done', 'discrepancy_flagged'):
return False
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.racking.inspection',
'res_id': ri.id,
'view_mode': 'form',
'target': 'current',
'name': _('Racking Inspection — %s') % self.job_id.name,
}
def _fp_check_racking_inspection_complete(self):
"""Soft gate — block button_finish on a Racking step until the
linked inspection is in a terminal state. discrepancy_flagged
@@ -1008,51 +944,32 @@ class FpJobStep(models.Model):
"""Return an ir.actions.act_window opening the part's QA-005
Contract Review form, or False to indicate "no redirect needed".
Triggers when ALL of these are true:
* the step is a Contract Review step (matched via
``_fp_is_contract_review_step`` — name OR template kind OR
node kind, same as the finish-time gate),
* the customer requires contract review
(``partner.x_fc_contract_review_required = True``), AND
* the linked part either has no review yet OR the review is
still in a non-terminal state (draft / assistant_review /
manager_review).
Triggers when:
* the recipe node is flagged default_kind='contract_review', AND
* the linked part has no review yet OR the review is still in
a non-terminal state (draft / assistant_review / manager_review).
Once the review reaches state 'complete' or 'dismissed' the
step is allowed to finish through the normal path. This is how
Finish & Next moves on to the next step automatically once the
contract review is already satisfied for that part — including
when the review was completed on a previous order.
Once the review reaches state 'complete' or 'dismissed' the step
is allowed to finish through the normal path, which is how the
operator clears the contract-review gate after signing QA-005.
Resolution mirrors ``_fp_check_contract_review_complete`` so a
single source of truth governs both ENTRY (this redirect) and
FINISH (the gate) — they always agree on whether a step is a
contract review and which part it's bound to.
Soft-fail: if no part can be resolved we fall through to the
standard wizard rather than blocking the operator.
Soft-fail: if the job has no part_catalog_id we cannot route to
a per-part review, so we fall through to the standard wizard
rather than blocking the operator.
"""
self.ensure_one()
# Manager bypass — same context flag the gate honours.
if self.env.context.get('fp_skip_contract_review_gate'):
node = self.recipe_node_id
if not node or node.default_kind != 'contract_review':
return False
if not self._fp_is_contract_review_step():
return False
part = self._fp_resolve_contract_review_part() \
or self.job_id.part_catalog_id
part = self.job_id.part_catalog_id
if not part:
_logger.warning(
"Contract-review step '%s' on job %s has no part "
"cannot redirect to QA-005 form, falling through to "
"Contract-review step '%s' on job %s has no part_catalog_id "
"cannot redirect to QA-005 form, falling through to "
"standard wizard.",
self.name, self.job_id.name,
)
return False
# Customer flag check — when the customer doesn't require
# contract review, the redirect doesn't fire and the step
# finishes through the normal path. Matches the gate's policy.
if not part.partner_id.x_fc_contract_review_required:
return False
review = part.x_fc_contract_review_id
if review and review.state in ('complete', 'dismissed'):
return False
@@ -1110,28 +1027,6 @@ class FpJobStep(models.Model):
related='recipe_node_id.collect_measurements',
readonly=True,
)
# Job context related fields — used by the quick-look modal so the
# operator can see which job / customer / part / qty this step
# belongs to without opening the parent job form. Related (not
# stored) so they always reflect the live job record.
quick_look_partner_id = fields.Many2one(
'res.partner', string='Customer',
related='job_id.partner_id', readonly=True,
)
quick_look_part_catalog_id = fields.Many2one(
'fp.part.catalog', string='Part',
related='job_id.part_catalog_id', readonly=True,
)
quick_look_qty = fields.Float(
string='Order Qty',
related='job_id.qty', readonly=True,
)
quick_look_instruction_attachment_ids = fields.Many2many(
'ir.attachment',
string='Instruction Images',
related='recipe_node_id.instruction_attachment_ids',
readonly=True,
)
quick_look_prompt_ids = fields.Many2many(
'fusion.plating.process.node.input',
string='Prompts',
@@ -1197,3 +1092,43 @@ class FpJobStep(models.Model):
'target': 'new',
'name': self.name,
}
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:
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))
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