feat(fusion_plating_jobs): auto-pause cron for stale in-progress steps
Plan tasks P2.4 + P2.5 batched.
Adds _cron_autopause_stale_steps method on fp.job.step + 30-min cron
registration. Flips in_progress steps idle > threshold to paused with
a chatter audit ("Auto-paused after Nh idle. Resume from the tablet
when work continues.").
Threshold from ir.config_parameter:
fp.shopfloor.autopause_threshold_hours (default 8.0)
Recipe nodes opt out via fusion.plating.process.node.long_running
(added in P2.1) — useful for 24h bakes and multi-shift soaks.
Fixes the 411-hour ghost timer that motivated the redesign. Doesn't
replace the existing nudge crons — those still notify the supervisor;
this one actually pauses the timer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,3 +6,4 @@ 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,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')
|
||||
Reference in New Issue
Block a user