fix(jobs): Sub 13 gate was DEAD due to duplicate button_start
User reproduced on WH/JOB/00342: clicked Start on Incoming Inspection
while Contract Review was still in_progress. Sub 13 should have raised
UserError. It didn't. Both steps ended up in_progress.
Investigation:
$ grep "def button_start" fusion_plating_jobs/models/fp_job_step.py
88: def button_start(self): ← Sub 13 gate code
876: def button_start(self): ← Policy B + Sub 8 (older)
Two definitions of the same method in the same class. Python uses the
SECOND. My Sub 13 gate at line 88 was dead code from the moment it
landed. WH/JOB/00342's Contract Review and Incoming Inspection both
ran in_progress because the live button_start (line 876) only did
Policy B Contract Review auto-open and Sub 8 Racking auto-open — no
predecessor check.
Fix:
* Removed the duplicate button_start at line 88 (left a marker
comment so the next person doesn't redo this footgun)
* Merged the Sub 13 predecessor gate AND the receiving soft check
into the line-876 button_start so all four behaviours run from
one method:
1. Predecessor gate (raise UserError if blocking)
2. Contract Review auto-open (route to QA-005)
3. Racking auto-open (route to inspection)
4. super().button_start() + receiving check + serial promotion
Helpers _fp_should_block_predecessors / can_start / _compute_can_start
preserved (used by view + Move wizard too).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Native Jobs',
|
'name': 'Fusion Plating — Native Jobs',
|
||||||
'version': '19.0.8.17.2',
|
'version': '19.0.8.17.3',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||||
'author': 'Nexa Systems Inc.',
|
'author': 'Nexa Systems Inc.',
|
||||||
|
|||||||
@@ -85,84 +85,12 @@ class FpJobStep(models.Model):
|
|||||||
)
|
)
|
||||||
step.can_start = not bool(blocking)
|
step.can_start = not bool(blocking)
|
||||||
|
|
||||||
def button_start(self):
|
# NOTE: the actual button_start override lives further down (~line
|
||||||
"""Override — soft gate when parts haven't been received yet,
|
# 876) where it merges Sub 13 predecessor gate + Policy B Contract
|
||||||
plus hard predecessor gate driven by the recipe + per-step
|
# Review auto-open + Sub 8 Racking auto-open + the receiving soft
|
||||||
sequential enforcement policy (Sub 13).
|
# check. Keeping ONE definition prevents the Python-overrides-the-
|
||||||
|
# earlier-method footgun that swallowed Sub 13 enforcement on
|
||||||
Receiving check is soft (logs to chatter) — manager wants the
|
# WH/JOB/00342.
|
||||||
shop to start prep regardless when parts are in-transit late.
|
|
||||||
|
|
||||||
Predecessor check IS hard-blocking — every earlier-sequence
|
|
||||||
step must be terminal (done / skipped / cancelled) before Start
|
|
||||||
fires, UNLESS:
|
|
||||||
* this step is flagged parallel_start (explicit per-step opt-out), OR
|
|
||||||
* the parent recipe has enforce_sequential=False AND this step
|
|
||||||
is not flagged requires_predecessor_done (legacy free-flow).
|
|
||||||
|
|
||||||
Manager bypass via fp_skip_predecessor_check=True.
|
|
||||||
"""
|
|
||||||
skip_pred = self.env.context.get('fp_skip_predecessor_check')
|
|
||||||
for step in self:
|
|
||||||
if skip_pred:
|
|
||||||
continue
|
|
||||||
if not step._fp_should_block_predecessors():
|
|
||||||
continue
|
|
||||||
blocking = step.job_id.step_ids.filtered(
|
|
||||||
lambda s: s.sequence < step.sequence and s.state not in (
|
|
||||||
'done', 'skipped', 'cancelled',
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if blocking:
|
|
||||||
raise UserError(_(
|
|
||||||
"Step '%s' cannot start until earlier steps are "
|
|
||||||
"finished, skipped, or cancelled.\n\nBlocking step(s):\n %s\n\n"
|
|
||||||
"Options:\n"
|
|
||||||
" * Finish/Skip the blocking step(s) first.\n"
|
|
||||||
" * If this step legitimately runs in parallel, ask "
|
|
||||||
"a manager to flag it as Parallel Start on the recipe.\n"
|
|
||||||
" * Manager override via context fp_skip_predecessor_check=True."
|
|
||||||
) % (
|
|
||||||
step.name,
|
|
||||||
'\n '.join(
|
|
||||||
f'#{s.sequence} {s.name} ({s.state})'
|
|
||||||
for s in blocking[:5]
|
|
||||||
),
|
|
||||||
))
|
|
||||||
result = super().button_start()
|
|
||||||
for step in self:
|
|
||||||
so = step.job_id.sale_order_id
|
|
||||||
if not so:
|
|
||||||
continue
|
|
||||||
recv = so.x_fc_receiving_status if (
|
|
||||||
'x_fc_receiving_status' in so._fields
|
|
||||||
) else None
|
|
||||||
if recv in (False, None, 'not_received'):
|
|
||||||
step.job_id.message_post(body=_(
|
|
||||||
'Step "%(step)s" started before parts were received '
|
|
||||||
'(SO %(so)s — receiving status: %(status)s). '
|
|
||||||
'Confirm the parts are physically on the floor before '
|
|
||||||
'continuing.'
|
|
||||||
) % {
|
|
||||||
'step': step.name,
|
|
||||||
'so': so.name or '',
|
|
||||||
'status': recv or 'unknown',
|
|
||||||
})
|
|
||||||
|
|
||||||
# Sub 12e v4 — when an operator starts a contract_review step,
|
|
||||||
# immediately route to the QA-005 form. The step stays
|
|
||||||
# in_progress in the background; the operator signs (or
|
|
||||||
# dismisses) the review on QA-005, navigates back to the job,
|
|
||||||
# then clicks Finish & Next to advance. This removes the
|
|
||||||
# earlier "click Start, then click Finish & Next, then maybe
|
|
||||||
# click Finish & Next again" friction.
|
|
||||||
# Single-record only — multi-record button_start (e.g. job
|
|
||||||
# bulk-start) shouldn't navigate.
|
|
||||||
if len(self) == 1:
|
|
||||||
cr_action = self._fp_contract_review_redirect()
|
|
||||||
if cr_action:
|
|
||||||
return cr_action
|
|
||||||
return result
|
|
||||||
|
|
||||||
def button_pause(self):
|
def button_pause(self):
|
||||||
"""Pause an in-progress step (operator break, end of shift).
|
"""Pause an in-progress step (operator break, end of shift).
|
||||||
@@ -874,7 +802,46 @@ class FpJobStep(models.Model):
|
|||||||
})
|
})
|
||||||
|
|
||||||
def button_start(self):
|
def button_start(self):
|
||||||
# Policy B — Contract Review takes priority (auto-opens QA-005).
|
"""Single source of truth for step start:
|
||||||
|
1. Sub 13 predecessor gate (raise UserError if blocking)
|
||||||
|
2. Policy B Contract Review auto-open (route to QA-005)
|
||||||
|
3. Sub 8 Racking auto-open (route to racking inspection)
|
||||||
|
4. super().button_start() + receiving soft check + serial
|
||||||
|
promotion for the standard path
|
||||||
|
|
||||||
|
Manager bypasses available via context:
|
||||||
|
fp_skip_predecessor_check=True skips the Sub 13 gate
|
||||||
|
"""
|
||||||
|
# ---- 1. Sub 13 predecessor gate ----------------------------------
|
||||||
|
skip_pred = self.env.context.get('fp_skip_predecessor_check')
|
||||||
|
for step in self:
|
||||||
|
if skip_pred:
|
||||||
|
continue
|
||||||
|
if not step._fp_should_block_predecessors():
|
||||||
|
continue
|
||||||
|
blocking = step.job_id.step_ids.filtered(
|
||||||
|
lambda s: s.sequence < step.sequence and s.state not in (
|
||||||
|
'done', 'skipped', 'cancelled',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if blocking:
|
||||||
|
raise UserError(_(
|
||||||
|
"Step '%s' cannot start until earlier steps are "
|
||||||
|
"finished, skipped, or cancelled.\n\nBlocking step(s):\n %s\n\n"
|
||||||
|
"Options:\n"
|
||||||
|
" * Finish/Skip the blocking step(s) first.\n"
|
||||||
|
" * If this step legitimately runs in parallel, ask "
|
||||||
|
"a manager to flag it as Parallel Start on the recipe.\n"
|
||||||
|
" * Manager override via context fp_skip_predecessor_check=True."
|
||||||
|
) % (
|
||||||
|
step.name,
|
||||||
|
'\n '.join(
|
||||||
|
f'#{s.sequence} {s.name} ({s.state})'
|
||||||
|
for s in blocking[:5]
|
||||||
|
),
|
||||||
|
))
|
||||||
|
|
||||||
|
# ---- 2. Policy B Contract Review auto-open -----------------------
|
||||||
for step in self:
|
for step in self:
|
||||||
if step._fp_is_contract_review_step():
|
if step._fp_is_contract_review_step():
|
||||||
action = step._fp_open_contract_review()
|
action = step._fp_open_contract_review()
|
||||||
@@ -883,7 +850,8 @@ class FpJobStep(models.Model):
|
|||||||
if step.state == 'in_progress':
|
if step.state == 'in_progress':
|
||||||
step._fp_promote_serials_on_start()
|
step._fp_promote_serials_on_start()
|
||||||
return action
|
return action
|
||||||
# Sub 8 — Racking step auto-opens the inspection form.
|
|
||||||
|
# ---- 3. Sub 8 Racking auto-open ----------------------------------
|
||||||
for step in self:
|
for step in self:
|
||||||
if step._fp_is_racking_step():
|
if step._fp_is_racking_step():
|
||||||
action = step._fp_open_racking_inspection()
|
action = step._fp_open_racking_inspection()
|
||||||
@@ -892,10 +860,29 @@ class FpJobStep(models.Model):
|
|||||||
if step.state == 'in_progress':
|
if step.state == 'in_progress':
|
||||||
step._fp_promote_serials_on_start()
|
step._fp_promote_serials_on_start()
|
||||||
return action
|
return action
|
||||||
|
|
||||||
|
# ---- 4. Standard path: start + receiving check + serial promote --
|
||||||
result = super().button_start()
|
result = super().button_start()
|
||||||
for step in self:
|
for step in self:
|
||||||
if step.state == 'in_progress':
|
if step.state == 'in_progress':
|
||||||
step._fp_promote_serials_on_start()
|
step._fp_promote_serials_on_start()
|
||||||
|
so = step.job_id.sale_order_id
|
||||||
|
if not so:
|
||||||
|
continue
|
||||||
|
recv = so.x_fc_receiving_status if (
|
||||||
|
'x_fc_receiving_status' in so._fields
|
||||||
|
) else None
|
||||||
|
if recv in (False, None, 'not_received'):
|
||||||
|
step.job_id.message_post(body=_(
|
||||||
|
'Step "%(step)s" started before parts were received '
|
||||||
|
'(SO %(so)s — receiving status: %(status)s). '
|
||||||
|
'Confirm the parts are physically on the floor before '
|
||||||
|
'continuing.'
|
||||||
|
) % {
|
||||||
|
'step': step.name,
|
||||||
|
'so': so.name or '',
|
||||||
|
'status': recv or 'unknown',
|
||||||
|
})
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def button_finish(self):
|
def button_finish(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user