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
|
||||
|
||||
Reference in New Issue
Block a user