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:
gsinghpal
2026-05-22 22:03:20 -04:00
parent c76eb94724
commit c06d3d442a
4 changed files with 154 additions and 0 deletions

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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')