From c76eb9472448ae19478775f55831518858715482 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Fri, 22 May 2026 22:01:58 -0400 Subject: [PATCH] feat(fusion_plating_jobs): late_risk_ratio + active_step_id computes on fp.job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan tasks P2.2 + P2.3 batched (both small additive computes on fp.job; local tests not run between them — entech verifies). late_risk_ratio — stored Float, remaining_planned / minutes_to_deadline. Drives the Manager At-Risk view (Phase 4). Recomputes on step state, duration, deadline changes. active_step_id — non-stored Many2one. Currently in_progress step (lowest sequence if multiple — defensive). Drives JobWorkspace landing focus. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../fusion_plating_jobs/models/fp_job.py | 51 +++++++++++++++ .../fusion_plating_jobs/tests/__init__.py | 2 + .../tests/test_active_step_id.py | 55 ++++++++++++++++ .../tests/test_late_risk_ratio.py | 65 +++++++++++++++++++ 4 files changed, 173 insertions(+) create mode 100644 fusion_plating/fusion_plating_jobs/tests/test_active_step_id.py create mode 100644 fusion_plating/fusion_plating_jobs/tests/test_late_risk_ratio.py diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job.py b/fusion_plating/fusion_plating_jobs/models/fp_job.py index 311a0a64..fed10e90 100644 --- a/fusion_plating/fusion_plating_jobs/models/fp_job.py +++ b/fusion_plating/fusion_plating_jobs/models/fp_job.py @@ -121,6 +121,57 @@ class FpJob(models.Model): tail = raw.rsplit('/', 1)[-1] job.display_wo_name = f'WO # {tail}' + # Phase 2 — At-Risk view + Workspace landing focus. + late_risk_ratio = fields.Float( + compute='_compute_late_risk_ratio', + store=True, + string='Late-risk Ratio', + help='remaining_planned_minutes / minutes_to_deadline. ' + '>1.0 means the job will be late if nothing changes. ' + 'Drives the At-Risk view on the manager dashboard.', + ) + active_step_id = fields.Many2one( + 'fp.job.step', + compute='_compute_active_step_id', + string='Active Step', + help='Currently in-progress step (lowest sequence if multiple — ' + 'defensive). Drives JobWorkspace landing focus.', + ) + + @api.depends( + 'date_deadline', + 'step_ids.state', + 'step_ids.duration_expected', + ) + def _compute_late_risk_ratio(self): + from datetime import datetime + for job in self: + if not job.date_deadline: + job.late_risk_ratio = 0.0 + continue + open_steps = job.step_ids.filtered( + lambda s: s.state not in ('done', 'skipped', 'cancelled') + ) + remaining_planned = sum(open_steps.mapped('duration_expected') or [0]) + if remaining_planned <= 0: + job.late_risk_ratio = 0.0 + continue + now = datetime.now() + # date_deadline is naive UTC in Odoo; compare directly + minutes_to_deadline = max( + 1.0, + (job.date_deadline - now).total_seconds() / 60.0, + ) + job.late_risk_ratio = remaining_planned / minutes_to_deadline + + @api.depends('step_ids.state', 'step_ids.sequence') + def _compute_active_step_id(self): + for job in self: + active = job.step_ids.filtered( + lambda s: s.state == 'in_progress' + ).sorted('sequence') + job.active_step_id = active[:1].id if active else False + # ------------------------------------------------------------------ # Sub 14 — Configurable workflow state (status bar milestone) # ------------------------------------------------------------------ diff --git a/fusion_plating/fusion_plating_jobs/tests/__init__.py b/fusion_plating/fusion_plating_jobs/tests/__init__.py index 542b4363..e1675ca4 100644 --- a/fusion_plating/fusion_plating_jobs/tests/__init__.py +++ b/fusion_plating/fusion_plating_jobs/tests/__init__.py @@ -4,3 +4,5 @@ from . import test_fp_job_milestone_cascade from . import test_qty_received_propagation from . import test_display_wo_name from . import test_blocker_compute +from . import test_late_risk_ratio +from . import test_active_step_id diff --git a/fusion_plating/fusion_plating_jobs/tests/test_active_step_id.py b/fusion_plating/fusion_plating_jobs/tests/test_active_step_id.py new file mode 100644 index 00000000..945a3832 --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/tests/test_active_step_id.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. — License OPL-1 +"""Plan task P2.3 — fp.job.active_step_id compute.""" +from odoo.tests.common import TransactionCase, tagged + + +@tagged('-at_install', 'post_install', 'fp_jobs') +class TestActiveStepId(TransactionCase): + + def setUp(self): + super().setUp() + self.partner = self.env['res.partner'].create({'name': 'AS'}) + self.product = self.env['product.product'].create({'name': 'AS'}) + self.job = self.env['fp.job'].create({ + 'name': 'WH/JOB/AS', + 'partner_id': self.partner.id, + 'product_id': self.product.id, + 'qty': 1, + }) + + def test_no_active_step(self): + self.env['fp.job.step'].create({ + 'job_id': self.job.id, + 'name': 'S1', + 'sequence': 10, + 'state': 'ready', + }) + self.job.invalidate_recordset(['active_step_id']) + self.assertFalse(self.job.active_step_id.id) + + def test_single_in_progress_step(self): + s = self.env['fp.job.step'].create({ + 'job_id': self.job.id, + 'name': 'S1', + 'sequence': 10, + 'state': 'in_progress', + }) + self.job.invalidate_recordset(['active_step_id']) + self.assertEqual(self.job.active_step_id.id, s.id) + + def test_multiple_in_progress_picks_lowest_sequence(self): + s1 = self.env['fp.job.step'].create({ + 'job_id': self.job.id, + 'name': 'S1', + 'sequence': 10, + 'state': 'in_progress', + }) + self.env['fp.job.step'].create({ + 'job_id': self.job.id, + 'name': 'S2', + 'sequence': 20, + 'state': 'in_progress', + }) + self.job.invalidate_recordset(['active_step_id']) + self.assertEqual(self.job.active_step_id.id, s1.id) diff --git a/fusion_plating/fusion_plating_jobs/tests/test_late_risk_ratio.py b/fusion_plating/fusion_plating_jobs/tests/test_late_risk_ratio.py new file mode 100644 index 00000000..1ea11515 --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/tests/test_late_risk_ratio.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. — License OPL-1 +"""Plan task P2.2 — fp.job.late_risk_ratio compute.""" +from datetime import datetime, timedelta + +from odoo.tests.common import TransactionCase, tagged + + +@tagged('-at_install', 'post_install', 'fp_jobs') +class TestLateRiskRatio(TransactionCase): + + def setUp(self): + super().setUp() + self.partner = self.env['res.partner'].create({'name': 'LR'}) + self.product = self.env['product.product'].create({'name': 'LR'}) + + def _make_job(self, deadline=None): + return self.env['fp.job'].create({ + 'name': 'WH/JOB/LR', + 'partner_id': self.partner.id, + 'product_id': self.product.id, + 'qty': 1, + 'date_deadline': deadline, + }) + + def test_no_deadline_zero(self): + job = self._make_job(deadline=False) + self.assertEqual(job.late_risk_ratio, 0.0) + + def test_no_open_steps_zero(self): + job = self._make_job(deadline=datetime.now() + timedelta(hours=8)) + self.assertEqual(job.late_risk_ratio, 0.0) + + def test_ratio_above_one_when_overrun(self): + job = self._make_job(deadline=datetime.now() + timedelta(hours=2)) + # One step planned for 240 min, only 120 min left → ratio ~ 2.0 + self.env['fp.job.step'].create({ + 'job_id': job.id, + 'name': 'Long step', + 'sequence': 10, + 'state': 'ready', + 'duration_expected': 240, + }) + job.invalidate_recordset(['late_risk_ratio']) + self.assertGreaterEqual(job.late_risk_ratio, 1.5) + + def test_done_steps_dont_count_toward_remaining(self): + job = self._make_job(deadline=datetime.now() + timedelta(hours=4)) + self.env['fp.job.step'].create({ + 'job_id': job.id, + 'name': 'Done', + 'sequence': 10, + 'state': 'done', + 'duration_expected': 999, + }) + self.env['fp.job.step'].create({ + 'job_id': job.id, + 'name': 'Tiny remaining', + 'sequence': 20, + 'state': 'ready', + 'duration_expected': 30, + }) + job.invalidate_recordset(['late_risk_ratio']) + # 30 min remaining vs 240 min to deadline → ratio ~ 0.125 + self.assertLess(job.late_risk_ratio, 0.3)