Compare commits

..

5 Commits

Author SHA1 Message Date
gsinghpal
5a28c7e90f chore(fusion_plating): bump versions for Phase 2 — cron + ACL + supporting computes
Some checks are pending
fusion_accounting CI / test (fusion_accounting_ai) (push) Waiting to run
fusion_accounting CI / test (fusion_accounting_core) (push) Waiting to run
fusion_accounting CI / test (fusion_accounting_migration) (push) Waiting to run
fusion_plating              19.0.20.7.0  (long_running on process node)
  fusion_plating_jobs         19.0.10.20.0 (late_risk_ratio, active_step_id, autopause cron)
  fusion_plating_shopfloor    19.0.27.1.0  (no code change; data-version bump for Phase 2)
  fusion_plating_certificates 19.0.7.9.0   (ACL lift — bumped in P2.6)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:04:26 -04:00
gsinghpal
3c2efae951 feat(fusion_plating): lift operator ACL for cert write + thickness create + override read
Plan task P2.6. Per the spec's "techs wear multiple hats" rule, lift
gates so technicians can do their work without permission walls:

  fp.certificate         operator: read → read+write
                         (flip draft→issued from tablet)
  fp.thickness.reading   operator: read → read+write+create
                         (capture Fischerscope readings from tablet)
  fp.job.node.override   operator: NEW read-only
                         (see opt-out badges on steps)

Supervisor-only operations (step Skip, hold Release, override
Re-include) remain enforced in workspace_controller, not ACL — so the
ACL stays minimal and the controller centralizes the gate logic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:04:05 -04:00
gsinghpal
c06d3d442a 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>
2026-05-22 22:03:20 -04:00
gsinghpal
c76eb94724 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>
2026-05-22 22:01:58 -04:00
gsinghpal
06dc6a62b9 feat(fusion_plating): long_running flag on process node (auto-pause opt-out)
Plan task P2.1. Boolean on fusion.plating.process.node that exempts
steps generated from this node from the shop-floor auto-pause cron
(added in P2.4/P2.5). Use for 24h bakes, multi-shift soaks, and
similar long-but-legitimate operations.

Toggle visible on the process-node form for operation/step types,
grouped with parallel_start in the Behaviour section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:01:13 -04:00
15 changed files with 349 additions and 6 deletions

View File

@@ -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': """

View File

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

View File

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

View File

@@ -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': """

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fp_certificate_operator fp.certificate.operator model_fp_certificate fusion_plating.group_fusion_plating_operator 1 0 1 0 0
3 access_fp_certificate_supervisor fp.certificate.supervisor model_fp_certificate fusion_plating.group_fusion_plating_supervisor 1 1 1 0
4 access_fp_certificate_manager fp.certificate.manager model_fp_certificate fusion_plating.group_fusion_plating_manager 1 1 1 1
5 access_fp_thickness_reading_operator fp.thickness.reading.operator model_fp_thickness_reading fusion_plating.group_fusion_plating_operator 1 0 1 0 1 0
6 access_fp_thickness_reading_supervisor fp.thickness.reading.supervisor model_fp_thickness_reading fusion_plating.group_fusion_plating_supervisor 1 1 1 0
7 access_fp_thickness_reading_manager fp.thickness.reading.manager model_fp_thickness_reading fusion_plating.group_fusion_plating_manager 1 1 1 1
8 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

View File

@@ -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.',

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

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

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

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

View File

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

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

View File

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

View File

@@ -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.',

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
14 access_fp_operator_queue_operator fp.operator.queue.operator model_fusion_plating_operator_queue fusion_plating.group_fusion_plating_operator 1 1 1 1
15 access_fp_operator_queue_supervisor fp.operator.queue.supervisor model_fusion_plating_operator_queue fusion_plating.group_fusion_plating_supervisor 1 1 1 1
16 access_fp_operator_queue_manager fp.operator.queue.manager model_fusion_plating_operator_queue fusion_plating.group_fusion_plating_manager 1 1 1 1
17 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