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:
gsinghpal
2026-05-03 22:49:01 -04:00
parent 2f8db6d592
commit e37eab9f23
2 changed files with 68 additions and 81 deletions

View File

@@ -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.',

View File

@@ -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):