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>
265 lines
10 KiB
Python
265 lines
10 KiB
Python
# -*- 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
|