diff --git a/fusion_plating/fusion_plating_jobs/data/fp_cron_data.xml b/fusion_plating/fusion_plating_jobs/data/fp_cron_data.xml index 0b95d33f..348f4ae0 100644 --- a/fusion_plating/fusion_plating_jobs/data/fp_cron_data.xml +++ b/fusion_plating/fusion_plating_jobs/data/fp_cron_data.xml @@ -31,4 +31,21 @@ hours + + + + Fusion Plating: Auto-pause stale in-progress steps + + code + model._cron_autopause_stale_steps() + 30 + minutes + -1 + + + diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job_step.py b/fusion_plating/fusion_plating_jobs/models/fp_job_step.py index a586a015..ec42d116 100644 --- a/fusion_plating/fusion_plating_jobs/models/fp_job_step.py +++ b/fusion_plating/fusion_plating_jobs/models/fp_job_step.py @@ -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( + "Auto-paused 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 diff --git a/fusion_plating/fusion_plating_jobs/tests/__init__.py b/fusion_plating/fusion_plating_jobs/tests/__init__.py index e1675ca4..0a84151c 100644 --- a/fusion_plating/fusion_plating_jobs/tests/__init__.py +++ b/fusion_plating/fusion_plating_jobs/tests/__init__.py @@ -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 diff --git a/fusion_plating/fusion_plating_jobs/tests/test_autopause_cron.py b/fusion_plating/fusion_plating_jobs/tests/test_autopause_cron.py new file mode 100644 index 00000000..00f03672 --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/tests/test_autopause_cron.py @@ -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')