From 3bed76aea452705a25482398c939e816f50e1379 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Mon, 27 Apr 2026 21:08:22 -0400 Subject: [PATCH] feat(sub12b): persistent state machine on fp.job.step.timelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../models/fp_job_step_timelog.py | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/fusion_plating/fusion_plating/models/fp_job_step_timelog.py b/fusion_plating/fusion_plating/models/fp_job_step_timelog.py index 78fb8b89..e5c1c692 100644 --- a/fusion_plating/fusion_plating/models/fp_job_step_timelog.py +++ b/fusion_plating/fusion_plating/models/fp_job_step_timelog.py @@ -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