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',
|
'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': """
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user