feat(jobs): add fp.job.step.timelog for granular timer tracking
Each button_start opens a fresh timelog row; button_finish closes the open row and recomputes step.duration_actual as the sum of all interval durations. Replicates Odoo MRP's mrp.workorder.time_ids granularity natively (no mrp dep). Schema: step_id (M2O cascade), user_id, date_started, date_finished, duration_minutes (computed, stored). ACLs: operator get create permission on timelogs because button_start creates them. Tests: test_start_creates_timelog (asserts the log row exists, date_finished is False, user_id is the current user) and test_finish_closes_timelog (asserts log gets date_finished, has a non-negative duration, and step.duration_actual matches). Manifest 19.0.8.5.1 -> 19.0.8.6.0. Part of: native job model migration (spec 2026-04-25) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating',
|
||||
'version': '19.0.8.5.1',
|
||||
'version': '19.0.8.6.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||
'description': """
|
||||
|
||||
@@ -18,6 +18,7 @@ from . import fp_process_node
|
||||
from . import fp_rack
|
||||
from . import fp_job
|
||||
from . import fp_job_step
|
||||
from . import fp_job_step_timelog
|
||||
from . import fp_operator_certification
|
||||
from . import fp_tz
|
||||
from . import res_company
|
||||
|
||||
@@ -75,6 +75,11 @@ class FpJobStep(models.Model):
|
||||
duration_expected = fields.Float(string='Expected Minutes')
|
||||
duration_actual = fields.Float(string='Actual Minutes', readonly=True)
|
||||
instructions = fields.Html(string='Step Instructions')
|
||||
time_log_ids = fields.One2many(
|
||||
'fp.job.step.timelog',
|
||||
'step_id',
|
||||
string='Time Logs',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Equipment + audit (Task 1.6)
|
||||
@@ -193,9 +198,16 @@ class FpJobStep(models.Model):
|
||||
"Step '%s' is in state '%s' — only ready/paused steps can start."
|
||||
) % (step.name, step.state))
|
||||
step.state = 'in_progress'
|
||||
# First-start audit (mirrors button_finish first-finish guard)
|
||||
if not step.date_started:
|
||||
step.date_started = fields.Datetime.now()
|
||||
step.started_by_user_id = self.env.user
|
||||
# Open a fresh timelog row for this start interval
|
||||
self.env['fp.job.step.timelog'].create({
|
||||
'step_id': step.id,
|
||||
'user_id': self.env.user.id,
|
||||
'date_started': fields.Datetime.now(),
|
||||
})
|
||||
return True
|
||||
|
||||
def button_finish(self):
|
||||
@@ -204,12 +216,15 @@ class FpJobStep(models.Model):
|
||||
raise UserError(_(
|
||||
"Step '%s' is in state '%s' — only in-progress steps can finish."
|
||||
) % (step.name, step.state))
|
||||
now = fields.Datetime.now()
|
||||
# Close the open timelog (the one with no date_finished)
|
||||
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
|
||||
open_log.write({'date_finished': now})
|
||||
step.state = 'done'
|
||||
# First-finish audit (mirrors button_start's first-start guard).
|
||||
# If a future rework flow re-opens then re-finishes, the original
|
||||
# finish timestamp/user is preserved. duration_actual rollups
|
||||
# in Task 1.7 will use timelog rows for the latest interval.
|
||||
# First-finish audit (mirrors button_start first-start guard)
|
||||
if not step.date_finished:
|
||||
step.date_finished = fields.Datetime.now()
|
||||
step.date_finished = now
|
||||
step.finished_by_user_id = self.env.user
|
||||
# Sum of all interval durations becomes duration_actual
|
||||
step.duration_actual = sum(step.time_log_ids.mapped('duration_minutes'))
|
||||
return True
|
||||
|
||||
42
fusion_plating/fusion_plating/models/fp_job_step_timelog.py
Normal file
42
fusion_plating/fusion_plating/models/fp_job_step_timelog.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# fp.job.step.timelog — granular start/stop intervals for a step.
|
||||
#
|
||||
# Each step.button_start() opens a fresh timelog row. Each
|
||||
# step.button_finish() (or button_pause once added) closes the open
|
||||
# row. duration_actual on fp.job.step is the sum of these intervals.
|
||||
#
|
||||
# Replicates Odoo MRP's mrp.workorder.time_ids granularity natively
|
||||
# (without depending on the mrp module).
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FpJobStepTimeLog(models.Model):
|
||||
_name = 'fp.job.step.timelog'
|
||||
_description = 'Plating Job Step Time Log'
|
||||
_order = 'date_started desc'
|
||||
|
||||
step_id = fields.Many2one(
|
||||
'fp.job.step',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
index=True,
|
||||
)
|
||||
user_id = fields.Many2one('res.users', required=True)
|
||||
date_started = fields.Datetime(required=True)
|
||||
date_finished = fields.Datetime()
|
||||
duration_minutes = fields.Float(
|
||||
compute='_compute_duration', store=True,
|
||||
)
|
||||
|
||||
@api.depends('date_started', 'date_finished')
|
||||
def _compute_duration(self):
|
||||
for log in self:
|
||||
if log.date_started and log.date_finished:
|
||||
delta = log.date_finished - log.date_started
|
||||
log.duration_minutes = delta.total_seconds() / 60.0
|
||||
else:
|
||||
log.duration_minutes = 0.0
|
||||
@@ -53,3 +53,5 @@ access_fp_job_manager,fp.job.manager,model_fp_job,fusion_plating.group_fusion_pl
|
||||
access_fp_job_step_operator,fp.job.step.operator,model_fp_job_step,fusion_plating.group_fusion_plating_operator,1,1,0,0
|
||||
access_fp_job_step_supervisor,fp.job.step.supervisor,model_fp_job_step,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_job_step_manager,fp.job.step.manager,model_fp_job_step,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_job_step_timelog_operator,fp.job.step.timelog.operator,model_fp_job_step_timelog,fusion_plating.group_fusion_plating_operator,1,1,1,0
|
||||
access_fp_job_step_timelog_manager,fp.job.step.timelog.manager,model_fp_job_step_timelog,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
|
||||
|
@@ -120,3 +120,40 @@ class TestFpJobStepStateMachine(TransactionCase):
|
||||
# Force recompute via invalidate (Odoo recomputes on next read).
|
||||
step.invalidate_recordset(['cost_total'])
|
||||
self.assertEqual(step.cost_total, 60.0)
|
||||
|
||||
|
||||
class TestFpJobStepTimeLog(TransactionCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.partner = self.env['res.partner'].create({'name': 'Cust'})
|
||||
self.product = self.env['product.product'].create({'name': 'Widget'})
|
||||
self.wc = self.env['fp.work.centre'].create({
|
||||
'name': 'WC', 'code': 'WC', 'kind': 'wet_line',
|
||||
})
|
||||
self.job = self.env['fp.job'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 1.0,
|
||||
})
|
||||
self.step = self.env['fp.job.step'].create({
|
||||
'job_id': self.job.id,
|
||||
'name': 'S',
|
||||
'sequence': 10,
|
||||
'work_centre_id': self.wc.id,
|
||||
'state': 'ready',
|
||||
})
|
||||
|
||||
def test_start_creates_timelog(self):
|
||||
self.step.button_start()
|
||||
self.assertEqual(len(self.step.time_log_ids), 1)
|
||||
self.assertFalse(self.step.time_log_ids[0].date_finished)
|
||||
self.assertEqual(self.step.time_log_ids[0].user_id, self.env.user)
|
||||
|
||||
def test_finish_closes_timelog(self):
|
||||
self.step.button_start()
|
||||
self.step.button_finish()
|
||||
log = self.step.time_log_ids[0]
|
||||
self.assertTrue(log.date_finished)
|
||||
self.assertGreaterEqual(log.duration_minutes, 0.0)
|
||||
# duration_actual on the step should match the sum of timelog durations
|
||||
self.assertEqual(self.step.duration_actual, log.duration_minutes)
|
||||
|
||||
Reference in New Issue
Block a user