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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
394
fusion_repairs/models/repair_flowchart.py
Normal file
394
fusion_repairs/models/repair_flowchart.py
Normal 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,
|
||||
}
|
||||
362
fusion_repairs/models/repair_flowchart_run.py
Normal file
362
fusion_repairs/models/repair_flowchart_run.py
Normal 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' → <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)
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
58
fusion_repairs/models/repair_symptom_class.py
Normal file
58
fusion_repairs/models/repair_symptom_class.py
Normal 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 '')
|
||||
)
|
||||
Reference in New Issue
Block a user