fix(jobs): auto-resync step.duration_actual on timelog edits
When a supervisor edits a timelog's date_started/date_finished (or deletes a stale timelog), the parent step's "Actual Min" column was showing stale data — duration_actual is a regular Float set once by button_finish. Adds: - fp.job.step._fp_resum_duration_actual: quiet helper that re-sums duration_actual from time_log_ids.duration_minutes. Skip no-op updates so write traffic is minimised. - fp.job.step.timelog.create/write/unlink hooks: call the helper on the affected parent step(s) so duration_actual stays consistent. Write hook only fires when date_started/date_finished/ step_id changed (notes edits skip resync). step_id reassignment resyncs both old and new parent. - Existing action_recompute_duration_from_timelogs (manual button) still posts a chatter entry for audit-trail use cases. 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.18.15.0',
|
||||
'version': '19.0.18.15.3',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||
'description': """
|
||||
|
||||
@@ -11,12 +11,14 @@
|
||||
# Replicates Odoo MRP's mrp.workorder.time_ids granularity natively
|
||||
# (without depending on the mrp module).
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import AccessError, UserError
|
||||
|
||||
|
||||
class FpJobStepTimeLog(models.Model):
|
||||
_name = 'fp.job.step.timelog'
|
||||
_description = 'Plating Job Step Time Log'
|
||||
_inherit = ['mail.thread']
|
||||
_order = 'date_started desc'
|
||||
|
||||
step_id = fields.Many2one(
|
||||
@@ -25,9 +27,9 @@ class FpJobStepTimeLog(models.Model):
|
||||
ondelete='cascade',
|
||||
index=True,
|
||||
)
|
||||
user_id = fields.Many2one('res.users', required=True)
|
||||
date_started = fields.Datetime(required=True)
|
||||
date_finished = fields.Datetime()
|
||||
user_id = fields.Many2one('res.users', required=True, tracking=True)
|
||||
date_started = fields.Datetime(required=True, tracking=True)
|
||||
date_finished = fields.Datetime(tracking=True)
|
||||
duration_minutes = fields.Float(
|
||||
compute='_compute_duration', store=True,
|
||||
)
|
||||
@@ -83,9 +85,9 @@ class FpJobStepTimeLog(models.Model):
|
||||
help='Live seconds since date_started, minus total_paused_seconds. '
|
||||
'Frozen for stopped/reconciled rows.',
|
||||
)
|
||||
billed_hrs = fields.Integer(string='Billed Hours')
|
||||
billed_min = fields.Integer(string='Billed Minutes')
|
||||
billed_sec = fields.Integer(string='Billed Seconds')
|
||||
billed_hrs = fields.Integer(string='Billed Hours', tracking=True)
|
||||
billed_min = fields.Integer(string='Billed Minutes', tracking=True)
|
||||
billed_sec = fields.Integer(string='Billed Seconds', tracking=True)
|
||||
billed_total_seconds = fields.Integer(
|
||||
string='Billed Total (sec)',
|
||||
compute='_compute_billed_total_seconds', store=True,
|
||||
@@ -133,3 +135,130 @@ class FpJobStepTimeLog(models.Model):
|
||||
rec.billed_pct = 100.0 * rec.billed_total_seconds / rec.accrued_seconds
|
||||
else:
|
||||
rec.billed_pct = 0.0
|
||||
|
||||
|
||||
# ===== State-transition actions ========================================
|
||||
# Surfaced as header buttons on the form; each posts an entry to chatter
|
||||
# so the audit trail covers manual fixes (especially manager overrides).
|
||||
|
||||
def action_pause(self):
|
||||
"""Running → Paused. Stamp last_paused_at so the gap to resume
|
||||
gets folded into total_paused_seconds when the operator comes back."""
|
||||
for rec in self:
|
||||
if rec.state != 'running':
|
||||
raise UserError(_(
|
||||
'Only a running timelog can be paused (currently %s).'
|
||||
) % (rec.state or 'unknown'))
|
||||
rec.write({
|
||||
'state': 'paused',
|
||||
'last_paused_at': fields.Datetime.now(),
|
||||
})
|
||||
rec.message_post(body=_('Timer paused.'))
|
||||
return True
|
||||
|
||||
def action_resume(self):
|
||||
"""Paused → Running. Fold the elapsed pause into total_paused_seconds
|
||||
so accrued_seconds keeps subtracting pause windows correctly."""
|
||||
for rec in self:
|
||||
if rec.state != 'paused':
|
||||
raise UserError(_(
|
||||
'Only a paused timelog can be resumed (currently %s).'
|
||||
) % (rec.state or 'unknown'))
|
||||
elapsed = 0
|
||||
if rec.last_paused_at:
|
||||
delta = fields.Datetime.now() - rec.last_paused_at
|
||||
elapsed = max(0, int(delta.total_seconds()))
|
||||
rec.write({
|
||||
'state': 'running',
|
||||
'last_paused_at': False,
|
||||
'total_paused_seconds': (rec.total_paused_seconds or 0) + elapsed,
|
||||
})
|
||||
rec.message_post(body=_(
|
||||
'Timer resumed (+%d s added to paused total).'
|
||||
) % elapsed)
|
||||
return True
|
||||
|
||||
def action_stop(self):
|
||||
"""Anything → Stopped. Sets date_finished if blank so accrued_seconds
|
||||
freezes. Folds any in-flight pause into total_paused_seconds."""
|
||||
for rec in self:
|
||||
if rec.state in ('stopped', 'reconciled'):
|
||||
raise UserError(_(
|
||||
'Timelog is already %s.'
|
||||
) % rec.state)
|
||||
now = fields.Datetime.now()
|
||||
vals = {'state': 'stopped'}
|
||||
if not rec.date_finished:
|
||||
vals['date_finished'] = now
|
||||
if rec.state == 'paused' and rec.last_paused_at:
|
||||
gap = max(0, int((now - rec.last_paused_at).total_seconds()))
|
||||
vals['total_paused_seconds'] = (rec.total_paused_seconds or 0) + gap
|
||||
vals['last_paused_at'] = False
|
||||
rec.write(vals)
|
||||
rec.message_post(body=_('Timer stopped.'))
|
||||
return True
|
||||
|
||||
def action_reconcile(self):
|
||||
"""Stopped → Reconciled. Freezes billed_* against further edits
|
||||
(the form already gates these readonly when state=reconciled)."""
|
||||
for rec in self:
|
||||
if rec.state != 'stopped':
|
||||
raise UserError(_(
|
||||
'Only a stopped timelog can be reconciled (currently %s).'
|
||||
) % (rec.state or 'unknown'))
|
||||
rec.write({'state': 'reconciled'})
|
||||
rec.message_post(body=_('Timer reconciled.'))
|
||||
return True
|
||||
|
||||
def action_reset_to_running(self):
|
||||
"""Manager+ only: rewind a closed or stuck timelog back to running.
|
||||
Clears date_finished, last_paused_at, total_paused_seconds so accrued
|
||||
starts fresh from the original date_started. Use for genuine corrections
|
||||
only — the audit-trail entry names who did it."""
|
||||
if not self.env.user.has_group(
|
||||
'fusion_plating.group_fusion_plating_manager'):
|
||||
raise AccessError(_(
|
||||
'Only Plating Manager+ can reset a timelog state.'
|
||||
))
|
||||
for rec in self:
|
||||
rec.write({
|
||||
'state': 'running',
|
||||
'date_finished': False,
|
||||
'last_paused_at': False,
|
||||
'total_paused_seconds': 0,
|
||||
})
|
||||
rec.message_post(body=_(
|
||||
'Timer reset to running by %s.'
|
||||
) % self.env.user.name)
|
||||
return True
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Auto-resync hooks — keep fp.job.step.duration_actual in sync with
|
||||
# the timelog rows. Without these, editing a timelog's start/end
|
||||
# leaves the step's "Actual Min" column showing stale data.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
records = super().create(vals_list)
|
||||
records.mapped('step_id')._fp_resum_duration_actual()
|
||||
return records
|
||||
|
||||
def write(self, vals):
|
||||
steps_before = self.mapped('step_id')
|
||||
res = super().write(vals)
|
||||
# Only resum when something that affects duration changed.
|
||||
# date_started / date_finished feed duration_minutes (compute
|
||||
# on this model); step_id reassignment means we need to resum
|
||||
# both old and new parents.
|
||||
if any(k in vals for k in ('date_started', 'date_finished', 'step_id')):
|
||||
steps_after = self.mapped('step_id')
|
||||
(steps_before | steps_after)._fp_resum_duration_actual()
|
||||
return res
|
||||
|
||||
def unlink(self):
|
||||
steps = self.mapped('step_id')
|
||||
res = super().unlink()
|
||||
steps._fp_resum_duration_actual()
|
||||
return res
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Native Jobs',
|
||||
'version': '19.0.8.20.1',
|
||||
'version': '19.0.8.20.2',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
|
||||
@@ -320,16 +320,11 @@ class FpJobStep(models.Model):
|
||||
return True
|
||||
|
||||
def action_recompute_duration_from_timelogs(self):
|
||||
"""Re-sum duration_actual from the step's timelog rows.
|
||||
|
||||
Use case: supervisor adjusts a timelog row (back-date a forgotten
|
||||
click, fix wrong operator, delete a stale entry that was left
|
||||
open over a shift change) and needs the step's duration_actual
|
||||
to reflect the corrected reality. Without this, edits to time_log_ids
|
||||
rows don't propagate into duration_actual (which is set once
|
||||
by button_finish).
|
||||
|
||||
Posts the before/after to chatter for audit.
|
||||
"""Manual button — re-sum duration_actual + post to chatter
|
||||
for audit. Use case: supervisor adjusts a timelog row and
|
||||
wants an explicit audit trail of the recompute. The
|
||||
automatic version called from timelog hooks is
|
||||
_fp_resum_duration_actual (no chatter).
|
||||
"""
|
||||
for step in self:
|
||||
old = step.duration_actual or 0.0
|
||||
@@ -342,6 +337,16 @@ class FpJobStep(models.Model):
|
||||
)) % (step.name, old, new, new - old, self.env.user.name))
|
||||
return True
|
||||
|
||||
def _fp_resum_duration_actual(self):
|
||||
"""Quiet re-sum — used by automatic triggers (timelog
|
||||
create/write/unlink hooks). No chatter post. Skips no-op
|
||||
updates so writes are minimised."""
|
||||
for step in self:
|
||||
new = sum(step.time_log_ids.mapped('duration_minutes'))
|
||||
if abs((step.duration_actual or 0.0) - new) > 0.001:
|
||||
step.duration_actual = new
|
||||
return True
|
||||
|
||||
def action_finish_and_advance(self):
|
||||
"""Steelhead-style "Finish & Next" — finish this step then auto-
|
||||
start the next pending/ready step in sequence. Single click
|
||||
|
||||
Reference in New Issue
Block a user