# -*- coding: utf-8 -*- # Copyright 2024-2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) """Flowchart runtime - one fusion.repair.flowchart.run per CS troubleshooting session, with one fusion.repair.flowchart.run.step per node the rep visited. The transcript is the audit + handoff artefact: when CS resolves on-call we post it to chatter; when they escalate we copy it to internal_notes so the dispatched tech can see what was tried BEFORE arriving at the client. """ import logging from markupsafe import Markup from odoo import _, api, fields, models from odoo.exceptions import UserError _logger = logging.getLogger(__name__) RUN_OUTCOMES = [ ('in_progress', 'In Progress'), ('resolved', 'Resolved on Call'), ('escalated', 'Escalated to Technician'), ('order_part', 'Captured Part Order'), ('abandoned', 'Abandoned'), ] class FusionRepairFlowchartRun(models.Model): _name = 'fusion.repair.flowchart.run' _inherit = ['mail.thread'] _description = 'Flowchart Troubleshooting Run' _order = 'started_at desc, id desc' name = fields.Char( compute='_compute_name', store=True, ) repair_order_id = fields.Many2one( 'repair.order', required=True, index=True, ondelete='cascade', ) flowchart_id = fields.Many2one( 'fusion.repair.flowchart', required=True, index=True, ondelete='restrict', ) flowchart_version = fields.Integer( string='Chart Version Snapshot', help='Version of the flowchart at the time the run was started; ' 'so the transcript stays valid even if the chart is later edited.', ) partner_id = fields.Many2one( related='repair_order_id.partner_id', store=True, readonly=True, ) started_at = fields.Datetime(default=fields.Datetime.now, required=True) started_by_id = fields.Many2one( 'res.users', default=lambda self: self.env.user, required=True, ) completed_at = fields.Datetime() completed_by_id = fields.Many2one('res.users') outcome = fields.Selection( RUN_OUTCOMES, default='in_progress', required=True, tracking=True, ) step_ids = fields.One2many( 'fusion.repair.flowchart.run.step', 'run_id', string='Steps', ) step_count = fields.Integer(compute='_compute_step_count') current_node_id = fields.Many2one( 'fusion.repair.flowchart.node', compute='_compute_current_node', store=True, help='Where the runner is right now - last step\'s node if no edge ' 'was chosen yet, otherwise the edge\'s target node.', ) transcript_html = fields.Html( compute='_compute_transcript', sanitize=False, # we control the markup ) company_id = fields.Many2one( 'res.company', default=lambda self: self.env.company, ) @api.depends('repair_order_id.name', 'flowchart_id.name') def _compute_name(self): for r in self: r.name = _('%s on %s') % ( r.flowchart_id.name or '?', r.repair_order_id.name or '?', ) @api.depends('step_ids') def _compute_step_count(self): for r in self: r.step_count = len(r.step_ids) @api.depends('step_ids.chosen_edge_id', 'step_ids.node_id', 'flowchart_id.start_node_id') def _compute_current_node(self): for r in self: if not r.step_ids: r.current_node_id = r.flowchart_id.start_node_id continue last = r.step_ids[-1] # If they chose an edge on the last step, we should already be # showing the target. If not (just landed on a card), it's the # current node itself. if last.chosen_edge_id: r.current_node_id = last.chosen_edge_id.target_node_id else: r.current_node_id = last.node_id @api.depends('step_ids.node_name_snapshot', 'step_ids.chosen_label_snapshot', 'step_ids.cs_note', 'outcome') def _compute_transcript(self): for r in self: if not r.step_ids: r.transcript_html = '
No steps recorded yet.
' continue lines = ['Outcome: {outcome_label}
') r.transcript_html = ''.join(lines) # ------------------------------------------------------------------ # RUNTIME RPC API (called from the OWL runner component) # ------------------------------------------------------------------ @api.model def runtime_start_or_resume(self, repair_id): """Open or resume a troubleshooting run for the given repair. - If an in-progress run exists, return it. - Otherwise resolve the flowchart from the repair's (category, symptom) and create a fresh run starting at the chart's start node. Returns the dict shape that the OWL runner consumes. """ repair = self.env['repair.order'].browse(repair_id).exists() if not repair: raise UserError(_('Repair order not found.')) existing = self.search([ ('repair_order_id', '=', repair.id), ('outcome', '=', 'in_progress'), ], limit=1) if existing: return existing._runtime_dict() if not repair.x_fc_symptom_class_id: raise UserError(_('Pick a Symptom on the repair before starting ' 'troubleshooting.')) chart = self.env['fusion.repair.flowchart'].sudo().search([ ('category_id', '=', repair.x_fc_repair_category_id.id), ('symptom_class_id', '=', repair.x_fc_symptom_class_id.id), ('published', '=', True), ('active', '=', True), ], limit=1) if not chart: raise UserError(_( 'No published troubleshooting flowchart found for ' '%(cat)s + %(sym)s. Ask a manager to publish one in ' 'Configuration > Troubleshooting Flowcharts.' ) % { 'cat': repair.x_fc_repair_category_id.name or '?', 'sym': repair.x_fc_symptom_class_id.name or '?', }) run = self.sudo().create({ 'repair_order_id': repair.id, 'flowchart_id': chart.id, 'flowchart_version': chart.version, }) return run._runtime_dict() def runtime_choose(self, edge_id, cs_note=''): """CS clicked an answer button on the current card. Records a step (current_node + chosen_edge + note) and returns the updated run dict (which the OWL component uses to render the next card). """ self.ensure_one() if self.outcome != 'in_progress': raise UserError(_('This run has already been completed.')) edge = self.env['fusion.repair.flowchart.edge'].browse(edge_id).exists() if not edge: raise UserError(_('Chosen answer not found.')) if edge.source_node_id != self.current_node_id: raise UserError(_( 'That answer does not belong to the current step. The chart ' 'may have been edited - please reload the runner.' )) self.env['fusion.repair.flowchart.run.step'].sudo().create({ 'run_id': self.id, 'sequence': len(self.step_ids) + 1, 'node_id': self.current_node_id.id, 'node_name_snapshot': self.current_node_id.name, 'chosen_edge_id': edge.id, 'chosen_label_snapshot': edge.label, 'cs_note': cs_note or False, }) # current_node_id auto-recomputes via depends self.invalidate_recordset(['current_node_id']) return self._runtime_dict() def runtime_complete(self, outcome, cs_note=''): """CS hit a terminal outcome (resolved / escalate / order_part) OR manually abandoned. Closes the run and triggers downstream effects.""" self.ensure_one() valid = {'resolved', 'escalated', 'order_part', 'abandoned'} if outcome not in valid: raise UserError(_('Unknown outcome %s.') % outcome) if self.outcome != 'in_progress': return self._runtime_dict() # Snapshot the final node as a step so the transcript shows it. node = self.current_node_id if node and (not self.step_ids or self.step_ids[-1].node_id != node): self.env['fusion.repair.flowchart.run.step'].sudo().create({ 'run_id': self.id, 'sequence': len(self.step_ids) + 1, 'node_id': node.id, 'node_name_snapshot': node.name, 'cs_note': cs_note or False, }) self.write({ 'outcome': outcome, 'completed_at': fields.Datetime.now(), 'completed_by_id': self.env.uid, }) # Dispatch the side-effects on the repair (see _apply_outcome). self._apply_outcome() return self._runtime_dict() def _apply_outcome(self): """Run the side-effects on the parent repair depending on outcome.""" for r in self: repair = r.repair_order_id transcript = Markup(r.transcript_html or '') if r.outcome == 'resolved': repair.x_fc_resolved_on_call = True repair.message_post(body=Markup(_( 'Resolved on call via troubleshooting flowchart ' '%(name)s:
%(transcript)s' )) % {'name': r.flowchart_id.name, 'transcript': transcript}) # Close the repair through the Odoo state machine. Reuse the # visit-report wizard helper pattern. try: if repair.state == 'draft': try: repair.action_validate() except Exception: repair._action_repair_confirm() if repair.state == 'confirmed': repair.action_repair_start() if repair.state == 'under_repair': repair.action_repair_end() except Exception as e: _logger.warning( 'Could not auto-close repair %s after on-call ' 'resolution: %s', repair.name, e, ) elif r.outcome == 'escalated': # Prepend the transcript to internal_notes so the tech sees # what was tried at the top of the form. existing = repair.internal_notes or '' header = Markup( 'CS troubleshooting summary (flowchart %s):
' ) % r.flowchart_id.name repair.internal_notes = (header + transcript + Markup('Escalated to technician via troubleshooting ' 'flowchart %(name)s:
%(transcript)s' )) % {'name': r.flowchart_id.name, 'transcript': transcript}) elif r.outcome == 'order_part': repair.message_post(body=Markup(_( 'Part order needed per troubleshooting flowchart ' '%(name)s:
%(transcript)s' )) % {'name': r.flowchart_id.name, 'transcript': transcript}) else: # abandoned repair.message_post(body=Markup(_( 'Troubleshooting run abandoned by %(user)s.
' '%(transcript)s' )) % { 'user': self.env.user.name, 'transcript': transcript, }) def _runtime_dict(self): """Serialise the run + current node + available edges for the OWL UI.""" self.ensure_one() node = self.current_node_id outgoing = self.env['fusion.repair.flowchart.edge'].sudo().search([ ('source_node_id', '=', node.id), ], order='sequence, id') if node else self.env['fusion.repair.flowchart.edge'] return { 'run_id': self.id, 'repair_id': self.repair_order_id.id, 'repair_name': self.repair_order_id.name, 'partner_name': self.partner_id.name or '', 'flowchart_id': self.flowchart_id.id, 'flowchart_name': self.flowchart_id.name, 'canvas_layout': self.flowchart_id.canvas_layout or '', 'outcome': self.outcome, 'current_node': node._designer_dict() if node else None, 'options': [{ 'edge_id': e.id, 'label': e.label or _('Continue'), 'target_node_id': e.target_node_id.id, 'target_node_type': e.target_node_id.node_type, 'target_outcome_kind': e.target_node_id.outcome_kind or '', } for e in outgoing], 'transcript_html': self.transcript_html, 'visited_node_ids': [s.node_id.id for s in self.step_ids if s.node_id], 'visited_edge_ids': [s.chosen_edge_id.id for s in self.step_ids if s.chosen_edge_id], 'step_count': self.step_count, } class FusionRepairFlowchartRunStep(models.Model): _name = 'fusion.repair.flowchart.run.step' _description = 'Flowchart Run Step' _order = 'run_id, sequence, id' run_id = fields.Many2one( 'fusion.repair.flowchart.run', required=True, index=True, ondelete='cascade', ) sequence = fields.Integer(default=10) node_id = fields.Many2one( 'fusion.repair.flowchart.node', ondelete='set null', index=True, ) node_name_snapshot = fields.Char( help='Frozen at the moment the step was recorded so transcript ' 'survives later chart edits.', ) chosen_edge_id = fields.Many2one( 'fusion.repair.flowchart.edge', ondelete='set null', ) chosen_label_snapshot = fields.Char() cs_note = fields.Text() recorded_at = fields.Datetime(default=fields.Datetime.now)