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>
This commit is contained in:
gsinghpal
2026-05-27 12:50:06 -04:00
parent 4acf9d7f85
commit eb186cac3c
30 changed files with 3277 additions and 32 deletions

View File

@@ -19,6 +19,9 @@ from . import repair_part_order
from . import repair_callout_rate
from . import repair_labor_warranty
from . import repair_delivery_charge
from . import repair_symptom_class
from . import repair_flowchart
from . import repair_flowchart_run
from . import product_template
from . import res_partner
from . import res_users

View File

@@ -130,6 +130,7 @@ class FusionRepairIntakeService(models.AbstractModel):
'x_fc_third_party_equipment': bool(item.get('third_party')),
'x_fc_urgency': item.get('urgency') or 'normal',
'x_fc_issue_category': item.get('issue_category') or False,
'x_fc_symptom_class_id': item.get('symptom_class_id') or False,
'x_fc_is_quote_only': bool(quote_only),
'x_fc_rush_requested': bool(rush_requested),
'x_fc_rush_tier': rush_tier or False,

View File

@@ -0,0 +1,394 @@
# -*- 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,
}

View File

@@ -0,0 +1,362 @@
# -*- 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 = '<p><em>No steps recorded yet.</em></p>'
continue
lines = ['<ol style="margin:0 0 8px 1.2em;padding:0;">']
for s in r.step_ids:
line = f'<li><strong>{s.node_name_snapshot or "?"}</strong>'
if s.chosen_label_snapshot:
line += f' &rarr; <em>{s.chosen_label_snapshot}</em>'
if s.cs_note:
line += f' <span style="color:#666;">(note: {s.cs_note})</span>'
line += '</li>'
lines.append(line)
lines.append('</ol>')
outcome_label = dict(self._fields['outcome'].selection).get(r.outcome, r.outcome)
lines.append(f'<p><strong>Outcome:</strong> {outcome_label}</p>')
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(_(
'<p><b>Resolved on call</b> via troubleshooting flowchart '
'<i>%(name)s</i>:</p>%(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(
'<p><b>CS troubleshooting summary (flowchart %s):</b></p>'
) % r.flowchart_id.name
repair.internal_notes = (header + transcript +
Markup('<hr/>') + Markup(existing))
repair.message_post(body=Markup(_(
'<p><b>Escalated to technician</b> via troubleshooting '
'flowchart <i>%(name)s</i>:</p>%(transcript)s'
)) % {'name': r.flowchart_id.name, 'transcript': transcript})
elif r.outcome == 'order_part':
repair.message_post(body=Markup(_(
'<p><b>Part order needed</b> per troubleshooting flowchart '
'<i>%(name)s</i>:</p>%(transcript)s'
)) % {'name': r.flowchart_id.name, 'transcript': transcript})
else: # abandoned
repair.message_post(body=Markup(_(
'<p>Troubleshooting run abandoned by %(user)s.</p>'
'%(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)

View File

@@ -1010,10 +1010,86 @@ class RepairOrder(models.Model):
index=True,
)
x_fc_issue_category = fields.Char(
string='Issue Category',
help='Symptom classification (e.g. "battery", "motor", "remote"). Used by '
'service catalogue matcher and AI prompt context.',
string='Issue Category (legacy)',
help='LEGACY free-text symptom classification. Superseded by '
'x_fc_symptom_class_id - kept for backwards compatibility with '
'historical records and the AI prompt context. New intakes '
'should set the M2O instead.',
)
# Bundle 11: proper structured symptom classification.
x_fc_symptom_class_id = fields.Many2one(
'fusion.repair.symptom.class',
string='Symptom',
index=True,
tracking=True,
domain="[('category_id', '=', x_fc_repair_category_id)]",
help='Drives flowchart lookup. Pick the (category, symptom) pair and '
'CS can start a troubleshooting flowchart if one is published.',
)
# Bundle 11: troubleshooting runs against this repair.
x_fc_flowchart_run_ids = fields.One2many(
'fusion.repair.flowchart.run', 'repair_order_id',
string='Troubleshooting Runs',
)
x_fc_flowchart_run_count = fields.Integer(
compute='_compute_flowchart_run_count',
)
x_fc_resolved_on_call = fields.Boolean(
string='Resolved on Call',
copy=False,
tracking=True,
help='True when the CS troubleshooting flowchart hit a "resolved" '
'outcome - no technician was ever dispatched.',
)
@api.depends('x_fc_flowchart_run_ids')
def _compute_flowchart_run_count(self):
for r in self:
r.x_fc_flowchart_run_count = len(r.x_fc_flowchart_run_ids)
def action_view_flowchart_runs(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Troubleshooting Runs'),
'res_model': 'fusion.repair.flowchart.run',
'view_mode': 'list,form',
'domain': [('repair_order_id', '=', self.id)],
'context': {'default_repair_order_id': self.id},
}
def action_start_troubleshoot(self):
"""Open the OWL flowchart runner for this repair (creates or resumes
an in-progress run). Raises a helpful UserError if the symptom is
not picked or no published chart exists for the (category, symptom)
pair."""
self.ensure_one()
if not self.x_fc_symptom_class_id:
raise UserError(_(
'Pick a Symptom on this repair first (Callout Pricing tab or '
'the Fusion Repairs section), then start troubleshooting.'
))
chart = self.env['fusion.repair.flowchart'].sudo().search([
('category_id', '=', self.x_fc_repair_category_id.id),
('symptom_class_id', '=', self.x_fc_symptom_class_id.id),
('published', '=', True),
('active', '=', True),
], limit=1)
if not chart:
raise UserError(_(
'No published troubleshooting flowchart for %(cat)s + %(sym)s. '
'Ask a manager to publish one in Configuration > '
'Troubleshooting Flowcharts.'
) % {
'cat': self.x_fc_repair_category_id.name or '?',
'sym': self.x_fc_symptom_class_id.name or '?',
})
return {
'type': 'ir.actions.client',
'tag': 'fusion_repair_flowchart_runner',
'name': _('Troubleshooting - %s') % self.name,
'params': {'repair_id': self.id},
}
# ------------------------------------------------------------------
# PHOTOS

View File

@@ -16,13 +16,28 @@ visit-report wizard in a structured way so:
auto-creates a follow-up dispatch task
The grumpy-old-client never has to call us asking for status updates.
Bundle 11 extension: tech often orders the part directly from the factory
WHILE on the phone. They want to:
- Pick the vendor from our contacts (filtered to vendors)
- Capture the OEM part #, cost, ETA date
- Auto-create a draft purchase.order line (office reviews + sends)
- Capture the factory's ticket # + RA # for tracking and warranty claims
- Tell the system whether the factory said it's under warranty
"""
import logging
from datetime import timedelta
from markupsafe import Markup
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
SPARE_PARTS_CATEGORY_NAME = 'Spare Parts'
class FusionRepairPartOrder(models.Model):
@@ -109,6 +124,64 @@ class FusionRepairPartOrder(models.Model):
copy=False,
)
# Bundle 11: vendor + PO + cost + factory tracking refs.
vendor_partner_id = fields.Many2one(
'res.partner',
string='Vendor',
tracking=True,
domain="[('is_company', '=', True)]",
help='Pick from our existing vendors. Filtered to companies; the tech '
'usually knows the factory by name (Handicare, Bruno, Pride...).',
)
purchase_order_id = fields.Many2one(
'purchase.order',
string='Draft Purchase Order',
readonly=True,
copy=False,
ondelete='set null',
help='Auto-created in DRAFT state when the tech submits a part order '
'with a vendor + cost. Office reviews and sends it to the factory.',
)
product_id = fields.Many2one(
'product.product',
string='Product',
readonly=True,
copy=False,
help='Auto-resolved or created based on the OEM part number.',
)
unit_cost = fields.Monetary(
string='Unit Cost',
currency_field='currency_id',
tracking=True,
help='Per-unit cost from the factory. Used as price_unit on the draft PO.',
)
currency_id = fields.Many2one(
'res.currency',
default=lambda self: self.env.company.currency_id,
)
internal_po_ref = fields.Char(
string='PO Reference (read to factory)',
tracking=True,
help='The Westin PO number the tech read to the factory for tracking. '
'Stamped on the draft purchase.order as partner_ref.',
)
factory_ticket_ref = fields.Char(
string='Factory Ticket #',
tracking=True,
help='Ticket number from the factory call - used for follow-up enquiries.',
)
factory_ra_number = fields.Char(
string='Factory RA #',
tracking=True,
help='Return Authorization number, when the factory issued a warranty '
'replacement that requires the old part returned.',
)
under_warranty = fields.Boolean(
string='Factory Warranty',
tracking=True,
help='Tick when the factory confirmed warranty coverage on the call.',
)
company_id = fields.Many2one(
'res.company',
default=lambda self: self.env.company,
@@ -144,6 +217,103 @@ class FusionRepairPartOrder(models.Model):
# ------------------------------------------------------------------
# ACTIONS
# ------------------------------------------------------------------
def action_create_draft_po(self):
"""Bundle 11: build a draft purchase.order for this part.
Idempotent - returns the existing PO if one is already linked.
Resolves / creates the product from the OEM number, sets the line
cost from unit_cost, stamps Westin's internal PO ref on the
purchase.order's partner_ref so the factory can find it. Office
reviews + confirms via the normal Odoo flow.
"""
self.ensure_one()
if self.purchase_order_id:
return self._open_record(self.purchase_order_id)
if not self.vendor_partner_id:
raise UserError(_(
'Pick a Vendor first - the draft PO needs to know who to send to.'
))
product = self._resolve_or_create_product()
PO = self.env['purchase.order'].sudo()
# Let Odoo derive product_uom from the product to avoid Odoo 19's
# uom_po_id moving between template/variant: passing product_id is
# enough for the PO line's onchange to populate the UOM correctly.
line_vals = {
'product_id': product.id,
'name': self.description or product.display_name,
'product_qty': self.quantity or 1.0,
'price_unit': self.unit_cost or 0.0,
'date_planned': fields.Datetime.now() + timedelta(
days=7 if not self.expected_date
else max((self.expected_date - fields.Date.context_today(self)).days, 0)
),
}
try:
order = PO.create({
'partner_id': self.vendor_partner_id.id,
'partner_ref': self.internal_po_ref or False,
'origin': self.repair_order_id.name or self.name,
'company_id': self.company_id.id,
'order_line': [(0, 0, line_vals)],
})
except Exception as e:
_logger.exception('Could not create draft PO for part %s: %s', self.name, e)
raise UserError(_(
'Could not create the draft purchase order: %s. The part info '
'was saved - you can create the PO manually from Purchase > '
'Orders > New.'
) % e)
self.write({
'purchase_order_id': order.id,
'product_id': product.id,
})
self.message_post(body=Markup(_(
'Draft purchase order <b>%(po)s</b> created with %(vendor)s. '
'Office to review and send.'
)) % {'po': order.name, 'vendor': self.vendor_partner_id.name})
return self._open_record(order)
def _resolve_or_create_product(self):
"""Find product.product by OEM part number (default_code) or create
a new service-type product in the 'Spare Parts' category. Returns
the product record."""
self.ensure_one()
Product = self.env['product.product'].sudo()
if self.product_id:
return self.product_id
if self.oem_part_number:
hit = Product.search([
('default_code', '=', self.oem_part_number),
], limit=1)
if hit:
return hit
# Create new. Use "service" type so it doesn't need inventory tracking,
# purchaseable + can be added to PO lines without warehouse setup.
ProductCategory = self.env['product.category'].sudo()
category = ProductCategory.search([
('name', '=', SPARE_PARTS_CATEGORY_NAME),
], limit=1)
if not category:
category = ProductCategory.create({'name': SPARE_PARTS_CATEGORY_NAME})
return Product.create({
'name': self.description or (self.oem_part_number or 'Spare Part'),
'default_code': self.oem_part_number or False,
'type': 'consu',
'purchase_ok': True,
'sale_ok': False,
'categ_id': category.id,
'standard_price': self.unit_cost or 0.0,
})
def _open_record(self, record):
return {
'type': 'ir.actions.act_window',
'name': record.display_name,
'res_model': record._name,
'view_mode': 'form',
'res_id': record.id,
}
def action_mark_ordered(self):
"""Office marks this part as ordered with the manufacturer."""
for rec in self:
@@ -153,6 +323,7 @@ class FusionRepairPartOrder(models.Model):
if not rec.expected_date:
rec.expected_date = fields.Date.context_today(rec) + timedelta(days=7)
rec._notify_client_parts_ordered()
rec._schedule_eta_activity()
def action_mark_received(self):
"""Office marks this part as received - triggers follow-up dispatch."""
@@ -197,6 +368,39 @@ class FusionRepairPartOrder(models.Model):
except Exception:
pass
def _schedule_eta_activity(self):
"""Bundle 11: schedule an activity on the repair so the office is
reminded on the ETA day to call the client back and book the
return visit."""
for rec in self:
repair = rec.repair_order_id
if not repair or not rec.expected_date:
continue
try:
act_type = self.env.ref(
'fusion_repairs.mail_activity_type_assign_technician',
raise_if_not_found=False,
)
repair.activity_schedule(
activity_type_id=act_type.id if act_type else False,
date_deadline=rec.expected_date,
summary=_('Parts arriving (%s) - schedule re-visit') % rec.name,
note=_(
'Part %(ref)s (%(desc)s) from %(vendor)s is expected '
'today. Call the client to confirm a return visit.'
) % {
'ref': rec.name,
'desc': rec.description or '',
'vendor': rec.vendor_partner_id.name or rec.manufacturer or '?',
},
user_id=repair.user_id.id or self.env.uid,
)
except Exception:
_logger.exception(
'Could not schedule ETA activity for part %s on repair %s',
rec.name, repair.name,
)
def _maybe_redispatch(self):
"""When the LAST outstanding part on a repair arrives, auto-create
a follow-up tech task so the office doesn't have to remember.

View File

@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Symptom classification.
Lookup table replacing the flat `x_fc_issue_category` Char on repair.order.
One symptom class belongs to one equipment category (stairlift > "Not Moving",
"Beeps Alarm", "Stops Midway"...). Together with the category, it's the key
that fusion.repair.flowchart uses to look up which troubleshooting flowchart
to run.
"""
from odoo import api, fields, models
class FusionRepairSymptomClass(models.Model):
_name = 'fusion.repair.symptom.class'
_description = 'Repair Symptom Class'
_order = 'category_id, sequence, name'
name = fields.Char(string='Name', required=True, translate=True)
code = fields.Char(
string='Code',
required=True,
help='Stable code used by data files and automation (e.g. "not_moving").',
)
category_id = fields.Many2one(
'fusion.repair.product.category',
string='Equipment Category',
required=True,
index=True,
ondelete='cascade',
)
sequence = fields.Integer(default=10)
icon = fields.Char(
string='Icon',
default='fa-exclamation-circle',
help='Font Awesome icon class shown next to the symptom in pickers.',
)
description = fields.Text(translate=True)
active = fields.Boolean(default=True)
company_id = fields.Many2one(
'res.company',
default=lambda self: self.env.company,
)
_code_per_category_unique = models.Constraint(
'unique(category_id, code, company_id)',
'Symptom code must be unique within a category.',
)
@api.depends('name', 'category_id.name')
def _compute_display_name(self):
for r in self:
r.display_name = (
f"{r.category_id.name} - {r.name}" if r.category_id else (r.name or '')
)