diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py index 15002879..2ea63d25 100644 --- a/fusion_plating/fusion_plating/__manifest__.py +++ b/fusion_plating/fusion_plating/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating', - 'version': '19.0.8.5.1', + 'version': '19.0.8.6.0', 'category': 'Manufacturing/Plating', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'description': """ diff --git a/fusion_plating/fusion_plating/models/__init__.py b/fusion_plating/fusion_plating/models/__init__.py index 600187ab..7ce307c6 100644 --- a/fusion_plating/fusion_plating/models/__init__.py +++ b/fusion_plating/fusion_plating/models/__init__.py @@ -18,6 +18,7 @@ from . import fp_process_node from . import fp_rack from . import fp_job from . import fp_job_step +from . import fp_job_step_timelog from . import fp_operator_certification from . import fp_tz from . import res_company diff --git a/fusion_plating/fusion_plating/models/fp_job_step.py b/fusion_plating/fusion_plating/models/fp_job_step.py index dd7fbc34..cf999da2 100644 --- a/fusion_plating/fusion_plating/models/fp_job_step.py +++ b/fusion_plating/fusion_plating/models/fp_job_step.py @@ -75,6 +75,11 @@ class FpJobStep(models.Model): duration_expected = fields.Float(string='Expected Minutes') duration_actual = fields.Float(string='Actual Minutes', readonly=True) instructions = fields.Html(string='Step Instructions') + time_log_ids = fields.One2many( + 'fp.job.step.timelog', + 'step_id', + string='Time Logs', + ) # ------------------------------------------------------------------ # Equipment + audit (Task 1.6) @@ -193,9 +198,16 @@ class FpJobStep(models.Model): "Step '%s' is in state '%s' — only ready/paused steps can start." ) % (step.name, step.state)) step.state = 'in_progress' + # First-start audit (mirrors button_finish first-finish guard) if not step.date_started: step.date_started = fields.Datetime.now() step.started_by_user_id = self.env.user + # Open a fresh timelog row for this start interval + self.env['fp.job.step.timelog'].create({ + 'step_id': step.id, + 'user_id': self.env.user.id, + 'date_started': fields.Datetime.now(), + }) return True def button_finish(self): @@ -204,12 +216,15 @@ class FpJobStep(models.Model): raise UserError(_( "Step '%s' is in state '%s' — only in-progress steps can finish." ) % (step.name, step.state)) + now = fields.Datetime.now() + # Close the open timelog (the one with no date_finished) + open_log = step.time_log_ids.filtered(lambda l: not l.date_finished) + open_log.write({'date_finished': now}) step.state = 'done' - # First-finish audit (mirrors button_start's first-start guard). - # If a future rework flow re-opens then re-finishes, the original - # finish timestamp/user is preserved. duration_actual rollups - # in Task 1.7 will use timelog rows for the latest interval. + # First-finish audit (mirrors button_start first-start guard) if not step.date_finished: - step.date_finished = fields.Datetime.now() + step.date_finished = now step.finished_by_user_id = self.env.user + # Sum of all interval durations becomes duration_actual + step.duration_actual = sum(step.time_log_ids.mapped('duration_minutes')) return True diff --git a/fusion_plating/fusion_plating/models/fp_job_step_timelog.py b/fusion_plating/fusion_plating/models/fp_job_step_timelog.py new file mode 100644 index 00000000..978fd80b --- /dev/null +++ b/fusion_plating/fusion_plating/models/fp_job_step_timelog.py @@ -0,0 +1,42 @@ +# -*- 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 + + +class FpJobStepTimeLog(models.Model): + _name = 'fp.job.step.timelog' + _description = 'Plating Job Step Time Log' + _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) + date_started = fields.Datetime(required=True) + date_finished = fields.Datetime() + 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 diff --git a/fusion_plating/fusion_plating/security/ir.model.access.csv b/fusion_plating/fusion_plating/security/ir.model.access.csv index ed9d9e44..3f165844 100644 --- a/fusion_plating/fusion_plating/security/ir.model.access.csv +++ b/fusion_plating/fusion_plating/security/ir.model.access.csv @@ -53,3 +53,5 @@ access_fp_job_manager,fp.job.manager,model_fp_job,fusion_plating.group_fusion_pl access_fp_job_step_operator,fp.job.step.operator,model_fp_job_step,fusion_plating.group_fusion_plating_operator,1,1,0,0 access_fp_job_step_supervisor,fp.job.step.supervisor,model_fp_job_step,fusion_plating.group_fusion_plating_supervisor,1,1,1,0 access_fp_job_step_manager,fp.job.step.manager,model_fp_job_step,fusion_plating.group_fusion_plating_manager,1,1,1,1 +access_fp_job_step_timelog_operator,fp.job.step.timelog.operator,model_fp_job_step_timelog,fusion_plating.group_fusion_plating_operator,1,1,1,0 +access_fp_job_step_timelog_manager,fp.job.step.timelog.manager,model_fp_job_step_timelog,fusion_plating.group_fusion_plating_manager,1,1,1,1 diff --git a/fusion_plating/fusion_plating/tests/test_fp_job_step_state_machine.py b/fusion_plating/fusion_plating/tests/test_fp_job_step_state_machine.py index abea0953..62ba96ef 100644 --- a/fusion_plating/fusion_plating/tests/test_fp_job_step_state_machine.py +++ b/fusion_plating/fusion_plating/tests/test_fp_job_step_state_machine.py @@ -120,3 +120,40 @@ class TestFpJobStepStateMachine(TransactionCase): # Force recompute via invalidate (Odoo recomputes on next read). step.invalidate_recordset(['cost_total']) self.assertEqual(step.cost_total, 60.0) + + +class TestFpJobStepTimeLog(TransactionCase): + def setUp(self): + super().setUp() + self.partner = self.env['res.partner'].create({'name': 'Cust'}) + self.product = self.env['product.product'].create({'name': 'Widget'}) + self.wc = self.env['fp.work.centre'].create({ + 'name': 'WC', 'code': 'WC', 'kind': 'wet_line', + }) + self.job = self.env['fp.job'].create({ + 'partner_id': self.partner.id, + 'product_id': self.product.id, + 'qty': 1.0, + }) + self.step = self.env['fp.job.step'].create({ + 'job_id': self.job.id, + 'name': 'S', + 'sequence': 10, + 'work_centre_id': self.wc.id, + 'state': 'ready', + }) + + def test_start_creates_timelog(self): + self.step.button_start() + self.assertEqual(len(self.step.time_log_ids), 1) + self.assertFalse(self.step.time_log_ids[0].date_finished) + self.assertEqual(self.step.time_log_ids[0].user_id, self.env.user) + + def test_finish_closes_timelog(self): + self.step.button_start() + self.step.button_finish() + log = self.step.time_log_ids[0] + self.assertTrue(log.date_finished) + self.assertGreaterEqual(log.duration_minutes, 0.0) + # duration_actual on the step should match the sum of timelog durations + self.assertEqual(self.step.duration_actual, log.duration_minutes)