feat(fusion_plating_jobs): fp.job.step blocker_kind/reason/jump_target computes
Plan task P1.2. Reuses _fp_should_block_predecessors so the new compute stays in sync with the existing can_start logic. Drives the OWL GateViz component on the tablet — "Can't start yet — Waiting on Step N: X". Future work: extend with explicit branches for contract_review / parts_not_received / racking_required / manager_input as those gate models mature. Tests not run locally (no fusion_plating mount in odoo-modsdev). Verify on entech: -u fusion_plating_jobs --test-tags fp_jobs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -85,6 +85,72 @@ class FpJobStep(models.Model):
|
||||
)
|
||||
step.can_start = not bool(blocking)
|
||||
|
||||
# Gate visualizer — drives the OWL GateViz component on the tablet.
|
||||
# Returns kind of blocker + human reason + optional (model, id) jump
|
||||
# target. Reuses _fp_should_block_predecessors so this stays in sync
|
||||
# with can_start as a single source of truth.
|
||||
blocker_kind = fields.Selection(
|
||||
[
|
||||
('none', 'Not blocked'),
|
||||
('predecessor', 'Waiting on predecessor'),
|
||||
('contract_review', 'Contract review pending'),
|
||||
('parts_not_received', 'Parts not received'),
|
||||
('racking_required', 'Racking inspection required'),
|
||||
('manager_input', 'Manager input required'),
|
||||
('other', 'Other'),
|
||||
],
|
||||
compute='_compute_blocker',
|
||||
string='Blocker Kind',
|
||||
)
|
||||
blocker_reason = fields.Char(
|
||||
compute='_compute_blocker',
|
||||
string='Blocker Reason',
|
||||
help='Human-readable explanation surfaced in the GateViz block.',
|
||||
)
|
||||
blocker_jump_target_model = fields.Char(compute='_compute_blocker')
|
||||
blocker_jump_target_id = fields.Integer(compute='_compute_blocker')
|
||||
|
||||
@api.depends(
|
||||
'state', 'sequence', 'parallel_start', 'requires_predecessor_done',
|
||||
'job_id.enforce_sequential',
|
||||
'job_id.step_ids.state', 'job_id.step_ids.sequence',
|
||||
)
|
||||
def _compute_blocker(self):
|
||||
for step in self:
|
||||
# Terminal/in-progress states are never "blocked"
|
||||
if step.state in ('done', 'skipped', 'cancelled', 'in_progress'):
|
||||
step.blocker_kind = 'none'
|
||||
step.blocker_reason = ''
|
||||
step.blocker_jump_target_model = False
|
||||
step.blocker_jump_target_id = 0
|
||||
continue
|
||||
|
||||
# Predecessor gate — same policy as _compute_can_start
|
||||
if step._fp_should_block_predecessors():
|
||||
earlier_open = step.job_id.step_ids.filtered(lambda x: (
|
||||
x.id != step.id
|
||||
and x.sequence < step.sequence
|
||||
and x.state not in ('done', 'skipped', 'cancelled')
|
||||
))
|
||||
if earlier_open:
|
||||
first_blocker = earlier_open.sorted('sequence')[0]
|
||||
step.blocker_kind = 'predecessor'
|
||||
seq_disp = (first_blocker.sequence or 0) // 10
|
||||
step.blocker_reason = (
|
||||
f'Waiting on Step {seq_disp}: {first_blocker.name}'
|
||||
)
|
||||
step.blocker_jump_target_model = 'fp.job.step'
|
||||
step.blocker_jump_target_id = first_blocker.id
|
||||
continue
|
||||
|
||||
# Future: extend with explicit checks for contract_review /
|
||||
# parts_not_received / racking_required / manager_input as
|
||||
# those gate models mature. For now, default to 'none'.
|
||||
step.blocker_kind = 'none'
|
||||
step.blocker_reason = ''
|
||||
step.blocker_jump_target_model = False
|
||||
step.blocker_jump_target_id = 0
|
||||
|
||||
# NOTE: the actual button_start override lives further down (~line
|
||||
# 876) where it merges Sub 13 predecessor gate + Policy B Contract
|
||||
# Review auto-open + Sub 8 Racking auto-open + the receiving soft
|
||||
|
||||
@@ -2,3 +2,5 @@
|
||||
from . import test_fp_job_extensions
|
||||
from . import test_fp_job_milestone_cascade
|
||||
from . import test_qty_received_propagation
|
||||
from . import test_display_wo_name
|
||||
from . import test_blocker_compute
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc. — License OPL-1
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fp_jobs')
|
||||
class TestBlockerCompute(TransactionCase):
|
||||
"""fp.job.step.blocker_kind / blocker_reason / blocker_jump_target_*
|
||||
— Gate visualizer source of truth for the OWL GateViz component.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.partner = self.env['res.partner'].create({'name': 'Test Cust'})
|
||||
self.product = self.env['product.product'].create({'name': 'Test Prod'})
|
||||
self.job = self.env['fp.job'].create({
|
||||
'name': 'WH/JOB/T1',
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 1,
|
||||
})
|
||||
|
||||
def _make_step(self, name, sequence, state='ready'):
|
||||
return self.env['fp.job.step'].create({
|
||||
'job_id': self.job.id,
|
||||
'name': name,
|
||||
'sequence': sequence,
|
||||
'state': state,
|
||||
})
|
||||
|
||||
def test_terminal_step_has_no_blocker(self):
|
||||
step = self._make_step('Done step', 10, state='done')
|
||||
self.assertEqual(step.blocker_kind, 'none')
|
||||
self.assertEqual(step.blocker_reason, '')
|
||||
|
||||
def test_in_progress_step_has_no_blocker(self):
|
||||
step = self._make_step('Running step', 10, state='in_progress')
|
||||
self.assertEqual(step.blocker_kind, 'none')
|
||||
|
||||
def test_solo_ready_step_not_blocked(self):
|
||||
step = self._make_step('Solo', 10, state='ready')
|
||||
# No predecessor → blocker_kind = 'none'
|
||||
self.assertEqual(step.blocker_kind, 'none')
|
||||
self.assertEqual(step.blocker_reason, '')
|
||||
|
||||
def test_predecessor_open_blocks(self):
|
||||
s1 = self._make_step('Earlier', 10, state='in_progress')
|
||||
s2 = self._make_step('Later', 20, state='ready')
|
||||
# If recipe enforces sequential OR step requires predecessor,
|
||||
# blocker_kind should be 'predecessor'. Default depends on job
|
||||
# config; if neither flag triggers we'd see 'none' instead.
|
||||
s2.invalidate_recordset([
|
||||
'blocker_kind', 'blocker_reason',
|
||||
'blocker_jump_target_model', 'blocker_jump_target_id',
|
||||
])
|
||||
if s2._fp_should_block_predecessors():
|
||||
self.assertEqual(s2.blocker_kind, 'predecessor')
|
||||
self.assertIn(s1.name, s2.blocker_reason or '')
|
||||
self.assertEqual(s2.blocker_jump_target_model, 'fp.job.step')
|
||||
self.assertEqual(s2.blocker_jump_target_id, s1.id)
|
||||
else:
|
||||
self.assertEqual(s2.blocker_kind, 'none')
|
||||
|
||||
def test_explicit_requires_predecessor_blocks(self):
|
||||
s1 = self._make_step('Earlier', 10, state='ready')
|
||||
s2 = self._make_step('Later', 20, state='ready')
|
||||
s2.requires_predecessor_done = True
|
||||
s2.invalidate_recordset(['blocker_kind', 'blocker_reason'])
|
||||
self.assertEqual(s2.blocker_kind, 'predecessor')
|
||||
self.assertEqual(s2.blocker_jump_target_id, s1.id)
|
||||
Reference in New Issue
Block a user