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:
@@ -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',
|
||||
|
||||
251
fusion_repairs/data/flowchart_stairlift_not_moving_data.xml
Normal file
251
fusion_repairs/data/flowchart_stairlift_not_moving_data.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
107
fusion_repairs/data/symptom_class_data.xml
Normal file
107
fusion_repairs/data/symptom_class_data.xml
Normal 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>
|
||||
89
fusion_repairs/migrations/19.0.2.2.0/post-migration.py
Normal file
89
fusion_repairs/migrations/19.0.2.2.0/post-migration.py
Normal 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.')
|
||||
@@ -19,6 +19,9 @@ from . import repair_part_order
|
||||
from . import repair_callout_rate
|
||||
from . import repair_labor_warranty
|
||||
from . import repair_delivery_charge
|
||||
from . import repair_symptom_class
|
||||
from . import repair_flowchart
|
||||
from . import repair_flowchart_run
|
||||
from . import product_template
|
||||
from . import res_partner
|
||||
from . import res_users
|
||||
|
||||
@@ -130,6 +130,7 @@ class FusionRepairIntakeService(models.AbstractModel):
|
||||
'x_fc_third_party_equipment': bool(item.get('third_party')),
|
||||
'x_fc_urgency': item.get('urgency') or 'normal',
|
||||
'x_fc_issue_category': item.get('issue_category') or False,
|
||||
'x_fc_symptom_class_id': item.get('symptom_class_id') or False,
|
||||
'x_fc_is_quote_only': bool(quote_only),
|
||||
'x_fc_rush_requested': bool(rush_requested),
|
||||
'x_fc_rush_tier': rush_tier or False,
|
||||
|
||||
394
fusion_repairs/models/repair_flowchart.py
Normal file
394
fusion_repairs/models/repair_flowchart.py
Normal file
@@ -0,0 +1,394 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
"""Troubleshooting flowchart designer-time models.
|
||||
|
||||
Three models cooperate:
|
||||
|
||||
fusion.repair.flowchart one chart per (category, symptom)
|
||||
-> fusion.repair.flowchart.node one row per visual node
|
||||
-> fusion.repair.flowchart.edge directed edges between nodes
|
||||
|
||||
The full visual layout (positions, drawflow internal state) is also serialised
|
||||
to flowchart.canvas_layout as JSON so the Drawflow designer round-trips
|
||||
without needing per-node x/y stored separately. The structured node/edge
|
||||
records exist for queries (e.g. "show me all flowcharts that mention 'control
|
||||
board' in any node"), reporting, runtime traversal, and so that the runner can
|
||||
operate without parsing the canvas JSON.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
NODE_TYPES = [
|
||||
('question', 'Question'),
|
||||
('suggestion', 'Suggestion'),
|
||||
('info', 'Info'),
|
||||
('outcome', 'Outcome'),
|
||||
]
|
||||
|
||||
OUTCOME_KINDS = [
|
||||
('resolved', 'Resolved on Call'),
|
||||
('escalate', 'Escalate to Technician'),
|
||||
('order_part', 'Order Part'),
|
||||
]
|
||||
|
||||
|
||||
class FusionRepairFlowchart(models.Model):
|
||||
_name = 'fusion.repair.flowchart'
|
||||
_inherit = ['mail.thread']
|
||||
_description = 'Repair Troubleshooting Flowchart'
|
||||
_order = 'category_id, symptom_class_id, name'
|
||||
|
||||
name = fields.Char(required=True, translate=True, tracking=True)
|
||||
category_id = fields.Many2one(
|
||||
'fusion.repair.product.category',
|
||||
string='Equipment Category',
|
||||
required=True,
|
||||
index=True,
|
||||
ondelete='cascade',
|
||||
tracking=True,
|
||||
)
|
||||
symptom_class_id = fields.Many2one(
|
||||
'fusion.repair.symptom.class',
|
||||
string='Symptom',
|
||||
required=True,
|
||||
index=True,
|
||||
ondelete='cascade',
|
||||
domain="[('category_id', '=', category_id)]",
|
||||
tracking=True,
|
||||
)
|
||||
description = fields.Html(translate=True)
|
||||
active = fields.Boolean(default=True, tracking=True)
|
||||
published = fields.Boolean(
|
||||
default=False,
|
||||
tracking=True,
|
||||
help='Only published flowcharts are offered to CS during intake. Draft '
|
||||
'charts are visible to admins for editing but never run.',
|
||||
)
|
||||
version = fields.Integer(
|
||||
default=1,
|
||||
readonly=True,
|
||||
copy=False,
|
||||
help='Bumped on every save in the designer; lets us snapshot which '
|
||||
'version a flowchart.run was based on.',
|
||||
)
|
||||
|
||||
canvas_layout = fields.Text(
|
||||
string='Designer State',
|
||||
help='Drawflow JSON for the visual designer canvas - positions, zoom, '
|
||||
'connection curves. Round-tripped by the designer UI.',
|
||||
)
|
||||
|
||||
node_ids = fields.One2many('fusion.repair.flowchart.node', 'flowchart_id')
|
||||
edge_ids = fields.One2many('fusion.repair.flowchart.edge', 'flowchart_id')
|
||||
node_count = fields.Integer(compute='_compute_counts')
|
||||
edge_count = fields.Integer(compute='_compute_counts')
|
||||
|
||||
start_node_id = fields.Many2one(
|
||||
'fusion.repair.flowchart.node',
|
||||
string='Start Node',
|
||||
compute='_compute_start_node',
|
||||
store=True,
|
||||
help='Auto-computed - whichever node has is_start=True. Validation '
|
||||
'enforces exactly one.',
|
||||
)
|
||||
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
_active_per_pair_unique = models.Constraint(
|
||||
# NULLs treated as distinct so multiple draft (active=False) charts
|
||||
# can coexist; only one active+published chart per pair is the
|
||||
# business rule we care about - enforced via Python below.
|
||||
'unique(category_id, symptom_class_id, company_id, published)',
|
||||
'Only one published flowchart per (category, symptom) per company. '
|
||||
'Unpublish or duplicate before publishing another.',
|
||||
)
|
||||
|
||||
@api.depends('node_ids', 'edge_ids')
|
||||
def _compute_counts(self):
|
||||
for r in self:
|
||||
r.node_count = len(r.node_ids)
|
||||
r.edge_count = len(r.edge_ids)
|
||||
|
||||
@api.depends('node_ids.is_start')
|
||||
def _compute_start_node(self):
|
||||
for r in self:
|
||||
r.start_node_id = r.node_ids.filtered(lambda n: n.is_start)[:1]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ACTIONS
|
||||
# ------------------------------------------------------------------
|
||||
def action_open_designer(self):
|
||||
"""Open the OWL Drawflow designer as a client action."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fusion_repair_flowchart_designer',
|
||||
'name': _('Flowchart Designer - %s') % self.name,
|
||||
'params': {'flowchart_id': self.id},
|
||||
}
|
||||
|
||||
def action_publish(self):
|
||||
for r in self:
|
||||
r._validate_publishable()
|
||||
r.published = True
|
||||
r.message_post(body=_('Flowchart published. CS will now see it for '
|
||||
'new intakes on this (category, symptom).'))
|
||||
|
||||
def action_unpublish(self):
|
||||
for r in self:
|
||||
r.published = False
|
||||
|
||||
def action_duplicate_as_draft(self):
|
||||
self.ensure_one()
|
||||
copy = self.copy({
|
||||
'name': _('%s (copy)') % self.name,
|
||||
'published': False,
|
||||
'version': 1,
|
||||
})
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': copy.name,
|
||||
'res_model': self._name,
|
||||
'view_mode': 'form',
|
||||
'res_id': copy.id,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# VALIDATION
|
||||
# ------------------------------------------------------------------
|
||||
def _validate_publishable(self):
|
||||
for r in self:
|
||||
starts = r.node_ids.filtered(lambda n: n.is_start)
|
||||
if len(starts) != 1:
|
||||
raise ValidationError(_(
|
||||
'Flowchart "%s" must have exactly one start node '
|
||||
'(found %d). Edit in the designer and try again.'
|
||||
) % (r.name, len(starts)))
|
||||
# Every non-outcome node should have at least one outgoing edge.
|
||||
for n in r.node_ids:
|
||||
if n.node_type != 'outcome' and not r.edge_ids.filtered(
|
||||
lambda e: e.source_node_id == n
|
||||
):
|
||||
raise ValidationError(_(
|
||||
'Node "%s" is not an outcome but has no outgoing edges. '
|
||||
'Connect it to the next step or change its type to '
|
||||
'Outcome.'
|
||||
) % n.name)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DESIGNER RPC API (called from the OWL component)
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def designer_load(self, flowchart_id):
|
||||
"""Return everything the designer needs to render this chart."""
|
||||
chart = self.browse(flowchart_id).exists()
|
||||
if not chart:
|
||||
raise UserError(_('Flowchart not found.'))
|
||||
return {
|
||||
'id': chart.id,
|
||||
'name': chart.name,
|
||||
'category_id': chart.category_id.id,
|
||||
'category_name': chart.category_id.name,
|
||||
'symptom_class_id': chart.symptom_class_id.id,
|
||||
'symptom_name': chart.symptom_class_id.name,
|
||||
'published': chart.published,
|
||||
'version': chart.version,
|
||||
'canvas_layout': chart.canvas_layout or '',
|
||||
'nodes': [n._designer_dict() for n in chart.node_ids],
|
||||
'edges': [e._designer_dict() for e in chart.edge_ids],
|
||||
}
|
||||
|
||||
def designer_save(self, payload):
|
||||
"""Replace the chart's nodes + edges with the designer's snapshot.
|
||||
|
||||
payload = {
|
||||
'canvas_layout': '<drawflow json string>',
|
||||
'nodes': [{'client_id': str, 'name': ..., 'node_type': ...,
|
||||
'content_html': ..., 'media_ids': [int],
|
||||
'is_start': bool, 'outcome_kind': ...,
|
||||
'canvas_x': int, 'canvas_y': int}, ...],
|
||||
'edges': [{'source_client_id': str, 'target_client_id': str,
|
||||
'label': str, 'sequence': int}, ...],
|
||||
}
|
||||
|
||||
Returns the saved chart (designer_load shape).
|
||||
"""
|
||||
self.ensure_one()
|
||||
# Atomic replace - simplest and safest. Edges depend on nodes so
|
||||
# delete edges first.
|
||||
self.edge_ids.unlink()
|
||||
self.node_ids.unlink()
|
||||
|
||||
client_to_db = {}
|
||||
Node = self.env['fusion.repair.flowchart.node'].sudo()
|
||||
for nd in payload.get('nodes', []):
|
||||
rec = Node.create({
|
||||
'flowchart_id': self.id,
|
||||
'name': nd.get('name') or _('Untitled'),
|
||||
'node_type': nd.get('node_type') or 'question',
|
||||
'content_html': nd.get('content_html') or False,
|
||||
'is_start': bool(nd.get('is_start')),
|
||||
'outcome_kind': nd.get('outcome_kind') or False,
|
||||
'canvas_x': int(nd.get('canvas_x') or 0),
|
||||
'canvas_y': int(nd.get('canvas_y') or 0),
|
||||
'media_ids': [(6, 0, nd.get('media_ids') or [])],
|
||||
})
|
||||
client_to_db[nd.get('client_id')] = rec.id
|
||||
|
||||
Edge = self.env['fusion.repair.flowchart.edge'].sudo()
|
||||
for ed in payload.get('edges', []):
|
||||
src = client_to_db.get(ed.get('source_client_id'))
|
||||
tgt = client_to_db.get(ed.get('target_client_id'))
|
||||
if not src or not tgt:
|
||||
_logger.warning(
|
||||
'Skipping edge with unknown client ids: %s -> %s',
|
||||
ed.get('source_client_id'), ed.get('target_client_id'),
|
||||
)
|
||||
continue
|
||||
Edge.create({
|
||||
'flowchart_id': self.id,
|
||||
'source_node_id': src,
|
||||
'target_node_id': tgt,
|
||||
'label': ed.get('label') or '',
|
||||
'sequence': int(ed.get('sequence') or 10),
|
||||
})
|
||||
|
||||
self.write({
|
||||
'canvas_layout': payload.get('canvas_layout') or '',
|
||||
'version': self.version + 1,
|
||||
})
|
||||
# Soft-validate (don't raise) - the designer can publish later.
|
||||
try:
|
||||
self._validate_publishable()
|
||||
except ValidationError as e:
|
||||
_logger.info('Saved chart %s but it is not yet publishable: %s',
|
||||
self.name, e)
|
||||
return self.designer_load(self.id)
|
||||
|
||||
|
||||
class FusionRepairFlowchartNode(models.Model):
|
||||
_name = 'fusion.repair.flowchart.node'
|
||||
_description = 'Flowchart Node'
|
||||
_order = 'flowchart_id, sequence, id'
|
||||
|
||||
flowchart_id = fields.Many2one(
|
||||
'fusion.repair.flowchart',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
index=True,
|
||||
)
|
||||
name = fields.Char(required=True, translate=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
node_type = fields.Selection(
|
||||
NODE_TYPES, default='question', required=True,
|
||||
)
|
||||
content_html = fields.Html(
|
||||
translate=True,
|
||||
sanitize=True,
|
||||
sanitize_overridable=True,
|
||||
help='Rich text shown to CS when this node is the current step. '
|
||||
'Photos / videos go in media_ids instead.',
|
||||
)
|
||||
media_ids = fields.Many2many(
|
||||
'ir.attachment',
|
||||
'fusion_repair_flowchart_node_media_rel',
|
||||
'node_id', 'attachment_id',
|
||||
string='Media',
|
||||
help='Photos and short videos that illustrate this step.',
|
||||
)
|
||||
is_start = fields.Boolean(
|
||||
string='Start',
|
||||
help='Exactly one node per flowchart must be the start.',
|
||||
)
|
||||
outcome_kind = fields.Selection(
|
||||
OUTCOME_KINDS,
|
||||
string='Outcome Kind',
|
||||
help='Required when node_type is Outcome - determines what happens '
|
||||
'when the run reaches this node.',
|
||||
)
|
||||
|
||||
# Designer-only - persisted for round-trip but not used by the runtime.
|
||||
canvas_x = fields.Integer(default=0)
|
||||
canvas_y = fields.Integer(default=0)
|
||||
|
||||
outgoing_edge_ids = fields.One2many(
|
||||
'fusion.repair.flowchart.edge', 'source_node_id',
|
||||
string='Outgoing Edges',
|
||||
)
|
||||
incoming_edge_ids = fields.One2many(
|
||||
'fusion.repair.flowchart.edge', 'target_node_id',
|
||||
string='Incoming Edges',
|
||||
)
|
||||
|
||||
def _designer_dict(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'node_type': self.node_type,
|
||||
'content_html': self.content_html or '',
|
||||
'is_start': self.is_start,
|
||||
'outcome_kind': self.outcome_kind or '',
|
||||
'canvas_x': self.canvas_x,
|
||||
'canvas_y': self.canvas_y,
|
||||
'media_ids': self.media_ids.ids,
|
||||
'media': [{
|
||||
'id': a.id,
|
||||
'name': a.name,
|
||||
'mimetype': a.mimetype,
|
||||
'url': f'/web/image/{a.id}',
|
||||
} for a in self.media_ids],
|
||||
}
|
||||
|
||||
|
||||
class FusionRepairFlowchartEdge(models.Model):
|
||||
_name = 'fusion.repair.flowchart.edge'
|
||||
_description = 'Flowchart Edge'
|
||||
_order = 'source_node_id, sequence, id'
|
||||
|
||||
flowchart_id = fields.Many2one(
|
||||
'fusion.repair.flowchart',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
index=True,
|
||||
)
|
||||
source_node_id = fields.Many2one(
|
||||
'fusion.repair.flowchart.node',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
index=True,
|
||||
)
|
||||
target_node_id = fields.Many2one(
|
||||
'fusion.repair.flowchart.node',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
index=True,
|
||||
)
|
||||
label = fields.Char(
|
||||
translate=True,
|
||||
help='Shown on the button in the runner ("Yes", "No", '
|
||||
'"Wheel turns freely"...).',
|
||||
)
|
||||
sequence = fields.Integer(default=10)
|
||||
|
||||
def _designer_dict(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'id': self.id,
|
||||
'source_node_id': self.source_node_id.id,
|
||||
'target_node_id': self.target_node_id.id,
|
||||
'label': self.label or '',
|
||||
'sequence': self.sequence,
|
||||
}
|
||||
362
fusion_repairs/models/repair_flowchart_run.py
Normal file
362
fusion_repairs/models/repair_flowchart_run.py
Normal file
@@ -0,0 +1,362 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
"""Flowchart runtime - one fusion.repair.flowchart.run per CS troubleshooting
|
||||
session, with one fusion.repair.flowchart.run.step per node the rep visited.
|
||||
|
||||
The transcript is the audit + handoff artefact: when CS resolves on-call we
|
||||
post it to chatter; when they escalate we copy it to internal_notes so the
|
||||
dispatched tech can see what was tried BEFORE arriving at the client.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
RUN_OUTCOMES = [
|
||||
('in_progress', 'In Progress'),
|
||||
('resolved', 'Resolved on Call'),
|
||||
('escalated', 'Escalated to Technician'),
|
||||
('order_part', 'Captured Part Order'),
|
||||
('abandoned', 'Abandoned'),
|
||||
]
|
||||
|
||||
|
||||
class FusionRepairFlowchartRun(models.Model):
|
||||
_name = 'fusion.repair.flowchart.run'
|
||||
_inherit = ['mail.thread']
|
||||
_description = 'Flowchart Troubleshooting Run'
|
||||
_order = 'started_at desc, id desc'
|
||||
|
||||
name = fields.Char(
|
||||
compute='_compute_name',
|
||||
store=True,
|
||||
)
|
||||
repair_order_id = fields.Many2one(
|
||||
'repair.order',
|
||||
required=True,
|
||||
index=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
flowchart_id = fields.Many2one(
|
||||
'fusion.repair.flowchart',
|
||||
required=True,
|
||||
index=True,
|
||||
ondelete='restrict',
|
||||
)
|
||||
flowchart_version = fields.Integer(
|
||||
string='Chart Version Snapshot',
|
||||
help='Version of the flowchart at the time the run was started; '
|
||||
'so the transcript stays valid even if the chart is later edited.',
|
||||
)
|
||||
partner_id = fields.Many2one(
|
||||
related='repair_order_id.partner_id', store=True, readonly=True,
|
||||
)
|
||||
started_at = fields.Datetime(default=fields.Datetime.now, required=True)
|
||||
started_by_id = fields.Many2one(
|
||||
'res.users', default=lambda self: self.env.user, required=True,
|
||||
)
|
||||
completed_at = fields.Datetime()
|
||||
completed_by_id = fields.Many2one('res.users')
|
||||
outcome = fields.Selection(
|
||||
RUN_OUTCOMES,
|
||||
default='in_progress',
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
step_ids = fields.One2many(
|
||||
'fusion.repair.flowchart.run.step', 'run_id',
|
||||
string='Steps',
|
||||
)
|
||||
step_count = fields.Integer(compute='_compute_step_count')
|
||||
current_node_id = fields.Many2one(
|
||||
'fusion.repair.flowchart.node',
|
||||
compute='_compute_current_node',
|
||||
store=True,
|
||||
help='Where the runner is right now - last step\'s node if no edge '
|
||||
'was chosen yet, otherwise the edge\'s target node.',
|
||||
)
|
||||
transcript_html = fields.Html(
|
||||
compute='_compute_transcript',
|
||||
sanitize=False, # we control the markup
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
@api.depends('repair_order_id.name', 'flowchart_id.name')
|
||||
def _compute_name(self):
|
||||
for r in self:
|
||||
r.name = _('%s on %s') % (
|
||||
r.flowchart_id.name or '?', r.repair_order_id.name or '?',
|
||||
)
|
||||
|
||||
@api.depends('step_ids')
|
||||
def _compute_step_count(self):
|
||||
for r in self:
|
||||
r.step_count = len(r.step_ids)
|
||||
|
||||
@api.depends('step_ids.chosen_edge_id', 'step_ids.node_id',
|
||||
'flowchart_id.start_node_id')
|
||||
def _compute_current_node(self):
|
||||
for r in self:
|
||||
if not r.step_ids:
|
||||
r.current_node_id = r.flowchart_id.start_node_id
|
||||
continue
|
||||
last = r.step_ids[-1]
|
||||
# If they chose an edge on the last step, we should already be
|
||||
# showing the target. If not (just landed on a card), it's the
|
||||
# current node itself.
|
||||
if last.chosen_edge_id:
|
||||
r.current_node_id = last.chosen_edge_id.target_node_id
|
||||
else:
|
||||
r.current_node_id = last.node_id
|
||||
|
||||
@api.depends('step_ids.node_name_snapshot', 'step_ids.chosen_label_snapshot',
|
||||
'step_ids.cs_note', 'outcome')
|
||||
def _compute_transcript(self):
|
||||
for r in self:
|
||||
if not r.step_ids:
|
||||
r.transcript_html = '<p><em>No steps recorded yet.</em></p>'
|
||||
continue
|
||||
lines = ['<ol style="margin:0 0 8px 1.2em;padding:0;">']
|
||||
for s in r.step_ids:
|
||||
line = f'<li><strong>{s.node_name_snapshot or "?"}</strong>'
|
||||
if s.chosen_label_snapshot:
|
||||
line += f' → <em>{s.chosen_label_snapshot}</em>'
|
||||
if s.cs_note:
|
||||
line += f' <span style="color:#666;">(note: {s.cs_note})</span>'
|
||||
line += '</li>'
|
||||
lines.append(line)
|
||||
lines.append('</ol>')
|
||||
outcome_label = dict(self._fields['outcome'].selection).get(r.outcome, r.outcome)
|
||||
lines.append(f'<p><strong>Outcome:</strong> {outcome_label}</p>')
|
||||
r.transcript_html = ''.join(lines)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# RUNTIME RPC API (called from the OWL runner component)
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def runtime_start_or_resume(self, repair_id):
|
||||
"""Open or resume a troubleshooting run for the given repair.
|
||||
|
||||
- If an in-progress run exists, return it.
|
||||
- Otherwise resolve the flowchart from the repair's (category, symptom)
|
||||
and create a fresh run starting at the chart's start node.
|
||||
|
||||
Returns the dict shape that the OWL runner consumes.
|
||||
"""
|
||||
repair = self.env['repair.order'].browse(repair_id).exists()
|
||||
if not repair:
|
||||
raise UserError(_('Repair order not found.'))
|
||||
existing = self.search([
|
||||
('repair_order_id', '=', repair.id),
|
||||
('outcome', '=', 'in_progress'),
|
||||
], limit=1)
|
||||
if existing:
|
||||
return existing._runtime_dict()
|
||||
if not repair.x_fc_symptom_class_id:
|
||||
raise UserError(_('Pick a Symptom on the repair before starting '
|
||||
'troubleshooting.'))
|
||||
chart = self.env['fusion.repair.flowchart'].sudo().search([
|
||||
('category_id', '=', repair.x_fc_repair_category_id.id),
|
||||
('symptom_class_id', '=', repair.x_fc_symptom_class_id.id),
|
||||
('published', '=', True),
|
||||
('active', '=', True),
|
||||
], limit=1)
|
||||
if not chart:
|
||||
raise UserError(_(
|
||||
'No published troubleshooting flowchart found for '
|
||||
'%(cat)s + %(sym)s. Ask a manager to publish one in '
|
||||
'Configuration > Troubleshooting Flowcharts.'
|
||||
) % {
|
||||
'cat': repair.x_fc_repair_category_id.name or '?',
|
||||
'sym': repair.x_fc_symptom_class_id.name or '?',
|
||||
})
|
||||
run = self.sudo().create({
|
||||
'repair_order_id': repair.id,
|
||||
'flowchart_id': chart.id,
|
||||
'flowchart_version': chart.version,
|
||||
})
|
||||
return run._runtime_dict()
|
||||
|
||||
def runtime_choose(self, edge_id, cs_note=''):
|
||||
"""CS clicked an answer button on the current card.
|
||||
|
||||
Records a step (current_node + chosen_edge + note) and returns the
|
||||
updated run dict (which the OWL component uses to render the next card).
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.outcome != 'in_progress':
|
||||
raise UserError(_('This run has already been completed.'))
|
||||
edge = self.env['fusion.repair.flowchart.edge'].browse(edge_id).exists()
|
||||
if not edge:
|
||||
raise UserError(_('Chosen answer not found.'))
|
||||
if edge.source_node_id != self.current_node_id:
|
||||
raise UserError(_(
|
||||
'That answer does not belong to the current step. The chart '
|
||||
'may have been edited - please reload the runner.'
|
||||
))
|
||||
self.env['fusion.repair.flowchart.run.step'].sudo().create({
|
||||
'run_id': self.id,
|
||||
'sequence': len(self.step_ids) + 1,
|
||||
'node_id': self.current_node_id.id,
|
||||
'node_name_snapshot': self.current_node_id.name,
|
||||
'chosen_edge_id': edge.id,
|
||||
'chosen_label_snapshot': edge.label,
|
||||
'cs_note': cs_note or False,
|
||||
})
|
||||
# current_node_id auto-recomputes via depends
|
||||
self.invalidate_recordset(['current_node_id'])
|
||||
return self._runtime_dict()
|
||||
|
||||
def runtime_complete(self, outcome, cs_note=''):
|
||||
"""CS hit a terminal outcome (resolved / escalate / order_part) OR
|
||||
manually abandoned. Closes the run and triggers downstream effects."""
|
||||
self.ensure_one()
|
||||
valid = {'resolved', 'escalated', 'order_part', 'abandoned'}
|
||||
if outcome not in valid:
|
||||
raise UserError(_('Unknown outcome %s.') % outcome)
|
||||
if self.outcome != 'in_progress':
|
||||
return self._runtime_dict()
|
||||
# Snapshot the final node as a step so the transcript shows it.
|
||||
node = self.current_node_id
|
||||
if node and (not self.step_ids or self.step_ids[-1].node_id != node):
|
||||
self.env['fusion.repair.flowchart.run.step'].sudo().create({
|
||||
'run_id': self.id,
|
||||
'sequence': len(self.step_ids) + 1,
|
||||
'node_id': node.id,
|
||||
'node_name_snapshot': node.name,
|
||||
'cs_note': cs_note or False,
|
||||
})
|
||||
self.write({
|
||||
'outcome': outcome,
|
||||
'completed_at': fields.Datetime.now(),
|
||||
'completed_by_id': self.env.uid,
|
||||
})
|
||||
# Dispatch the side-effects on the repair (see _apply_outcome).
|
||||
self._apply_outcome()
|
||||
return self._runtime_dict()
|
||||
|
||||
def _apply_outcome(self):
|
||||
"""Run the side-effects on the parent repair depending on outcome."""
|
||||
for r in self:
|
||||
repair = r.repair_order_id
|
||||
transcript = Markup(r.transcript_html or '')
|
||||
if r.outcome == 'resolved':
|
||||
repair.x_fc_resolved_on_call = True
|
||||
repair.message_post(body=Markup(_(
|
||||
'<p><b>Resolved on call</b> via troubleshooting flowchart '
|
||||
'<i>%(name)s</i>:</p>%(transcript)s'
|
||||
)) % {'name': r.flowchart_id.name, 'transcript': transcript})
|
||||
# Close the repair through the Odoo state machine. Reuse the
|
||||
# visit-report wizard helper pattern.
|
||||
try:
|
||||
if repair.state == 'draft':
|
||||
try:
|
||||
repair.action_validate()
|
||||
except Exception:
|
||||
repair._action_repair_confirm()
|
||||
if repair.state == 'confirmed':
|
||||
repair.action_repair_start()
|
||||
if repair.state == 'under_repair':
|
||||
repair.action_repair_end()
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
'Could not auto-close repair %s after on-call '
|
||||
'resolution: %s', repair.name, e,
|
||||
)
|
||||
elif r.outcome == 'escalated':
|
||||
# Prepend the transcript to internal_notes so the tech sees
|
||||
# what was tried at the top of the form.
|
||||
existing = repair.internal_notes or ''
|
||||
header = Markup(
|
||||
'<p><b>CS troubleshooting summary (flowchart %s):</b></p>'
|
||||
) % r.flowchart_id.name
|
||||
repair.internal_notes = (header + transcript +
|
||||
Markup('<hr/>') + Markup(existing))
|
||||
repair.message_post(body=Markup(_(
|
||||
'<p><b>Escalated to technician</b> via troubleshooting '
|
||||
'flowchart <i>%(name)s</i>:</p>%(transcript)s'
|
||||
)) % {'name': r.flowchart_id.name, 'transcript': transcript})
|
||||
elif r.outcome == 'order_part':
|
||||
repair.message_post(body=Markup(_(
|
||||
'<p><b>Part order needed</b> per troubleshooting flowchart '
|
||||
'<i>%(name)s</i>:</p>%(transcript)s'
|
||||
)) % {'name': r.flowchart_id.name, 'transcript': transcript})
|
||||
else: # abandoned
|
||||
repair.message_post(body=Markup(_(
|
||||
'<p>Troubleshooting run abandoned by %(user)s.</p>'
|
||||
'%(transcript)s'
|
||||
)) % {
|
||||
'user': self.env.user.name,
|
||||
'transcript': transcript,
|
||||
})
|
||||
|
||||
def _runtime_dict(self):
|
||||
"""Serialise the run + current node + available edges for the OWL UI."""
|
||||
self.ensure_one()
|
||||
node = self.current_node_id
|
||||
outgoing = self.env['fusion.repair.flowchart.edge'].sudo().search([
|
||||
('source_node_id', '=', node.id),
|
||||
], order='sequence, id') if node else self.env['fusion.repair.flowchart.edge']
|
||||
return {
|
||||
'run_id': self.id,
|
||||
'repair_id': self.repair_order_id.id,
|
||||
'repair_name': self.repair_order_id.name,
|
||||
'partner_name': self.partner_id.name or '',
|
||||
'flowchart_id': self.flowchart_id.id,
|
||||
'flowchart_name': self.flowchart_id.name,
|
||||
'canvas_layout': self.flowchart_id.canvas_layout or '',
|
||||
'outcome': self.outcome,
|
||||
'current_node': node._designer_dict() if node else None,
|
||||
'options': [{
|
||||
'edge_id': e.id,
|
||||
'label': e.label or _('Continue'),
|
||||
'target_node_id': e.target_node_id.id,
|
||||
'target_node_type': e.target_node_id.node_type,
|
||||
'target_outcome_kind': e.target_node_id.outcome_kind or '',
|
||||
} for e in outgoing],
|
||||
'transcript_html': self.transcript_html,
|
||||
'visited_node_ids': [s.node_id.id for s in self.step_ids if s.node_id],
|
||||
'visited_edge_ids': [s.chosen_edge_id.id for s in self.step_ids if s.chosen_edge_id],
|
||||
'step_count': self.step_count,
|
||||
}
|
||||
|
||||
|
||||
class FusionRepairFlowchartRunStep(models.Model):
|
||||
_name = 'fusion.repair.flowchart.run.step'
|
||||
_description = 'Flowchart Run Step'
|
||||
_order = 'run_id, sequence, id'
|
||||
|
||||
run_id = fields.Many2one(
|
||||
'fusion.repair.flowchart.run',
|
||||
required=True,
|
||||
index=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
sequence = fields.Integer(default=10)
|
||||
node_id = fields.Many2one(
|
||||
'fusion.repair.flowchart.node',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
)
|
||||
node_name_snapshot = fields.Char(
|
||||
help='Frozen at the moment the step was recorded so transcript '
|
||||
'survives later chart edits.',
|
||||
)
|
||||
chosen_edge_id = fields.Many2one(
|
||||
'fusion.repair.flowchart.edge',
|
||||
ondelete='set null',
|
||||
)
|
||||
chosen_label_snapshot = fields.Char()
|
||||
cs_note = fields.Text()
|
||||
recorded_at = fields.Datetime(default=fields.Datetime.now)
|
||||
@@ -1010,10 +1010,86 @@ class RepairOrder(models.Model):
|
||||
index=True,
|
||||
)
|
||||
x_fc_issue_category = fields.Char(
|
||||
string='Issue Category',
|
||||
help='Symptom classification (e.g. "battery", "motor", "remote"). Used by '
|
||||
'service catalogue matcher and AI prompt context.',
|
||||
string='Issue Category (legacy)',
|
||||
help='LEGACY free-text symptom classification. Superseded by '
|
||||
'x_fc_symptom_class_id - kept for backwards compatibility with '
|
||||
'historical records and the AI prompt context. New intakes '
|
||||
'should set the M2O instead.',
|
||||
)
|
||||
# Bundle 11: proper structured symptom classification.
|
||||
x_fc_symptom_class_id = fields.Many2one(
|
||||
'fusion.repair.symptom.class',
|
||||
string='Symptom',
|
||||
index=True,
|
||||
tracking=True,
|
||||
domain="[('category_id', '=', x_fc_repair_category_id)]",
|
||||
help='Drives flowchart lookup. Pick the (category, symptom) pair and '
|
||||
'CS can start a troubleshooting flowchart if one is published.',
|
||||
)
|
||||
# Bundle 11: troubleshooting runs against this repair.
|
||||
x_fc_flowchart_run_ids = fields.One2many(
|
||||
'fusion.repair.flowchart.run', 'repair_order_id',
|
||||
string='Troubleshooting Runs',
|
||||
)
|
||||
x_fc_flowchart_run_count = fields.Integer(
|
||||
compute='_compute_flowchart_run_count',
|
||||
)
|
||||
x_fc_resolved_on_call = fields.Boolean(
|
||||
string='Resolved on Call',
|
||||
copy=False,
|
||||
tracking=True,
|
||||
help='True when the CS troubleshooting flowchart hit a "resolved" '
|
||||
'outcome - no technician was ever dispatched.',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_flowchart_run_ids')
|
||||
def _compute_flowchart_run_count(self):
|
||||
for r in self:
|
||||
r.x_fc_flowchart_run_count = len(r.x_fc_flowchart_run_ids)
|
||||
|
||||
def action_view_flowchart_runs(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Troubleshooting Runs'),
|
||||
'res_model': 'fusion.repair.flowchart.run',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('repair_order_id', '=', self.id)],
|
||||
'context': {'default_repair_order_id': self.id},
|
||||
}
|
||||
|
||||
def action_start_troubleshoot(self):
|
||||
"""Open the OWL flowchart runner for this repair (creates or resumes
|
||||
an in-progress run). Raises a helpful UserError if the symptom is
|
||||
not picked or no published chart exists for the (category, symptom)
|
||||
pair."""
|
||||
self.ensure_one()
|
||||
if not self.x_fc_symptom_class_id:
|
||||
raise UserError(_(
|
||||
'Pick a Symptom on this repair first (Callout Pricing tab or '
|
||||
'the Fusion Repairs section), then start troubleshooting.'
|
||||
))
|
||||
chart = self.env['fusion.repair.flowchart'].sudo().search([
|
||||
('category_id', '=', self.x_fc_repair_category_id.id),
|
||||
('symptom_class_id', '=', self.x_fc_symptom_class_id.id),
|
||||
('published', '=', True),
|
||||
('active', '=', True),
|
||||
], limit=1)
|
||||
if not chart:
|
||||
raise UserError(_(
|
||||
'No published troubleshooting flowchart for %(cat)s + %(sym)s. '
|
||||
'Ask a manager to publish one in Configuration > '
|
||||
'Troubleshooting Flowcharts.'
|
||||
) % {
|
||||
'cat': self.x_fc_repair_category_id.name or '?',
|
||||
'sym': self.x_fc_symptom_class_id.name or '?',
|
||||
})
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fusion_repair_flowchart_runner',
|
||||
'name': _('Troubleshooting - %s') % self.name,
|
||||
'params': {'repair_id': self.id},
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PHOTOS
|
||||
|
||||
@@ -16,13 +16,28 @@ visit-report wizard in a structured way so:
|
||||
auto-creates a follow-up dispatch task
|
||||
|
||||
The grumpy-old-client never has to call us asking for status updates.
|
||||
|
||||
Bundle 11 extension: tech often orders the part directly from the factory
|
||||
WHILE on the phone. They want to:
|
||||
- Pick the vendor from our contacts (filtered to vendors)
|
||||
- Capture the OEM part #, cost, ETA date
|
||||
- Auto-create a draft purchase.order line (office reviews + sends)
|
||||
- Capture the factory's ticket # + RA # for tracking and warranty claims
|
||||
- Tell the system whether the factory said it's under warranty
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
SPARE_PARTS_CATEGORY_NAME = 'Spare Parts'
|
||||
|
||||
|
||||
class FusionRepairPartOrder(models.Model):
|
||||
@@ -109,6 +124,64 @@ class FusionRepairPartOrder(models.Model):
|
||||
copy=False,
|
||||
)
|
||||
|
||||
# Bundle 11: vendor + PO + cost + factory tracking refs.
|
||||
vendor_partner_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Vendor',
|
||||
tracking=True,
|
||||
domain="[('is_company', '=', True)]",
|
||||
help='Pick from our existing vendors. Filtered to companies; the tech '
|
||||
'usually knows the factory by name (Handicare, Bruno, Pride...).',
|
||||
)
|
||||
purchase_order_id = fields.Many2one(
|
||||
'purchase.order',
|
||||
string='Draft Purchase Order',
|
||||
readonly=True,
|
||||
copy=False,
|
||||
ondelete='set null',
|
||||
help='Auto-created in DRAFT state when the tech submits a part order '
|
||||
'with a vendor + cost. Office reviews and sends it to the factory.',
|
||||
)
|
||||
product_id = fields.Many2one(
|
||||
'product.product',
|
||||
string='Product',
|
||||
readonly=True,
|
||||
copy=False,
|
||||
help='Auto-resolved or created based on the OEM part number.',
|
||||
)
|
||||
unit_cost = fields.Monetary(
|
||||
string='Unit Cost',
|
||||
currency_field='currency_id',
|
||||
tracking=True,
|
||||
help='Per-unit cost from the factory. Used as price_unit on the draft PO.',
|
||||
)
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency',
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
internal_po_ref = fields.Char(
|
||||
string='PO Reference (read to factory)',
|
||||
tracking=True,
|
||||
help='The Westin PO number the tech read to the factory for tracking. '
|
||||
'Stamped on the draft purchase.order as partner_ref.',
|
||||
)
|
||||
factory_ticket_ref = fields.Char(
|
||||
string='Factory Ticket #',
|
||||
tracking=True,
|
||||
help='Ticket number from the factory call - used for follow-up enquiries.',
|
||||
)
|
||||
factory_ra_number = fields.Char(
|
||||
string='Factory RA #',
|
||||
tracking=True,
|
||||
help='Return Authorization number, when the factory issued a warranty '
|
||||
'replacement that requires the old part returned.',
|
||||
)
|
||||
under_warranty = fields.Boolean(
|
||||
string='Factory Warranty',
|
||||
tracking=True,
|
||||
help='Tick when the factory confirmed warranty coverage on the call.',
|
||||
)
|
||||
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
default=lambda self: self.env.company,
|
||||
@@ -144,6 +217,103 @@ class FusionRepairPartOrder(models.Model):
|
||||
# ------------------------------------------------------------------
|
||||
# ACTIONS
|
||||
# ------------------------------------------------------------------
|
||||
def action_create_draft_po(self):
|
||||
"""Bundle 11: build a draft purchase.order for this part.
|
||||
|
||||
Idempotent - returns the existing PO if one is already linked.
|
||||
Resolves / creates the product from the OEM number, sets the line
|
||||
cost from unit_cost, stamps Westin's internal PO ref on the
|
||||
purchase.order's partner_ref so the factory can find it. Office
|
||||
reviews + confirms via the normal Odoo flow.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.purchase_order_id:
|
||||
return self._open_record(self.purchase_order_id)
|
||||
if not self.vendor_partner_id:
|
||||
raise UserError(_(
|
||||
'Pick a Vendor first - the draft PO needs to know who to send to.'
|
||||
))
|
||||
product = self._resolve_or_create_product()
|
||||
PO = self.env['purchase.order'].sudo()
|
||||
# Let Odoo derive product_uom from the product to avoid Odoo 19's
|
||||
# uom_po_id moving between template/variant: passing product_id is
|
||||
# enough for the PO line's onchange to populate the UOM correctly.
|
||||
line_vals = {
|
||||
'product_id': product.id,
|
||||
'name': self.description or product.display_name,
|
||||
'product_qty': self.quantity or 1.0,
|
||||
'price_unit': self.unit_cost or 0.0,
|
||||
'date_planned': fields.Datetime.now() + timedelta(
|
||||
days=7 if not self.expected_date
|
||||
else max((self.expected_date - fields.Date.context_today(self)).days, 0)
|
||||
),
|
||||
}
|
||||
try:
|
||||
order = PO.create({
|
||||
'partner_id': self.vendor_partner_id.id,
|
||||
'partner_ref': self.internal_po_ref or False,
|
||||
'origin': self.repair_order_id.name or self.name,
|
||||
'company_id': self.company_id.id,
|
||||
'order_line': [(0, 0, line_vals)],
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('Could not create draft PO for part %s: %s', self.name, e)
|
||||
raise UserError(_(
|
||||
'Could not create the draft purchase order: %s. The part info '
|
||||
'was saved - you can create the PO manually from Purchase > '
|
||||
'Orders > New.'
|
||||
) % e)
|
||||
self.write({
|
||||
'purchase_order_id': order.id,
|
||||
'product_id': product.id,
|
||||
})
|
||||
self.message_post(body=Markup(_(
|
||||
'Draft purchase order <b>%(po)s</b> created with %(vendor)s. '
|
||||
'Office to review and send.'
|
||||
)) % {'po': order.name, 'vendor': self.vendor_partner_id.name})
|
||||
return self._open_record(order)
|
||||
|
||||
def _resolve_or_create_product(self):
|
||||
"""Find product.product by OEM part number (default_code) or create
|
||||
a new service-type product in the 'Spare Parts' category. Returns
|
||||
the product record."""
|
||||
self.ensure_one()
|
||||
Product = self.env['product.product'].sudo()
|
||||
if self.product_id:
|
||||
return self.product_id
|
||||
if self.oem_part_number:
|
||||
hit = Product.search([
|
||||
('default_code', '=', self.oem_part_number),
|
||||
], limit=1)
|
||||
if hit:
|
||||
return hit
|
||||
# Create new. Use "service" type so it doesn't need inventory tracking,
|
||||
# purchaseable + can be added to PO lines without warehouse setup.
|
||||
ProductCategory = self.env['product.category'].sudo()
|
||||
category = ProductCategory.search([
|
||||
('name', '=', SPARE_PARTS_CATEGORY_NAME),
|
||||
], limit=1)
|
||||
if not category:
|
||||
category = ProductCategory.create({'name': SPARE_PARTS_CATEGORY_NAME})
|
||||
return Product.create({
|
||||
'name': self.description or (self.oem_part_number or 'Spare Part'),
|
||||
'default_code': self.oem_part_number or False,
|
||||
'type': 'consu',
|
||||
'purchase_ok': True,
|
||||
'sale_ok': False,
|
||||
'categ_id': category.id,
|
||||
'standard_price': self.unit_cost or 0.0,
|
||||
})
|
||||
|
||||
def _open_record(self, record):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': record.display_name,
|
||||
'res_model': record._name,
|
||||
'view_mode': 'form',
|
||||
'res_id': record.id,
|
||||
}
|
||||
|
||||
def action_mark_ordered(self):
|
||||
"""Office marks this part as ordered with the manufacturer."""
|
||||
for rec in self:
|
||||
@@ -153,6 +323,7 @@ class FusionRepairPartOrder(models.Model):
|
||||
if not rec.expected_date:
|
||||
rec.expected_date = fields.Date.context_today(rec) + timedelta(days=7)
|
||||
rec._notify_client_parts_ordered()
|
||||
rec._schedule_eta_activity()
|
||||
|
||||
def action_mark_received(self):
|
||||
"""Office marks this part as received - triggers follow-up dispatch."""
|
||||
@@ -197,6 +368,39 @@ class FusionRepairPartOrder(models.Model):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _schedule_eta_activity(self):
|
||||
"""Bundle 11: schedule an activity on the repair so the office is
|
||||
reminded on the ETA day to call the client back and book the
|
||||
return visit."""
|
||||
for rec in self:
|
||||
repair = rec.repair_order_id
|
||||
if not repair or not rec.expected_date:
|
||||
continue
|
||||
try:
|
||||
act_type = self.env.ref(
|
||||
'fusion_repairs.mail_activity_type_assign_technician',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
repair.activity_schedule(
|
||||
activity_type_id=act_type.id if act_type else False,
|
||||
date_deadline=rec.expected_date,
|
||||
summary=_('Parts arriving (%s) - schedule re-visit') % rec.name,
|
||||
note=_(
|
||||
'Part %(ref)s (%(desc)s) from %(vendor)s is expected '
|
||||
'today. Call the client to confirm a return visit.'
|
||||
) % {
|
||||
'ref': rec.name,
|
||||
'desc': rec.description or '',
|
||||
'vendor': rec.vendor_partner_id.name or rec.manufacturer or '?',
|
||||
},
|
||||
user_id=repair.user_id.id or self.env.uid,
|
||||
)
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
'Could not schedule ETA activity for part %s on repair %s',
|
||||
rec.name, repair.name,
|
||||
)
|
||||
|
||||
def _maybe_redispatch(self):
|
||||
"""When the LAST outstanding part on a repair arrives, auto-create
|
||||
a follow-up tech task so the office doesn't have to remember.
|
||||
|
||||
58
fusion_repairs/models/repair_symptom_class.py
Normal file
58
fusion_repairs/models/repair_symptom_class.py
Normal file
@@ -0,0 +1,58 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
"""Symptom classification.
|
||||
|
||||
Lookup table replacing the flat `x_fc_issue_category` Char on repair.order.
|
||||
One symptom class belongs to one equipment category (stairlift > "Not Moving",
|
||||
"Beeps Alarm", "Stops Midway"...). Together with the category, it's the key
|
||||
that fusion.repair.flowchart uses to look up which troubleshooting flowchart
|
||||
to run.
|
||||
"""
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FusionRepairSymptomClass(models.Model):
|
||||
_name = 'fusion.repair.symptom.class'
|
||||
_description = 'Repair Symptom Class'
|
||||
_order = 'category_id, sequence, name'
|
||||
|
||||
name = fields.Char(string='Name', required=True, translate=True)
|
||||
code = fields.Char(
|
||||
string='Code',
|
||||
required=True,
|
||||
help='Stable code used by data files and automation (e.g. "not_moving").',
|
||||
)
|
||||
category_id = fields.Many2one(
|
||||
'fusion.repair.product.category',
|
||||
string='Equipment Category',
|
||||
required=True,
|
||||
index=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
sequence = fields.Integer(default=10)
|
||||
icon = fields.Char(
|
||||
string='Icon',
|
||||
default='fa-exclamation-circle',
|
||||
help='Font Awesome icon class shown next to the symptom in pickers.',
|
||||
)
|
||||
description = fields.Text(translate=True)
|
||||
active = fields.Boolean(default=True)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
_code_per_category_unique = models.Constraint(
|
||||
'unique(category_id, code, company_id)',
|
||||
'Symptom code must be unique within a category.',
|
||||
)
|
||||
|
||||
@api.depends('name', 'category_id.name')
|
||||
def _compute_display_name(self):
|
||||
for r in self:
|
||||
r.display_name = (
|
||||
f"{r.category_id.name} - {r.name}" if r.category_id else (r.name or '')
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
@@ -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, "<");
|
||||
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);
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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 & 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 & 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>
|
||||
1
fusion_repairs/static/src/lib/drawflow/drawflow.min.css
vendored
Normal file
1
fusion_repairs/static/src/lib/drawflow/drawflow.min.css
vendored
Normal 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}
|
||||
1
fusion_repairs/static/src/lib/drawflow/drawflow.min.js
vendored
Normal file
1
fusion_repairs/static/src/lib/drawflow/drawflow.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -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"
|
||||
|
||||
224
fusion_repairs/views/repair_flowchart_views.xml
Normal file
224
fusion_repairs/views/repair_flowchart_views.xml
Normal 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>
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
40
fusion_repairs/views/repair_symptom_class_views.xml
Normal file
40
fusion_repairs/views/repair_symptom_class_views.xml
Normal 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>
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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.',
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user