# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) # # Real implementations for the state-machine action stubs that # fusion_plating core's fp.job.step shipped as NotImplementedError # placeholders. Per spec §5.2 state machine. import logging import re from markupsafe import Markup from odoo import _, api, fields, models from odoo.exceptions import UserError _logger = logging.getLogger(__name__) class FpJobStep(models.Model): _inherit = 'fp.job.step' def button_start(self): """Override — soft gate when parts haven't been received yet, plus hard predecessor gate for steps flagged requires_predecessor_done by the recipe author. Receiving check is soft (logs to chatter) — manager wants the shop to start prep regardless when parts are in-transit late. Predecessor check IS hard-blocking — if the recipe author marked this step as serial-required, every earlier-sequence step must be terminal (done / skipped / cancelled) before Start fires. Manager bypass via fp_skip_predecessor_check=True. """ skip_pred = self.env.context.get('fp_skip_predecessor_check') for step in self: if not step.requires_predecessor_done or skip_pred: continue blocking = step.job_id.step_ids.filtered( lambda s: s.sequence < step.sequence and s.state not in ( 'done', 'skipped', 'cancelled', ) ) if blocking: raise UserError(_( "Step '%s' requires predecessors done first. " "Blocking earlier step(s):\n %s\n\nFinish or skip " "those before starting this one (manager can " "override via context fp_skip_predecessor_check=True)." ) % ( step.name, '\n '.join( f'#{s.sequence} {s.name} ({s.state})' for s in blocking[:5] ), )) result = super().button_start() for step in self: so = step.job_id.sale_order_id if not so: continue recv = so.x_fc_receiving_status if ( 'x_fc_receiving_status' in so._fields ) else None if recv in (False, None, 'not_received'): step.job_id.message_post(body=_( 'Step "%(step)s" started before parts were received ' '(SO %(so)s — receiving status: %(status)s). ' 'Confirm the parts are physically on the floor before ' 'continuing.' ) % { 'step': step.name, 'so': so.name or '', 'status': recv or 'unknown', }) return result def button_pause(self): """Pause an in-progress step (operator break, end of shift). Closes the open timelog row, sums duration_actual, transitions state to 'paused'. button_start re-opens a fresh timelog when the operator resumes. """ for step in self: if step.state != 'in_progress': raise UserError(_( "Step '%s' is in state '%s' — only in-progress steps can pause." ) % (step.name, step.state)) now = fields.Datetime.now() open_log = step.time_log_ids.filtered(lambda l: not l.date_finished) if open_log: open_log.write({'date_finished': now}) step.state = 'paused' step.duration_actual = sum(step.time_log_ids.mapped('duration_minutes')) return True def button_skip(self): """Skip a pending/ready step (e.g. opt-in step the planner decided not to activate for this job). """ for step in self: if step.state not in ('pending', 'ready'): raise UserError(_( "Step '%s' is in state '%s' — only pending/ready steps can be skipped." ) % (step.name, step.state)) step.state = 'skipped' return True def button_cancel(self): """Cancel a single step. Use fp.job.action_cancel to cancel the whole job. """ for step in self: if step.state == 'done': raise UserError(_( "Step '%s' is done — cannot cancel." ) % step.name) if step.state == 'cancelled': raise UserError(_( "Step '%s' is already cancelled." ) % step.name) step.state = 'cancelled' return True def write(self, vals): """Post a chatter trail on the parent JOB whenever an active step gets reassigned. The step itself already tracks assigned_user_id (tracking=True) but supervisors don't open each step's chatter — they read the job. Without a job-level post the takeover is invisible. Only fires for steps in active states (in_progress / paused) so creating a draft job + assigning a step to someone doesn't spam the job chatter. Comparing to the OLD assignment so we don't post on the initial set-from-False either. """ post_for = [] if 'assigned_user_id' in vals: new_uid = vals['assigned_user_id'] for step in self: if step.state not in ('in_progress', 'paused'): continue old_uid = step.assigned_user_id.id if not old_uid: continue if new_uid == old_uid: continue post_for.append((step, old_uid, new_uid)) result = super().write(vals) Users = self.env['res.users'] for step, old_uid, new_uid in post_for: old_name = Users.browse(old_uid).name if old_uid else '(unassigned)' new_name = Users.browse(new_uid).name if new_uid else '(unassigned)' step.job_id.message_post(body=Markup(_( 'Step %s reassigned from %s to %s ' '(state=%s) by %s.' )) % (step.name, old_name, new_name, step.state, self.env.user.name)) return result @api.model def _cron_nudge_stale_paused(self, threshold_hours=24): """Daily nudge for steps stuck in `paused` longer than threshold.""" return self._cron_nudge_stale_steps( states=('paused',), threshold_hours=threshold_hours, label='paused', ) @api.model def _cron_nudge_stale_in_progress(self, threshold_hours=8): """Cron nudge for steps stuck in `in_progress` longer than threshold. Default 8 hours — operator started, walked away, timelog accumulating phantom hours. """ return self._cron_nudge_stale_steps( states=('in_progress',), threshold_hours=threshold_hours, label='in-progress', ) @api.model def _cron_nudge_stale_steps(self, states=('paused',), threshold_hours=24, label='stale'): """Generic stale-step nudger. Finds every fp.job.step in any of `states` with date_started older than N hours. Schedules a 'todo' mail.activity on the parent job for the job's manager_id (falls back to the user who started the step). Idempotent — won't double-schedule if an open activity with the same summary already exists. """ from datetime import timedelta as _td cutoff = fields.Datetime.now() - _td(hours=threshold_hours) stale = self.search([ ('state', 'in', list(states)), ('date_started', '<', cutoff), ('date_started', '!=', False), ]) Activity = self.env['mail.activity'] ActivityType = self.env.ref( 'mail.mail_activity_data_todo', raise_if_not_found=False, ) nudged_count = 0 for step in stale: job = step.job_id assignee = (job.manager_id or step.assigned_user_id or step.started_by_user_id or self.env.user) summary = _('Stale %s step: %s') % (label, step.name) existing = Activity.search([ ('res_model', '=', job._name), ('res_id', '=', job.id), ('summary', '=', summary), ], limit=1) if existing: continue age_h = (fields.Datetime.now() - step.date_started).total_seconds() / 3600.0 note = _( 'Step "%(step)s" on job %(job)s has been in %(label)s state for ' '%(hours).1f hours (since %(start)s). Investigate: operator ' 'reassignment, equipment failure, or finish + close out.' ) % { 'step': step.name, 'job': job.name, 'label': label, 'hours': age_h, 'start': step.date_started, } vals = { 'res_model_id': self.env['ir.model']._get(job._name).id, 'res_id': job.id, 'summary': summary, 'note': note, 'user_id': assignee.id, 'date_deadline': fields.Date.context_today(self), } if ActivityType: vals['activity_type_id'] = ActivityType.id Activity.create(vals) nudged_count += 1 job.message_post(body=Markup(_( 'Stale %s step: %s has been idle %.1f hours. ' 'Activity created for %s.' )) % (label, step.name, age_h, assignee.name)) if nudged_count: _logger.info( 'fp.job.step stale-%s cron: nudged %d step(s)', label, nudged_count, ) return nudged_count def action_abort_for_retry(self, reason=None, new_tank_id=None, new_bath_id=None): """Abort an in_progress / paused step so the operator can restart it (typically after an equipment failure mid-step). Closes the open timelog (preserves the partial-work record on the audit trail), posts a clear chatter event on the JOB explaining why + which tank, optionally moves the step to a different tank/bath, and resets the step to `ready` so the operator can hit Start again. Without this method the operator's only options are button_cancel (kills the step entirely) or pause → write tank → start (no failure audit). """ if not reason: reason = _('Equipment failure / abort for retry') for step in self: if step.state not in ('in_progress', 'paused'): raise UserError(_( "Step '%s' is in state '%s' — only in_progress / " "paused steps can be aborted for retry." ) % (step.name, step.state)) old_tank = step.tank_id.display_name or '(no tank set)' old_bath = step.bath_id.display_name or '(no bath set)' now = fields.Datetime.now() open_logs = step.time_log_ids.filtered( lambda l: not l.date_finished ) if open_logs: open_logs.write({'date_finished': now}) partial_min = sum(step.time_log_ids.mapped('duration_minutes')) change_msg = '' if new_tank_id: step.tank_id = new_tank_id change_msg += ' -> tank %s' % step.tank_id.display_name if new_bath_id: step.bath_id = new_bath_id change_msg += ' -> bath %s' % step.bath_id.display_name step.state = 'ready' step.duration_actual = partial_min step.job_id.message_post(body=Markup(_( '⚠️ Step %s aborted for retry by %s.
' 'Reason: %s
' 'Equipment: tank=%s, bath=%s%s
' 'Partial work captured: %.2f min in %d timelog(s). ' 'Step is back in ready state — operator can ' 'restart when the issue is resolved.' )) % ( step.name, self.env.user.name, reason, old_tank, old_bath, change_msg, partial_min, len(step.time_log_ids), )) 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. """ for step in self: old = step.duration_actual or 0.0 new = sum(step.time_log_ids.mapped('duration_minutes')) step.duration_actual = new if abs(old - new) > 0.001: step.job_id.message_post(body=Markup(_( 'Step %s duration recomputed from timelog rows: ' '%.2f min → %.2f min (Δ %+.2f). Recomputed by %s.' )) % (step.name, old, new, new - old, self.env.user.name)) return True def button_finish(self): """Override to: 1) Auto-spawn a bake.window when a wet plating step finishes on a coating that requires hydrogen-embrittlement relief (AS9100 / Nadcap compliance); 2) Post a chatter warning when duration_actual exceeds 1.5× duration_expected — silent overruns are a red flag for scheduling and costing. Both actions are idempotent and never block the finish itself. """ result = super().button_finish() BW = self.env['fusion.plating.bake.window'] Bath = self.env['fusion.plating.bath'] for step in self: if step.state != 'done': continue # Duration-overrun chatter alert. if step.duration_expected and step.duration_actual: ratio = step.duration_actual / step.duration_expected if ratio >= 1.5: step.job_id.message_post(body=Markup(_( '⚠️ Step "%s" ran %.1fx expected — ' 'expected %.0f min, actual %.0f min. Investigate: ' 'equipment issue, training gap, or recipe time ' 'estimate too tight.' )) % (step.name, ratio, step.duration_expected, step.duration_actual)) coating = step.job_id.coating_config_id \ if 'coating_config_id' in step.job_id._fields else False if not coating: continue requires = getattr(coating, 'requires_bake_relief', False) window_hrs = getattr(coating, 'bake_window_hours', 0.0) if not requires or not window_hrs: continue # Trigger only on the actual plating-out step. We want # exactly ONE bake.window per job (not one per step that # happens to have "plate" in the name). Heuristic: # - step.kind == 'wet' (clean, recipe-authored signal); OR # - the step name contains "plating" as a word # Explicit excludes: inspection / bake / mask / rack steps # whose names might happen to mention plating in passing # (e.g. "Post-plate Inspection"). name_l = (step.name or '').lower() kind_match = step.kind == 'wet' name_match = bool(re.search(r'\bplating\b', name_l)) excluded = any(kw in name_l for kw in ( 'inspect', 'inspection', 'bake', 'mask', 'rack', )) if (not kind_match and not name_match) or excluded: continue # Idempotency — only one bake.window per (job, step). existing = BW.sudo().search([ ('part_ref', '=', step.job_id.name), ('lot_ref', '=', f'step-{step.id}'), ], limit=1) if existing: continue # Pick a bath: step.bath_id wins; fall back to the first # active bath in the facility (best-effort — operator can # correct on the bake.window record). bath = step.bath_id or Bath.sudo().search( [('facility_id', '=', step.facility_id.id)], limit=1, ) if step.facility_id else False if not bath: bath = Bath.sudo().search([], limit=1) if not bath: _logger.warning( 'Step %s: bake-window auto-spawn skipped — no bath ' 'configured.', step.name, ) continue bw = BW.sudo().create({ 'bath_id': bath.id, 'plate_exit_time': step.date_finished or fields.Datetime.now(), 'window_hours': window_hrs, 'part_ref': step.job_id.name, 'lot_ref': f'step-{step.id}', 'customer_ref': step.job_id.partner_id.display_name or '', 'quantity': int(step.job_id.qty or 0), }) step.job_id.message_post(body=Markup(_( 'Bake window %s auto-created — %.1fh window from ' 'plate exit. Required by %s.' )) % (bw.name, window_hrs, bw.bake_required_by)) return result # ================================================================== # Phase 2 multi-serial — auto-promote serials on step transitions # ================================================================== def _fp_promote_serials_on_start(self): """When this step transitions to in_progress, lift any serial attached to the parent SO line out of `received` / `racked` and into `in_process`. Idempotent — already-promoted serials are skipped. """ for step in self: job = step.job_id if not job.sale_order_line_ids: continue serials = job.sale_order_line_ids.mapped('x_fc_serial_ids') to_promote = serials.filtered( lambda s: s.state in ('received', 'racked') ) if to_promote: # Use sudo on the helper so operator-tier users can promote # serial state without needing direct write on fp.serial. to_promote.sudo()._set_state('in_process', message=_( 'Promoted to In Process on step "%s" start by %s.' ) % (step.name, self.env.user.name)) def _fp_promote_serials_on_finish(self): """When the LAST step of this step's job finishes (sequenced terminal step OR an explicit inspect/final-inspect kind), bump in-flight serials to `inspected` so the shipper sees them ready for packing. Conservative — only promotes from `in_process`.""" for step in self: job = step.job_id if not job.sale_order_line_ids: continue # Is this the highest-sequence non-cancelled step on the job? siblings = job.step_ids.filtered( lambda s: s.state not in ('cancelled', 'skipped') ) if not siblings: continue last_seq = max(siblings.mapped('sequence')) is_terminal = (step.sequence == last_seq) or ( step.kind == 'inspect' or 'final' in (step.name or '').lower() ) if not is_terminal: continue serials = job.sale_order_line_ids.mapped('x_fc_serial_ids') to_promote = serials.filtered(lambda s: s.state == 'in_process') if to_promote: to_promote.sudo()._set_state('inspected', message=_( 'Promoted to Inspected on step "%s" finish by %s.' ) % (step.name, self.env.user.name)) # ================================================================== # Policy B (2026-04-28) — Contract Review enforcement # ================================================================== # When a recipe author drops a "Contract Review" step into a recipe, # button_start opens the QA-005 audit form for the linked part (auto- # creates one if missing) and button_finish blocks completion until # the form is `complete` AND the current user is on the recipe's # contract_review_user_ids approver list (when configured). # # Detection — case-insensitive match on the step name OR # recipe_node_id mapped from a step.template with default_kind == # 'contract_review' (the simple-editor library entry). def _fp_is_contract_review_step(self): self.ensure_one() if (self.name or '').strip().lower() in ('contract review', 'qa-005'): return True node = self.recipe_node_id if not node: return False # Source template kind (when authored via simple editor library) if 'source_template_id' in node._fields and node.source_template_id: if node.source_template_id.default_kind == 'contract_review': return True if 'default_kind' in node._fields and node.default_kind == 'contract_review': return True return False def _fp_resolve_contract_review_part(self): """Find the fp.part.catalog this step's job is for. Used by the Contract Review hooks to auto-create / look up the QA-005 form. Falls through to None when no part can be resolved (no SO line, SO line without x_fc_part_catalog_id, etc.).""" self.ensure_one() for so_line in self.job_id.sale_order_line_ids: if (so_line.x_fc_part_catalog_id and 'fp.contract.review' in self.env): return so_line.x_fc_part_catalog_id return None def _fp_open_contract_review(self): """Auto-create the QA-005 form for this step's part if missing, return the act_window pointing at it. Called from button_start on Contract Review steps.""" self.ensure_one() part = self._fp_resolve_contract_review_part() if not part: return None Review = self.env.get('fp.contract.review') if Review is None: return None # quality module not installed — skip review = part.x_fc_contract_review_id if not review: review = Review.sudo().create({ 'part_id': part.id, 'state': 'assistant_review', }) part.sudo().write({ 'x_fc_contract_review_id': review.id, 'x_fc_contract_review_dismissed': False, }) self.job_id.message_post(body=_( 'Contract Review (QA-005) auto-created for %(part)s on ' 'Contract Review step start by %(user)s.' ) % { 'part': part.display_name or part.part_number or '', 'user': self.env.user.name, }) return { 'type': 'ir.actions.act_window', 'res_model': 'fp.contract.review', 'res_id': review.id, 'view_mode': 'form', 'target': 'current', 'name': _('Contract Review — %s') % ( part.display_name or part.part_number or '' ), } def _fp_check_contract_review_complete(self): """Block button_finish on a Contract Review step until QA-005 is signed off. Only enforced when the customer has partner.x_fc_contract_review_required=True. Manager bypass via context fp_skip_contract_review_gate=True.""" if self.env.context.get('fp_skip_contract_review_gate'): return for step in self: if not step._fp_is_contract_review_step(): continue part = step._fp_resolve_contract_review_part() if not part or not part.partner_id.x_fc_contract_review_required: continue review = part.x_fc_contract_review_id if not review or review.state != 'complete': state_label = ( review.state if review else _('not started') ) raise UserError(_( 'Contract Review for %(part)s is %(state)s — must be ' '"complete" before this step can finish. Open the ' 'QA-005 form (smart button on the part), get both ' 'sections signed off, then retry. Manager bypass: ' 'fp_skip_contract_review_gate=True in context.' ) % { 'part': part.display_name or part.part_number or '', 'state': state_label, }) # Approver-list gate (restored from pre-Sub-11). When the # recipe author named approvers on the recipe root, only those # users can finish the Contract Review step. recipe = step.recipe_node_id and step.recipe_node_id.recipe_root_id approvers = (recipe.contract_review_user_ids if (recipe and 'contract_review_user_ids' in recipe._fields) else False) if approvers and self.env.user not in approvers: raise UserError(_( 'Only authorised Contract Review approvers can finish ' 'this step. Approvers: %s.\n\nContact your Plating ' 'Manager to add yourself if this is wrong, or hand ' 'the step to one of the approvers.' ) % ', '.join(approvers.mapped('name'))) # ================================================================== # Sub 8 follow-up (2026-04-28) — Racking Inspection enforcement # ================================================================== # When the recipe-side "Racking" step starts, auto-promote the linked # fp.racking.inspection from draft → inspecting and route the operator # straight into the inspection form. When the same step finishes, # block unless the inspection is in `done` or `discrepancy_flagged` # (operator cleared every line). Manager bypass via context # `fp_skip_racking_inspection_gate=True`. def _fp_is_racking_step(self): self.ensure_one() if (self.name or '').strip().lower() in ('racking', 'rack'): return True node = self.recipe_node_id if not node: return False if 'source_template_id' in node._fields and node.source_template_id: if node.source_template_id.default_kind == 'racking': return True if 'default_kind' in node._fields and node.default_kind == 'racking': return True if self.kind == 'rack': return True return False def _fp_open_racking_inspection(self): """Auto-promote draft → inspecting + return act_window for the linked racking inspection. Auto-creates one if missing.""" self.ensure_one() if 'fp.racking.inspection' not in self.env: return None # Reach the job's existing inspection (auto-created on action_confirm) # or trigger a fresh create if none exists. ri = self.job_id.racking_inspection_id if not ri: self.job_id._fp_create_racking_inspection() self.job_id.invalidate_recordset(['racking_inspection_ids']) ri = self.job_id.racking_inspection_id if not ri: return None # Promote draft → inspecting. action_start raises if state isn't # draft, so guard. if ri.state == 'draft': ri.sudo().action_start() self.job_id.message_post(body=_( 'Racking inspection auto-promoted to "Inspecting" on ' '%(step)s start by %(user)s.' ) % {'step': self.name, 'user': self.env.user.name}) return { 'type': 'ir.actions.act_window', 'res_model': 'fp.racking.inspection', 'res_id': ri.id, 'view_mode': 'form', 'target': 'current', 'name': _('Racking Inspection — %s') % self.job_id.name, } def _fp_check_racking_inspection_complete(self): """Soft gate — block button_finish on a Racking step until the linked inspection is in a terminal state. discrepancy_flagged counts as complete (the operator finished but flagged issues — the discrepancy activity will route to the manager separately).""" if self.env.context.get('fp_skip_racking_inspection_gate'): return for step in self: if not step._fp_is_racking_step(): continue ri = step.job_id.racking_inspection_id if not ri: # No inspection at all — still let it finish, but log a # chatter warning so the manager sees the gap. step.job_id.message_post(body=_( '⚠️ Racking step "%s" finished without a racking ' 'inspection on file. Sub 8 expected one to be ' 'auto-created on job confirm.' ) % step.name) continue if ri.state not in ('done', 'discrepancy_flagged'): state_label = dict(ri._fields['state'].selection).get( ri.state, ri.state) raise UserError(_( 'Racking inspection for %(job)s is "%(state)s" — must ' 'be Done or Discrepancy Flagged before this step can ' 'finish. Click the Racking Insp. smart button on the ' 'job, complete the line check-off, then retry. ' 'Manager bypass: fp_skip_racking_inspection_gate=True.' ) % { 'job': step.job_id.name, 'state': state_label, }) def button_start(self): # Policy B — Contract Review takes priority (auto-opens QA-005). for step in self: if step._fp_is_contract_review_step(): action = step._fp_open_contract_review() if action: super(FpJobStep, step).button_start() if step.state == 'in_progress': step._fp_promote_serials_on_start() return action # Sub 8 — Racking step auto-opens the inspection form. for step in self: if step._fp_is_racking_step(): action = step._fp_open_racking_inspection() if action: super(FpJobStep, step).button_start() if step.state == 'in_progress': step._fp_promote_serials_on_start() return action result = super().button_start() for step in self: if step.state == 'in_progress': step._fp_promote_serials_on_start() return result def button_finish(self): # Policy B — block until QA-005 complete (when customer requires it). self._fp_check_contract_review_complete() # Sub 8 — block until racking inspection is Done / Flagged. self._fp_check_racking_inspection_complete() result = super().button_finish() for step in self: if step.state == 'done': step._fp_promote_serials_on_finish() return result # ================================================================== # Per-row shortcut actions used by the job form's inline action column # ================================================================== def action_open_move_wizard(self): """Open the Move wizard with this step pre-filled as the from-step.""" self.ensure_one() return { 'type': 'ir.actions.act_window', 'res_model': 'fp.job.step.move.wizard', 'view_mode': 'form', 'target': 'new', 'name': _('Move from %s') % self.name, 'context': { 'default_from_step_id': self.id, 'default_job_id': self.job_id.id, }, } def action_open_input_wizard(self): """Open the Input Recording wizard for this step.""" self.ensure_one() return { 'type': 'ir.actions.act_window', 'res_model': 'fp.job.step.input.wizard', 'view_mode': 'form', 'target': 'new', 'name': _('Record Inputs — %s') % self.name, 'context': { 'default_step_id': self.id, }, } # ------------------------------------------------------------------ # Live duration helper — view binds to a non-stored compute that # ticks each time the form re-reads. For a true live ticking clock # we'd need an OWL widget; this gives "minutes since start" that's # accurate at every record refresh, which is good enough for a # backend manager's view. # ------------------------------------------------------------------ duration_running_minutes = fields.Float( string='Running Min', compute='_compute_duration_running', help='Minutes since the step\'s current open timelog started. ' 'Re-reads on every form refresh; equals duration_actual once ' 'the step is finished.', ) @api.depends('state', 'date_started', 'time_log_ids', 'time_log_ids.date_started', 'time_log_ids.date_finished', 'duration_actual') def _compute_duration_running(self): now = fields.Datetime.now() for step in self: if step.state == 'in_progress': # Sum closed intervals + (now - open interval start) closed = sum(step.time_log_ids.mapped('duration_minutes')) open_log = step.time_log_ids.filtered( lambda l: not l.date_finished )[:1] running = 0.0 if open_log and open_log.date_started: delta = (now - open_log.date_started).total_seconds() / 60.0 running = max(0.0, delta) step.duration_running_minutes = closed + running else: step.duration_running_minutes = step.duration_actual or 0.0