feat(fusion_plating_jobs): late_risk_ratio + active_step_id computes on fp.job
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user