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:
gsinghpal
2026-05-11 23:53:45 -04:00
parent b0070afc1b
commit 1c68fd0555
4 changed files with 153 additions and 19 deletions

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating', 'name': 'Fusion Plating',
'version': '19.0.18.15.0', 'version': '19.0.18.15.3',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """ 'description': """

View File

@@ -11,12 +11,14 @@
# Replicates Odoo MRP's mrp.workorder.time_ids granularity natively # Replicates Odoo MRP's mrp.workorder.time_ids granularity natively
# (without depending on the mrp module). # (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): class FpJobStepTimeLog(models.Model):
_name = 'fp.job.step.timelog' _name = 'fp.job.step.timelog'
_description = 'Plating Job Step Time Log' _description = 'Plating Job Step Time Log'
_inherit = ['mail.thread']
_order = 'date_started desc' _order = 'date_started desc'
step_id = fields.Many2one( step_id = fields.Many2one(
@@ -25,9 +27,9 @@ class FpJobStepTimeLog(models.Model):
ondelete='cascade', ondelete='cascade',
index=True, index=True,
) )
user_id = fields.Many2one('res.users', required=True) user_id = fields.Many2one('res.users', required=True, tracking=True)
date_started = fields.Datetime(required=True) date_started = fields.Datetime(required=True, tracking=True)
date_finished = fields.Datetime() date_finished = fields.Datetime(tracking=True)
duration_minutes = fields.Float( duration_minutes = fields.Float(
compute='_compute_duration', store=True, compute='_compute_duration', store=True,
) )
@@ -83,9 +85,9 @@ class FpJobStepTimeLog(models.Model):
help='Live seconds since date_started, minus total_paused_seconds. ' help='Live seconds since date_started, minus total_paused_seconds. '
'Frozen for stopped/reconciled rows.', 'Frozen for stopped/reconciled rows.',
) )
billed_hrs = fields.Integer(string='Billed Hours') billed_hrs = fields.Integer(string='Billed Hours', tracking=True)
billed_min = fields.Integer(string='Billed Minutes') billed_min = fields.Integer(string='Billed Minutes', tracking=True)
billed_sec = fields.Integer(string='Billed Seconds') billed_sec = fields.Integer(string='Billed Seconds', tracking=True)
billed_total_seconds = fields.Integer( billed_total_seconds = fields.Integer(
string='Billed Total (sec)', string='Billed Total (sec)',
compute='_compute_billed_total_seconds', store=True, 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 rec.billed_pct = 100.0 * rec.billed_total_seconds / rec.accrued_seconds
else: else:
rec.billed_pct = 0.0 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

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
{ {
'name': 'Fusion Plating — Native Jobs', 'name': 'Fusion Plating — Native Jobs',
'version': '19.0.8.20.1', 'version': '19.0.8.20.2',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.', 'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.', 'author': 'Nexa Systems Inc.',

View File

@@ -320,16 +320,11 @@ class FpJobStep(models.Model):
return True return True
def action_recompute_duration_from_timelogs(self): def action_recompute_duration_from_timelogs(self):
"""Re-sum duration_actual from the step's timelog rows. """Manual button — re-sum duration_actual + post to chatter
for audit. Use case: supervisor adjusts a timelog row and
Use case: supervisor adjusts a timelog row (back-date a forgotten wants an explicit audit trail of the recompute. The
click, fix wrong operator, delete a stale entry that was left automatic version called from timelog hooks is
open over a shift change) and needs the step's duration_actual _fp_resum_duration_actual (no chatter).
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.
""" """
for step in self: for step in self:
old = step.duration_actual or 0.0 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)) )) % (step.name, old, new, new - old, self.env.user.name))
return True 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): def action_finish_and_advance(self):
"""Steelhead-style "Finish & Next" — finish this step then auto- """Steelhead-style "Finish & Next" — finish this step then auto-
start the next pending/ready step in sequence. Single click start the next pending/ready step in sequence. Single click