Files
Odoo-Modules/fusion_repairs/models/repair_flowchart.py
gsinghpal eb186cac3c feat(fusion_repairs): Bundle 11 - CS guided troubleshooting flowcharts + vendor PO
Two big workflow additions:

1. Visual drag-and-drop flowchart designer (Drawflow) + card-by-card runner
   (with show-whole-tree toggle) so admins build per-(category, symptom)
   decision trees with embedded photos/videos and CS walks callers through
   them on the phone. Resolved-on-call closes the repair; escalated copies
   the full transcript into internal_notes so the dispatched tech sees what
   was already tried before they arrive at the client.

2. Vendor + draft-PO + factory-tracking on the part-order capture. Tech on
   the phone with the factory picks the vendor from contacts, types the OEM
   part #, cost, ETA date (calendar widget), factory ticket #, RA #, ticks
   under_warranty, and the system auto-creates a draft purchase.order with
   the right product (looked up or created from OEM) + activity for the
   office on the ETA day + client email with ETA prominently shown and
   cost intentionally omitted.

NEW MODELS

fusion.repair.symptom.class - lookup table (category + name + code).
  Replaces the flat x_fc_issue_category Char on repair.order. Seeded with
  7 stairlift symptoms + lighter coverage for hospital bed / porch lift /
  lift chair. Equipment Class added to fusion.repair.product.category
  (this carried over from the Bundle 10 plan).

fusion.repair.flowchart + .node + .edge - design-time graph.
  - flowchart has name, category, symptom, version, published flag,
    canvas_layout (Drawflow JSON), node_ids, edge_ids, computed start_node
  - node has node_type (question / suggestion / info / outcome),
    content_html, media_ids (M2M ir.attachment for photos + videos),
    is_start, outcome_kind (resolved / escalate / order_part),
    canvas_x/y for Drawflow round-trip
  - edge has source, target, label, sequence - supports N-ary branching
    (not just Yes/No)
  - designer_load() and designer_save(payload) RPC API the OWL component
    consumes; save is atomic-replace + bumps version + soft-validates

fusion.repair.flowchart.run + .step - runtime sessions.
  - One run per repair, audited; runtime_start_or_resume() returns the
    existing in-progress run or creates a fresh one for the matching chart
  - runtime_choose(edge_id, cs_note) records a step + advances current_node
  - runtime_complete(outcome) snapshots final node + calls _apply_outcome:
      resolved   -> auto-close via action_repair_start + action_repair_end,
                    set x_fc_resolved_on_call, post transcript to chatter
      escalated  -> prepend transcript to repair.internal_notes so the tech
                    sees it first when they open the form
      order_part -> chatter note; tech opens visit-report wizard next
      abandoned  -> just save transcript
  - Each step snapshots node_name + chosen_label at write time so the
    transcript survives later chart edits without breaking.

REPAIR.ORDER EXTENSIONS

- x_fc_symptom_class_id (M2O) - new structured symptom field
- x_fc_resolved_on_call (Boolean, tracked) - true after a resolved outcome
- x_fc_flowchart_run_ids + x_fc_flowchart_run_count
- action_start_troubleshoot() - opens the runner client action, raises a
  helpful UserError if no symptom set or no published chart exists
- action_view_flowchart_runs() smart button
- x_fc_issue_category renamed string to "(legacy)" - kept for back-compat
  + AI prompt context; new intakes set the M2O

DRAWFLOW DESIGNER (OWL)

static/src/lib/drawflow/drawflow.min.{js,css} - vendored Drawflow 0.0.59
  (MIT). Loaded only in web.assets_backend, ~48KB total.

components/flowchart_designer/flowchart_designer.{js,xml,scss}:
  - Client action "fusion_repair_flowchart_designer" with full drag-drop
    canvas + zoom + pan
  - 4 custom node templates color-banded by type (question blue,
    suggestion green, info gray, outcome red/green/amber per outcome_kind)
  - Right-panel editor for selected node: title, type, outcome kind,
    content (HTML), media uploader (drag-drop or click), set-as-start
    toggle, per-outgoing-edge label editor
  - Save serializes Drawflow JSON to canvas_layout + atomic-replaces the
    structured node/edge rows via the designer_save RPC

CARD RUNNER (OWL)

