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:
gsinghpal
2026-05-22 21:44:15 -04:00
parent 1d04ac8cb7
commit 81da9bf71c
3 changed files with 138 additions and 0 deletions

View File

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

View File

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

View File

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