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]
|
tail = raw.rsplit('/', 1)[-1]
|
||||||
job.display_wo_name = f'WO # {tail}'
|
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)
|
# 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_qty_received_propagation
|
||||||
from . import test_display_wo_name
|
from . import test_display_wo_name
|
||||||
from . import test_blocker_compute
|
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