diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py index 35e4ff27..b4ed0b2b 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.18.15.0', + 'version': '19.0.18.15.3', 'category': 'Manufacturing/Plating', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'description': """ 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 e5c1c692..684d1152 100644 --- a/fusion_plating/fusion_plating/models/fp_job_step_timelog.py +++ b/fusion_plating/fusion_plating/models/fp_job_step_timelog.py @@ -11,12 +11,14 @@ # Replicates Odoo MRP's mrp.workorder.time_ids granularity natively # (without depending on the mrp module). -from odoo import api, fields, models +from odoo import _, api, fields, models +from odoo.exceptions import AccessError, UserError class FpJobStepTimeLog(models.Model): _name = 'fp.job.step.timelog' _description = 'Plating Job Step Time Log' + _inherit = ['mail.thread'] _order = 'date_started desc' step_id = fields.Many2one( @@ -25,9 +27,9 @@ class FpJobStepTimeLog(models.Model): ondelete='cascade', index=True, ) - user_id = fields.Many2one('res.users', required=True) - date_started = fields.Datetime(required=True) - date_finished = fields.Datetime() + user_id = fields.Many2one('res.users', required=True, tracking=True) + date_started = fields.Datetime(required=True, tracking=True) + date_finished = fields.Datetime(tracking=True) duration_minutes = fields.Float( compute='_compute_duration', store=True, ) @@ -83,9 +85,9 @@ class FpJobStepTimeLog(models.Model): 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_hrs = fields.Integer(string='Billed Hours', tracking=True) + billed_min = fields.Integer(string='Billed Minutes', tracking=True) + billed_sec = fields.Integer(string='Billed Seconds', tracking=True) billed_total_seconds = fields.Integer( string='Billed Total (sec)', compute='_compute_billed_total_seconds', store=True, @@ -133,3 +135,130 @@ class FpJobStepTimeLog(models.Model): rec.billed_pct = 100.0 * rec.billed_total_seconds / rec.accrued_seconds else: rec.billed_pct = 0.0 + + + # ===== State-transition actions ======================================== + # Surfaced as header buttons on the form; each posts an entry to chatter + # so the audit trail covers manual fixes (especially manager overrides). + + def action_pause(self): + """Running → Paused. Stamp last_paused_at so the gap to resume + gets folded into total_paused_seconds when the operator comes back.""" + for rec in self: + if rec.state != 'running': + raise UserError(_( + 'Only a running timelog can be paused (currently %s).' + ) % (rec.state or 'unknown')) + rec.write({ + 'state': 'paused', + 'last_paused_at': fields.Datetime.now(), + }) + rec.message_post(body=_('Timer paused.')) + return True + + def action_resume(self): + """Paused → Running. Fold the elapsed pause into total_paused_seconds + so accrued_seconds keeps subtracting pause windows correctly.""" + for rec in self: + if rec.state != 'paused': + raise UserError(_( + 'Only a paused timelog can be resumed (currently %s).' + ) % (rec.state or 'unknown')) + elapsed = 0 + if rec.last_paused_at: + delta = fields.Datetime.now() - rec.last_paused_at + elapsed = max(0, int(delta.total_seconds())) + rec.write({ + 'state': 'running', + 'last_paused_at': False, + 'total_paused_seconds': (rec.total_paused_seconds or 0) + elapsed, + }) + rec.message_post(body=_( + 'Timer resumed (+%d s added to paused total).' + ) % elapsed) + return True + + def action_stop(self): + """Anything → Stopped. Sets date_finished if blank so accrued_seconds + freezes. Folds any in-flight pause into total_paused_seconds.""" + for rec in self: + if rec.state in ('stopped', 'reconciled'): + raise UserError(_( + 'Timelog is already %s.' + ) % rec.state) + now = fields.Datetime.now() + vals = {'state': 'stopped'} + if not rec.date_finished: + vals['date_finished'] = now + if rec.state == 'paused' and rec.last_paused_at: + gap = max(0, int((now - rec.last_paused_at).total_seconds())) + vals['total_paused_seconds'] = (rec.total_paused_seconds or 0) + gap + vals['last_paused_at'] = False + rec.write(vals) + rec.message_post(body=_('Timer stopped.')) + return True + + def action_reconcile(self): + """Stopped → Reconciled. Freezes billed_* against further edits + (the form already gates these readonly when state=reconciled).""" + for rec in self: + if rec.state != 'stopped': + raise UserError(_( + 'Only a stopped timelog can be reconciled (currently %s).' + ) % (rec.state or 'unknown')) + rec.write({'state': 'reconciled'}) + rec.message_post(body=_('Timer reconciled.')) + return True + + def action_reset_to_running(self): + """Manager+ only: rewind a closed or stuck timelog back to running. + Clears date_finished, last_paused_at, total_paused_seconds so accrued + starts fresh from the original date_started. Use for genuine corrections + only — the audit-trail entry names who did it.""" + if not self.env.user.has_group( + 'fusion_plating.group_fusion_plating_manager'): + raise AccessError(_( + 'Only Plating Manager+ can reset a timelog state.' + )) + for rec in self: + rec.write({ + 'state': 'running', + 'date_finished': False, + 'last_paused_at': False, + 'total_paused_seconds': 0, + }) + rec.message_post(body=_( + 'Timer reset to running by %s.' + ) % self.env.user.name) + return True + + + # ------------------------------------------------------------------ + # Auto-resync hooks — keep fp.job.step.duration_actual in sync with + # the timelog rows. Without these, editing a timelog's start/end + # leaves the step's "Actual Min" column showing stale data. + # ------------------------------------------------------------------ + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + records.mapped('step_id')._fp_resum_duration_actual() + return records + + def write(self, vals): + steps_before = self.mapped('step_id') + res = super().write(vals) + # Only resum when something that affects duration changed. + # date_started / date_finished feed duration_minutes (compute + # on this model); step_id reassignment means we need to resum + # both old and new parents. + if any(k in vals for k in ('date_started', 'date_finished', 'step_id')): + steps_after = self.mapped('step_id') + (steps_before | steps_after)._fp_resum_duration_actual() + return res + + def unlink(self): + steps = self.mapped('step_id') + res = super().unlink() + steps._fp_resum_duration_actual() + return res diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py index 5ebf8994..0cbbd692 100644 --- a/fusion_plating/fusion_plating_jobs/__manifest__.py +++ b/fusion_plating/fusion_plating_jobs/__manifest__.py @@ -3,7 +3,7 @@ # License OPL-1 (Odoo Proprietary License v1.0) { 'name': 'Fusion Plating — Native Jobs', - 'version': '19.0.8.20.1', + 'version': '19.0.8.20.2', 'category': 'Manufacturing/Plating', 'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.', 'author': 'Nexa Systems Inc.', diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job_step.py b/fusion_plating/fusion_plating_jobs/models/fp_job_step.py index fae21e65..5eee6827 100644 --- a/fusion_plating/fusion_plating_jobs/models/fp_job_step.py +++ b/fusion_plating/fusion_plating_jobs/models/fp_job_step.py @@ -320,16 +320,11 @@ class FpJobStep(models.Model): return True def action_recompute_duration_from_timelogs(self): - """Re-sum duration_actual from the step's timelog rows. - - Use case: supervisor adjusts a timelog row (back-date a forgotten - click, fix wrong operator, delete a stale entry that was left - open over a shift change) and needs the step's duration_actual - to reflect the corrected reality. Without this, edits to time_log_ids - rows don't propagate into duration_actual (which is set once - by button_finish). - - Posts the before/after to chatter for audit. + """Manual button — re-sum duration_actual + post to chatter + for audit. Use case: supervisor adjusts a timelog row and + wants an explicit audit trail of the recompute. The + automatic version called from timelog hooks is + _fp_resum_duration_actual (no chatter). """ for step in self: old = step.duration_actual or 0.0 @@ -342,6 +337,16 @@ class FpJobStep(models.Model): )) % (step.name, old, new, new - old, self.env.user.name)) return True + def _fp_resum_duration_actual(self): + """Quiet re-sum — used by automatic triggers (timelog + create/write/unlink hooks). No chatter post. Skips no-op + updates so writes are minimised.""" + for step in self: + new = sum(step.time_log_ids.mapped('duration_minutes')) + if abs((step.duration_actual or 0.0) - new) > 0.001: + step.duration_actual = new + return True + def action_finish_and_advance(self): """Steelhead-style "Finish & Next" — finish this step then auto- start the next pending/ready step in sequence. Single click