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:
gsinghpal
2026-04-24 22:27:26 -04:00
parent 57a3aea16f
commit 28892f56b5
6 changed files with 103 additions and 6 deletions

View File

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

View File

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

View 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