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:
gsinghpal
2026-04-27 21:08:22 -04:00
parent dcd6df71c0
commit 3bed76aea4

View File

@@ -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