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)
|
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
|
# NOTE: the actual button_start override lives further down (~line
|
||||||
# 876) where it merges Sub 13 predecessor gate + Policy B Contract
|
# 876) where it merges Sub 13 predecessor gate + Policy B Contract
|
||||||
# Review auto-open + Sub 8 Racking auto-open + the receiving soft
|
# 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_extensions
|
||||||
from . import test_fp_job_milestone_cascade
|
from . import test_fp_job_milestone_cascade
|
||||||
from . import test_qty_received_propagation
|
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