From 9d58f5f61e30ee1bccee12211f50b02ec34f11bf Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 17 May 2026 02:47:08 -0400 Subject: [PATCH] feat(portal): per-stage timestamps on fp.portal.job Adds received_at, in_progress_started_at, qc_started_at, ready_to_ship_at, shipped_at - snapshotted on state change via write() override using super().write() to avoid recursion. Required for the vertical-timeline rendering on the job detail page (Phase 3). Idempotent: re-transitioning to a state already-stamped does not overwrite the original timestamp. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../models/fp_portal_job.py | 58 +++++++++++++++++++ .../tests/test_portal_dashboard.py | 17 ++++++ 2 files changed, 75 insertions(+) diff --git a/fusion_plating/fusion_plating_portal/models/fp_portal_job.py b/fusion_plating/fusion_plating_portal/models/fp_portal_job.py index d6496e58..7a4188da 100644 --- a/fusion_plating/fusion_plating_portal/models/fp_portal_job.py +++ b/fusion_plating/fusion_plating_portal/models/fp_portal_job.py @@ -63,6 +63,31 @@ class FpPortalJob(models.Model): string='Actual Ship Date', tracking=True, ) + + # Per-stage Datetime timestamps for the customer-facing timeline. + # Snapshotted by write()/create() on state changes (idempotent). + received_at = fields.Datetime( + string='Received Timestamp', + readonly=True, + help='Auto-set when state first reaches received.', + ) + in_progress_started_at = fields.Datetime( + string='In Progress Started At', + readonly=True, + ) + qc_started_at = fields.Datetime( + string='QC Started At', + readonly=True, + ) + ready_to_ship_at = fields.Datetime( + string='Ready to Ship At', + readonly=True, + ) + shipped_at = fields.Datetime( + string='Shipped At', + readonly=True, + ) + process_type_ids = fields.Many2many( 'fusion.plating.process.type', 'fp_portal_job_process_type_rel', @@ -164,3 +189,36 @@ class FpPortalJob(models.Model): walk(child, depth + 1) walk(mo.x_fc_recipe_id, 0) return result + + # ========================================================================== + # Per-stage timestamp snapshots + # ========================================================================== + _STATE_TO_TS_FIELD = { + 'received': 'received_at', + 'in_progress': 'in_progress_started_at', + 'quality_check': 'qc_started_at', + 'ready_to_ship': 'ready_to_ship_at', + 'shipped': 'shipped_at', + } + + def write(self, vals): + if 'state' in vals: + ts_field = self._STATE_TO_TS_FIELD.get(vals['state']) + if ts_field: + now = fields.Datetime.now() + # Snapshot the timestamp only for records that don't have it yet, + # so re-transitioning to the same state doesn't overwrite history. + for rec in self: + if not rec[ts_field]: + super(FpPortalJob, rec).write({ts_field: now}) + return super().write(vals) + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + now = fields.Datetime.now() + for rec in records: + ts_field = self._STATE_TO_TS_FIELD.get(rec.state) + if ts_field and not rec[ts_field]: + super(FpPortalJob, rec).write({ts_field: now}) + return records diff --git a/fusion_plating/fusion_plating_portal/tests/test_portal_dashboard.py b/fusion_plating/fusion_plating_portal/tests/test_portal_dashboard.py index 220c9198..347fbfb8 100644 --- a/fusion_plating/fusion_plating_portal/tests/test_portal_dashboard.py +++ b/fusion_plating/fusion_plating_portal/tests/test_portal_dashboard.py @@ -57,3 +57,20 @@ class TestPortalDashboard(TransactionCase): self.assertEqual(active, 2) self.assertEqual(awaiting_review, 1) self.assertEqual(ready_to_ship, 1) + + def test_state_change_snapshots_timestamp(self): + """write({'state': 'in_progress'}) sets in_progress_started_at.""" + from odoo import fields as odoo_fields + Job = self.env['fusion.plating.portal.job'] + job = Job.create({ + 'name': 'WO-TS-001', + 'partner_id': self.partner.id, + 'state': 'received', + }) + self.assertTrue(job.received_at, 'received_at set on create') + before = odoo_fields.Datetime.now() + job.state = 'in_progress' + self.assertTrue(job.in_progress_started_at, 'in_progress_started_at set') + self.assertGreaterEqual(job.in_progress_started_at, before) + # received_at must not be overwritten when state advances + self.assertTrue(job.received_at)