feat(sub12b): persistent state machine on fp.job.step.timelog
Extends the existing timelog (used by S1/S2 battle tests) with: - state: running / paused / stopped / reconciled (default running) - last_paused_at + total_paused_seconds (drives accrued compute) - accrued_seconds (compute, depends date_started/_finished/paused) - billed_hrs/min/sec + billed_total_seconds + billed_pct (compute) - product_id (split-by-product reconciliation per screen 10) - notes - job_id (related, indexed — for fp.job.active_timer_ids O2M) Field naming follows the existing date_started / date_finished convention (NOT started_at / stopped_at as my plan said — adjusted inline to match what's already in the file). The existing battle tests use the timelog without state — default 'running' so they're unaffected. State only flips when Sub 12b's Stop Timer dialog commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -52,3 +52,84 @@ class FpJobStepTimeLog(models.Model):
|
||||
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')
|
||||
billed_min = fields.Integer(string='Billed Minutes')
|
||||
billed_sec = fields.Integer(string='Billed Seconds')
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user