Compare commits
5 Commits
phase1-wor
...
phase2-cro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a28c7e90f | ||
|
|
3c2efae951 | ||
|
|
c06d3d442a | ||
|
|
c76eb94724 | ||
|
|
06dc6a62b9 |
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating',
|
||||
'version': '19.0.20.6.2',
|
||||
'version': '19.0.20.7.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||
'description': """
|
||||
|
||||
@@ -263,6 +263,16 @@ class FpProcessNode(models.Model):
|
||||
'progress (e.g. paperwork or QA review that runs alongside '
|
||||
'production).',
|
||||
)
|
||||
long_running = fields.Boolean(
|
||||
string='Long-running step',
|
||||
default=False,
|
||||
help='When True, steps generated from this recipe node are exempt '
|
||||
'from the shop-floor auto-pause cron. Use for 24h bakes, '
|
||||
'multi-shift soaks, and similar legitimately-long operations '
|
||||
'that would otherwise be auto-paused after the idle threshold '
|
||||
'(ir.config_parameter fp.shopfloor.autopause_threshold_hours, '
|
||||
'default 8h). See plan 2026-05-22-shopfloor-tablet-redesign.',
|
||||
)
|
||||
opt_in_out = fields.Selection(
|
||||
[
|
||||
('disabled', 'Required'),
|
||||
|
||||
@@ -96,6 +96,11 @@
|
||||
<field name="parallel_start"
|
||||
invisible="node_type not in ('operation', 'step')"
|
||||
help="When the parent recipe is Sequential, ticking this lets the step start while earlier-sequence steps are still in progress."/>
|
||||
<!-- Phase 2 tablet redesign — opt out of the
|
||||
auto-pause cron for legitimately-long steps
|
||||
(24h bakes, multi-shift soaks). -->
|
||||
<field name="long_running"
|
||||
invisible="node_type not in ('operation', 'step')"/>
|
||||
<field name="requires_predecessor_done"
|
||||
invisible="node_type not in ('operation', 'step')"
|
||||
groups="fusion_plating.group_fusion_plating_supervisor"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Certificates',
|
||||
'version': '19.0.7.8.0',
|
||||
'version': '19.0.7.9.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
||||
'description': """
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fp_certificate_operator,fp.certificate.operator,model_fp_certificate,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_certificate_operator,fp.certificate.operator,model_fp_certificate,fusion_plating.group_fusion_plating_operator,1,1,0,0
|
||||
access_fp_certificate_supervisor,fp.certificate.supervisor,model_fp_certificate,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_certificate_manager,fp.certificate.manager,model_fp_certificate,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_thickness_reading_operator,fp.thickness.reading.operator,model_fp_thickness_reading,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_thickness_reading_operator,fp.thickness.reading.operator,model_fp_thickness_reading,fusion_plating.group_fusion_plating_operator,1,1,1,0
|
||||
access_fp_thickness_reading_supervisor,fp.thickness.reading.supervisor,model_fp_thickness_reading,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_thickness_reading_manager,fp.thickness.reading.manager,model_fp_thickness_reading,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_cert_void_wiz_sup,fp.cert.void.wiz.supervisor,model_fp_cert_void_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
|
||||
|
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Native Jobs',
|
||||
'version': '19.0.10.19.0',
|
||||
'version': '19.0.10.20.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
|
||||
@@ -31,4 +31,21 @@
|
||||
<field name="interval_type">hours</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- Phase 2 tablet redesign — actual auto-pause (not just nudge).
|
||||
Flips in_progress steps idle > N hours to paused with chatter
|
||||
audit. Threshold configurable via ir.config_parameter
|
||||
`fp.shopfloor.autopause_threshold_hours` (default 8.0). Recipe
|
||||
nodes can opt out via long_running=True (e.g. 24h bakes). -->
|
||||
<record id="ir_cron_autopause_stale_steps" model="ir.cron">
|
||||
<field name="name">Fusion Plating: Auto-pause stale in-progress steps</field>
|
||||
<field name="model_id" ref="fusion_plating.model_fp_job_step"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_autopause_stale_steps()</field>
|
||||
<field name="interval_number">30</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field name="doall" eval="False"/>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@@ -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)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -151,6 +151,62 @@ class FpJobStep(models.Model):
|
||||
step.blocker_jump_target_model = False
|
||||
step.blocker_jump_target_id = 0
|
||||
|
||||
# ==================================================================
|
||||
# Shop-Floor auto-pause cron (Phase 2 — tablet redesign)
|
||||
# ==================================================================
|
||||
@api.model
|
||||
def _cron_autopause_stale_steps(self):
|
||||
"""Flip in_progress steps idle > threshold to paused.
|
||||
|
||||
Threshold read from ir.config_parameter
|
||||
fp.shopfloor.autopause_threshold_hours (default 8.0)
|
||||
|
||||
Recipes can opt out per node via
|
||||
fusion.plating.process.node.long_running (Phase 2 — P2.1)
|
||||
|
||||
Fixes the 411-hour ghost timer that bit us on the original tablet
|
||||
when an operator started a step and never tapped Finish. Posts an
|
||||
audit chatter entry on the step so the operator can see what
|
||||
happened when they resume.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
threshold = float(
|
||||
self.env['ir.config_parameter'].sudo()
|
||||
.get_param('fp.shopfloor.autopause_threshold_hours', 8)
|
||||
)
|
||||
deadline = fields.Datetime.now() - timedelta(hours=threshold)
|
||||
domain = [
|
||||
('state', '=', 'in_progress'),
|
||||
('date_started', '<', deadline),
|
||||
'|',
|
||||
('recipe_node_id', '=', False),
|
||||
('recipe_node_id.long_running', '=', False),
|
||||
]
|
||||
stale = self.search(domain)
|
||||
paused = 0
|
||||
for step in stale:
|
||||
try:
|
||||
step.button_pause()
|
||||
step.message_post(body=Markup(
|
||||
"<b>Auto-paused</b> after %.1fh idle. "
|
||||
"Resume from the tablet when work continues."
|
||||
) % threshold)
|
||||
_logger.info(
|
||||
"Auto-paused step %s (%s) after %.1fh idle",
|
||||
step.id, step.name, threshold,
|
||||
)
|
||||
paused += 1
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
"Auto-pause failed for step %s — skipping", step.id,
|
||||
)
|
||||
if paused:
|
||||
_logger.info(
|
||||
"_cron_autopause_stale_steps: paused %d step(s) "
|
||||
"(threshold %.1fh)", paused, threshold,
|
||||
)
|
||||
return paused
|
||||
|
||||
# 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
|
||||
|
||||
@@ -4,3 +4,6 @@ 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
|
||||
from . import test_autopause_cron
|
||||
|
||||
@@ -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,80 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc. — License OPL-1
|
||||
"""Plan task P2.4 — _cron_autopause_stale_steps method."""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fp_jobs')
|
||||
class TestAutopauseCron(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.partner = self.env['res.partner'].create({'name': 'AP'})
|
||||
self.product = self.env['product.product'].create({'name': 'AP'})
|
||||
self.job = self.env['fp.job'].create({
|
||||
'name': 'WH/JOB/AP',
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 1,
|
||||
})
|
||||
|
||||
def test_stale_step_flips_to_paused(self):
|
||||
step = self.env['fp.job.step'].create({
|
||||
'job_id': self.job.id,
|
||||
'name': 'Stale',
|
||||
'sequence': 10,
|
||||
'state': 'in_progress',
|
||||
'date_started': datetime.now() - timedelta(hours=10),
|
||||
})
|
||||
paused = self.env['fp.job.step']._cron_autopause_stale_steps()
|
||||
self.assertGreaterEqual(paused, 1)
|
||||
step.invalidate_recordset(['state'])
|
||||
self.assertEqual(step.state, 'paused')
|
||||
|
||||
def test_fresh_step_unchanged(self):
|
||||
step = self.env['fp.job.step'].create({
|
||||
'job_id': self.job.id,
|
||||
'name': 'Fresh',
|
||||
'sequence': 10,
|
||||
'state': 'in_progress',
|
||||
'date_started': datetime.now() - timedelta(hours=2),
|
||||
})
|
||||
self.env['fp.job.step']._cron_autopause_stale_steps()
|
||||
step.invalidate_recordset(['state'])
|
||||
self.assertEqual(step.state, 'in_progress')
|
||||
|
||||
def test_long_running_node_exempt(self):
|
||||
node = self.env['fusion.plating.process.node'].create({
|
||||
'name': 'Long bake',
|
||||
'long_running': True,
|
||||
'node_type': 'operation',
|
||||
})
|
||||
step = self.env['fp.job.step'].create({
|
||||
'job_id': self.job.id,
|
||||
'name': 'Long',
|
||||
'sequence': 10,
|
||||
'state': 'in_progress',
|
||||
'date_started': datetime.now() - timedelta(hours=20),
|
||||
'recipe_node_id': node.id,
|
||||
})
|
||||
self.env['fp.job.step']._cron_autopause_stale_steps()
|
||||
step.invalidate_recordset(['state'])
|
||||
self.assertEqual(step.state, 'in_progress')
|
||||
|
||||
def test_threshold_config_parameter_respected(self):
|
||||
self.env['ir.config_parameter'].sudo().set_param(
|
||||
'fp.shopfloor.autopause_threshold_hours', '24',
|
||||
)
|
||||
step = self.env['fp.job.step'].create({
|
||||
'job_id': self.job.id,
|
||||
'name': 'Within 24h',
|
||||
'sequence': 10,
|
||||
'state': 'in_progress',
|
||||
'date_started': datetime.now() - timedelta(hours=10),
|
||||
})
|
||||
self.env['fp.job.step']._cron_autopause_stale_steps()
|
||||
step.invalidate_recordset(['state'])
|
||||
# 10h < 24h → still in_progress
|
||||
self.assertEqual(step.state, 'in_progress')
|
||||
@@ -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)
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Shop Floor',
|
||||
'version': '19.0.27.0.0',
|
||||
'version': '19.0.27.1.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
||||
'first-piece inspection gates.',
|
||||
|
||||
@@ -14,3 +14,4 @@ access_fp_first_piece_gate_manager,fp.first.piece.gate.manager,model_fusion_plat
|
||||
access_fp_operator_queue_operator,fp.operator.queue.operator,model_fusion_plating_operator_queue,fusion_plating.group_fusion_plating_operator,1,1,1,1
|
||||
access_fp_operator_queue_supervisor,fp.operator.queue.supervisor,model_fusion_plating_operator_queue,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_operator_queue_manager,fp.operator.queue.manager,model_fusion_plating_operator_queue,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_job_node_override_operator,fp.job.node.override.operator,fusion_plating_jobs.model_fp_job_node_override,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
|
||||
|
Reference in New Issue
Block a user