diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job_step.py b/fusion_plating/fusion_plating_jobs/models/fp_job_step.py index 5f8a91df..a586a015 100644 --- a/fusion_plating/fusion_plating_jobs/models/fp_job_step.py +++ b/fusion_plating/fusion_plating_jobs/models/fp_job_step.py @@ -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 diff --git a/fusion_plating/fusion_plating_jobs/tests/__init__.py b/fusion_plating/fusion_plating_jobs/tests/__init__.py index 86247ad2..542b4363 100644 --- a/fusion_plating/fusion_plating_jobs/tests/__init__.py +++ b/fusion_plating/fusion_plating_jobs/tests/__init__.py @@ -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 diff --git a/fusion_plating/fusion_plating_jobs/tests/test_blocker_compute.py b/fusion_plating/fusion_plating_jobs/tests/test_blocker_compute.py new file mode 100644 index 00000000..d7415341 --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/tests/test_blocker_compute.py @@ -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)