components/flowchart_runner/flowchart_runner.{js,xml,scss}:
  - Client action "fusion_repair_flowchart_runner"
  - DEFAULT MODE: card-by-card. One big card per node, embedded photos +
    inline <video controls>, answer buttons sized for phone use, CS note
    textarea (saved as cs_note on the step), running transcript at the
    bottom
  - TOGGLE: "Show Whole Tree" loads the same Drawflow lib in read-only
    fixed mode, imports the canvas_layout JSON, highlights current node
    yellow / visited green via .fr-current / .fr-visited classes
  - Outcome buttons drive the right runtime_complete() call; success
    notifications + auto-return to the parent repair form
  - "Abandon & Escalate" header button at all times - transcript is saved
    even on bail-out so the dispatched tech still benefits

PART ORDER + VENDOR PO

repair.part.order new fields:
  vendor_partner_id (M2O res.partner, is_company domain), purchase_order_id
  (auto-created draft PO), product_id (auto-resolved or created),
  unit_cost (Monetary) + currency_id, internal_po_ref, factory_ticket_ref,
  factory_ra_number, under_warranty.

action_create_draft_po() - resolves product.product by OEM (default_code)
  or creates a new one in a "Spare Parts" product.category, creates a
  purchase.order in draft state with one line (product + qty + price_unit
  + date_planned from expected_date or +7d), stamps Westin's internal PO
  ref as partner_ref so the factory can find it on return. Office reviews
  and confirms via the normal Odoo flow.

_schedule_eta_activity() - schedules a Repair: Assign Technician activity
  on the parent repair.order due on expected_date, assigned to
  repair.user_id, so the office is reminded to call the client and book
  the return visit on the day parts arrive.

VISIT-REPORT WIZARD PARTLINE EXTENSIONS

Same new fields exposed inline on the partline list so the tech captures
everything on the phone with the factory in one form:
  vendor_partner_id (vendors-only filter), unit_cost + currency,
  expected_date (calendar widget) replacing expected_lead_days as the
  preferred input, under_warranty, internal_po_ref, factory_ticket_ref,
  factory_ra_number, create_draft_po (default True - auto-builds PO on
  submit when vendor + cost are both set).

CLIENT EMAIL TIGHTENED

email_template_parts_ordered:
  - Subject now includes ETA "Parts ordered for your stairlift - expected 2026-06-06"
  - Hero ETA panel: large blue-bordered card with "Expected Arrival" label
    and the date in 24px bold
  - Cost INTENTIONALLY OMITTED - "Our office will call you to confirm a
    return visit time. If you have any questions about pricing or
    scheduling, please reach out to our office directly."
  - "There is nothing for you to do right now." callout

UI

- repair.order form header: new "Start Troubleshooting" button (info
  style, sitemap icon, visible when state in (draft, confirmed,
  under_repair) AND symptom is set)
- repair.order form intake row: x_fc_symptom_class_id picker filtered to
  the category, x_fc_resolved_on_call display when true
- repair.part.order form: header button "Create Draft Purchase Order"
  + new Vendor / Cost / Warranty group + System group with the PO link
- Intake wizard equipment line: symptom_class_id picker
- New menus:
    Configuration > Symptom Classes
    Configuration > Troubleshooting Flowcharts
    Fusion Repairs > Troubleshooting Sessions (run history)

SECURITY

18 new ACL rows for the 6 new models, scoped Manager-full / User-read /
FieldTech-read. Flowchart runs and steps get write access for User so CS
can record steps; Manager owns flowchart + node + edge CRUD.

POST-MIGRATION (19.0.2.2.0)

Existing installs: walks all distinct (category, x_fc_issue_category) text
pairs on repair.order, creates a placeholder fusion.repair.symptom.class
per pair (or reuses an existing match by code/name), back-fills the new
x_fc_symptom_class_id M2O. Idempotent + safe to re-run.

DEPENDENCY

Added 'purchase' to depends (action_create_draft_po needs purchase.order).

