Files
Odoo-Modules/fusion_plating/fusion_plating/models/fp_job_step_timelog.py
gsinghpal cd2584d6ee ui(rename): "Plating Job" -> "Work Order" / display "WO # 01368"
Standardise user-facing terminology across 5 modules (27 files):
  - display_name compute: 'Work Order # 01368' -> 'WO # 01368'
  - _description on 5 models: Plating Job{," Step"," Step Time Log"," Margin Report"," Recipe Node Override"} -> Work Order equivalents
  - field labels (string=...) on 13 Many2one / One2many fields
    across fp.batch, fp.thickness_reading, fp.quality.hold,
    fp.job_consumption, fp.portal.job, fp.certificate, fp.delivery,
    fp.quality.check, fp.racking.inspection, res.partner, sale.order
  - XML view labels: action names, list/form/search strings,
    portal template names, dashboard tile titles

What's deliberately preserved:
  - DB model name 'fp.job' (technical identifier — used by
    sale_order.x_fc_plating_job_ids and all comodel refs)
  - Module name 'fusion_plating_jobs' (directory / import path)
  - Settings -> Apps display label 'Fusion Plating Jobs' (module
    identity for Odoo's app picker)
  - 'Use Native Plating Jobs' migration toggle (internal mechanism
    flag, not user-facing terminology)

Verified on entech: WH/JOB/01368 now displays as 'WO # 01368'
everywhere humans look (form header, breadcrumbs, M2O dropdowns,
error messages, smart-button titles).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 08:22:09 -04:00

265 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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
from odoo.exceptions import AccessError, UserError
class FpJobStepTimeLog(models.Model):
_name = 'fp.job.step.timelog'
_description = 'Work Order Step Time Log'
_inherit = ['mail.thread']
_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, 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,
)
@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
@api.depends('user_id', 'date_started', 'duration_minutes')
def _compute_display_name(self):
for log in self:
user = log.user_id.name or 'User'
when = log.date_started.strftime('%Y-%m-%d %H:%M') if log.date_started else ''
mins = ('%.0f min' % log.duration_minutes) if log.duration_minutes else 'open'
rec_bits = [user]
if when:
rec_bits.append(when)
rec_bits.append(mins)
log.display_name = ' · '.join(rec_bits)
# ===== Sub 12b — persistent timer state machine =========================
# Extends the existing timelog (used by S1/S2 battle tests) with a state
# field + reconciliation columns. Default state='running' → existing
# battle tests are unaffected. Stop Timer dialog (Task 13) flips to
# stopped → reconciled with operator-edited billed_*.
state = fields.Selection(
[
('running', 'Running'),
('paused', 'Paused'),
('stopped', 'Stopped'),
('reconciled', 'Reconciled'),
],
string='State', default='running', tracking=True,
)
job_id = fields.Many2one(
'fp.job', related='step_id.job_id',
store=True, string='Job', index=True,
)
last_paused_at = fields.Datetime(string='Last Paused')
total_paused_seconds = fields.Integer(
string='Total Paused (sec)', default=0,
help='Cumulative time spent in paused state since date_started.',
)
accrued_seconds = fields.Integer(
string='Accrued (sec)',
compute='_compute_accrued_seconds',
help='Live seconds since date_started, minus total_paused_seconds. '
'Frozen for stopped/reconciled rows.',
)
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,
)
billed_pct = fields.Float(
string='% Billed',
compute='_compute_billed_pct',
help='billed_total / accrued × 100. Surfaces on Stop Timer dialog.',
)
product_id = fields.Many2one(
'product.product', string='Reconciled Product',
ondelete='set null',
help='When the operator splits a timer across multiple products, '
'this row carries the destination product (Steelhead screen 10).',
)
notes = fields.Text(string='Operator Notes')
@api.depends(
'state', 'date_started', 'date_finished',
'last_paused_at', 'total_paused_seconds',
)
def _compute_accrued_seconds(self):
now = fields.Datetime.now()
for rec in self:
if not rec.date_started:
rec.accrued_seconds = 0
continue
end = rec.date_finished or now
elapsed = (end - rec.date_started).total_seconds()
rec.accrued_seconds = max(0, int(elapsed) - (rec.total_paused_seconds or 0))
@api.depends('billed_hrs', 'billed_min', 'billed_sec')
def _compute_billed_total_seconds(self):
for rec in self:
rec.billed_total_seconds = (
(rec.billed_hrs or 0) * 3600
+ (rec.billed_min or 0) * 60
+ (rec.billed_sec or 0)
)
@api.depends('billed_total_seconds', 'accrued_seconds')
def _compute_billed_pct(self):
for rec in self:
if rec.accrued_seconds:
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