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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user