VERIFIED END-TO-END on local westin-v19 (Margaret persona, 0 bugs):

  STEP 0 seed: chart v1 8 nodes / 12 edges / published, 7 stairlift
                  symptoms, stairlift class=lift_elevating
  STEP 1 CS creates RO-202605-60 with symptom Not Moving
  STEP 2 Start Troubleshooting -> client action tag returned
  STEP 3 walk run: Power on? Yes -> Seatbelt? Yes -> Swivel? Yes ->
                   outcome 'Still not moving - dispatch technician'
                   (outcome_kind=escalate)
  STEP 4 runtime_complete('escalated') -> internal_notes prepended with
                   CS troubleshooting summary
  STEP 5 visit-report parts_needed with vendor Handicare + cost $425 +
                   warranty + factory refs -> PART-00008 created + draft
                   PO 26690 auto-built with line "Handicare 1100 control
                   board" qty 1 @ $425, partner_ref WH-2026-1042
  STEP 6 mark_ordered -> client email queued (NO cost mentioned, ETA
                   shown prominently) + office activity scheduled for
                   2026-06-06
  STEP 7 fresh resume returns same run; resolved outcome auto-closes the
                   repair (state=done, x_fc_resolved_on_call=True)

Bumped to 19.0.2.2.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 12:50:06 -04:00

395 lines
14 KiB
Python

# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Troubleshooting flowchart designer-time models.
Three models cooperate:
fusion.repair.flowchart one chart per (category, symptom)
-> fusion.repair.flowchart.node one row per visual node
-> fusion.repair.flowchart.edge directed edges between nodes
The full visual layout (positions, drawflow internal state) is also serialised
to flowchart.canvas_layout as JSON so the Drawflow designer round-trips
without needing per-node x/y stored separately. The structured node/edge
records exist for queries (e.g. "show me all flowcharts that mention 'control
board' in any node"), reporting, runtime traversal, and so that the runner can
operate without parsing the canvas JSON.
"""
import json
import logging
from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError
_logger = logging.getLogger(__name__)
NODE_TYPES = [
('question', 'Question'),
('suggestion', 'Suggestion'),
('info', 'Info'),
('outcome', 'Outcome'),
]
OUTCOME_KINDS = [
('resolved', 'Resolved on Call'),
('escalate', 'Escalate to Technician'),
('order_part', 'Order Part'),
]
class FusionRepairFlowchart(models.Model):
_name = 'fusion.repair.flowchart'
_inherit = ['mail.thread']
_description = 'Repair Troubleshooting Flowchart'
_order = 'category_id, symptom_class_id, name'
name = fields.Char(required=True, translate=True, tracking=True)
category_id = fields.Many2one(
'fusion.repair.product.category',
string='Equipment Category',
required=True,
index=True,
ondelete='cascade',
tracking=True,
)
symptom_class_id = fields.Many2one(
'fusion.repair.symptom.class',
string='Symptom',
required=True,
index=True,
ondelete='cascade',
domain="[('category_id', '=', category_id)]",
tracking=True,
)
description = fields.Html(translate=True)
active = fields.Boolean(default=True, tracking=True)
published = fields.Boolean(
default=False,
tracking=True,
help='Only published flowcharts are offered to CS during intake. Draft '
'charts are visible to admins for editing but never run.',
)
version = fields.Integer(
default=1,
readonly=True,
copy=False,
help='Bumped on every save in the designer; lets us snapshot which '
'version a flowchart.run was based on.',
)
canvas_layout = fields.Text(
string='Designer State',
help='Drawflow JSON for the visual designer canvas - positions, zoom, '
'connection curves. Round-tripped by the designer UI.',
)
node_ids = fields.One2many('fusion.repair.flowchart.node', 'flowchart_id')
edge_ids = fields.One2many('fusion.repair.flowchart.edge', 'flowchart_id')
node_count = fields.Integer(compute='_compute_counts')
edge_count = fields.Integer(compute='_compute_counts')
start_node_id = fields.Many2one(
'fusion.repair.flowchart.node',
string='Start Node',
compute='_compute_start_node',
store=True,
help='Auto-computed - whichever node has is_start=True. Validation '
'enforces exactly one.',
)
company_id = fields.Many2one(
'res.company',
default=lambda self: self.env.company,
)
_active_per_pair_unique = models.Constraint(
# NULLs treated as distinct so multiple draft (active=False) charts
# can coexist; only one active+published chart per pair is the
# business rule we care about - enforced via Python below.
'unique(category_id, symptom_class_id, company_id, published)',
'Only one published flowchart per (category, symptom) per company. '
'Unpublish or duplicate before publishing another.',
)
@api.depends('node_ids', 'edge_ids')
def _compute_counts(self):
for r in self:
r.node_count = len(r.node_ids)
r.edge_count = len(r.edge_ids)
@api.depends('node_ids.is_start')
def _compute_start_node(self):
for r in self:
r.start_node_id = r.node_ids.filtered(lambda n: n.is_start)[:1]
# ------------------------------------------------------------------
# ACTIONS
# ------------------------------------------------------------------
def action_open_designer(self):
"""Open the OWL Drawflow designer as a client action."""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'fusion_repair_flowchart_designer',
'name': _('Flowchart Designer - %s') % self.name,
'params': {'flowchart_id': self.id},
}
def action_publish(self):
for r in self:
r._validate_publishable()
r.published = True
r.message_post(body=_('Flowchart published. CS will now see it for '
'new intakes on this (category, symptom).'))
def action_unpublish(self):
for r in self:
r.published = False
def action_duplicate_as_draft(self):
self.ensure_one()
copy = self.copy({
'name': _('%s (copy)') % self.name,
'published': False,
'version': 1,
})
return {
'type': 'ir.actions.act_window',
'name': copy.name,
'res_model': self._name,
'view_mode': 'form',
'res_id': copy.id,
}
# ------------------------------------------------------------------
# VALIDATION
# ------------------------------------------------------------------
def _validate_publishable(self):
for r in self:
starts = r.node_ids.filtered(lambda n: n.is_start)
if len(starts) != 1:
raise ValidationError(_(
'Flowchart "%s" must have exactly one start node '
'(found %d). Edit in the designer and try again.'
) % (r.name, len(starts)))
# Every non-outcome node should have at least one outgoing edge.
for n in r.node_ids:
if n.node_type != 'outcome' and not r.edge_ids.filtered(
lambda e: e.source_node_id == n
):
raise ValidationError(_(
'Node "%s" is not an outcome but has no outgoing edges. '
'Connect it to the next step or change its type to '
'Outcome.'
) % n.name)
# ------------------------------------------------------------------
# DESIGNER RPC API (called from the OWL component)
# ------------------------------------------------------------------
@api.model
def designer_load(self, flowchart_id):
"""Return everything the designer needs to render this chart."""
chart = self.browse(flowchart_id).exists()
if not chart:
raise UserError(_('Flowchart not found.'))
return {
'id': chart.id,
'name': chart.name,
'category_id': chart.category_id.id,
'category_name': chart.category_id.name,
'symptom_class_id': chart.symptom_class_id.id,
'symptom_name': chart.symptom_class_id.name,
'published': chart.published,
'version': chart.version,
'canvas_layout': chart.canvas_layout or '',
'nodes': [n._designer_dict() for n in chart.node_ids],
'edges': [e._designer_dict() for e in chart.edge_ids],
}
def designer_save(self, payload):
"""Replace the chart's nodes + edges with the designer's snapshot.
payload = {
'canvas_layout': '<drawflow json string>',
'nodes': [{'client_id': str, 'name': ..., 'node_type': ...,
'content_html': ..., 'media_ids': [int],
'is_start': bool, 'outcome_kind': ...,
'canvas_x': int, 'canvas_y': int}, ...],
'edges': [{'source_client_id': str, 'target_client_id': str,
'label': str, 'sequence': int}, ...],
}
Returns the saved chart (designer_load shape).
"""
self.ensure_one()
# Atomic replace - simplest and safest. Edges depend on nodes so
# delete edges first.
self.edge_ids.unlink()
self.node_ids.unlink()
client_to_db = {}
Node = self.env['fusion.repair.flowchart.node'].sudo()
for nd in payload.get('nodes', []):
rec = Node.create({
'flowchart_id': self.id,
'name': nd.get('name') or _('Untitled'),
'node_type': nd.get('node_type') or 'question',
'content_html': nd.get('content_html') or False,
'is_start': bool(nd.get('is_start')),
'outcome_kind': nd.get('outcome_kind') or False,
'canvas_x': int(nd.get('canvas_x') or 0),
'canvas_y': int(nd.get('canvas_y') or 0),
'media_ids': [(6, 0, nd.get('media_ids') or [])],
})
client_to_db[nd.get('client_id')] = rec.id
Edge = self.env['fusion.repair.flowchart.edge'].sudo()
for ed in payload.get('edges', []):
src = client_to_db.get(ed.get('source_client_id'))
tgt = client_to_db.get(ed.get('target_client_id'))
if not src or not tgt:
_logger.warning(
'Skipping edge with unknown client ids: %s -> %s',
ed.get('source_client_id'), ed.get('target_client_id'),
)
continue
Edge.create({
'flowchart_id': self.id,
'source_node_id': src,
'target_node_id': tgt,
'label': ed.get('label') or '',
'sequence': int(ed.get('sequence') or 10),
})
self.write({
'canvas_layout': payload.get('canvas_layout') or '',
'version': self.version + 1,
})
# Soft-validate (don't raise) - the designer can publish later.
try:
self._validate_publishable()
except ValidationError as e:
_logger.info('Saved chart %s but it is not yet publishable: %s',
self.name, e)
return self.designer_load(self.id)
class FusionRepairFlowchartNode(models.Model):
_name = 'fusion.repair.flowchart.node'
_description = 'Flowchart Node'
_order = 'flowchart_id, sequence, id'
flowchart_id = fields.Many2one(
'fusion.repair.flowchart',
required=True,
ondelete='cascade',
index=True,
)
name = fields.Char(required=True, translate=True)
sequence = fields.Integer(default=10)
node_type = fields.Selection(
NODE_TYPES, default='question', required=True,
)
content_html = fields.Html(
translate=True,
sanitize=True,
sanitize_overridable=True,
help='Rich text shown to CS when this node is the current step. '
'Photos / videos go in media_ids instead.',
)
media_ids = fields.Many2many(
'ir.attachment',
'fusion_repair_flowchart_node_media_rel',
'node_id', 'attachment_id',
string='Media',
help='Photos and short videos that illustrate this step.',
)
is_start = fields.Boolean(
string='Start',
help='Exactly one node per flowchart must be the start.',
)
outcome_kind = fields.Selection(
OUTCOME_KINDS,
string='Outcome Kind',
help='Required when node_type is Outcome - determines what happens '
'when the run reaches this node.',
)
# Designer-only - persisted for round-trip but not used by the runtime.
canvas_x = fields.Integer(default=0)
canvas_y = fields.Integer(default=0)
outgoing_edge_ids = fields.One2many(
'fusion.repair.flowchart.edge', 'source_node_id',
string='Outgoing Edges',
)
incoming_edge_ids = fields.One2many(
'fusion.repair.flowchart.edge', 'target_node_id',
string='Incoming Edges',
)
def _designer_dict(self):
self.ensure_one()
return {
'id': self.id,
'name': self.name,
'node_type': self.node_type,
'content_html': self.content_html or '',
'is_start': self.is_start,
'outcome_kind': self.outcome_kind or '',
'canvas_x': self.canvas_x,
'canvas_y': self.canvas_y,
'media_ids': self.media_ids.ids,
'media': [{
'id': a.id,
'name': a.name,
'mimetype': a.mimetype,
'url': f'/web/image/{a.id}',
} for a in self.media_ids],
}
class FusionRepairFlowchartEdge(models.Model):
_name = 'fusion.repair.flowchart.edge'
_description = 'Flowchart Edge'
_order = 'source_node_id, sequence, id'
flowchart_id = fields.Many2one(
'fusion.repair.flowchart',
required=True,
ondelete='cascade',
index=True,
)
source_node_id = fields.Many2one(
'fusion.repair.flowchart.node',
required=True,
ondelete='cascade',
index=True,
)
target_node_id = fields.Many2one(
'fusion.repair.flowchart.node',
required=True,
ondelete='cascade',
index=True,
)
label = fields.Char(
translate=True,
help='Shown on the button in the runner ("Yes", "No", '
'"Wheel turns freely"...).',
)
sequence = fields.Integer(default=10)
def _designer_dict(self):
self.ensure_one()
return {
'id': self.id,
'source_node_id': self.source_node_id.id,
'target_node_id': self.target_node_id.id,
'label': self.label or '',
'sequence': self.sequence,
}