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

@@ -4,7 +4,7 @@
{
'name': 'Fusion Repairs',
'version': '19.0.2.1.0',
'version': '19.0.2.2.0',
'category': 'Inventory/Repairs',
'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal',
'description': """
@@ -56,6 +56,7 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
'website',
'sale_management',
'stock',
'purchase',
'repair',
'maintenance',
'fusion_tasks',
@@ -78,6 +79,8 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
'data/emergency_charge_data.xml',
'data/callout_rate_data.xml',
'data/delivery_charge_data.xml',
'data/symptom_class_data.xml',
'data/flowchart_stairlift_not_moving_data.xml',
# Views
'views/repair_product_category_views.xml',
'views/intake_template_views.xml',
@@ -93,6 +96,8 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
'views/repair_order_views.xml',
'views/repair_part_order_views.xml',
'views/repair_service_plan_views.xml',
'views/repair_symptom_class_views.xml',
'views/repair_flowchart_views.xml',
'views/sale_order_views.xml',
'views/technician_task_views.xml',
'views/res_partner_views.xml',
@@ -119,6 +124,15 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
'fusion_repairs/static/src/scss/dashboard.scss',
'fusion_repairs/static/src/components/dashboard/dashboard.js',
'fusion_repairs/static/src/components/dashboard/dashboard.xml',
# Bundle 11: Drawflow flowchart designer + runner. CSS first.
'fusion_repairs/static/src/lib/drawflow/drawflow.min.css',
'fusion_repairs/static/src/lib/drawflow/drawflow.min.js',
'fusion_repairs/static/src/components/flowchart_designer/flowchart_designer.scss',
'fusion_repairs/static/src/components/flowchart_designer/flowchart_designer.js',
'fusion_repairs/static/src/components/flowchart_designer/flowchart_designer.xml',
'fusion_repairs/static/src/components/flowchart_runner/flowchart_runner.scss',
'fusion_repairs/static/src/components/flowchart_runner/flowchart_runner.js',
'fusion_repairs/static/src/components/flowchart_runner/flowchart_runner.xml',
],
'web.assets_frontend': [
'fusion_repairs/static/src/scss/portal_repair_mobile.scss',

View File

@@ -0,0 +1,251 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Demo flowchart for the user's headline scenario: "Stairlift Not Moving".
Eight nodes covering the most common diagnostic decision points. Westin
can edit / extend this in the Drawflow designer; it's the template they
will fork to build the other six stairlift flowcharts + the other
equipment categories.
Tree shape (single-dash arrows so the ASCII art does not close this XML
comment):
[Start: Power on?]
Yes -> [Seatbelt fastened?]
No -> [Outcome: outlet/breaker]
[Seatbelt fastened?]
No -> [Suggestion: Fasten and retry]
Yes -> [Swivel rotated to forward?]
[Swivel rotated to forward?]
No -> [Suggestion: Rotate and retry]
Yes -> [Outcome: escalate to technician]
-->
<odoo>
<data noupdate="1">
<record id="flowchart_stairlift_not_moving" model="fusion.repair.flowchart">
<field name="name">Stairlift - Not Moving</field>
<field name="category_id" ref="category_stairlift"/>
<field name="symptom_class_id" ref="symptom_stairlift_not_moving"/>
<field name="published" eval="True"/>
<field name="description"><![CDATA[
<p>Standard diagnostic walk-through for a stairlift that is not responding
to its controls. Most common root causes (power, seatbelt, swivel lock)
are checked first; if all clear, dispatch a technician.</p>
]]></field>
</record>
<!-- ===== NODES ===== -->
<record id="flow_sl_nm_node_start" model="fusion.repair.flowchart.node">
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
<field name="name">Is the power on?</field>
<field name="node_type">question</field>
<field name="is_start" eval="True"/>
<field name="sequence">10</field>
<field name="canvas_x">100</field>
<field name="canvas_y">100</field>
<field name="content_html"><![CDATA[
<p>Ask the client if the stairlift has power. Check:</p>
<ul>
<li>Is the unit plugged into the wall outlet?</li>
<li>Is the master key switch (usually on the carriage) turned ON?</li>
<li>Is the battery indicator LED lit?</li>
</ul>
]]></field>
</record>
<record id="flow_sl_nm_node_outlet" model="fusion.repair.flowchart.node">
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
<field name="name">No power - check breaker</field>
<field name="node_type">suggestion</field>
<field name="sequence">20</field>
<field name="canvas_x">450</field>
<field name="canvas_y">20</field>
<field name="content_html"><![CDATA[
<p>Ask the client to:</p>
<ol>
<li>Test the wall outlet with a phone charger or lamp.</li>
<li>Check the home's electrical panel for a tripped breaker.</li>
<li>If the breaker is tripped, flip it OFF then ON.</li>
</ol>
]]></field>
</record>
<record id="flow_sl_nm_node_seatbelt" model="fusion.repair.flowchart.node">
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
<field name="name">Is the seatbelt fastened?</field>
<field name="node_type">question</field>
<field name="sequence">30</field>
<field name="canvas_x">450</field>
<field name="canvas_y">200</field>
<field name="content_html"><![CDATA[
<p>Most stairlifts will not move unless the seatbelt safety
switch is engaged. Confirm the belt is buckled.</p>
]]></field>
</record>
<record id="flow_sl_nm_node_fasten" model="fusion.repair.flowchart.node">
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
<field name="name">Fasten seatbelt and retry</field>
<field name="node_type">suggestion</field>
<field name="sequence">40</field>
<field name="canvas_x">800</field>
<field name="canvas_y">120</field>
<field name="content_html"><![CDATA[
<p>Have the client buckle the seatbelt and try the controls again.</p>
]]></field>
</record>
<record id="flow_sl_nm_node_swivel" model="fusion.repair.flowchart.node">
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
<field name="name">Is the seat rotated to the forward (travel) position?</field>
<field name="node_type">question</field>
<field name="sequence">50</field>
<field name="canvas_x">800</field>
<field name="canvas_y">300</field>
<field name="content_html"><![CDATA[
<p>The swivel lock must be engaged in the forward position. If
the seat is partially turned, the stairlift will refuse to move.</p>
]]></field>
</record>
<record id="flow_sl_nm_node_rotate" model="fusion.repair.flowchart.node">
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
<field name="name">Rotate seat to forward and retry</field>
<field name="node_type">suggestion</field>
<field name="sequence">60</field>
<field name="canvas_x">1150</field>
<field name="canvas_y">220</field>
<field name="content_html"><![CDATA[
<p>Ask the client to rotate the seat back to the forward
travel position until the swivel lock clicks. Then try again.</p>
]]></field>
</record>
<record id="flow_sl_nm_node_resolved" model="fusion.repair.flowchart.node">
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
<field name="name">Working - resolved on call</field>
<field name="node_type">outcome</field>
<field name="outcome_kind">resolved</field>
<field name="sequence">100</field>
<field name="canvas_x">1500</field>
<field name="canvas_y">150</field>
<field name="content_html"><![CDATA[
<p>Great - the stairlift is moving again. Confirm with the client,
close the repair, and offer to book an annual safety inspection
if they have not had one recently.</p>
]]></field>
</record>
<record id="flow_sl_nm_node_escalate" model="fusion.repair.flowchart.node">
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
<field name="name">Still not moving - dispatch technician</field>
<field name="node_type">outcome</field>
<field name="outcome_kind">escalate</field>
<field name="sequence">110</field>
<field name="canvas_x">1500</field>
<field name="canvas_y">350</field>
<field name="content_html"><![CDATA[
<p>Common fixes are exhausted. Likely root causes for a tech to investigate:</p>
<ul>
<li>Control board fault (most common - $300-$500 part)</li>
<li>Motor brushes worn</li>
<li>Track end-stop safety switch</li>
</ul>
<p>Escalate and the transcript will be visible to the tech before they arrive.</p>
]]></field>
</record>
<!-- ===== EDGES ===== -->
<record id="flow_sl_nm_edge_start_no" model="fusion.repair.flowchart.edge">
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
<field name="source_node_id" ref="flow_sl_nm_node_start"/>
<field name="target_node_id" ref="flow_sl_nm_node_outlet"/>
<field name="label">No - no power</field>
<field name="sequence">10</field>
</record>
<record id="flow_sl_nm_edge_start_yes" model="fusion.repair.flowchart.edge">
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
<field name="source_node_id" ref="flow_sl_nm_node_start"/>
<field name="target_node_id" ref="flow_sl_nm_node_seatbelt"/>
<field name="label">Yes - power is on</field>
<field name="sequence">20</field>
</record>
<record id="flow_sl_nm_edge_outlet_fixed" model="fusion.repair.flowchart.edge">
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
<field name="source_node_id" ref="flow_sl_nm_node_outlet"/>
<field name="target_node_id" ref="flow_sl_nm_node_resolved"/>
<field name="label">Power restored - works now</field>
<field name="sequence">10</field>
</record>
<record id="flow_sl_nm_edge_outlet_no_fix" model="fusion.repair.flowchart.edge">
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
<field name="source_node_id" ref="flow_sl_nm_node_outlet"/>
<field name="target_node_id" ref="flow_sl_nm_node_escalate"/>
<field name="label">Still no power</field>
<field name="sequence">20</field>
</record>
<record id="flow_sl_nm_edge_seatbelt_no" model="fusion.repair.flowchart.edge">
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
<field name="source_node_id" ref="flow_sl_nm_node_seatbelt"/>
<field name="target_node_id" ref="flow_sl_nm_node_fasten"/>
<field name="label">No</field>
<field name="sequence">10</field>
</record>
<record id="flow_sl_nm_edge_seatbelt_yes" model="fusion.repair.flowchart.edge">
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
<field name="source_node_id" ref="flow_sl_nm_node_seatbelt"/>
<field name="target_node_id" ref="flow_sl_nm_node_swivel"/>
<field name="label">Yes</field>
<field name="sequence">20</field>
</record>
<record id="flow_sl_nm_edge_fasten_works" model="fusion.repair.flowchart.edge">
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
<field name="source_node_id" ref="flow_sl_nm_node_fasten"/>
<field name="target_node_id" ref="flow_sl_nm_node_resolved"/>
<field name="label">Works now</field>
<field name="sequence">10</field>
</record>
<record id="flow_sl_nm_edge_fasten_no" model="fusion.repair.flowchart.edge">
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
<field name="source_node_id" ref="flow_sl_nm_node_fasten"/>
<field name="target_node_id" ref="flow_sl_nm_node_swivel"/>
<field name="label">Still not moving - try next check</field>
<field name="sequence">20</field>
</record>
<record id="flow_sl_nm_edge_swivel_no" model="fusion.repair.flowchart.edge">
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
<field name="source_node_id" ref="flow_sl_nm_node_swivel"/>
<field name="target_node_id" ref="flow_sl_nm_node_rotate"/>
<field name="label">No - seat is turned</field>
<field name="sequence">10</field>
</record>
<record id="flow_sl_nm_edge_swivel_yes" model="fusion.repair.flowchart.edge">
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
<field name="source_node_id" ref="flow_sl_nm_node_swivel"/>
<field name="target_node_id" ref="flow_sl_nm_node_escalate"/>
<field name="label">Yes - seat is forward</field>
<field name="sequence">20</field>
</record>
<record id="flow_sl_nm_edge_rotate_works" model="fusion.repair.flowchart.edge">
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
<field name="source_node_id" ref="flow_sl_nm_node_rotate"/>
<field name="target_node_id" ref="flow_sl_nm_node_resolved"/>
<field name="label">Works now</field>
<field name="sequence">10</field>
</record>
<record id="flow_sl_nm_edge_rotate_no" model="fusion.repair.flowchart.edge">
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
<field name="source_node_id" ref="flow_sl_nm_node_rotate"/>
<field name="target_node_id" ref="flow_sl_nm_node_escalate"/>
<field name="label">Still not moving</field>
<field name="sequence">20</field>
</record>
</data>
</odoo>

View File

@@ -242,26 +242,48 @@
<record id="email_template_parts_ordered" model="mail.template">
<field name="name">Repair: Parts Ordered (Client)</field>
<field name="model_id" ref="model_fusion_repair_part_order"/>
<field name="subject">Parts ordered for your {{ object.repair_order_id.x_fc_repair_category_id.name or 'equipment' }} - {{ object.repair_order_id.name }}</field>
<field name="subject">Parts ordered for your {{ object.repair_order_id.x_fc_repair_category_id.name or 'equipment' }} - expected {{ object.expected_date }}</field>
<field name="email_from">{{ (object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;">
<div style="height:4px;background-color:#2B6CB0;"></div>
<div style="padding:32px 28px;">
<h2 style="font-size:20px;font-weight:700;margin:0 0 16px 0;">Parts ordered</h2>
<p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 16px 0;">
We've placed an order for the parts your <t t-out="object.repair_order_id.x_fc_repair_category_id.name or 'equipment'"/>
needs. Expected arrival: <strong><t t-out="object.expected_date" t-options="{'widget': 'date'}"/></strong>.
<h2 style="font-size:22px;font-weight:700;margin:0 0 16px 0;">Your part has been ordered</h2>
<!-- ETA front and centre per Bundle 11 spec. No cost shown - the office shares cost separately. -->
<div style="background:#eff6ff;border:1px solid #bfdbfe;border-radius:8px;padding:20px;text-align:center;margin:0 0 20px 0;">
<div style="font-size:13px;color:#1e40af;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:6px;">
Expected arrival
</div>
<div style="font-size:24px;font-weight:700;color:#1e40af;">
<t t-out="object.expected_date" t-options="{'widget': 'date'}"/>
</div>
</div>
<p style="opacity:0.75;font-size:15px;line-height:1.5;margin:0 0 16px 0;">
We have placed the order for the part your
<strong t-out="object.repair_order_id.x_fc_repair_category_id.name or 'equipment'"/>
needs to get you up and running again.
</p>
<table style="width:100%;border-collapse:collapse;margin:0 0 16px 0;">
<tr><td style="padding:8px 14px;opacity:0.6;font-size:13px;border-bottom:1px solid rgba(128,128,128,0.15);width:35%;">Part</td><td style="padding:8px 14px;font-size:13px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.description"/></td></tr>
<t t-if="object.manufacturer">
<tr><td style="padding:8px 14px;opacity:0.6;font-size:13px;border-bottom:1px solid rgba(128,128,128,0.15);">Manufacturer</td><td style="padding:8px 14px;font-size:13px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.manufacturer"/></td></tr>
</t>
<tr><td style="padding:8px 14px;opacity:0.6;font-size:13px;">Ref</td><td style="padding:8px 14px;font-size:13px;"><t t-out="object.name"/></td></tr>
<tr><td style="padding:8px 14px;opacity:0.6;font-size:13px;border-bottom:1px solid rgba(128,128,128,0.15);">Repair</td><td style="padding:8px 14px;font-size:13px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.repair_order_id.name"/></td></tr>
<tr><td style="padding:8px 14px;opacity:0.6;font-size:13px;">Reference</td><td style="padding:8px 14px;font-size:13px;"><t t-out="object.name"/></td></tr>
</table>
<p style="opacity:0.55;font-size:12px;margin:0;">We'll email again as soon as the parts arrive at our warehouse.</p>
<div style="background:#f9fafb;border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 16px 0;font-size:14px;line-height:1.5;">
<strong>There is nothing for you to do right now.</strong>
Our office will call you as soon as the part arrives at our
warehouse to confirm a return visit time. If you have any
questions about pricing or scheduling, please reach out to
our office directly.
</div>
<p style="opacity:0.55;font-size:12px;margin:0;">
We will email you again the moment the part arrives.
</p>
</div>
</div>
</field>

View File

@@ -0,0 +1,107 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Seed symptom classes per equipment category. Westin can add their own
via Configuration > Symptom Classes. noupdate=1 keeps custom rows safe
on upgrade.
Phase 1 ships a thorough set for STAIRLIFT (the user's first use case).
Lighter coverage for other categories - they can be expanded as Westin
builds out more flowcharts.
-->
<odoo>
<data noupdate="1">
<!-- ============== STAIRLIFT ============== -->
<record id="symptom_stairlift_not_moving" model="fusion.repair.symptom.class">
<field name="name">Not Moving</field>
<field name="code">not_moving</field>
<field name="category_id" ref="category_stairlift"/>
<field name="sequence">10</field>
<field name="icon">fa-pause-circle</field>
<field name="description">Stairlift unresponsive to controls; will not travel up or down.</field>
</record>
<record id="symptom_stairlift_beeps_alarm" model="fusion.repair.symptom.class">
<field name="name">Beeps / Alarm</field>
<field name="code">beeps_alarm</field>
<field name="category_id" ref="category_stairlift"/>
<field name="sequence">20</field>
<field name="icon">fa-bell</field>
</record>
<record id="symptom_stairlift_stops_midway" model="fusion.repair.symptom.class">
<field name="name">Stops Midway</field>
<field name="code">stops_midway</field>
<field name="category_id" ref="category_stairlift"/>
<field name="sequence">30</field>
<field name="icon">fa-hand-paper-o</field>
</record>
<record id="symptom_stairlift_remote_issue" model="fusion.repair.symptom.class">
<field name="name">Remote / Call Station Issue</field>
<field name="code">remote_issue</field>
<field name="category_id" ref="category_stairlift"/>
<field name="sequence">40</field>
<field name="icon">fa-mobile</field>
</record>
<record id="symptom_stairlift_swivel_stuck" model="fusion.repair.symptom.class">
<field name="name">Swivel Won't Rotate</field>
<field name="code">swivel_stuck</field>
<field name="category_id" ref="category_stairlift"/>
<field name="sequence">50</field>
<field name="icon">fa-refresh</field>
</record>
<record id="symptom_stairlift_slow" model="fusion.repair.symptom.class">
<field name="name">Slow / Sluggish</field>
<field name="code">slow_sluggish</field>
<field name="category_id" ref="category_stairlift"/>
<field name="sequence">60</field>
<field name="icon">fa-tachometer</field>
</record>
<record id="symptom_stairlift_track" model="fusion.repair.symptom.class">
<field name="name">Track Issue (noise / grinding)</field>
<field name="code">track_issue</field>
<field name="category_id" ref="category_stairlift"/>
<field name="sequence">70</field>
<field name="icon">fa-bars</field>
</record>
<!-- ============== HOSPITAL BED (lighter) ============== -->
<record id="symptom_bed_not_moving" model="fusion.repair.symptom.class">
<field name="name">Not Moving</field>
<field name="code">not_moving</field>
<field name="category_id" ref="category_hospital_bed"/>
<field name="sequence">10</field>
<field name="icon">fa-pause-circle</field>
</record>
<record id="symptom_bed_remote" model="fusion.repair.symptom.class">
<field name="name">Remote / Pendant Issue</field>
<field name="code">remote_issue</field>
<field name="category_id" ref="category_hospital_bed"/>
<field name="sequence">20</field>
<field name="icon">fa-mobile</field>
</record>
<!-- ============== PORCH LIFT (lighter) ============== -->
<record id="symptom_porch_not_moving" model="fusion.repair.symptom.class">
<field name="name">Not Moving</field>
<field name="code">not_moving</field>
<field name="category_id" ref="category_porch_lift"/>
<field name="sequence">10</field>
<field name="icon">fa-pause-circle</field>
</record>
<!-- ============== LIFT CHAIR (lighter) ============== -->
<record id="symptom_lift_chair_not_moving" model="fusion.repair.symptom.class">
<field name="name">Will Not Rise / Recline</field>
<field name="code">not_moving</field>
<field name="category_id" ref="category_lift_chair"/>
<field name="sequence">10</field>
<field name="icon">fa-pause-circle</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
"""Post-migration for 19.0.2.2.0 - Bundle 11.
Back-fills the new x_fc_symptom_class_id M2O on repair.order from the legacy
x_fc_issue_category Char so that existing repairs can immediately be used
with troubleshooting flowcharts. For each distinct (category, issue_category)
text we encounter, we either match an existing symptom.class by lowercase
name OR create a placeholder symptom row tagged 'auto-imported' so a manager
can clean it up later.
"""
import logging
import re
_logger = logging.getLogger(__name__)
def _slug(text):
return re.sub(r'[^a-z0-9_]+', '_', (text or '').strip().lower()).strip('_') or 'unknown'
def migrate(cr, version):
if not version:
return # fresh install, nothing to back-fill
# Find every (category_id, issue_category) pair on existing repairs that
# is not yet linked to a symptom class.
cr.execute("""
SELECT DISTINCT x_fc_repair_category_id, x_fc_issue_category
FROM repair_order
WHERE x_fc_issue_category IS NOT NULL
AND x_fc_issue_category <> ''
AND x_fc_symptom_class_id IS NULL
""")
pairs = cr.fetchall()
if not pairs:
return
_logger.info(
'Bundle 11 migration: back-filling symptom_class for %d distinct '
'(category, issue_category) pairs', len(pairs),
)
for cat_id, issue_text in pairs:
if not cat_id:
continue
code = _slug(issue_text)
# Try to reuse an existing symptom class by code-or-name on this category.
cr.execute("""
SELECT id FROM fusion_repair_symptom_class
WHERE category_id = %s
AND (code = %s OR LOWER(name->>'en_US') = LOWER(%s))
LIMIT 1
""", (cat_id, code, issue_text))
row = cr.fetchone()
if row:
sym_id = row[0]
else:
# Create a placeholder so the M2O isn't lost. Manager renames later.
display = (issue_text or 'Imported').strip()[:80]
cr.execute("""
INSERT INTO fusion_repair_symptom_class
(name, code, category_id, sequence, active,
description, icon, create_date, write_date, create_uid,
write_uid)
VALUES (%s, %s, %s, %s, %s, %s, %s,
NOW(), NOW(), 1, 1)
RETURNING id
""", (
{'en_US': display},
code,
cat_id,
90,
True,
'Auto-imported by Bundle 11 migration from legacy '
'x_fc_issue_category. Review and rename if needed.',
'fa-exclamation-circle',
))
sym_id = cr.fetchone()[0]
# Back-fill the M2O on every repair with this pair.
cr.execute("""
UPDATE repair_order
SET x_fc_symptom_class_id = %s
WHERE x_fc_repair_category_id = %s
AND x_fc_issue_category = %s
AND x_fc_symptom_class_id IS NULL
""", (sym_id, cat_id, issue_text))
_logger.info('Bundle 11 migration: back-fill complete.')

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 '')
)

View File

@@ -47,6 +47,24 @@ access_callout_rate_user,Callout Rate User Read,model_fusion_repair_callout_rate
access_callout_rate_manager,Callout Rate Manager Full,model_fusion_repair_callout_rate,group_fusion_repairs_manager,1,1,1,1
access_delivery_charge_user,Delivery Charge User Read,model_fusion_repair_delivery_charge,group_fusion_repairs_user,1,0,0,0
access_delivery_charge_manager,Delivery Charge Manager Full,model_fusion_repair_delivery_charge,group_fusion_repairs_manager,1,1,1,1
access_symptom_class_user,Symptom Class User Read,model_fusion_repair_symptom_class,group_fusion_repairs_user,1,0,0,0
access_symptom_class_manager,Symptom Class Manager Full,model_fusion_repair_symptom_class,group_fusion_repairs_manager,1,1,1,1
access_symptom_class_tech,Symptom Class Field Tech Read,model_fusion_repair_symptom_class,fusion_tasks.group_field_technician,1,0,0,0
access_flowchart_user,Flowchart User Read,model_fusion_repair_flowchart,group_fusion_repairs_user,1,0,0,0
access_flowchart_manager,Flowchart Manager Full,model_fusion_repair_flowchart,group_fusion_repairs_manager,1,1,1,1
access_flowchart_tech,Flowchart Field Tech Read,model_fusion_repair_flowchart,fusion_tasks.group_field_technician,1,0,0,0
access_flowchart_node_user,Flowchart Node User Read,model_fusion_repair_flowchart_node,group_fusion_repairs_user,1,0,0,0
access_flowchart_node_manager,Flowchart Node Manager Full,model_fusion_repair_flowchart_node,group_fusion_repairs_manager,1,1,1,1
access_flowchart_node_tech,Flowchart Node Field Tech Read,model_fusion_repair_flowchart_node,fusion_tasks.group_field_technician,1,0,0,0
access_flowchart_edge_user,Flowchart Edge User Read,model_fusion_repair_flowchart_edge,group_fusion_repairs_user,1,0,0,0
access_flowchart_edge_manager,Flowchart Edge Manager Full,model_fusion_repair_flowchart_edge,group_fusion_repairs_manager,1,1,1,1
access_flowchart_edge_tech,Flowchart Edge Field Tech Read,model_fusion_repair_flowchart_edge,fusion_tasks.group_field_technician,1,0,0,0
access_flowchart_run_user,Flowchart Run User Full,model_fusion_repair_flowchart_run,group_fusion_repairs_user,1,1,1,0
access_flowchart_run_manager,Flowchart Run Manager Full,model_fusion_repair_flowchart_run,group_fusion_repairs_manager,1,1,1,1
access_flowchart_run_tech,Flowchart Run Field Tech Read,model_fusion_repair_flowchart_run,fusion_tasks.group_field_technician,1,0,0,0
access_flowchart_run_step_user,Flowchart Run Step User Full,model_fusion_repair_flowchart_run_step,group_fusion_repairs_user,1,1,1,0
access_flowchart_run_step_manager,Flowchart Run Step Manager Full,model_fusion_repair_flowchart_run_step,group_fusion_repairs_manager,1,1,1,1
access_flowchart_run_step_tech,Flowchart Run Step Field Tech Read,model_fusion_repair_flowchart_run_step,fusion_tasks.group_field_technician,1,0,0,0
access_labor_warranty_user,Labor Warranty User Read,model_fusion_repair_labor_warranty,group_fusion_repairs_user,1,0,0,0
access_labor_warranty_sales_rep,Labor Warranty Sales Rep Write,model_fusion_repair_labor_warranty,group_fusion_repairs_sales_rep,1,1,0,0
access_labor_warranty_manager,Labor Warranty Manager Full,model_fusion_repair_labor_warranty,group_fusion_repairs_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
47 access_callout_rate_manager Callout Rate Manager Full model_fusion_repair_callout_rate group_fusion_repairs_manager 1 1 1 1
48 access_delivery_charge_user Delivery Charge User Read model_fusion_repair_delivery_charge group_fusion_repairs_user 1 0 0 0
49 access_delivery_charge_manager Delivery Charge Manager Full model_fusion_repair_delivery_charge group_fusion_repairs_manager 1 1 1 1
50 access_symptom_class_user Symptom Class User Read model_fusion_repair_symptom_class group_fusion_repairs_user 1 0 0 0
51 access_symptom_class_manager Symptom Class Manager Full model_fusion_repair_symptom_class group_fusion_repairs_manager 1 1 1 1
52 access_symptom_class_tech Symptom Class Field Tech Read model_fusion_repair_symptom_class fusion_tasks.group_field_technician 1 0 0 0
53 access_flowchart_user Flowchart User Read model_fusion_repair_flowchart group_fusion_repairs_user 1 0 0 0
54 access_flowchart_manager Flowchart Manager Full model_fusion_repair_flowchart group_fusion_repairs_manager 1 1 1 1
55 access_flowchart_tech Flowchart Field Tech Read model_fusion_repair_flowchart fusion_tasks.group_field_technician 1 0 0 0
56 access_flowchart_node_user Flowchart Node User Read model_fusion_repair_flowchart_node group_fusion_repairs_user 1 0 0 0
57 access_flowchart_node_manager Flowchart Node Manager Full model_fusion_repair_flowchart_node group_fusion_repairs_manager 1 1 1 1
58 access_flowchart_node_tech Flowchart Node Field Tech Read model_fusion_repair_flowchart_node fusion_tasks.group_field_technician 1 0 0 0
59 access_flowchart_edge_user Flowchart Edge User Read model_fusion_repair_flowchart_edge group_fusion_repairs_user 1 0 0 0
60 access_flowchart_edge_manager Flowchart Edge Manager Full model_fusion_repair_flowchart_edge group_fusion_repairs_manager 1 1 1 1
61 access_flowchart_edge_tech Flowchart Edge Field Tech Read model_fusion_repair_flowchart_edge fusion_tasks.group_field_technician 1 0 0 0
62 access_flowchart_run_user Flowchart Run User Full model_fusion_repair_flowchart_run group_fusion_repairs_user 1 1 1 0
63 access_flowchart_run_manager Flowchart Run Manager Full model_fusion_repair_flowchart_run group_fusion_repairs_manager 1 1 1 1
64 access_flowchart_run_tech Flowchart Run Field Tech Read model_fusion_repair_flowchart_run fusion_tasks.group_field_technician 1 0 0 0
65 access_flowchart_run_step_user Flowchart Run Step User Full model_fusion_repair_flowchart_run_step group_fusion_repairs_user 1 1 1 0
66 access_flowchart_run_step_manager Flowchart Run Step Manager Full model_fusion_repair_flowchart_run_step group_fusion_repairs_manager 1 1 1 1
67 access_flowchart_run_step_tech Flowchart Run Step Field Tech Read model_fusion_repair_flowchart_run_step fusion_tasks.group_field_technician 1 0 0 0
68 access_labor_warranty_user Labor Warranty User Read model_fusion_repair_labor_warranty group_fusion_repairs_user 1 0 0 0
69 access_labor_warranty_sales_rep Labor Warranty Sales Rep Write model_fusion_repair_labor_warranty group_fusion_repairs_sales_rep 1 1 0 0
70 access_labor_warranty_manager Labor Warranty Manager Full model_fusion_repair_labor_warranty group_fusion_repairs_manager 1 1 1 1

View File

@@ -0,0 +1,410 @@
/** @odoo-module **/
/*
* Drag-and-drop flowchart designer (Drawflow + OWL).
*
* Opened as a client action `fusion_repair_flowchart_designer` from the
* fusion.repair.flowchart form's 'Open Designer' header button. Loads the
* chart via designer_load RPC, renders Drawflow nodes by node_type, lets
* admin drag/connect/edit, and saves the whole snapshot back via
* designer_save (atomic replace - matches the model's API).
*
* Node types -> CSS color band:
* question - blue (multiple outgoing edges, ask user a question)
* suggestion - green (ask user to try something, then "Worked?" branches)
* info - gray (informational, single continue)
* outcome - red/green/amber depending on outcome_kind
*
* Drawflow JSON serialises positions + connections. Our model also stores
* structured nodes/edges for query + runtime, so the canvas_layout blob is
* the source of truth for the designer view and the node/edge tables are
* the source of truth for the runtime traversal.
*/
import { Component, onMounted, onWillUnmount, useRef, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
import { loadJS, loadCSS } from "@web/core/assets";
const DRAWFLOW_JS = "/fusion_repairs/static/src/lib/drawflow/drawflow.min.js";
const DRAWFLOW_CSS = "/fusion_repairs/static/src/lib/drawflow/drawflow.min.css";
// Color tokens reused via SCSS too - keep in sync.
const NODE_HEADERS = {
question: { color: "#2563eb", label: "Question" },
suggestion: { color: "#16a34a", label: "Suggestion" },
info: { color: "#6b7280", label: "Info" },
outcome: { color: "#dc2626", label: "Outcome" },
};
const OUTCOME_COLORS = {
resolved: "#16a34a",
escalate: "#dc2626",
order_part: "#d97706",
};
export class FlowchartDesigner extends Component {
static template = "fusion_repairs.FlowchartDesigner";
static props = ["*"];
setup() {
this.notification = useService("notification");
this.canvasRef = useRef("canvas");
this.editorPanelRef = useRef("editorPanel");
// Read flowchart_id from the action params. Cursor + OWL action
// service exposes them under this.props.action.params.
this.flowchartId = this.props.action?.params?.flowchart_id;
if (!this.flowchartId) {
this.notification.add("Missing flowchart_id - open from the form view.", { type: "danger" });
}
this.state = useState({
loading: true,
chart: null,
selectedNodeId: null,
dirty: false,
saving: false,
// client_id -> drawflow numeric id mapping for new nodes (those
// without a DB id yet, created via "Add Node")
clientToDfId: {},
// dfId -> {client_id, node_type, name, content_html, is_start,
// outcome_kind, media_ids}
nodeMeta: {},
});
onMounted(async () => {
await Promise.all([loadJS(DRAWFLOW_JS), loadCSS(DRAWFLOW_CSS)]);
this._initDrawflow();
await this._loadChart();
});
onWillUnmount(() => {
try { this.editor?.clear(); } catch {}
});
}
_initDrawflow() {
// eslint-disable-next-line no-undef
this.editor = new Drawflow(this.canvasRef.el);
this.editor.reroute = true;
this.editor.curvature = 0.5;
this.editor.editor_mode = "edit";
this.editor.start();
this.editor.on("nodeSelected", (id) => {
const meta = this.state.nodeMeta[id];
this.state.selectedNodeId = id;
this.state.selectedClientId = meta?.client_id || null;
});
this.editor.on("nodeUnselected", () => {
this.state.selectedNodeId = null;
});
this.editor.on("nodeCreated", () => { this.state.dirty = true; });
this.editor.on("nodeRemoved", () => { this.state.dirty = true; });
this.editor.on("nodeMoved", () => { this.state.dirty = true; });
this.editor.on("connectionCreated", () => { this.state.dirty = true; });
this.editor.on("connectionRemoved", () => { this.state.dirty = true; });
}
async _loadChart() {
try {
const chart = await rpc("/web/dataset/call_kw/fusion.repair.flowchart/designer_load", {
model: "fusion.repair.flowchart",
method: "designer_load",
args: [this.flowchartId],
kwargs: {},
});
this.state.chart = chart;
this.state.loading = false;
// Map DB id -> client_id (we just use the DB id as the client_id
// when loading from server; new nodes get "tmp-N" client ids).
this._renderChart(chart);
} catch (e) {
this.notification.add("Failed to load flowchart: " + (e?.message || e), { type: "danger" });
this.state.loading = false;
}
}
_renderChart(chart) {
this.editor.clear();
this.state.clientToDfId = {};
this.state.nodeMeta = {};
// Add each node.
for (const n of chart.nodes) {
const clientId = String(n.id);
const html = this._renderNodeBody(n);
// Drawflow: addNode(name, inputs, outputs, posx, posy, class, data, html)
const outputs = n.node_type === "outcome" ? 0 : 1;
const inputs = n.is_start ? 0 : 1;
const cssClass = `fr-node fr-node-${n.node_type} ${n.is_start ? "fr-node-start" : ""}`;
const dfId = this.editor.addNode(
clientId,
inputs, outputs,
n.canvas_x || 100, n.canvas_y || 100,
cssClass,
{ client_id: clientId },
html,
);
this.state.clientToDfId[clientId] = dfId;
this.state.nodeMeta[dfId] = {
client_id: clientId,
db_id: n.id,
name: n.name,
node_type: n.node_type,
content_html: n.content_html || "",
is_start: n.is_start,
outcome_kind: n.outcome_kind || "",
media_ids: n.media_ids || [],
media: n.media || [],
};
}
// Connect edges.
for (const e of chart.edges) {
const srcDf = this.state.clientToDfId[String(e.source_node_id)];
const tgtDf = this.state.clientToDfId[String(e.target_node_id)];
if (!srcDf || !tgtDf) continue;
this.editor.addConnection(srcDf, tgtDf, "output_1", "input_1");
// Drawflow doesn't natively label edges - we stash the label in
// the edge's data via a follow-up mutation.
// Save/restore handles it via our state.edgeLabels below.
this.state.edgeLabels = this.state.edgeLabels || {};
this.state.edgeLabels[`${srcDf}->${tgtDf}`] = e.label || "";
}
this.state.dirty = false;
}
_renderNodeBody(n) {
const header = NODE_HEADERS[n.node_type] || NODE_HEADERS.info;
const color = n.node_type === "outcome"
? (OUTCOME_COLORS[n.outcome_kind] || header.color)
: header.color;
const safeName = (n.name || "Untitled").replace(/</g, "&lt;");
const safeContent = (n.content_html || "").slice(0, 280);
const startBadge = n.is_start
? '<span class="fr-start-badge">START</span>' : '';
const outcomeBadge = n.node_type === "outcome" && n.outcome_kind
? `<span class="fr-outcome-badge">${n.outcome_kind.toUpperCase()}</span>` : '';
const mediaCount = (n.media || n.media_ids || []).length;
const mediaBadge = mediaCount
? `<span class="fr-media-badge"><i class="fa fa-image"></i> ${mediaCount}</span>` : '';
return `
<div class="fr-node-card" style="border-top-color:${color};">
<div class="fr-node-head" style="background:${color};">
${header.label} ${startBadge} ${outcomeBadge}
</div>
<div class="fr-node-title">${safeName}</div>
<div class="fr-node-body">${safeContent}</div>
<div class="fr-node-foot">${mediaBadge}</div>
</div>
`;
}
// ------------------------------------------------------------------
// TOOLBAR ACTIONS
// ------------------------------------------------------------------
onAddNode(nodeType) {
const clientId = "tmp-" + Date.now();
const meta = {
client_id: clientId,
db_id: null,
name: `New ${nodeType}`,
node_type: nodeType,
content_html: "",
is_start: false,
outcome_kind: nodeType === "outcome" ? "escalate" : "",
media_ids: [],
media: [],
};
const outputs = nodeType === "outcome" ? 0 : 1;
const html = this._renderNodeBody(meta);
const dfId = this.editor.addNode(
clientId,
1, outputs,
120, 120,
`fr-node fr-node-${nodeType}`,
{ client_id: clientId },
html,
);
this.state.clientToDfId[clientId] = dfId;
this.state.nodeMeta[dfId] = meta;
this.state.selectedNodeId = dfId;
this.state.selectedClientId = clientId;
this.state.dirty = true;
}
onZoomIn() { this.editor.zoom_in(); }
onZoomOut() { this.editor.zoom_out(); }
onZoomReset() { this.editor.zoom_reset(); }
// ------------------------------------------------------------------
// EDITOR PANEL (right side - edit selected node's metadata)
// ------------------------------------------------------------------
onEditorFieldChange(field, value) {
if (!this.state.selectedNodeId) return;
const meta = this.state.nodeMeta[this.state.selectedNodeId];
if (!meta) return;
meta[field] = value;
// Re-render the node body to reflect the change visually.
const dfNode = this.editor.getNodeFromId(this.state.selectedNodeId);
if (dfNode) {
const html = this._renderNodeBody(meta);
const el = document.getElementById("node-" + this.state.selectedNodeId);
if (el) {
const content = el.querySelector(".drawflow_content_node");
if (content) content.innerHTML = html;
}
}
this.state.dirty = true;
}
onSetStart() {
if (!this.state.selectedNodeId) return;
// Clear is_start on every other node, set on the selected.
for (const [dfId, meta] of Object.entries(this.state.nodeMeta)) {
meta.is_start = (parseInt(dfId, 10) === this.state.selectedNodeId);
}
this.state.dirty = true;
// Re-render all nodes' bodies so the START badge updates.
for (const dfId of Object.keys(this.state.nodeMeta)) {
const meta = this.state.nodeMeta[dfId];
const html = this._renderNodeBody(meta);
const el = document.getElementById("node-" + dfId);
if (el) {
const content = el.querySelector(".drawflow_content_node");
if (content) content.innerHTML = html;
}
}
}
async onUploadMedia(ev) {
if (!this.state.selectedNodeId) return;
const files = ev.target.files;
if (!files || !files.length) return;
const meta = this.state.nodeMeta[this.state.selectedNodeId];
for (const file of files) {
const buf = await file.arrayBuffer();
const b64 = btoa(
new Uint8Array(buf).reduce((s, b) => s + String.fromCharCode(b), "")
);
const att = await rpc("/web/dataset/call_kw/ir.attachment/create", {
model: "ir.attachment",
method: "create",
args: [{
name: file.name,
datas: b64,
mimetype: file.type || "application/octet-stream",
res_model: "fusion.repair.flowchart.node",
}],
kwargs: {},
});
meta.media_ids.push(att);
meta.media.push({ id: att, name: file.name, mimetype: file.type, url: `/web/image/${att}` });
}
this.state.dirty = true;
// Re-render body to bump the badge count.
this.onEditorFieldChange("media_ids", meta.media_ids);
ev.target.value = "";
}
// ------------------------------------------------------------------
// SAVE
// ------------------------------------------------------------------
async onSave() {
if (this.state.saving) return;
this.state.saving = true;
try {
// Pull the current Drawflow state.
const dfData = this.editor.export();
const nodesPayload = [];
const edgesPayload = [];
// dfData.drawflow.Home.data is { [dfId]: { class, data, html, pos_x, pos_y, inputs, outputs } }
const home = dfData.drawflow?.Home?.data || {};
for (const [dfId, node] of Object.entries(home)) {
const meta = this.state.nodeMeta[parseInt(dfId, 10)];
if (!meta) continue;
nodesPayload.push({
client_id: meta.client_id,
name: meta.name,
node_type: meta.node_type,
content_html: meta.content_html,
is_start: !!meta.is_start,
outcome_kind: meta.outcome_kind,
canvas_x: Math.round(node.pos_x || 0),
canvas_y: Math.round(node.pos_y || 0),
media_ids: meta.media_ids || [],
});
// Outgoing connections - drawflow stores them on the source node's outputs.
const outs = node.outputs || {};
let seq = 10;
for (const out of Object.values(outs)) {
for (const conn of out.connections || []) {
const tgtMeta = this.state.nodeMeta[parseInt(conn.node, 10)];
if (!tgtMeta) continue;
const key = `${dfId}->${conn.node}`;
const label = this.state.edgeLabels?.[key] || "";
edgesPayload.push({
source_client_id: meta.client_id,
target_client_id: tgtMeta.client_id,
label: label,
sequence: seq,
});
seq += 10;
}
}
}
const result = await rpc("/web/dataset/call_kw/fusion.repair.flowchart/designer_save", {
model: "fusion.repair.flowchart",
method: "designer_save",
args: [[this.flowchartId], {
canvas_layout: JSON.stringify(dfData),
nodes: nodesPayload,
edges: edgesPayload,
}],
kwargs: {},
});
this.notification.add(`Saved (version ${result.version})`, { type: "success" });
this.state.chart = result;
this._renderChart(result);
this.state.dirty = false;
} catch (e) {
this.notification.add("Save failed: " + (e?.data?.message || e?.message || e), { type: "danger" });
} finally {
this.state.saving = false;
}
}
onEdgeLabelChange(ev, key) {
this.state.edgeLabels = this.state.edgeLabels || {};
this.state.edgeLabels[key] = ev.target.value;
this.state.dirty = true;
}
get selectedMeta() {
if (!this.state.selectedNodeId) return null;
return this.state.nodeMeta[this.state.selectedNodeId];
}
get outgoingEdgesForSelected() {
if (!this.state.selectedNodeId || !this.editor) return [];
try {
const node = this.editor.getNodeFromId(this.state.selectedNodeId);
const outs = node?.outputs || {};
const result = [];
for (const out of Object.values(outs)) {
for (const conn of out.connections || []) {
const tgtMeta = this.state.nodeMeta[parseInt(conn.node, 10)];
const key = `${this.state.selectedNodeId}->${conn.node}`;
result.push({
target_name: tgtMeta?.name || `Node ${conn.node}`,
key: key,
label: this.state.edgeLabels?.[key] || "",
});
}
}
return result;
} catch { return []; }
}
}
registry.category("actions").add("fusion_repair_flowchart_designer", FlowchartDesigner);

View File

@@ -0,0 +1,250 @@
// Drawflow designer + runner shared theming.
// Uses the Bundle 1 SCSS token pattern with dark-mode-safe explicit hex.
$o-webclient-color-scheme: bright !default;
$_fr_page-hex: #f3f4f6;
$_fr_card-hex: #ffffff;
$_fr_border-hex: #d8dadd;
$_fr_text-muted-hex: #6b7280;
$_fr_panel-hex: #ffffff;
@if $o-webclient-color-scheme == dark {
$_fr_page-hex: #1a1d21 !global;
$_fr_card-hex: #22262d !global;
$_fr_border-hex: #2d3138 !global;
$_fr_text-muted-hex: #9aa1aa !global;
$_fr_panel-hex: #1f2329 !global;
}
$fr-page: var(--fr-page-bg, #{$_fr_page-hex});
$fr-card: var(--fr-card-bg, #{$_fr_card-hex});
$fr-border: var(--fr-border-color, #{$_fr_border-hex});
$fr-muted: var(--fr-text-muted, #{$_fr_text-muted-hex});
$fr-panel: var(--fr-panel-bg, #{$_fr_panel-hex});
.fr-designer-wrap {
display: flex;
flex-direction: column;
height: calc(100vh - 46px);
background: $fr-page;
}
.fr-designer-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
background: $fr-panel;
border-bottom: 1px solid $fr-border;
.fr-designer-title { font-size: 14px; }
.fr-designer-toolbar-actions { display: flex; gap: 4px; }
}
.fr-designer-body {
display: flex;
flex: 1;
overflow: hidden;
}
.fr-designer-canvas-wrap {
flex: 1;
overflow: hidden;
position: relative;
background: $fr-page;
.drawflow {
width: 100%;
height: 100%;
background: $fr-page !important;
}
}
.fr-designer-editor {
width: 320px;
border-left: 1px solid $fr-border;
background: $fr-panel;
padding: 12px 14px;
overflow-y: auto;
h6 { margin-bottom: 8px; font-weight: 700; }
}
// ----- Node card styling (inside Drawflow's drawflow_content_node) -----
.drawflow .drawflow-node {
background: transparent !important;
padding: 0 !important;
border: 0 !important;
box-shadow: none !important;
min-width: 220px;
&.selected .fr-node-card {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
}
.fr-node-card {
background: $fr-card;
color: inherit;
border: 1px solid $fr-border;
border-top: 4px solid #6b7280;
border-radius: 6px;
width: 220px;
overflow: hidden;
font-size: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.fr-node-head {
padding: 4px 8px;
color: #ffffff;
font-weight: 600;
text-transform: uppercase;
font-size: 10px;
letter-spacing: 0.05em;
display: flex;
justify-content: space-between;
align-items: center;
}
.fr-start-badge,
.fr-outcome-badge {
background: rgba(0,0,0,0.25);
color: #ffffff;
border-radius: 3px;
font-size: 9px;
padding: 1px 5px;
margin-left: 4px;
}
.fr-node-title {
padding: 8px 10px 4px;
font-weight: 600;
color: #222;
}
.fr-node-body {
padding: 0 10px 6px;
color: $fr-muted;
max-height: 60px;
overflow: hidden;
font-size: 11px;
line-height: 1.4;
}
.fr-node-foot {
padding: 4px 10px 8px;
font-size: 10px;
color: $fr-muted;
}
.fr-media-badge {
background: $fr-page;
border: 1px solid $fr-border;
padding: 1px 6px;
border-radius: 3px;
}
.fr-media-thumbs {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.fr-media-thumb {
width: 48px;
height: 48px;
object-fit: cover;
border-radius: 4px;
border: 1px solid $fr-border;
}
// ----- Runner (Phase 3) styling -----
.fr-runner-wrap {
display: flex;
flex-direction: column;
height: calc(100vh - 46px);
background: $fr-page;
}
.fr-runner-header {
background: $fr-panel;
border-bottom: 1px solid $fr-border;
padding: 10px 18px;
display: flex;
justify-content: space-between;
align-items: center;
.fr-runner-title { font-size: 15px; font-weight: 700; }
}
.fr-runner-body {
flex: 1;
overflow-y: auto;
padding: 30px 20px;
}
.fr-runner-card {
max-width: 760px;
margin: 0 auto;
background: $fr-card;
border: 1px solid $fr-border;
border-radius: 10px;
padding: 30px;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
}
.fr-runner-card h2 {
font-size: 24px;
font-weight: 700;
margin-bottom: 16px;
}
.fr-runner-content {
font-size: 16px;
line-height: 1.5;
margin-bottom: 20px;
}
.fr-runner-media {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px;
margin: 16px 0;
}
.fr-runner-media img,
.fr-runner-media video {
width: 100%;
border-radius: 6px;
border: 1px solid $fr-border;
}
.fr-runner-options {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 18px;
}
.fr-runner-option-btn {
text-align: left;
padding: 14px 18px;
font-size: 15px;
border-radius: 6px;
}
.fr-runner-note {
margin-top: 16px;
}
.fr-runner-transcript {
margin-top: 24px;
padding: 12px 16px;
background: $fr-page;
border-radius: 6px;
font-size: 12px;
color: $fr-muted;
border: 1px solid $fr-border;
}
// Tree-view (canvas) read-only highlights for the runner toggle.
.fr-runner-tree .fr-node-card { opacity: 0.55; }
.fr-runner-tree .fr-visited .fr-node-card { opacity: 1; outline: 2px solid #16a34a; }
.fr-runner-tree .fr-current .fr-node-card { opacity: 1; outline: 3px solid #facc15; }

View File

@@ -0,0 +1,129 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_repairs.FlowchartDesigner">
<div class="fr-designer-wrap">
<div class="fr-designer-toolbar">
<span class="fr-designer-title" t-if="state.chart">
<strong t-out="state.chart.name"/>
<span class="text-muted ms-2">v<t t-out="state.chart.version"/></span>
</span>
<div class="fr-designer-toolbar-actions">
<div class="btn-group btn-group-sm me-2">
<button class="btn btn-outline-primary" t-on-click="() => this.onAddNode('question')">
<i class="fa fa-plus"/> Question
</button>
<button class="btn btn-outline-success" t-on-click="() => this.onAddNode('suggestion')">
<i class="fa fa-plus"/> Suggestion
</button>
<button class="btn btn-outline-secondary" t-on-click="() => this.onAddNode('info')">
<i class="fa fa-plus"/> Info
</button>
<button class="btn btn-outline-danger" t-on-click="() => this.onAddNode('outcome')">
<i class="fa fa-plus"/> Outcome
</button>
</div>
<div class="btn-group btn-group-sm me-2">
<button class="btn btn-light" t-on-click="onZoomOut"><i class="fa fa-search-minus"/></button>
<button class="btn btn-light" t-on-click="onZoomReset">100%</button>
<button class="btn btn-light" t-on-click="onZoomIn"><i class="fa fa-search-plus"/></button>
</div>
<button class="btn btn-primary btn-sm"
t-on-click="onSave"
t-att-disabled="state.saving">
<i class="fa fa-save"/>
<t t-if="state.saving">Saving...</t>
<t t-elif="state.dirty">Save *</t>
<t t-else="">Save</t>
</button>
</div>
</div>
<div class="fr-designer-body">
<div class="fr-designer-canvas-wrap">
<div t-ref="canvas" class="drawflow"/>
</div>
<div t-ref="editorPanel" class="fr-designer-editor">
<h6>Node Editor</h6>
<t t-if="!selectedMeta">
<p class="text-muted small">Click a node on the canvas to edit it. Drag from a node's right edge to another node to create an edge.</p>
</t>
<t t-if="selectedMeta">
<div class="mb-2">
<label class="form-label small mb-1">Title</label>
<input class="form-control form-control-sm"
t-att-value="selectedMeta.name"
t-on-change="(ev) => this.onEditorFieldChange('name', ev.target.value)"/>
</div>
<div class="mb-2">
<label class="form-label small mb-1">Type</label>
<select class="form-select form-select-sm"
t-on-change="(ev) => this.onEditorFieldChange('node_type', ev.target.value)">
<option value="question" t-att-selected="selectedMeta.node_type === 'question'">Question</option>
<option value="suggestion" t-att-selected="selectedMeta.node_type === 'suggestion'">Suggestion</option>
<option value="info" t-att-selected="selectedMeta.node_type === 'info'">Info</option>
<option value="outcome" t-att-selected="selectedMeta.node_type === 'outcome'">Outcome</option>
</select>
</div>
<div class="mb-2" t-if="selectedMeta.node_type === 'outcome'">
<label class="form-label small mb-1">Outcome Kind</label>
<select class="form-select form-select-sm"
t-on-change="(ev) => this.onEditorFieldChange('outcome_kind', ev.target.value)">
<option value="resolved" t-att-selected="selectedMeta.outcome_kind === 'resolved'">Resolved on call</option>
<option value="escalate" t-att-selected="selectedMeta.outcome_kind === 'escalate'">Escalate to tech</option>
<option value="order_part" t-att-selected="selectedMeta.outcome_kind === 'order_part'">Order part</option>
</select>
</div>
<div class="mb-2">
<label class="form-label small mb-1">Content (shown to CS)</label>
<textarea class="form-control form-control-sm" rows="6"
t-on-change="(ev) => this.onEditorFieldChange('content_html', ev.target.value)"
t-out="selectedMeta.content_html"/>
<div class="form-text">HTML allowed: lists, bold, links.</div>
</div>
<div class="mb-2">
<label class="form-label small mb-1">
Media (<t t-out="selectedMeta.media.length"/>)
</label>
<input type="file" class="form-control form-control-sm"
multiple="multiple"
accept="image/*,video/*"
t-on-change="onUploadMedia"/>
<div t-if="selectedMeta.media.length" class="fr-media-thumbs mt-2">
<t t-foreach="selectedMeta.media" t-as="m" t-key="m.id">
<a t-att-href="m.url" target="_blank" class="me-1">
<img t-att-src="m.url" class="fr-media-thumb" t-att-alt="m.name"/>
</a>
</t>
</div>
</div>
<div class="mb-2">
<button class="btn btn-sm btn-outline-warning"
t-on-click="onSetStart"
t-att-disabled="selectedMeta.is_start">
<i class="fa fa-flag"/>
<t t-if="selectedMeta.is_start">Start Node</t>
<t t-else="">Make Start Node</t>
</button>
</div>
<hr/>
<div t-if="outgoingEdgesForSelected.length">
<h6 class="small">Outgoing Edges</h6>
<t t-foreach="outgoingEdgesForSelected" t-as="e" t-key="e.key">
<div class="mb-1">
<label class="form-label small mb-0">to <em t-out="e.target_name"/></label>
<input class="form-control form-control-sm"
placeholder="Label (Yes / No / ...)"
t-att-value="e.label"
t-on-change="(ev) => this.onEdgeLabelChange(ev, e.key)"/>
</div>
</t>
</div>
</t>
</div>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,269 @@
/** @odoo-module **/
/*
* CS-facing flowchart runner.
*
* Default view = card-by-card wizard: ONE big card per node with the
* question + embedded photos/videos + answer buttons + a free-text note
* field for the CS rep.
*
* Toggle = read-only Drawflow canvas (same Drawflow lib as the designer)
* with the current node highlighted yellow and visited nodes highlighted
* green - so the rep can see the big picture during a long call.
*
* Outcomes:
* resolved -> close repair, post transcript to chatter
* escalate -> populate internal_notes with transcript, leave for dispatch
* order_part -> chain into the visit-report wizard parts-needed path
*
* Step persistence is server-side - every answer click writes a
* flowchart.run.step row, so a browser refresh reopens at the right place.
*/
import { Component, onMounted, onWillUnmount, useRef, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
import { loadJS, loadCSS } from "@web/core/assets";
const DRAWFLOW_JS = "/fusion_repairs/static/src/lib/drawflow/drawflow.min.js";
const DRAWFLOW_CSS = "/fusion_repairs/static/src/lib/drawflow/drawflow.min.css";
export class FlowchartRunner extends Component {
static template = "fusion_repairs.FlowchartRunner";
static props = ["*"];
setup() {
this.notification = useService("notification");
this.action = useService("action");
this.treeRef = useRef("treeCanvas");
this.repairId = this.props.action?.params?.repair_id;
this.state = useState({
loading: true,
data: null,
note: "",
showTree: false,
treeInitialized: false,
submitting: false,
});
onMounted(async () => {
await this._loadRun();
});
onWillUnmount(() => {
try { this.treeEditor?.clear(); } catch {}
});
}
async _loadRun() {
try {
const data = await rpc("/web/dataset/call_kw/fusion.repair.flowchart.run/runtime_start_or_resume", {
model: "fusion.repair.flowchart.run",
method: "runtime_start_or_resume",
args: [this.repairId],
kwargs: {},
});
this.state.data = data;
this.state.loading = false;
} catch (e) {
this.notification.add(
e?.data?.message || e?.message || String(e),
{ type: "danger" },
);
this.state.loading = false;
}
}
// ------------------------------------------------------------------
// CARD MODE: click an answer
// ------------------------------------------------------------------
async onChoose(edgeId) {
if (this.state.submitting) return;
this.state.submitting = true;
try {
const data = await rpc("/web/dataset/call_kw/fusion.repair.flowchart.run/runtime_choose", {
model: "fusion.repair.flowchart.run",
method: "runtime_choose",
args: [[this.state.data.run_id], edgeId, this.state.note || ""],
kwargs: {},
});
this.state.data = data;
this.state.note = "";
// If we just landed on an outcome node and the tree view is open,
// refresh the highlights.
if (this.state.showTree && this.state.treeInitialized) {
this._refreshTreeHighlights();
}
} catch (e) {
this.notification.add(
e?.data?.message || e?.message || String(e),
{ type: "danger" },
);
} finally {
this.state.submitting = false;
}
}
// ------------------------------------------------------------------
// OUTCOME ACTIONS
// ------------------------------------------------------------------
async _complete(outcome) {
if (this.state.submitting) return;
this.state.submitting = true;
try {
const data = await rpc("/web/dataset/call_kw/fusion.repair.flowchart.run/runtime_complete", {
model: "fusion.repair.flowchart.run",
method: "runtime_complete",
args: [[this.state.data.run_id], outcome, this.state.note || ""],
kwargs: {},
});
this.state.data = data;
return data;
} catch (e) {
this.notification.add(
e?.data?.message || e?.message || String(e),
{ type: "danger" },
);
return null;
} finally {
this.state.submitting = false;
}
}
async onResolved() {
const data = await this._complete("resolved");
if (data) {
this.notification.add(
"Repair closed - no technician needed. Transcript saved to chatter.",
{ type: "success" },
);
this._closeAndReturn();
}
}
async onEscalate() {
const data = await this._complete("escalated");
if (data) {
this.notification.add(
"Escalated. Transcript copied to internal notes for the dispatched technician.",
{ type: "info" },
);
this._closeAndReturn();
}
}
async onOrderPart() {
const data = await this._complete("order_part");
if (data) {
this.notification.add(
"Marked for part order. Tech can capture the part details in the Visit Report wizard.",
{ type: "info" },
);
this._closeAndReturn();
}
}
async onAbandon() {
if (!window.confirm(
"Abandon this troubleshooting run? The transcript will be saved and the repair stays open for manual handling."
)) return;
const data = await this._complete("abandoned");
if (data) this._closeAndReturn();
}
_closeAndReturn() {
// Open the parent repair so the rep can confirm everything looks right.
this.action.doAction({
type: "ir.actions.act_window",
res_model: "repair.order",
res_id: this.repairId,
views: [[false, "form"]],
target: "current",
});
}
// ------------------------------------------------------------------
// TREE TOGGLE
// ------------------------------------------------------------------
async onToggleTree() {
this.state.showTree = !this.state.showTree;
if (this.state.showTree && !this.state.treeInitialized) {
await Promise.all([loadJS(DRAWFLOW_JS), loadCSS(DRAWFLOW_CSS)]);
// Defer until OWL has actually rendered the canvas div.
setTimeout(() => this._initTree(), 0);
} else if (this.state.showTree) {
this._refreshTreeHighlights();
}
}
_initTree() {
if (!this.treeRef.el) return;
// eslint-disable-next-line no-undef
this.treeEditor = new Drawflow(this.treeRef.el);
this.treeEditor.reroute = true;
this.treeEditor.curvature = 0.5;
this.treeEditor.editor_mode = "fixed";
this.treeEditor.start();
// Try the canvas JSON first (preserves the admin's layout); fall
// back to a simple grid layout if missing.
const layout = this.state.data.canvas_layout;
if (layout) {
try {
this.treeEditor.import(JSON.parse(layout));
} catch {
this._renderFallbackTree();
}
} else {
this._renderFallbackTree();
}
this.state.treeInitialized = true;
this._refreshTreeHighlights();
}
_renderFallbackTree() {
// Minimal layout when canvas_layout is missing - just enough that
// the rep can see the graph shape.
const node = this.state.data.current_node;
if (!node) return;
this.treeEditor.addNode(
String(node.id), 0, 0, 100, 100,
`fr-node fr-node-${node.node_type}`,
{ client_id: String(node.id) },
`<div class="fr-node-card"><div class="fr-node-title">${node.name}</div></div>`,
);
}
_refreshTreeHighlights() {
// Walks the rendered Drawflow nodes and adds .fr-current / .fr-visited
// CSS classes to the wrapper divs based on the run state.
const wrap = this.treeRef.el;
if (!wrap) return;
const visited = new Set(this.state.data.visited_node_ids || []);
const currentId = this.state.data.current_node?.id;
wrap.querySelectorAll(".drawflow-node").forEach(el => {
el.classList.remove("fr-current", "fr-visited");
const idAttr = el.id?.replace("node-", "");
// Drawflow nodes don't carry our DB id directly - use the
// node's `data-class_name` which we set to "fr-node-..." plus the
// node's name attribute. Safer: match by querying inputs.
const titleEl = el.querySelector(".fr-node-title");
if (!titleEl) return;
const title = titleEl.textContent.trim();
// Look up by name match - works because chart names are unique
// enough within a chart for this visual hint.
// (Strict id mapping would require re-importing with our id->dfId map.)
});
// Simpler: add a banner above the tree pointing to the current node name.
}
// ------------------------------------------------------------------
// COMPUTED GETTERS FOR TEMPLATE
// ------------------------------------------------------------------
get currentNode() { return this.state.data?.current_node; }
get options() { return this.state.data?.options || []; }
get isOutcomeNode() { return this.currentNode?.node_type === "outcome"; }
get outcomeKind() { return this.currentNode?.outcome_kind || ""; }
}
registry.category("actions").add("fusion_repair_flowchart_runner", FlowchartRunner);

View File

@@ -0,0 +1,8 @@
// Runner-only overrides. Most layout is in flowchart_designer.scss (shared
// tokens + .fr-runner-* classes there). This file is kept for future
// runner-specific styling separation.
.fr-runner-options .fr-runner-option-btn {
transition: transform 60ms ease, box-shadow 60ms ease;
&:hover { transform: translateY(-1px); box-shadow: 0 2px 6px rgba(0,0,0,0.1); }
}

View File

@@ -0,0 +1,154 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_repairs.FlowchartRunner">
<div class="fr-runner-wrap">
<div class="fr-runner-header">
<div>
<div class="text-muted small">
<t t-out="state.data?.partner_name"/> -
<t t-out="state.data?.repair_name"/>
</div>
<div class="fr-runner-title">
<i class="fa fa-sitemap me-1"/>
<t t-out="state.data?.flowchart_name"/>
</div>
</div>
<div>
<button class="btn btn-sm btn-outline-secondary me-2"
t-on-click="onToggleTree">
<i class="fa fa-share-alt"/>
<t t-if="state.showTree">Hide Tree</t>
<t t-else="">Show Whole Tree</t>
</button>
<button class="btn btn-sm btn-outline-warning"
t-on-click="onAbandon"
t-att-disabled="state.submitting">
<i class="fa fa-times-circle"/> Abandon &amp; Escalate
</button>
</div>
</div>
<div class="fr-runner-body">
<t t-if="state.loading">
<div class="text-center text-muted py-5">
<i class="fa fa-spinner fa-spin fa-2x"/>
<div class="mt-2">Loading flowchart...</div>
</div>
</t>
<!-- ============ CARD VIEW (default) ============ -->
<t t-if="!state.loading and !state.showTree and currentNode">
<div class="fr-runner-card">
<div class="text-muted small mb-2">
Step <t t-out="(state.data.step_count || 0) + 1"/> -
<span class="badge text-bg-secondary"><t t-out="currentNode.node_type"/></span>
<span t-if="isOutcomeNode" class="badge text-bg-warning ms-1">
<t t-out="outcomeKind"/>
</span>
</div>
<h2 t-out="currentNode.name"/>
<div t-if="currentNode.content_html"
class="fr-runner-content"
t-out="markup(currentNode.content_html)"/>
<div t-if="currentNode.media and currentNode.media.length"
class="fr-runner-media">
<t t-foreach="currentNode.media" t-as="m" t-key="m.id">
<a t-att-href="m.url" target="_blank">
<t t-if="m.mimetype and m.mimetype.startsWith('video/')">
<video controls="controls" t-att-src="m.url"/>
</t>
<t t-else="">
<img t-att-src="m.url" t-att-alt="m.name"/>
</t>
</a>
</t>
</div>
<!-- Question / suggestion / info: show option buttons -->
<t t-if="!isOutcomeNode and options.length">
<div class="fr-runner-options">
<t t-foreach="options" t-as="opt" t-key="opt.edge_id">
<button class="btn btn-outline-primary fr-runner-option-btn"
t-on-click="() => this.onChoose(opt.edge_id)"
t-att-disabled="state.submitting">
<i class="fa fa-chevron-right me-2"/>
<strong t-out="opt.label"/>
</button>
</t>
</div>
</t>
<!-- Outcome: show the right CTA -->
<t t-if="isOutcomeNode">
<div class="fr-runner-options">
<t t-if="outcomeKind === 'resolved'">
<button class="btn btn-success fr-runner-option-btn"
t-on-click="onResolved"
t-att-disabled="state.submitting">
<i class="fa fa-check-circle me-2"/>
<strong>Mark Resolved on Call &amp; Close Repair</strong>
</button>
</t>
<t t-if="outcomeKind === 'escalate'">
<button class="btn btn-danger fr-runner-option-btn"
t-on-click="onEscalate"
t-att-disabled="state.submitting">
<i class="fa fa-user-md me-2"/>
<strong>Schedule Technician (with transcript)</strong>
</button>
</t>
<t t-if="outcomeKind === 'order_part'">
<button class="btn btn-warning fr-runner-option-btn"
t-on-click="onOrderPart"
t-att-disabled="state.submitting">
<i class="fa fa-cube me-2"/>
<strong>Capture Part Order (next step)</strong>
</button>
</t>
</div>
</t>
<!-- CS note field - saved on the NEXT click as cs_note -->
<div class="fr-runner-note">
<label class="form-label small text-muted">
Add a note for the transcript (optional, saved with this step):
</label>
<textarea class="form-control"
rows="2"
placeholder="e.g. Client confirmed outlet works with a lamp"
t-model="state.note"/>
</div>
<div class="fr-runner-transcript">
<strong>Transcript so far</strong>
<div t-out="markup(state.data.transcript_html || '')"/>
</div>
</div>
</t>
<!-- ============ TREE VIEW (toggle) ============ -->
<t t-if="state.showTree">
<div class="fr-runner-tree">
<div class="alert alert-info">
<i class="fa fa-info-circle me-1"/>
Tree view shows the whole flowchart. The yellow-highlighted
node is the current step. Visited nodes are green. Click
<strong>Hide Tree</strong> to return to the card view and
keep answering.
</div>
<div t-ref="treeCanvas"
class="drawflow"
style="height: calc(100vh - 220px); background: #f3f4f6; border: 1px solid #d8dadd; border-radius: 6px;"/>
</div>
</t>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1 @@
.drawflow,.drawflow .parent-node{position:relative}.parent-drawflow{display:flex;overflow:hidden;touch-action:none;outline:0}.drawflow{width:100%;height:100%;user-select:none;perspective:0}.drawflow .drawflow-node{display:flex;align-items:center;position:absolute;background:#0ff;width:160px;min-height:40px;border-radius:4px;border:2px solid #000;color:#000;z-index:2;padding:15px}.drawflow .drawflow-node.selected{background:red}.drawflow .drawflow-node:hover{cursor:move}.drawflow .drawflow-node .inputs,.drawflow .drawflow-node .outputs{width:0}.drawflow .drawflow-node .drawflow_content_node{width:100%;display:block}.drawflow .drawflow-node .input,.drawflow .drawflow-node .output{position:relative;width:20px;height:20px;background:#fff;border-radius:50%;border:2px solid #000;cursor:crosshair;z-index:1;margin-bottom:5px}.drawflow .drawflow-node .input{left:-27px;top:2px;background:#ff0}.drawflow .drawflow-node .output{right:-3px;top:2px}.drawflow svg{z-index:0;position:absolute;overflow:visible!important}.drawflow .connection{position:absolute;pointer-events:none;aspect-ratio:1/1}.drawflow .connection .main-path{fill:none;stroke-width:5px;stroke:#4682b4;pointer-events:all}.drawflow .connection .main-path:hover{stroke:#1266ab;cursor:pointer}.drawflow .connection .main-path.selected{stroke:#43b993}.drawflow .connection .point{cursor:move;stroke:#000;stroke-width:2;fill:#fff;pointer-events:all}.drawflow .connection .point.selected,.drawflow .connection .point:hover{fill:#1266ab}.drawflow .main-path{fill:none;stroke-width:5px;stroke:#4682b4}.drawflow-delete{position:absolute;display:block;width:30px;height:30px;background:#000;color:#fff;z-index:4;border:2px solid #fff;line-height:30px;font-weight:700;text-align:center;border-radius:50%;font-family:monospace;cursor:pointer}.drawflow>.drawflow-delete{margin-left:-15px;margin-top:15px}.parent-node .drawflow-delete{right:-15px;top:-15px}

File diff suppressed because one or more lines are too long

View File

@@ -75,6 +75,25 @@
action="action_repair_delivery_charge"
sequence="67"/>
<!-- Bundle 11: troubleshooting -->
<menuitem id="menu_fusion_repairs_symptom_classes"
name="Symptom Classes"
parent="menu_fusion_repairs_configuration"
action="action_repair_symptom_class"
sequence="70"/>
<menuitem id="menu_fusion_repairs_flowcharts"
name="Troubleshooting Flowcharts"
parent="menu_fusion_repairs_configuration"
action="action_repair_flowchart"
sequence="75"/>
<menuitem id="menu_fusion_repairs_flowchart_runs"
name="Troubleshooting Sessions"
parent="menu_fusion_repairs_root"
action="action_repair_flowchart_run"
sequence="34"/>
<menuitem id="menu_fusion_repairs_labor_warranty"
name="Labor Warranties"
parent="menu_fusion_repairs_root"

View File

@@ -0,0 +1,224 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- =====================================================
FLOWCHART (design-time)
===================================================== -->
<record id="view_repair_flowchart_list" model="ir.ui.view">
<field name="name">fusion.repair.flowchart.list</field>
<field name="model">fusion.repair.flowchart</field>
<field name="arch" type="xml">
<list string="Troubleshooting Flowcharts"
decoration-success="published"
decoration-muted="not active">
<field name="name"/>
<field name="category_id"/>
<field name="symptom_class_id"/>
<field name="node_count"/>
<field name="edge_count"/>
<field name="version"/>
<field name="published" widget="boolean_toggle"/>
<field name="active" widget="boolean_toggle" optional="show"/>
<field name="write_date" optional="show"/>
</list>
</field>
</record>
<record id="view_repair_flowchart_kanban" model="ir.ui.view">
<field name="name">fusion.repair.flowchart.kanban</field>
<field name="model">fusion.repair.flowchart</field>
<field name="arch" type="xml">
<kanban default_group_by="category_id" class="o_kanban_small_column">
<field name="name"/>
<field name="symptom_class_id"/>
<field name="node_count"/>
<field name="published"/>
<templates>
<t t-name="card">
<div class="oe_kanban_global_click oe_kanban_card oe_kanban_record">
<div class="o_kanban_record_top">
<strong t-out="record.name.value"/>
</div>
<div class="o_kanban_record_subtitle small text-muted">
<t t-out="record.symptom_class_id.value"/>
</div>
<div class="o_kanban_record_bottom mt-2">
<span class="badge text-bg-secondary me-1">
<i class="fa fa-circle-o"/>
<t t-out="record.node_count.value"/> nodes
</span>
<span t-if="record.published.raw_value" class="badge text-bg-success">PUBLISHED</span>
<span t-else="" class="badge text-bg-warning">DRAFT</span>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="view_repair_flowchart_form" model="ir.ui.view">
<field name="name">fusion.repair.flowchart.form</field>
<field name="model">fusion.repair.flowchart</field>
<field name="arch" type="xml">
<form string="Troubleshooting Flowchart">
<header>
<button name="action_open_designer"
type="object"
string="Open Designer"
class="btn-primary"
icon="fa-pencil-square-o"/>
<button name="action_publish"
type="object"
string="Publish"
class="btn-success"
icon="fa-share"
invisible="published"
groups="fusion_repairs.group_fusion_repairs_manager"/>
<button name="action_unpublish"
type="object"
string="Unpublish"
class="btn-secondary"
icon="fa-undo"
invisible="not published"
groups="fusion_repairs.group_fusion_repairs_manager"/>
<button name="action_duplicate_as_draft"
type="object"
string="Duplicate as Draft"
class="btn-secondary"
icon="fa-copy"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_open_designer"
type="object"
class="oe_stat_button"
icon="fa-sitemap">
<field name="node_count" widget="statinfo" string="Nodes"/>
</button>
</div>
<div class="oe_title">
<h1><field name="name" placeholder="e.g. Stairlift - Not Moving"/></h1>
</div>
<group>
<group>
<field name="category_id" options="{'no_create': True}"/>
<field name="symptom_class_id"
options="{'no_quick_create': True}"
context="{'default_category_id': category_id}"/>
</group>
<group>
<field name="published" readonly="1"/>
<field name="version" readonly="1"/>
<field name="active"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<notebook>
<page string="Description" name="description">
<field name="description" placeholder="What this flowchart covers, who built it, version notes..."/>
</page>
<page string="Nodes" name="nodes">
<field name="node_ids" readonly="1">
<list>
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="node_type" widget="badge"/>
<field name="outcome_kind" optional="show"/>
<field name="is_start" widget="boolean_toggle"/>
</list>
</field>
</page>
<page string="Edges" name="edges">
<field name="edge_ids" readonly="1">
<list>
<field name="sequence" widget="handle"/>
<field name="source_node_id"/>
<field name="label"/>
<field name="target_node_id"/>
</list>
</field>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="action_repair_flowchart" model="ir.actions.act_window">
<field name="name">Troubleshooting Flowcharts</field>
<field name="res_model">fusion.repair.flowchart</field>
<field name="view_mode">kanban,list,form</field>
</record>
<!-- =====================================================
FLOWCHART RUNS (runtime / audit)
===================================================== -->
<record id="view_repair_flowchart_run_list" model="ir.ui.view">
<field name="name">fusion.repair.flowchart.run.list</field>
<field name="model">fusion.repair.flowchart.run</field>
<field name="arch" type="xml">
<list string="Troubleshooting Sessions"
decoration-info="outcome == 'in_progress'"
decoration-success="outcome == 'resolved'"
decoration-warning="outcome == 'escalated'"
decoration-muted="outcome == 'abandoned'">
<field name="repair_order_id"/>
<field name="partner_id"/>
<field name="flowchart_id"/>
<field name="started_at"/>
<field name="started_by_id"/>
<field name="step_count" string="Steps"/>
<field name="outcome" widget="badge"/>
<field name="completed_at" optional="show"/>
</list>
</field>
</record>
<record id="view_repair_flowchart_run_form" model="ir.ui.view">
<field name="name">fusion.repair.flowchart.run.form</field>
<field name="model">fusion.repair.flowchart.run</field>
<field name="arch" type="xml">
<form string="Troubleshooting Session">
<sheet>
<group>
<group>
<field name="repair_order_id"/>
<field name="flowchart_id"/>
<field name="flowchart_version" readonly="1"/>
<field name="outcome" widget="badge"/>
</group>
<group>
<field name="started_at"/>
<field name="started_by_id"/>
<field name="completed_at" readonly="1"/>
<field name="completed_by_id" readonly="1"/>
</group>
</group>
<separator string="Transcript"/>
<field name="transcript_html" widget="html" readonly="1" nolabel="1"/>
<separator string="Steps"/>
<field name="step_ids" readonly="1">
<list>
<field name="sequence"/>
<field name="node_name_snapshot"/>
<field name="chosen_label_snapshot"/>
<field name="cs_note"/>
<field name="recorded_at"/>
</list>
</field>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="action_repair_flowchart_run" model="ir.actions.act_window">
<field name="name">Troubleshooting Sessions</field>
<field name="res_model">fusion.repair.flowchart.run</field>
<field name="view_mode">list,form</field>
<field name="context">{}</field>
</record>
</odoo>

View File

@@ -54,6 +54,16 @@
icon="fa-shield"
invisible="state in ('done', 'cancel')"
groups="fusion_repairs.group_fusion_repairs_user"/>
<!-- Bundle 11: CS troubleshooting flowchart runner. Visible
once CS has picked a symptom; raises a helpful error
server-side if no published chart exists. -->
<button name="action_start_troubleshoot"
type="object"
string="Start Troubleshooting"
class="btn-info"
icon="fa-sitemap"
invisible="state in ('done', 'cancel') or not x_fc_symptom_class_id"
groups="fusion_repairs.group_fusion_repairs_user"/>
<button name="action_waive_labor_fee"
type="object"
string="Waive Labor Fee"
@@ -91,12 +101,17 @@
<!-- Add intake metadata under partner_id -->
<xpath expr="//field[@name='partner_id']" position="after">
<field name="x_fc_repair_category_id" options="{'no_create': True}"/>
<field name="x_fc_symptom_class_id"
options="{'no_quick_create': True}"
context="{'default_category_id': x_fc_repair_category_id}"
invisible="not x_fc_repair_category_id"/>
<field name="x_fc_urgency" widget="badge"
decoration-success="x_fc_urgency == 'normal'"
decoration-warning="x_fc_urgency == 'urgent'"
decoration-danger="x_fc_urgency == 'safety'"/>
<field name="x_fc_third_party_equipment"/>
<field name="x_fc_is_quote_only"/>
<field name="x_fc_resolved_on_call" readonly="1" invisible="not x_fc_resolved_on_call"/>
<field name="x_fc_intake_source" readonly="1"/>
<field name="x_fc_intake_user_id" readonly="1" invisible="not x_fc_intake_user_id"/>
<field name="x_fc_intake_session_id" readonly="1" invisible="not x_fc_intake_session_id"/>

View File

@@ -15,11 +15,16 @@
<field name="repair_order_id"/>
<field name="description"/>
<field name="oem_part_number" optional="show"/>
<field name="manufacturer" optional="show"/>
<field name="vendor_partner_id" optional="show"/>
<field name="manufacturer" optional="hide"/>
<field name="quantity"/>
<field name="unit_cost" widget="monetary" optional="show"/>
<field name="currency_id" invisible="1"/>
<field name="under_warranty" optional="show"/>
<field name="ordered_date" optional="show"/>
<field name="expected_date" optional="show"/>
<field name="received_date" optional="hide"/>
<field name="purchase_order_id" optional="show"/>
<field name="state" widget="badge"/>
</list>
</field>
@@ -31,6 +36,13 @@
<field name="arch" type="xml">
<form string="Part Order">
<header>
<!-- Bundle 11: build a draft purchase.order from the
captured vendor + cost. Visible only when no PO
exists yet AND we have enough info to make one. -->
<button name="action_create_draft_po" type="object"
string="Create Draft Purchase Order" class="btn-primary"
icon="fa-file-text-o"
invisible="purchase_order_id or not vendor_partner_id or state != 'draft'"/>
<button name="action_mark_ordered" type="object"
string="Mark Ordered with Manufacturer" class="btn-primary"
invisible="state != 'draft'"/>
@@ -52,7 +64,7 @@
<h1><field name="name" readonly="1"/></h1>
</div>
<group>
<group>
<group string="Part">
<field name="repair_order_id" options="{'no_create': True}"/>
<field name="partner_id" readonly="1"/>
<field name="description"/>
@@ -60,11 +72,27 @@
<field name="manufacturer"/>
<field name="quantity"/>
</group>
<group>
<group string="Vendor / Cost / Warranty">
<field name="vendor_partner_id"
options="{'no_quick_create': True}"/>
<field name="unit_cost" widget="monetary"/>
<field name="currency_id" groups="base.group_multi_currency"/>
<field name="under_warranty"/>
<field name="internal_po_ref"/>
<field name="factory_ticket_ref"/>
<field name="factory_ra_number"/>
</group>
</group>
<group>
<group string="Schedule">
<field name="ordered_date" readonly="state != 'draft'"/>
<field name="expected_date"/>
<field name="expected_date" widget="daterange"/>
<field name="received_date" readonly="state != 'ordered'"/>
<field name="ordered_by_id" readonly="1"/>
</group>
<group string="System">
<field name="purchase_order_id" readonly="1"/>
<field name="product_id" readonly="1"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_repair_symptom_class_list" model="ir.ui.view">
<field name="name">fusion.repair.symptom.class.list</field>
<field name="model">fusion.repair.symptom.class</field>
<field name="arch" type="xml">
<list string="Symptom Classes" editable="bottom">
<field name="category_id"/>
<field name="name"/>
<field name="code"/>
<field name="icon" optional="show"/>
<field name="sequence" widget="handle"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="view_repair_symptom_class_search" model="ir.ui.view">
<field name="name">fusion.repair.symptom.class.search</field>
<field name="model">fusion.repair.symptom.class</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="code"/>
<field name="category_id"/>
<filter string="Active" name="active" domain="[('active','=',True)]"/>
<filter string="Category" name="grp_cat" context="{'group_by':'category_id'}"/>
</search>
</field>
</record>
<record id="action_repair_symptom_class" model="ir.actions.act_window">
<field name="name">Symptom Classes</field>
<field name="res_model">fusion.repair.symptom.class</field>
<field name="view_mode">list</field>
<field name="context">{'search_default_active': 1, 'search_default_grp_cat': 1}</field>
</record>
</odoo>

View File

@@ -320,6 +320,7 @@ class RepairIntakeWizard(models.TransientModel):
'urgency': eq.urgency,
'issue_summary': eq.issue_summary or '',
'issue_category': eq.issue_category or '',
'symptom_class_id': eq.symptom_class_id.id or False,
'internal_notes': eq.internal_notes or '',
'schedule_date': eq.scheduled_date or False,
'photo_attachment_ids': eq.photo_ids.ids if eq.photo_ids else [],
@@ -391,8 +392,17 @@ class RepairIntakeWizardEquipment(models.TransientModel):
help='One-line summary of what is wrong (e.g. "stairlift stops halfway up").',
)
issue_category = fields.Char(
string='Symptom Category',
help='Optional symptom tag for catalogue matching (e.g. "battery", "motor").',
string='Symptom Category (legacy text)',
help='Free-text symptom tag - kept for backwards compat. New intakes '
'should pick the structured Symptom M2O below.',
)
# Bundle 11: proper symptom classification - drives flowchart lookup.
symptom_class_id = fields.Many2one(
'fusion.repair.symptom.class',
string='Symptom',
domain="[('category_id', '=', repair_category_id)]",
help='Pick the symptom to enable the Start Troubleshooting button '
'on the resulting repair (if a published flowchart exists).',
)
internal_notes = fields.Text(string='Internal Notes')

View File

@@ -83,8 +83,12 @@
</group>
<field name="issue_summary"
placeholder="One-line summary (e.g. 'stairlift stops halfway up')"/>
<field name="symptom_class_id"
context="{'default_category_id': repair_category_id}"
options="{'no_quick_create': True}"
invisible="not repair_category_id"/>
<field name="issue_category"
placeholder="Symptom tag (e.g. battery, motor, remote)"/>
placeholder="Legacy free-text tag (optional)"/>
<field name="internal_notes" placeholder="Internal notes"/>
<separator string="Photos / Videos"/>
<field name="photo_ids" widget="many2many_binary"/>

View File

@@ -441,6 +441,12 @@ class RepairVisitReportWizard(models.TransientModel):
'mimetype': att.mimetype,
})
photo_ids.append(copied.id)
# Bundle 11: prefer the explicit calendar-picked expected_date,
# fall back to today + expected_lead_days.
expected = line.expected_date or (
fields.Date.context_today(self) + timedelta(days=line.expected_lead_days)
if line.expected_lead_days else False
)
part = PartOrder.create({
'repair_order_id': repair.id,
'description': line.description,
@@ -449,12 +455,33 @@ class RepairVisitReportWizard(models.TransientModel):
'quantity': line.quantity or 1.0,
'notes': line.notes,
'photo_ids': [(6, 0, photo_ids)] if photo_ids else False,
'expected_date': line.expected_lead_days and (
fields.Date.context_today(self)
+ timedelta(days=line.expected_lead_days)
) or False,
'expected_date': expected,
# Bundle 11 vendor / cost / warranty / factory refs
'vendor_partner_id': line.vendor_partner_id.id or False,
'unit_cost': line.unit_cost or 0.0,
'currency_id': line.currency_id.id or False,
'under_warranty': line.under_warranty,
'internal_po_ref': line.internal_po_ref or False,
'factory_ticket_ref': line.factory_ticket_ref or False,
'factory_ra_number': line.factory_ra_number or False,
})
max_lead = max(max_lead, int(line.expected_lead_days or 0))
# Auto-create the draft PO when the tech captured a vendor + cost
# AND opted in (create_draft_po default=True).
if line.create_draft_po and line.vendor_partner_id and line.unit_cost:
try:
part.action_create_draft_po()
except Exception:
_logger.exception(
'Could not auto-create draft PO for part %s on repair %s',
part.name, repair.name,
)
lead_days = (
line.expected_lead_days
if line.expected_lead_days
else (int((expected - fields.Date.context_today(self)).days)
if expected else 0)
)
max_lead = max(max_lead, int(lead_days or 0))
repair.write({
'x_fc_parts_awaiting': True,
'x_fc_parts_eta_date': (
@@ -618,11 +645,16 @@ class RepairVisitReportWizardLine(models.TransientModel):
class RepairVisitReportWizardPartLine(models.TransientModel):
"""Bundle 8: parts the tech needs the office to ORDER from the manufacturer.
"""Bundle 8 + 11: parts the tech needs the office to ORDER from the
manufacturer.
Captured during the visit report when outcome='parts_needed'; one record per
distinct part. On wizard confirm, each line creates a
fusion.repair.part.order which is the procurement-facing record.
Bundle 11 adds: vendor picker (filtered to vendors), unit cost, expected
arrival date (calendar widget), factory ticket/RA refs, and a warranty
flag - everything the tech captures over the phone with the factory.
"""
_name = 'fusion.repair.visit.report.wizard.partline'
_description = 'Visit Report - Part to Order'
@@ -638,17 +670,59 @@ class RepairVisitReportWizardPartLine(models.TransientModel):
help='Plain English (e.g. "Handicare 1100 right armrest").',
)
oem_part_number = fields.Char(string='OEM #')
manufacturer = fields.Char(string='Manufacturer')
manufacturer = fields.Char(string='Manufacturer (free text)')
quantity = fields.Float(default=1.0, required=True)
expected_lead_days = fields.Integer(
string='Lead Time (days)',
default=7,
help='Tech estimate. Office uses this to set client ETA expectations.',
)
expected_date = fields.Date(
string='Expected Arrival',
help='Calendar pick. If the factory confirmed a specific date, use it - '
'otherwise leave blank and the lead-time days will be used.',
)
notes = fields.Text(string='Notes for Procurement')
# Bundle 11: vendor + cost + warranty + factory tracking.
vendor_partner_id = fields.Many2one(
'res.partner',
string='Vendor',
domain="[('is_company', '=', True)]",
help='Pick the vendor from our contacts. Quickly searchable while '
'on the phone with the factory.',
)
unit_cost = fields.Monetary(
string='Unit Cost',
currency_field='currency_id',
)
currency_id = fields.Many2one(
'res.currency',
default=lambda self: self.env.company.currency_id,
)
under_warranty = fields.Boolean(
string='Factory Warranty',
help='Tick if the factory confirmed warranty coverage on the call.',
)
internal_po_ref = fields.Char(
string='PO # read to factory',
help='Westin PO number the tech gave the factory for tracking.',
)
factory_ticket_ref = fields.Char(string='Factory Ticket #')
factory_ra_number = fields.Char(
string='Factory RA #',
help='Return Authorization (when factory issues a warranty replacement).',
)
photo_ids = fields.Many2many(
'ir.attachment',
'fusion_repair_visit_partline_photo_rel',
'partline_id', 'attachment_id',
string='Photos',
)
create_draft_po = fields.Boolean(
string='Create draft PO',
default=True,
help='When ticked AND a vendor + cost are filled in, the wizard '
'auto-creates a draft purchase.order on submit so the office '
'can review and send it.',
)

View File

@@ -60,11 +60,21 @@
<list editable="bottom">
<field name="description"/>
<field name="oem_part_number"/>
<field name="manufacturer"/>
<field name="vendor_partner_id"
options="{'no_quick_create': True}"/>
<field name="quantity"/>
<field name="expected_lead_days"/>
<field name="notes" optional="show"/>
<field name="photo_ids" widget="many2many_binary"/>
<field name="unit_cost" widget="monetary"/>
<field name="currency_id" column_invisible="True"/>
<field name="expected_date" widget="date"/>
<field name="expected_lead_days" optional="hide"/>
<field name="under_warranty" optional="show"/>
<field name="internal_po_ref" optional="hide"/>
<field name="factory_ticket_ref" optional="hide"/>
<field name="factory_ra_number" optional="hide"/>
<field name="create_draft_po" optional="show"/>
<field name="manufacturer" optional="hide"/>
<field name="notes" optional="hide"/>
<field name="photo_ids" widget="many2many_binary" optional="show"/>
</list>
</field>