Compare commits

..

2 Commits

Author SHA1 Message Date
gsinghpal
396170b438 feat(fusion_helpdesk): owner-approval engagement flow + AI summary + reporting
Ships the design spec at docs/superpowers/specs/2026-05-27-owner-approval-flow-design.md.

What's new on central (fusion_helpdesk_central 19.0.1.2.0 -> 19.0.2.0.0):

- Engagement model: 8 new fields on helpdesk.ticket (state, snapshotted
  owner email/name, single-use UUID4 token, sent/reminded/decided
  timestamps, AI summary, stored-computed turnaround hours).
- Wizard: single + bulk modes on one fusion.helpdesk.engagement.wizard
  TransientModel with a child wizard.line for per-ticket bulk summaries.
  default_get pulls the OpenAI summary on open; AI fan-out for bulk is
  parallel via ThreadPoolExecutor (max 5 workers, 30s overall cap).
- OpenAI client in utils.py — stdlib urllib, 15s per-call timeout, every
  failure collapses to '' so the wizard's manual-summary fallback fires.
- Public portal: /fusion_helpdesk/engagement/<token>/<decision> GET +
  POST, four branded standalone QWeb pages (confirm/done/invalid/error).
  Token is single-use, cleared on confirm. Decision posts a public
  comment attributed to the resolved owner partner; chatter propagates
  to the employee's My Tickets thread per the "fully visible" UX choice.
- Mail templates (single + bulk) with magic-link buttons. Bulk template
  renders one card per ticket, each with its own approve/reject URL.
- Reminder cron: daily, single-shot per engagement, configurable via
  fusion_helpdesk_central.engagement_reminder_days ICP (default 3, 0
  disables).
- Reporting dashboard: pivot/graph/list/kanban over helpdesk.ticket
  filtered to engaged ones, with avg-turnaround measure. Menu lives
  under Helpdesk > Reporting > Owner Engagements.
- Client_key extended with owner_email/owner_name fields; ticket.create
  upserts them from the client-side piggyback (no new sync endpoint).
- 100% coverage on utils + integration tests on wizard, controllers,
  re-engagement, cron, computed turnaround. OpenAI mocked in CI.

What's new on client (fusion_helpdesk 19.0.1.7.1 -> 19.0.2.0.0):

- Two new ICP settings: fusion_helpdesk.owner_email / .owner_name with
  a new "Owner Approval" block in Settings > Fusion Helpdesk.
- controllers/main.py::submit piggybacks both keys on every ticket
  payload so central keeps client_key.owner_email/name fresh
  automatically.

Verified live end-to-end on entech -> nexa: payload upsert, wizard with
mocked AI, action_send, portal GET/POST/GET-again cycle, second click
hits the friendly invalid-token page. Token entropy = 122 bits (UUID4).
2026-05-27 13:03:23 -04:00
gsinghpal
eb186cac3c feat(fusion_repairs): Bundle 11 - CS guided troubleshooting flowcharts + vendor PO
Two big workflow additions:

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

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

NEW MODELS

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

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

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

REPAIR.ORDER EXTENSIONS

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

DRAWFLOW DESIGNER (OWL)

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

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

CARD RUNNER (OWL)

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

PART ORDER + VENDOR PO

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

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

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

VISIT-REPORT WIZARD PARTLINE EXTENSIONS

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

CLIENT EMAIL TIGHTENED

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

UI

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

SECURITY

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

POST-MIGRATION (19.0.2.2.0)

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

DEPENDENCY

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

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

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

Bumped to 19.0.2.2.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 12:50:06 -04:00
54 changed files with 5623 additions and 39 deletions

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Helpdesk Reporter',
'version': '19.0.1.7.1',
'version': '19.0.2.0.0',
'category': 'Productivity',
'summary': 'One-click in-app bug reporting & feature requesting — '
'auto-creates a helpdesk.ticket on a central Odoo Helpdesk.',

View File

@@ -101,6 +101,15 @@ class FusionHelpdeskController(http.Controller):
company_name=request.env.company.name,
is_critical=bool(is_critical),
)
# Piggyback the configured owner contact on every submission so the
# central can keep fusion.helpdesk.client.key.owner_email/name in
# sync without a dedicated endpoint. The central's create override
# pops these keys before super().create() sees them — they're not
# real helpdesk.ticket columns, just a sync side-channel.
if cfg.get('owner_email'):
ticket_vals['x_fc_owner_email'] = cfg['owner_email']
if cfg.get('owner_name'):
ticket_vals['x_fc_owner_name'] = cfg['owner_name']
# ---- Talk to remote Odoo --------------------------------------
try:
@@ -219,6 +228,12 @@ class FusionHelpdeskController(http.Controller):
'client_label': (
ICP.get_param('fusion_helpdesk.client_label') or ''
).strip(),
'owner_email': (
ICP.get_param('fusion_helpdesk.owner_email') or ''
).strip(),
'owner_name': (
ICP.get_param('fusion_helpdesk.owner_name') or ''
).strip(),
}
def _authenticate(self, cfg):

View File

@@ -50,3 +50,22 @@ class ResConfigSettings(models.TransientModel):
'can tell which client deployment a ticket came from. '
'e.g. "ENTECH""[ENTECH] My subject"',
)
# Owner contact for the central engagement / approval flow. Optional —
# leaving these blank disables the "Request Owner Approval" button on
# the central side for this client. Both values piggyback on every
# ticket submission (see controllers/main.py::submit) so central always
# has the latest contact without a dedicated sync endpoint.
fhd_owner_email = fields.Char(
string='Owner Email',
config_parameter='fusion_helpdesk.owner_email',
help='Email of the real decision-maker at your company — the '
'person who can approve feature requests or bug-fix scope. '
'Used when central support hits a ticket that needs sign-off. '
'Leave blank if your deployment doesn\'t require approvals.',
)
fhd_owner_name = fields.Char(
string='Owner Name',
config_parameter='fusion_helpdesk.owner_name',
help='Display name for the owner — shown in the approval email '
'greeting and in the chatter attribution after they decide.',
)

View File

@@ -43,6 +43,19 @@
<field name="fhd_client_label" placeholder="ENTECH"/>
</setting>
</block>
<block title="Owner Approval"
name="fhd_owner_approval">
<setting id="fhd_owner_name"
string="Owner Name"
help="Display name of the real decision-maker at your company. Used in approval emails and chatter attribution.">
<field name="fhd_owner_name" placeholder="Jane Doe"/>
</setting>
<setting id="fhd_owner_email"
string="Owner Email"
help="Email of the real decision-maker. Used when central support requests approval for a ticket that needs sign-off. Leave blank to disable approval requests for this deployment.">
<field name="fhd_owner_email" placeholder="owner@yourcompany.com"/>
</setting>
</block>
</app>
</xpath>
</field>

View File

@@ -1,2 +1,3 @@
# -*- coding: utf-8 -*-
from . import models
from . import controllers

View File

@@ -3,7 +3,7 @@
# License OPL-1
{
'name': 'Fusion Helpdesk Central — Client API Keys',
'version': '19.0.1.2.0',
'version': '19.0.2.0.0',
'category': 'Productivity',
'summary': 'Admin UI on the central Odoo for issuing per-client API '
'keys used by fusion_helpdesk client deployments.',
@@ -29,9 +29,14 @@ Depends only on `helpdesk`. No client-side install needed.
'security/ir.model.access.csv',
'data/ir_config_parameter_data.xml',
'data/mail_template_ack.xml',
'data/mail_template_engagement.xml',
'data/helpdesk_tag_critical.xml',
'data/ir_cron_engagement_reminder.xml',
'views/fusion_helpdesk_client_key_views.xml',
'views/helpdesk_ticket_views.xml',
'views/engagement_wizard_views.xml',
'views/engagement_reporting_views.xml',
'views/portal_templates.xml',
],
'installable': True,
'auto_install': False,

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import engagement

View File

@@ -0,0 +1,147 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
"""Public portal routes for the owner-approval magic links.
Owner clicks the Approve / Reject button in the email -> GET lands here.
Page shows ticket title + AI summary + comment box + Confirm button.
POST records the decision via helpdesk.ticket._fc_finalize_engagement.
No login required; the UUID4 in the URL is the auth. Tokens are single-
use (cleared on finalize), so the second click on the same link shows a
friendly "link no longer valid" page instead of double-recording the
decision.
"""
import logging
from odoo import _, http
from odoo.http import request
_logger = logging.getLogger(__name__)
class FusionHelpdeskEngagementController(http.Controller):
# ------------------------------------------------------------------
# Token resolution — single source of truth for the GET + POST handlers.
# ------------------------------------------------------------------
def _resolve(self, token, decision):
"""Return (ticket, decision_state) or (None, None) on any problem.
The "no problem" cases:
- token is non-empty
- decision is one of {'approve', 'reject'}
- a single ticket matches the token AND is in state='pending'
Anything else -> (None, None), caller renders the friendly
"link no longer valid" page.
"""
if not token or not isinstance(token, str):
return (None, None)
if decision not in ('approve', 'reject'):
return (None, None)
decision_state = 'approved' if decision == 'approve' else 'rejected'
ticket = request.env['helpdesk.ticket'].sudo().search(
[('x_fc_engagement_token', '=', token),
('x_fc_engagement_state', '=', 'pending')],
limit=1,
)
if not ticket:
return (None, None)
return (ticket, decision_state)
# ------------------------------------------------------------------
# GET — render the confirmation page (or invalid-link page).
# ------------------------------------------------------------------
@http.route(
'/fusion_helpdesk/engagement/<string:token>/<string:decision>',
type='http', auth='public', methods=['GET'], csrf=False, sitemap=False,
)
def engagement_show(self, token, decision, **kw):
ticket, decision_state = self._resolve(token, decision)
if not ticket:
return request.render(
'fusion_helpdesk_central.engagement_invalid', {},
)
return request.render(
'fusion_helpdesk_central.engagement_confirm',
{
'ticket': ticket,
'decision': decision, # url-friendly string
'decision_state': decision_state, # 'approved' / 'rejected'
'token': token,
},
)
# ------------------------------------------------------------------
# POST — record the decision, post chatter, clear token.
# ------------------------------------------------------------------
@http.route(
'/fusion_helpdesk/engagement/<string:token>/<string:decision>',
type='http', auth='public', methods=['POST'], csrf=False, sitemap=False,
)
def engagement_submit(self, token, decision, **post):
ticket, decision_state = self._resolve(token, decision)
if not ticket:
# Could be a second click on the same link, or a token rotated
# by a re-engagement, or a typo. Same friendly page for all.
return request.render(
'fusion_helpdesk_central.engagement_invalid', {},
)
comment = (post.get('comment') or '').strip()
owner_partner = self._find_or_create_owner_partner(ticket)
try:
ticket._fc_finalize_engagement(
decision_state, owner_partner, comment=comment or None,
)
except Exception:
_logger.exception(
'fusion_helpdesk_central: failed to finalize engagement '
'for ticket %s (token=%s, decision=%s)',
ticket.id, token, decision_state,
)
return request.render(
'fusion_helpdesk_central.engagement_error', {},
)
return request.render(
'fusion_helpdesk_central.engagement_done',
{
'ticket': ticket,
'decision_state': decision_state,
},
)
# ------------------------------------------------------------------
def _find_or_create_owner_partner(self, ticket):
"""Resolve the res.partner used to attribute the chatter message.
Find-or-create by snapshotted email — mirrors the customer-reply
attribution pattern in fusion_helpdesk/controllers/main.py so the
approval chatter shows up under a proper partner name (matters for
the employee's My Tickets thread per the "fully visible" UX).
Falls back to no author (= bot user) if email is empty or the
partner create fails.
"""
email = (ticket.x_fc_engagement_email or '').strip().lower()
name = (ticket.x_fc_engagement_name or '').strip()
if not email:
return None
Partner = request.env['res.partner'].sudo()
# Use exact match on lowercased email — the snapshot was already
# normalised at engagement time.
partner = Partner.search([('email', '=ilike', email)],
order='id asc', limit=1)
if partner:
return partner
try:
return Partner.create({
'name': name or email.split('@')[0].title(),
'email': email,
})
except Exception:
_logger.warning(
'fusion_helpdesk_central: could not create owner partner '
'for %s on ticket %s; chatter will be attributed to the '
'service account.', email, ticket.id,
)
return None

View File

@@ -10,4 +10,19 @@
<field name="value">support@nexasystems.ca</field>
</record>
<!--
Owner-approval defaults. openai_api_key intentionally NOT seeded —
admin sets it via Settings before the wizard can generate summaries.
Default model is the cheap/fast tier; bump for harder summarisation.
Reminder days = 0 disables the cron entirely.
-->
<record id="fhc_default_openai_model" model="ir.config_parameter">
<field name="key">fusion_helpdesk_central.openai_model</field>
<field name="value">gpt-4o-mini</field>
</record>
<record id="fhc_default_reminder_days" model="ir.config_parameter">
<field name="key">fusion_helpdesk_central.engagement_reminder_days</field>
<field name="value">3</field>
</record>
</odoo>

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1
Single-shot reminder cron for the owner-approval engagement flow.
Runs once a day; for each pending engagement older than N days that
hasn't been reminded yet, re-sends the same template with
is_reminder=True (different subject + soft "still waiting" intro,
same magic links). Sets reminded_at so we never send a 2nd reminder.
Odoo 19: no `numbercall` field (dropped). The cron keeps running
while active=True; once-a-day cadence is enforced by interval_*.
-->
<odoo>
<data noupdate="1">
<record id="ir_cron_engagement_reminder" model="ir.cron">
<field name="name">Fusion Helpdesk — Owner Engagement Reminder</field>
<field name="model_id" ref="helpdesk.model_helpdesk_ticket"/>
<field name="state">code</field>
<field name="code">model._fc_send_engagement_reminders()</field>
<field name="user_id" ref="base.user_root"/>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active" eval="True"/>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,169 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1
Two mail templates for the owner-approval engagement flow:
* mail_template_engagement — single ticket
* mail_template_engagement_bulk — multiple tickets, one card per ticket,
per-ticket approve/reject buttons
Both use {{ ctx.fhc_personal_note }} + {{ ctx.fhc_is_reminder }} from the
wizard's with_context(**data) so dynamic per-send data reaches the body.
noupdate=1 so support's later tweaks (e.g. a different reply-to)
survive every fusion_helpdesk_central upgrade.
-->
<odoo>
<data noupdate="1">
<!-- ============== SINGLE TICKET ENGAGEMENT ============== -->
<record id="mail_template_engagement" model="mail.template">
<field name="name">Helpdesk: Owner Approval Request (Single)</field>
<field name="model_id" ref="helpdesk.model_helpdesk_ticket"/>
<field name="subject">{{ ctx.get('fhc_is_reminder') and 'Reminder: still waiting on your approval' or 'Action needed: please review' }} — "{{ object.name }}"</field>
<field name="email_to">{{ object.x_fc_engagement_email }}</field>
<field name="reply_to">{{ (object.user_id.email or object.team_id.alias_email or object.company_id.email or '') }}</field>
<field name="lang">en_US</field>
<field name="auto_delete" eval="True"/>
<field name="body_html" type="html">
<div style="margin:0; padding:0; font-family:Arial, Helvetica, sans-serif; color:#21252b; font-size:14px;">
<p>Hi <t t-out="object.x_fc_engagement_name or 'there'"/>,</p>
<t t-if="ctx.get('fhc_is_reminder')">
<p style="background:#fff8e6; border-left:3px solid #d4a017; padding:8px 12px; margin:12px 0;">
Following up on the request below — the original
email was sent a few days ago and we haven't
heard back yet. Same Approve / Reject buttons,
same one click.
</p>
</t>
<t t-elif="ctx.get('fhc_personal_note')">
<p style="font-style:italic; color:#444;">
<t t-out="ctx.get('fhc_personal_note')"/>
</p>
</t>
<p>
Your team at <b><t t-out="object.x_fc_client_label or 'your deployment'"/></b>
has filed a request that needs your sign-off before
our team proceeds. A quick AI-prepared summary is
below; the full thread expands at the bottom if you
want the detail.
</p>
<div style="margin:14px 0; padding:12px 14px; background:#f9fafb; border:1px solid #e5e7eb; border-radius:6px;">
<div style="font-size:0.78rem; font-weight:700; text-transform:uppercase; letter-spacing:0.06em; color:#6c757d; margin-bottom:6px;">Summary</div>
<div style="white-space:pre-wrap; line-height:1.5;"><t t-out="object.x_fc_ai_summary or '(no AI summary available — see full thread below)'"/></div>
</div>
<div style="margin:18px 0;">
<div style="font-size:0.78rem; font-weight:700; text-transform:uppercase; letter-spacing:0.06em; color:#6c757d; margin-bottom:6px;">Request</div>
<div style="font-weight:600; font-size:1.02rem;"><t t-out="object.name"/></div>
</div>
<table cellpadding="0" cellspacing="0" style="margin:18px 0;">
<tr>
<td style="padding-right:8px;">
<a t-attf-href="{{ object.get_base_url() }}/fusion_helpdesk/engagement/{{ object.x_fc_engagement_token }}/approve"
target="_blank"
style="background:linear-gradient(135deg, #5cc66f 0%, #28a745 100%); padding:12px 22px; text-decoration:none; color:#ffffff; border-radius:6px; font-weight:700; font-size:14px; display:inline-block;">
✓ Approve
</a>
</td>
<td>
<a t-attf-href="{{ object.get_base_url() }}/fusion_helpdesk/engagement/{{ object.x_fc_engagement_token }}/reject"
target="_blank"
style="background:linear-gradient(135deg, #e85d68 0%, #dc3545 100%); padding:12px 22px; text-decoration:none; color:#ffffff; border-radius:6px; font-weight:700; font-size:14px; display:inline-block;">
✗ Reject
</a>
</td>
</tr>
</table>
<details style="margin:18px 0; color:#444; font-size:13px;">
<summary style="cursor:pointer; color:#2c89e9;">View original request &amp; full thread</summary>
<div style="margin-top:10px; padding:10px 14px; border-left:3px solid #d8dadd;">
<t t-out="object.description or ''"/>
</div>
</details>
<p style="color:#6c757d; font-size:12px; margin-top:24px;">
This Approve / Reject link is single-use and will
stop working once you've clicked it.
</p>
<p style="color:#6c757d; font-size:12px;">— Nexa Systems Support</p>
</div>
</field>
</record>
<!-- ============== BULK ENGAGEMENT ============== -->
<record id="mail_template_engagement_bulk" model="mail.template">
<field name="name">Helpdesk: Owner Approval Request (Bulk)</field>
<field name="model_id" ref="helpdesk.model_helpdesk_ticket"/>
<field name="subject">Action needed: {{ len(ctx.get('fhc_bulk_ticket_ids') or []) }} requests need your sign-off</field>
<field name="email_to">{{ object.x_fc_engagement_email }}</field>
<field name="reply_to">{{ (object.user_id.email or object.team_id.alias_email or object.company_id.email or '') }}</field>
<field name="lang">en_US</field>
<field name="auto_delete" eval="True"/>
<field name="body_html" type="html">
<div style="margin:0; padding:0; font-family:Arial, Helvetica, sans-serif; color:#21252b; font-size:14px;">
<p>Hi <t t-out="object.x_fc_engagement_name or 'there'"/>,</p>
<t t-if="ctx.get('fhc_personal_note')">
<p style="font-style:italic; color:#444;">
<t t-out="ctx.get('fhc_personal_note')"/>
</p>
</t>
<p>
<t t-out="len(ctx.get('fhc_bulk_ticket_ids') or [])"/>
requests from <b><t t-out="object.x_fc_client_label or 'your deployment'"/></b>
need your sign-off. Each can be approved or rejected
independently — clicking a button on one card only
acts on that card.
</p>
<t t-set="bulk_tickets" t-value="object.env['helpdesk.ticket'].browse(ctx.get('fhc_bulk_ticket_ids') or [])"/>
<t t-foreach="bulk_tickets" t-as="bt">
<div style="margin:18px 0; padding:14px 16px; background:#fff; border:1px solid #d8dadd; border-radius:8px;">
<div style="font-size:0.75rem; color:#6c757d; margin-bottom:6px;">Request <t t-out="bt_index + 1"/> of <t t-out="bt_size"/></div>
<div style="font-weight:600; font-size:1.02rem; margin-bottom:8px;"><t t-out="bt.name"/></div>
<div style="white-space:pre-wrap; line-height:1.5; background:#f9fafb; padding:10px 12px; border-radius:6px; border:1px solid #e5e7eb; font-size:0.92rem; margin-bottom:12px;">
<t t-out="bt.x_fc_ai_summary or '(no AI summary — open the full ticket via the team if needed)'"/>
</div>
<table cellpadding="0" cellspacing="0">
<tr>
<td style="padding-right:8px;">
<a t-attf-href="{{ object.get_base_url() }}/fusion_helpdesk/engagement/{{ bt.x_fc_engagement_token }}/approve"
target="_blank"
style="background:linear-gradient(135deg, #5cc66f 0%, #28a745 100%); padding:10px 18px; text-decoration:none; color:#ffffff; border-radius:6px; font-weight:700; font-size:13px; display:inline-block;">
✓ Approve
</a>
</td>
<td>
<a t-attf-href="{{ object.get_base_url() }}/fusion_helpdesk/engagement/{{ bt.x_fc_engagement_token }}/reject"
target="_blank"
style="background:linear-gradient(135deg, #e85d68 0%, #dc3545 100%); padding:10px 18px; text-decoration:none; color:#ffffff; border-radius:6px; font-weight:700; font-size:13px; display:inline-block;">
✗ Reject
</a>
</td>
</tr>
</table>
</div>
</t>
<p style="color:#6c757d; font-size:12px; margin-top:24px;">
Each Approve / Reject link is single-use. Tap the
button on the card you want to decide on; the others
stay live in this email until you act on them or we
send a fresh request.
</p>
<p style="color:#6c757d; font-size:12px;">— Nexa Systems Support</p>
</div>
</field>
</record>
</data>
</odoo>

View File

@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
from . import fusion_helpdesk_client_key
from . import helpdesk_ticket
from . import engagement_wizard

View File

@@ -0,0 +1,380 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
"""Wizard that drives the owner-approval engagement flow.
Two modes, one wizard:
- Single-ticket: opened from the ticket form button. `ticket_id` is set,
`ticket_ids` is empty. One AI summary, one email, one engagement.
- Bulk: opened from the list-view server action. `ticket_ids` is set,
`ticket_id` is empty. One AI summary per ticket (via a child transient
model), one combined email with one card per ticket, each card with
its own approve/reject tokens.
The wizard generates the AI summary on `default_get` so the user sees a
ready-to-edit brief the moment the modal opens, then sends mail + writes
the engagement state on `action_send`.
"""
import concurrent.futures
import logging
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.tools import html2plaintext
from odoo.addons.fusion_helpdesk_central.utils import (
build_summary_prompt,
call_openai_chat,
truncate_for_openai,
)
_logger = logging.getLogger(__name__)
# Parallel OpenAI calls for bulk mode. Five is enough to keep the wizard
# snappy without slamming OpenAI's rate limits on a single deployment.
_BULK_AI_WORKERS = 5
_BULK_AI_TIMEOUT = 30 # seconds; overall cap for the parallel summary fan-out
class FusionHelpdeskEngagementWizard(models.TransientModel):
_name = 'fusion.helpdesk.engagement.wizard'
_description = 'Fusion Helpdesk — Owner Engagement Wizard'
# Mode: single vs bulk. The view branches on `mode`; it's a computed
# store=False field so we don't need to set it on default_get manually
# in every action.
mode = fields.Selection(
[('single', 'Single ticket'), ('bulk', 'Bulk')],
compute='_compute_mode', store=False,
)
ticket_id = fields.Many2one(
'helpdesk.ticket', string='Ticket', ondelete='cascade',
)
ticket_ids = fields.Many2many(
'helpdesk.ticket', string='Tickets (bulk)',
)
line_ids = fields.One2many(
'fusion.helpdesk.engagement.wizard.line', 'wizard_id',
string='Per-Ticket Summaries',
)
personal_note = fields.Char(
string='Personal Note',
help='One-line note from you, prepended above the AI summary in the '
'email body. Optional. Skip if the summary speaks for itself.',
)
ai_summary = fields.Text(
string='AI Summary',
help='OpenAI-generated brief. Edit before sending if you want to '
'tweak the framing. Empty? The wizard fell back to manual — '
'type your own brief, send normally.',
)
owner_email_display = fields.Char(
string='Owner Email', compute='_compute_owner_display', store=False,
)
owner_name_display = fields.Char(
string='Owner Name', compute='_compute_owner_display', store=False,
)
ai_unavailable = fields.Boolean(
string='AI unavailable', store=False,
help='True when OpenAI returned no summary on wizard open. The view '
'shows a soft banner so the user knows to write a manual brief.',
)
# ------------------------------------------------------------------
@api.depends('ticket_id', 'ticket_ids')
def _compute_mode(self):
for w in self:
w.mode = 'bulk' if w.ticket_ids else 'single'
@api.depends('ticket_id', 'ticket_ids')
def _compute_owner_display(self):
for w in self:
ticket = w.ticket_id or (w.ticket_ids[:1] if w.ticket_ids else None)
if ticket:
email, name = ticket._fc_owner_contact()
else:
email, name = (False, False)
w.owner_email_display = email or ''
w.owner_name_display = name or ''
# ------------------------------------------------------------------
# Wizard open: pull tickets from context, generate AI summary(ies).
# ------------------------------------------------------------------
@api.model
def default_get(self, fields_list):
vals = super().default_get(fields_list)
ctx = self.env.context or {}
ticket_id = ctx.get('default_ticket_id') or ctx.get('active_id')
ticket_ids = ctx.get('default_ticket_ids') or ctx.get('active_ids') or []
active_model = ctx.get('active_model')
# Disambiguate single vs bulk by what the caller actually selected.
# The list-view server action passes active_ids; the form button
# passes a single active_id via a deliberate context key.
if ctx.get('fhc_bulk') and ticket_ids:
return self._default_get_bulk(vals, ticket_ids)
if active_model == 'helpdesk.ticket' and ticket_ids and not ticket_id:
# Edge: opened from list selection without our explicit context
# key. If exactly one, treat as single; otherwise bulk.
if len(ticket_ids) == 1:
ticket_id = ticket_ids[0]
else:
return self._default_get_bulk(vals, ticket_ids)
if ticket_id:
return self._default_get_single(vals, ticket_id)
return vals
def _default_get_single(self, vals, ticket_id):
ticket = self.env['helpdesk.ticket'].browse(ticket_id)
if not ticket.exists():
raise UserError(_('Ticket %s no longer exists.') % ticket_id)
self._validate_engagement_target(ticket)
summary = self._generate_summary(ticket)
vals.update({
'ticket_id': ticket.id,
'ai_summary': summary,
'ai_unavailable': not bool(summary),
})
return vals
def _default_get_bulk(self, vals, ticket_ids):
tickets = self.env['helpdesk.ticket'].browse(ticket_ids).exists()
self._validate_bulk_targets(tickets)
# One summary per ticket, fanned out in parallel so the modal doesn't
# block for N * 15s. If the fan-out itself times out we still open
# the wizard — the user just has to fill in summaries manually.
summaries = self._generate_summaries_parallel(tickets)
any_ok = any(s for s in summaries.values())
vals.update({
'ticket_ids': [(6, 0, tickets.ids)],
'line_ids': [
(0, 0, {'ticket_id': t.id,
'ai_summary': summaries.get(t.id, '')})
for t in tickets
],
'ai_unavailable': not any_ok,
})
return vals
# ------------------------------------------------------------------
# Validation gates — run BEFORE we waste an OpenAI call.
# ------------------------------------------------------------------
def _validate_engagement_target(self, ticket):
if not ticket.x_fc_client_label:
raise UserError(_(
'This ticket is not tagged with a client deployment, so the '
'central has no owner contact to send to. Owner-approval is '
'only available on in-app tickets.'
))
email, _name = ticket._fc_owner_contact()
if not email:
raise UserError(_(
'No owner contact configured for client "%s". Ask the client '
'to fill it in under Settings → Fusion Helpdesk → Owner '
'Approval, then file any ticket from that deployment so the '
'central learns the contact.'
) % (ticket.x_fc_client_label,))
def _validate_bulk_targets(self, tickets):
if not tickets:
raise UserError(_('No tickets selected.'))
labels = {t.x_fc_client_label for t in tickets}
labels.discard(False)
labels.discard('')
if len(labels) != 1:
raise UserError(_(
'Cannot bulk-engage tickets across different deployments — '
'one owner per engagement. Selected labels: %s.'
) % (', '.join(sorted(labels)) or '(none)'))
blockers = tickets.filtered(
lambda t: t.x_fc_engagement_state in ('pending', 'approved')
)
if blockers:
raise UserError(_(
'%(n)s of the selected tickets already have a pending or '
'approved engagement. Re-engage them individually from the '
'ticket form. Tickets: %(ids)s'
) % {'n': len(blockers),
'ids': ', '.join('#%s' % t.id for t in blockers)})
# Reuse single validation for the owner-contact check (any one
# ticket suffices since they share the same client_label).
self._validate_engagement_target(tickets[0])
# ------------------------------------------------------------------
# AI summary generation
# ------------------------------------------------------------------
def _summary_inputs(self, ticket):
"""Pull the data we feed to OpenAI: title + plain-text description +
plain-text public messages (oldest first). Internal notes excluded."""
msgs = self.env['mail.message'].search([
('model', '=', 'helpdesk.ticket'),
('res_id', '=', ticket.id),
('message_type', 'in', ('comment', 'email')),
('subtype_id.internal', '=', False),
], order='id asc')
msg_data = [{
'author': m.author_id.name or m.email_from or 'unknown',
'date': fields.Datetime.to_string(m.date) if m.date else '',
'body_plain': html2plaintext(m.body or '') or '',
} for m in msgs]
return (
ticket.name or '',
html2plaintext(ticket.description or '') or '',
msg_data,
)
def _generate_summary(self, ticket):
"""Single-ticket summary. Returns '' on any failure — the wizard
treats empty as "AI unavailable" and shows the manual fallback."""
ICP = self.env['ir.config_parameter'].sudo()
api_key = (ICP.get_param(
'fusion_helpdesk_central.openai_api_key') or '').strip()
if not api_key:
return ''
model = (ICP.get_param(
'fusion_helpdesk_central.openai_model') or 'gpt-4o-mini').strip()
name, desc, msgs = self._summary_inputs(ticket)
prompt = truncate_for_openai(build_summary_prompt(name, desc, msgs))
return call_openai_chat(api_key, model, prompt)
def _generate_summaries_parallel(self, tickets):
"""{ticket_id: summary_or_empty} for the bulk wizard.
Submits N calls in parallel via a thread pool. Each call has its own
15s timeout; the whole batch is capped at _BULK_AI_TIMEOUT so a slow
single call doesn't hold up the rest. Anything still pending at the
cap returns ''."""
ICP = self.env['ir.config_parameter'].sudo()
api_key = (ICP.get_param(
'fusion_helpdesk_central.openai_api_key') or '').strip()
if not api_key:
return {t.id: '' for t in tickets}
model = (ICP.get_param(
'fusion_helpdesk_central.openai_model') or 'gpt-4o-mini').strip()
# Build inputs serially (DB-bound, fast) before fanning out the
# HTTP calls in parallel.
inputs = {}
for t in tickets:
name, desc, msgs = self._summary_inputs(t)
inputs[t.id] = truncate_for_openai(
build_summary_prompt(name, desc, msgs))
results = {t.id: '' for t in tickets}
with concurrent.futures.ThreadPoolExecutor(
max_workers=_BULK_AI_WORKERS) as pool:
futures = {
pool.submit(call_openai_chat, api_key, model, p): tid
for tid, p in inputs.items()
}
try:
for fut in concurrent.futures.as_completed(
futures, timeout=_BULK_AI_TIMEOUT):
tid = futures[fut]
try:
results[tid] = fut.result() or ''
except Exception as e: # noqa: BLE001 — log + continue
_logger.warning(
'fusion_helpdesk_central: bulk AI summary for '
'ticket %s failed: %s', tid, e,
)
except concurrent.futures.TimeoutError:
_logger.warning(
'fusion_helpdesk_central: bulk AI summary fan-out timed '
'out after %ss; remaining tickets will get empty '
'summaries.', _BULK_AI_TIMEOUT,
)
return results
# ------------------------------------------------------------------
# Send: write engagement state + queue mail
# ------------------------------------------------------------------
def action_send(self):
self.ensure_one()
if self.mode == 'bulk':
return self._action_send_bulk()
return self._action_send_single()
def _action_send_single(self):
ticket = self.ticket_id
if not ticket:
raise UserError(_('Wizard has no ticket attached.'))
# Re-resolve owner from client_key in case it changed between
# default_get and Send (small window, but the source of truth wins).
email, name = ticket._fc_owner_contact()
if not email:
raise UserError(_('Owner contact disappeared since you opened '
'the wizard. Refresh and try again.'))
ticket._fc_reset_engagement(email, name, self.ai_summary or '')
template = self.env.ref(
'fusion_helpdesk_central.mail_template_engagement',
raise_if_not_found=False,
)
if not template:
raise UserError(_('Engagement mail template not found — was '
'fusion_helpdesk_central installed cleanly?'))
# Pass the personal note + is_reminder=False into the template's
# rendering context. Use `with_context(**data)` per CLAUDE.md —
# `ctx=...` won't reach the template body.
template.with_context(
fhc_personal_note=self.personal_note or '',
fhc_is_reminder=False,
).send_mail(ticket.id, force_send=False)
return {'type': 'ir.actions.act_window_close'}
def _action_send_bulk(self):
if not self.ticket_ids:
raise UserError(_('Wizard has no tickets attached.'))
# Snapshot owner from the first ticket — bulk is locked to a single
# client_label by validation, so they all share an owner.
email, name = self.ticket_ids[0]._fc_owner_contact()
if not email:
raise UserError(_('Owner contact disappeared since you opened '
'the wizard. Refresh and try again.'))
summary_by_id = {line.ticket_id.id: line.ai_summary or ''
for line in self.line_ids}
for ticket in self.ticket_ids:
ticket._fc_reset_engagement(
email, name, summary_by_id.get(ticket.id, ''),
)
template = self.env.ref(
'fusion_helpdesk_central.mail_template_engagement_bulk',
raise_if_not_found=False,
)
if not template:
raise UserError(_('Bulk engagement mail template not found — '
'was fusion_helpdesk_central installed cleanly?'))
# The bulk template renders once against the FIRST ticket but reads
# the full set from context. Each ticket already has its own token
# (snapped above), so the template iterates self.env['helpdesk.ticket']
# by the ids we pass in.
template.with_context(
fhc_personal_note=self.personal_note or '',
fhc_is_reminder=False,
fhc_bulk_ticket_ids=self.ticket_ids.ids,
).send_mail(self.ticket_ids[0].id, force_send=False)
return {'type': 'ir.actions.act_window_close'}
class FusionHelpdeskEngagementWizardLine(models.TransientModel):
_name = 'fusion.helpdesk.engagement.wizard.line'
_description = 'Fusion Helpdesk — Per-Ticket Bulk Engagement Line'
wizard_id = fields.Many2one(
'fusion.helpdesk.engagement.wizard', required=True,
ondelete='cascade',
)
ticket_id = fields.Many2one(
'helpdesk.ticket', required=True, ondelete='cascade',
)
ticket_name = fields.Char(
related='ticket_id.name', readonly=True,
)
ai_summary = fields.Text(
string='AI Summary',
help='Per-ticket summary — edit before send.',
)

View File

@@ -33,6 +33,24 @@ class FusionHelpdeskClientKey(models.Model):
string='Notes',
help='Optional. Stamp deployment URL, contact, install date.',
)
# Owner contact for the engagement / approval flow. Auto-refreshed
# from each incoming ticket's payload (see helpdesk_ticket.create
# override) so support always has the current owner without manual
# sync. Manual overrides on this row stick until the next ticket
# carries different values.
owner_email = fields.Char(
string='Owner Email',
help='Email of the client\'s real decision-maker (the person paying '
'the bill, not the Odoo "Manager" role). Used to send approval '
'requests when central support hits a feature that needs '
'sign-off. Auto-populated from the client\'s entech settings '
'on every ticket submission.',
)
owner_name = fields.Char(
string='Owner Name',
help='Display name for the owner — used in email greeting and the '
'chatter attribution when they approve / reject.',
)
bot_user_id = fields.Many2one(
'res.users', string='Bot User', readonly=True,
ondelete='restrict',

View File

@@ -1,15 +1,28 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
"""Central-side helpdesk.ticket extensions for the customer follow-up flow.
"""Central-side helpdesk.ticket extensions for the customer follow-up flow
and the owner-approval engagement flow.
Adds the `x_fc_client_label` deployment tag (set by the in-app reporter so
the embedded inbox can scope per client) and sends a branded acknowledgement
email — carrying the portal magic link — when an in-app ticket is created.
Adds:
- `x_fc_client_label` deployment tag (set by the in-app reporter so the
embedded inbox can scope per client).
- Branded acknowledgement email on create for in-app tickets.
- Auto-tag with Critical when priority=3 + has client_label.
- The full owner-approval engagement field set: state, token, snapshotted
owner email/name, AI summary, sent / reminded / decided timestamps, and a
stored computed turnaround for the reporting pivot.
- Upserts the client_key row's owner_email/owner_name from each incoming
ticket payload so the central always has the current owner contact
without a dedicated sync endpoint.
"""
import logging
import uuid
from datetime import timedelta
from odoo import api, fields, models
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.tools import email_normalize
_logger = logging.getLogger(__name__)
@@ -24,13 +37,311 @@ class HelpdeskTicket(models.Model):
'lets support filter tickets by originating deployment.',
)
# ------------------------------------------------------------------
# Owner-approval engagement fields
# ------------------------------------------------------------------
x_fc_engagement_state = fields.Selection(
selection=[
('none', 'None'),
('pending', 'Pending'),
('approved', 'Approved'),
('rejected', 'Rejected'),
],
string='Owner Approval', default='none', copy=False, index=True,
help='State of the owner-approval engagement: '
'none = never requested, pending = email sent / awaiting click, '
'approved / rejected = owner has decided.',
)
x_fc_engagement_email = fields.Char(
string='Engaged Owner Email', copy=False,
help='Snapshot of the owner email reached for THIS engagement. '
'Survives later edits to fusion.helpdesk.client.key.owner_email '
'so audit history stays correct.',
)
x_fc_engagement_name = fields.Char(
string='Engaged Owner Name', copy=False,
)
x_fc_engagement_token = fields.Char(
string='Engagement Token', copy=False, index=True,
help='UUID4 in the magic link. Single-use — cleared after the '
'owner confirms a decision in the portal.',
)
x_fc_engagement_sent_at = fields.Datetime(
string='Engagement Sent At', copy=False, readonly=True,
)
x_fc_engagement_reminded_at = fields.Datetime(
string='Engagement Reminded At', copy=False, readonly=True,
help='Set by the daily reminder cron. We send at most one '
'reminder per engagement to avoid spamming the owner.',
)
x_fc_engagement_decided_at = fields.Datetime(
string='Engagement Decided At', copy=False, readonly=True,
)
x_fc_ai_summary = fields.Text(
string='AI Summary', copy=False,
help='OpenAI-generated brief shown to the owner in the approval '
'email. Editable in the wizard before sending; frozen after.',
)
x_fc_engagement_turnaround_hours = fields.Float(
string='Owner Turnaround (h)',
compute='_compute_engagement_turnaround',
store=True, copy=False, digits=(8, 2),
help='Hours between engagement-sent and owner decision. Stored so '
'the Owner Engagements pivot can aggregate without recomputing.',
)
# message_post-friendly index for the reminder cron + token resolution.
_engagement_state_idx = models.Index(
'(x_fc_engagement_state, x_fc_engagement_sent_at)'
)
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
@api.model_create_multi
def create(self, vals_list):
# Sync owner contact from payload BEFORE creating tickets so the
# client_key row reflects the latest contact even if ticket-create
# itself fails (e.g. validation error elsewhere).
self._fc_sync_owner_contacts(vals_list)
tickets = super().create(vals_list)
tickets._fc_send_ack_email()
tickets._fc_auto_tag_critical()
return tickets
@api.model
def _fc_sync_owner_contacts(self, vals_list):
"""Upsert fusion.helpdesk.client.key.owner_email/name from incoming
ticket vals so the central always has the latest owner contact.
Pulls keys 'x_fc_owner_email' / 'x_fc_owner_name' which the
fusion_helpdesk client controller piggybacks on every submit. These
are NOT real helpdesk.ticket fields — they're stripped here before
super().create() sees them so Odoo doesn't choke on unknown columns.
"""
ClientKey = self.env['fusion.helpdesk.client.key'].sudo()
for vals in vals_list:
# Pop the piggyback keys regardless of whether we use them.
owner_email = (vals.pop('x_fc_owner_email', None) or '').strip()
owner_name = (vals.pop('x_fc_owner_name', None) or '').strip()
label = (vals.get('x_fc_client_label') or '').strip()
if not label or not (owner_email or owner_name):
continue
row = ClientKey.search([('client_label', '=', label)], limit=1)
if not row:
# Don't auto-create a client_key row from a ticket — that
# would bypass API-key issuance. Just log and move on.
_logger.info(
'fusion_helpdesk_central: ticket carried owner contact '
'for unknown client_label "%s"; skipping sync.', label,
)
continue
updates = {}
if owner_email and owner_email != (row.owner_email or ''):
updates['owner_email'] = owner_email
if owner_name and owner_name != (row.owner_name or ''):
updates['owner_name'] = owner_name
if updates:
row.write(updates)
@api.depends('x_fc_engagement_sent_at', 'x_fc_engagement_decided_at')
def _compute_engagement_turnaround(self):
for rec in self:
sent = rec.x_fc_engagement_sent_at
decided = rec.x_fc_engagement_decided_at
if sent and decided and decided > sent:
delta = decided - sent
rec.x_fc_engagement_turnaround_hours = (
delta.total_seconds() / 3600.0
)
else:
rec.x_fc_engagement_turnaround_hours = 0.0
# ------------------------------------------------------------------
# Owner engagement plumbing
# ------------------------------------------------------------------
def _fc_owner_contact(self):
"""Return (email, name) for this ticket's client_key owner contact,
or (False, False) if the client_key is missing / unconfigured.
Single source of truth for the wizard + the form button's enable
check — we never read directly from the ticket's snapshot fields
for *new* engagements (those snapshot AT engagement time).
"""
self.ensure_one()
if not self.x_fc_client_label:
return (False, False)
row = self.env['fusion.helpdesk.client.key'].sudo().search(
[('client_label', '=', self.x_fc_client_label)], limit=1,
)
if not row:
return (False, False)
return (row.owner_email or False, row.owner_name or False)
def _fc_new_engagement_token(self):
"""Allocate a fresh single-use token. Centralised so tests can
monkeypatch it for deterministic assertions."""
return uuid.uuid4().hex
def _fc_reset_engagement(self, owner_email, owner_name, ai_summary):
"""Stamp a fresh pending engagement on this ticket — invalidates any
previous token + clears decided/reminded timestamps so the cron and
the reporting view see a clean slate.
Owner email is normalised here (lowercase, rejected if not a valid
single address) so a typo'd contact like "kris@x; jim@y" can't end
up as the snapshot. If normalisation fails, we still proceed using
the raw value — the email will probably bounce but state is
consistent and re-engaging fixes it.
"""
self.ensure_one()
normalised = email_normalize(owner_email or '') or (owner_email or '')
self.write({
'x_fc_engagement_state': 'pending',
'x_fc_engagement_email': normalised,
'x_fc_engagement_name': (owner_name or '').strip(),
'x_fc_engagement_token': self._fc_new_engagement_token(),
'x_fc_engagement_sent_at': fields.Datetime.now(),
'x_fc_engagement_reminded_at': False,
'x_fc_engagement_decided_at': False,
'x_fc_ai_summary': ai_summary or '',
})
def action_open_engagement_wizard(self):
"""Form-button handler: open the wizard targeting this single ticket.
Validation lives on the wizard's default_get so the error path is
symmetrical with the bulk action — same UserError messages, same
soft fallback when AI is unavailable."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Request Owner Approval'),
'res_model': 'fusion.helpdesk.engagement.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_ticket_id': self.id,
'active_id': self.id,
'active_model': 'helpdesk.ticket',
},
}
@api.model
def action_open_engagement_wizard_bulk(self):
"""Server-action handler: open the wizard targeting the list-view
selection. Bound from a server action XML record. Reads ids from
the env context (`active_ids`) — the action ensures it's only
callable from a list/kanban with selection."""
ticket_ids = self.env.context.get('active_ids') or []
if not ticket_ids:
raise UserError(_('Select at least one ticket first.'))
return {
'type': 'ir.actions.act_window',
'name': _('Request Owner Approval (Bulk)'),
'res_model': 'fusion.helpdesk.engagement.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_ticket_ids': ticket_ids,
'active_ids': ticket_ids,
'active_model': 'helpdesk.ticket',
'fhc_bulk': True,
},
}
@api.model
def _fc_send_engagement_reminders(self):
"""Cron entry-point: re-send one reminder for stale pending engagements.
N days configurable via ICP `engagement_reminder_days` (default 3,
0 = disabled). Single-shot per engagement — `reminded_at` set after
send so we never spam. Same token, same magic links, so the owner
can click whichever email is in front of them.
Idempotent on its own: a second cron run within the same day won't
re-find anything because `reminded_at` is now non-NULL.
"""
ICP = self.env['ir.config_parameter'].sudo()
try:
N = int(ICP.get_param(
'fusion_helpdesk_central.engagement_reminder_days') or 3)
except (TypeError, ValueError):
N = 3
if N <= 0:
_logger.info('fusion_helpdesk_central: reminder cron disabled '
'(engagement_reminder_days <= 0); skipping.')
return 0
cutoff = fields.Datetime.now() - timedelta(days=N)
stale = self.search([
('x_fc_engagement_state', '=', 'pending'),
('x_fc_engagement_sent_at', '<=', cutoff),
('x_fc_engagement_reminded_at', '=', False),
])
if not stale:
return 0
template = self.env.ref(
'fusion_helpdesk_central.mail_template_engagement',
raise_if_not_found=False,
)
if not template:
_logger.warning(
'fusion_helpdesk_central: reminder cron found %s stale '
'engagements but the mail template is missing; aborting.',
len(stale),
)
return 0
now = fields.Datetime.now()
for ticket in stale:
try:
template.with_context(
fhc_is_reminder=True,
fhc_personal_note='',
).send_mail(ticket.id, force_send=False)
ticket.x_fc_engagement_reminded_at = now
except Exception: # noqa: BLE001 — reminder must never break cron loop
_logger.exception(
'fusion_helpdesk_central: reminder send failed for '
'ticket %s; will retry next run.', ticket.id,
)
_logger.info(
'fusion_helpdesk_central: reminder cron sent %s reminder(s).',
len(stale),
)
return len(stale)
def _fc_finalize_engagement(self, decision, owner_partner, comment=None):
"""Apply the owner's decision: post chatter (public), clear token,
write state + decided_at. Called from the public portal controller
after a magic link is clicked + confirmed.
Chatter is posted as a public comment (subtype mail.mt_comment) so
it propagates to the employee's My Tickets thread per the
"fully visible" UX choice in the spec.
"""
from odoo.addons.fusion_helpdesk_central.utils import (
format_engagement_chatter,
)
self.ensure_one()
body = format_engagement_chatter(
decision, self.x_fc_engagement_name, comment,
)
author_id = owner_partner.id if owner_partner else False
self.message_post(
body=body,
message_type='comment',
subtype_xmlid='mail.mt_comment',
author_id=author_id,
)
self.write({
'x_fc_engagement_state': decision,
'x_fc_engagement_decided_at': fields.Datetime.now(),
'x_fc_engagement_token': False,
})
# ------------------------------------------------------------------
# Existing customer-followup hooks (unchanged behaviour)
# ------------------------------------------------------------------
def _fc_auto_tag_critical(self):
"""Auto-apply the Critical tag on in-app tickets that were filed with
priority='3' (Urgent — the client-side "Mark as Critical" toggle).

View File

@@ -1,2 +1,4 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fhc_client_key_admin,fusion.helpdesk.client.key.admin,model_fusion_helpdesk_client_key,base.group_system,1,1,1,1
access_fhc_engagement_wizard_user,fusion.helpdesk.engagement.wizard.user,model_fusion_helpdesk_engagement_wizard,base.group_user,1,1,1,1
access_fhc_engagement_wizard_line_user,fusion.helpdesk.engagement.wizard.line.user,model_fusion_helpdesk_engagement_wizard_line,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fhc_client_key_admin fusion.helpdesk.client.key.admin model_fusion_helpdesk_client_key base.group_system 1 1 1 1
3 access_fhc_engagement_wizard_user fusion.helpdesk.engagement.wizard.user model_fusion_helpdesk_engagement_wizard base.group_user 1 1 1 1
4 access_fhc_engagement_wizard_line_user fusion.helpdesk.engagement.wizard.line.user model_fusion_helpdesk_engagement_wizard_line base.group_user 1 1 1 1

View File

@@ -1,2 +1,4 @@
# -*- coding: utf-8 -*-
from . import test_identity
from . import test_utils
from . import test_engagement

View File

@@ -0,0 +1,414 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
"""Integration-ish tests for the owner-approval engagement flow.
These need an env (helpdesk.ticket, client_key, wizard, portal controller),
so they run as TransactionCase. OpenAI is mocked at the utils boundary —
no live API calls in CI. HTTP requests use the standard Odoo test client.
"""
from datetime import timedelta
from unittest.mock import patch
from odoo import fields
from odoo.exceptions import UserError
from odoo.tests import TransactionCase, HttpCase, tagged
def _patch_openai(return_value='• summary bullet one\n• bullet two'):
"""Mock the OpenAI client used by the wizard, returning a deterministic
summary so tests don't depend on network or API keys."""
return patch(
'odoo.addons.fusion_helpdesk_central.models.engagement_wizard.'
'call_openai_chat',
return_value=return_value,
)
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
class TestEngagementBase(TransactionCase):
"""Shared fixtures: a client_key with an owner, an ENTECH ticket."""
@classmethod
def setUpClass(cls):
super().setUpClass()
# client_key.create() requires an actual bot user; set bot login
# to the admin so the model can find it during setUp.
cls.env['ir.config_parameter'].sudo().set_param(
'fusion_helpdesk.bot_login', cls.env.user.login,
)
cls.client_key = cls.env['fusion.helpdesk.client.key'].create({
'client_label': 'TESTCLIENT',
'owner_email': 'owner@testclient.com',
'owner_name': 'Test Owner',
})
cls.team = cls.env['helpdesk.team'].create({
'name': 'Test Team for engagement',
})
def _make_ticket(self, **overrides):
vals = {
'name': '[TESTCLIENT] Bug Report: Test ticket for engagement',
'team_id': self.team.id,
'x_fc_client_label': 'TESTCLIENT',
'description': '<p>Steps to reproduce: do X, see Y.</p>',
}
vals.update(overrides)
return self.env['helpdesk.ticket'].create(vals)
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
class TestOwnerContactSync(TestEngagementBase):
def test_sync_owner_contacts_from_payload(self):
# Simulate the client-side submit piggyback: x_fc_owner_email +
# x_fc_owner_name on create vals. Central must consume them
# (pop from vals) and upsert the client_key row.
self._make_ticket(
x_fc_owner_email='newowner@testclient.com',
x_fc_owner_name='New Owner',
)
self.client_key.invalidate_recordset()
self.assertEqual(self.client_key.owner_email, 'newowner@testclient.com')
self.assertEqual(self.client_key.owner_name, 'New Owner')
def test_sync_no_owner_payload_leaves_client_key_alone(self):
# No piggyback keys → existing client_key contacts must NOT be
# nuked. (We had a bug like this in the customer-followup ship.)
original_email = self.client_key.owner_email
self._make_ticket()
self.client_key.invalidate_recordset()
self.assertEqual(self.client_key.owner_email, original_email)
def test_sync_unknown_client_label_is_silently_skipped(self):
# If a ticket arrives for a client_label we don't have a row for,
# we must not create one (would bypass API-key issuance). Just
# log and move on without raising.
self._make_ticket(
x_fc_client_label='UNKNOWN_CLIENT',
x_fc_owner_email='wat@example.com',
)
# No client_key row was created for UNKNOWN_CLIENT
unknown = self.env['fusion.helpdesk.client.key'].search(
[('client_label', '=', 'UNKNOWN_CLIENT')])
self.assertFalse(unknown)
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
class TestEngagementReset(TestEngagementBase):
def test_reset_engagement_sets_all_fields(self):
t = self._make_ticket()
t._fc_reset_engagement('o@x.com', 'Owner', 'Summary text')
self.assertEqual(t.x_fc_engagement_state, 'pending')
self.assertEqual(t.x_fc_engagement_email, 'o@x.com')
self.assertEqual(t.x_fc_engagement_name, 'Owner')
self.assertEqual(t.x_fc_ai_summary, 'Summary text')
self.assertTrue(t.x_fc_engagement_token)
self.assertTrue(t.x_fc_engagement_sent_at)
self.assertFalse(t.x_fc_engagement_reminded_at)
self.assertFalse(t.x_fc_engagement_decided_at)
def test_re_engagement_rotates_token_and_clears_decision(self):
t = self._make_ticket()
t._fc_reset_engagement('o@x.com', 'Owner', 'summary 1')
original_token = t.x_fc_engagement_token
# Simulate the owner having decided…
t.write({
'x_fc_engagement_state': 'rejected',
'x_fc_engagement_decided_at': fields.Datetime.now(),
'x_fc_engagement_reminded_at': fields.Datetime.now(),
})
# …then re-engage. State must reset, token must rotate.
t._fc_reset_engagement('o@x.com', 'Owner', 'summary 2')
self.assertEqual(t.x_fc_engagement_state, 'pending')
self.assertNotEqual(t.x_fc_engagement_token, original_token)
self.assertFalse(t.x_fc_engagement_reminded_at)
self.assertFalse(t.x_fc_engagement_decided_at)
def test_token_is_unique_per_call(self):
t = self._make_ticket()
tokens = set()
for _ in range(20):
t._fc_reset_engagement('o@x.com', 'Owner', '')
tokens.add(t.x_fc_engagement_token)
self.assertEqual(len(tokens), 20)
def test_finalize_posts_chatter_and_clears_token(self):
t = self._make_ticket()
t._fc_reset_engagement('o@x.com', 'Owner', 's')
partner = self.env['res.partner'].create({
'name': 'Owner', 'email': 'o@x.com',
})
before_count = self.env['mail.message'].search_count(
[('res_id', '=', t.id), ('model', '=', 'helpdesk.ticket')])
t._fc_finalize_engagement('approved', partner, comment='LGTM')
after_count = self.env['mail.message'].search_count(
[('res_id', '=', t.id), ('model', '=', 'helpdesk.ticket')])
self.assertGreater(after_count, before_count)
self.assertEqual(t.x_fc_engagement_state, 'approved')
self.assertFalse(t.x_fc_engagement_token)
self.assertTrue(t.x_fc_engagement_decided_at)
def test_turnaround_hours_computed(self):
t = self._make_ticket()
now = fields.Datetime.now()
t.write({
'x_fc_engagement_sent_at': now - timedelta(hours=5),
'x_fc_engagement_decided_at': now,
})
self.assertAlmostEqual(
t.x_fc_engagement_turnaround_hours, 5.0, places=1,
)
def test_turnaround_zero_when_not_decided(self):
t = self._make_ticket()
t.write({
'x_fc_engagement_sent_at': fields.Datetime.now() - timedelta(hours=2),
})
self.assertEqual(t.x_fc_engagement_turnaround_hours, 0.0)
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
class TestEngagementWizard(TestEngagementBase):
def test_single_send_via_wizard(self):
t = self._make_ticket()
with _patch_openai():
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
default_ticket_id=t.id,
active_id=t.id,
active_model='helpdesk.ticket',
).create({})
self.assertEqual(wizard.mode, 'single')
self.assertIn('summary bullet', wizard.ai_summary)
wizard.personal_note = 'please review'
result = wizard.action_send()
# action returns the standard close-modal action
self.assertEqual(result.get('type'), 'ir.actions.act_window_close')
self.assertEqual(t.x_fc_engagement_state, 'pending')
self.assertEqual(t.x_fc_engagement_email, 'owner@testclient.com')
self.assertTrue(t.x_fc_engagement_token)
def test_single_send_uses_current_client_key_owner(self):
# The wizard must read the FRESH owner contact from client_key,
# not a stale snapshot — if the client_key is updated between
# default_get and Send, Send wins.
t = self._make_ticket()
with _patch_openai():
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
default_ticket_id=t.id,
).create({})
self.client_key.owner_email = 'changed@testclient.com'
wizard.action_send()
self.assertEqual(t.x_fc_engagement_email, 'changed@testclient.com')
def test_wizard_rejects_ticket_without_client_label(self):
t = self._make_ticket(x_fc_client_label=False)
with _patch_openai(), self.assertRaises(UserError):
self.env['fusion.helpdesk.engagement.wizard'].with_context(
default_ticket_id=t.id,
).create({})
def test_wizard_rejects_when_owner_contact_missing(self):
self.client_key.write({'owner_email': False, 'owner_name': False})
t = self._make_ticket()
with _patch_openai(), self.assertRaises(UserError):
self.env['fusion.helpdesk.engagement.wizard'].with_context(
default_ticket_id=t.id,
).create({})
def test_wizard_marks_ai_unavailable_when_summary_empty(self):
t = self._make_ticket()
with _patch_openai(return_value=''):
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
default_ticket_id=t.id,
).create({})
self.assertTrue(wizard.ai_unavailable)
self.assertEqual(wizard.ai_summary, '')
def test_bulk_send_creates_one_engagement_per_ticket(self):
ts = self.env['helpdesk.ticket']
for i in range(3):
ts |= self._make_ticket(name='[TESTCLIENT] Bug %s' % i)
with _patch_openai():
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
default_ticket_ids=ts.ids,
active_ids=ts.ids,
active_model='helpdesk.ticket',
fhc_bulk=True,
).create({})
self.assertEqual(wizard.mode, 'bulk')
self.assertEqual(len(wizard.line_ids), 3)
wizard.action_send()
for t in ts:
self.assertEqual(t.x_fc_engagement_state, 'pending')
self.assertTrue(t.x_fc_engagement_token)
# Each ticket must have its OWN token
tokens = {t.x_fc_engagement_token for t in ts}
self.assertEqual(len(tokens), 3)
def test_bulk_rejects_mixed_clients(self):
t1 = self._make_ticket()
# Need another client_key for the mix to be valid otherwise the
# owner-contact check fires first.
self.env['fusion.helpdesk.client.key'].create({
'client_label': 'OTHERCLIENT',
'owner_email': 'other@x.com', 'owner_name': 'Other',
})
t2 = self._make_ticket(
name='[OTHERCLIENT] x', x_fc_client_label='OTHERCLIENT')
with _patch_openai(), self.assertRaises(UserError):
self.env['fusion.helpdesk.engagement.wizard'].with_context(
default_ticket_ids=[t1.id, t2.id],
fhc_bulk=True,
).create({})
def test_bulk_rejects_already_pending_in_selection(self):
t1 = self._make_ticket()
t1._fc_reset_engagement('o@x.com', 'Owner', '') # already pending
t2 = self._make_ticket(name='[TESTCLIENT] B')
with _patch_openai(), self.assertRaises(UserError):
self.env['fusion.helpdesk.engagement.wizard'].with_context(
default_ticket_ids=[t1.id, t2.id],
fhc_bulk=True,
).create({})
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
class TestReminderCron(TestEngagementBase):
def test_reminder_fires_for_stale_pending_only(self):
# 1 stale (should be reminded), 1 recent (no reminder), 1 already
# reminded (no second reminder), 1 already-decided (no reminder).
old = fields.Datetime.now() - timedelta(days=10)
recent = fields.Datetime.now() - timedelta(hours=2)
stale = self._make_ticket()
stale._fc_reset_engagement('o@x.com', 'Owner', '')
stale.x_fc_engagement_sent_at = old
too_recent = self._make_ticket(name='[TESTCLIENT] too recent')
too_recent._fc_reset_engagement('o@x.com', 'Owner', '')
too_recent.x_fc_engagement_sent_at = recent
already_reminded = self._make_ticket(name='[TESTCLIENT] already')
already_reminded._fc_reset_engagement('o@x.com', 'Owner', '')
already_reminded.write({
'x_fc_engagement_sent_at': old,
'x_fc_engagement_reminded_at': old,
})
decided = self._make_ticket(name='[TESTCLIENT] decided')
decided._fc_reset_engagement('o@x.com', 'Owner', '')
decided.write({
'x_fc_engagement_sent_at': old,
'x_fc_engagement_state': 'approved',
})
# Default ICP is 3 days, so >=10 days qualifies.
sent = self.env['helpdesk.ticket']._fc_send_engagement_reminders()
self.assertEqual(sent, 1)
self.assertTrue(stale.x_fc_engagement_reminded_at)
self.assertFalse(too_recent.x_fc_engagement_reminded_at)
# already_reminded's reminded_at must not have moved
self.assertEqual(
already_reminded.x_fc_engagement_reminded_at, old,
)
def test_reminder_disabled_when_days_zero(self):
self.env['ir.config_parameter'].sudo().set_param(
'fusion_helpdesk_central.engagement_reminder_days', '0')
t = self._make_ticket()
t._fc_reset_engagement('o@x.com', 'Owner', '')
t.x_fc_engagement_sent_at = fields.Datetime.now() - timedelta(days=30)
sent = self.env['helpdesk.ticket']._fc_send_engagement_reminders()
self.assertEqual(sent, 0)
self.assertFalse(t.x_fc_engagement_reminded_at)
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
class TestEngagementPortal(HttpCase):
"""HTTP-layer tests for the public approve/reject portal pages."""
def setUp(self):
super().setUp()
self.env['ir.config_parameter'].sudo().set_param(
'fusion_helpdesk.bot_login', self.env.user.login,
)
self.client_key = self.env['fusion.helpdesk.client.key'].create({
'client_label': 'PORTALCLIENT',
'owner_email': 'owner@portalclient.com',
'owner_name': 'Portal Owner',
})
self.team = self.env['helpdesk.team'].create({
'name': 'Test team portal',
})
def _make_pending_ticket(self):
t = self.env['helpdesk.ticket'].create({
'name': '[PORTALCLIENT] Bug Report: portal smoke',
'team_id': self.team.id,
'x_fc_client_label': 'PORTALCLIENT',
'description': '<p>nothing fancy</p>',
})
t._fc_reset_engagement('owner@portalclient.com', 'Portal Owner', 'sm')
# Make sure cursor sees it for the public request
self.env.cr.commit()
return t
def test_get_with_valid_token_renders_confirm(self):
t = self._make_pending_ticket()
try:
r = self.url_open(
'/fusion_helpdesk/engagement/%s/approve' % t.x_fc_engagement_token,
timeout=10,
)
self.assertEqual(r.status_code, 200)
self.assertIn('Confirm Approval', r.text)
self.assertIn(t.name, r.text)
finally:
t.unlink()
self.env.cr.commit()
def test_get_with_bad_token_renders_invalid(self):
r = self.url_open(
'/fusion_helpdesk/engagement/bogus-token/approve', timeout=10,
)
self.assertEqual(r.status_code, 200)
self.assertIn('Link no longer valid', r.text)
def test_get_with_bad_decision_renders_invalid(self):
t = self._make_pending_ticket()
try:
r = self.url_open(
'/fusion_helpdesk/engagement/%s/sideways'
% t.x_fc_engagement_token, timeout=10,
)
self.assertEqual(r.status_code, 200)
self.assertIn('Link no longer valid', r.text)
finally:
t.unlink()
self.env.cr.commit()
def test_post_records_decision_and_invalidates_token(self):
t = self._make_pending_ticket()
token = t.x_fc_engagement_token
try:
r = self.url_open(
'/fusion_helpdesk/engagement/%s/approve' % token,
data={'comment': 'looks good'}, timeout=10,
)
self.assertEqual(r.status_code, 200)
self.assertIn('Approval recorded', r.text)
t.invalidate_recordset()
self.assertEqual(t.x_fc_engagement_state, 'approved')
self.assertFalse(t.x_fc_engagement_token)
# Second click on the same URL must now show the invalid page.
r2 = self.url_open(
'/fusion_helpdesk/engagement/%s/approve' % token, timeout=10,
)
self.assertIn('Link no longer valid', r2.text)
finally:
t.unlink()
self.env.cr.commit()

View File

@@ -0,0 +1,193 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
"""Pure-helper tests for fusion_helpdesk_central.utils.
No Odoo env, no network — these run in any environment with `markupsafe`
installed. Mirrors the pattern in fusion_helpdesk/tests/test_utils.py.
"""
from unittest.mock import patch
from odoo.tests import TransactionCase, tagged
from odoo.addons.fusion_helpdesk_central.utils import (
SUMMARY_PROMPT,
OPENAI_PROMPT_MAX_CHARS,
build_summary_prompt,
call_openai_chat,
format_engagement_chatter,
truncate_for_openai,
)
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
class TestBuildSummaryPrompt(TransactionCase):
def test_includes_title_description_and_messages(self):
prompt = build_summary_prompt(
'My ticket',
'Detailed description here',
[{'author': 'Alice', 'date': '2026-05-27 10:00:00',
'body_plain': 'first reply'}],
)
self.assertIn('My ticket', prompt)
self.assertIn('Detailed description here', prompt)
self.assertIn('Alice', prompt)
self.assertIn('first reply', prompt)
def test_empty_messages_becomes_explicit_marker(self):
# The model needs to know "no replies yet" — a blank section would
# invite hallucination.
prompt = build_summary_prompt('t', 'd', [])
self.assertIn('(no replies yet)', prompt)
def test_empty_title_and_description_use_fallbacks(self):
prompt = build_summary_prompt('', '', [])
self.assertIn('(untitled)', prompt)
self.assertIn('(no description)', prompt)
def test_empty_message_body_is_marked_not_dropped(self):
prompt = build_summary_prompt('t', 'd', [
{'author': 'A', 'date': 'X', 'body_plain': ''},
])
# An empty body must still produce a line so author + date context
# survives; '(empty)' marker keeps the model honest.
self.assertIn('(empty)', prompt)
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
class TestTruncateForOpenAI(TransactionCase):
def test_no_truncation_when_under_limit(self):
prompt = 'x' * 100
self.assertEqual(truncate_for_openai(prompt, max_chars=200), prompt)
def test_truncation_appends_marker_and_respects_cap(self):
prompt = 'x' * 500
out = truncate_for_openai(prompt, max_chars=200)
self.assertLessEqual(len(out), 200)
self.assertIn('truncated', out.lower())
def test_default_cap_is_8000(self):
# Regression guard — flipping this default has real $$ implications.
self.assertEqual(OPENAI_PROMPT_MAX_CHARS, 8000)
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
class TestFormatEngagementChatter(TransactionCase):
def test_approve_without_comment(self):
out = str(format_engagement_chatter('approved', 'Kris'))
self.assertIn('Approved', out)
self.assertIn('Kris', out)
self.assertIn('', out)
# No blockquote when no comment.
self.assertNotIn('blockquote', out)
def test_reject_with_comment(self):
out = str(format_engagement_chatter(
'rejected', 'Kris', comment='not in scope this quarter'))
self.assertIn('Rejected', out)
self.assertIn('', out)
self.assertIn('blockquote', out)
self.assertIn('not in scope this quarter', out)
def test_comment_html_escaped(self):
# XSS guard: a malicious comment must not inject script tags.
out = str(format_engagement_chatter(
'approved', 'Kris', comment='<script>alert(1)</script>'))
self.assertNotIn('<script>', out)
self.assertIn('&lt;script&gt;', out)
def test_owner_name_html_escaped(self):
# Same XSS guard on the owner name path.
out = str(format_engagement_chatter(
'approved', '<b>Pwn</b>'))
self.assertNotIn('<b>Pwn</b>', out)
self.assertIn('&lt;b&gt;', out)
def test_missing_owner_name_falls_back(self):
out = str(format_engagement_chatter('approved', None))
self.assertIn('the owner', out)
def test_invalid_decision_raises(self):
# Typo upstream should fail loud, not silently post a malformed line.
with self.assertRaises(ValueError):
format_engagement_chatter('maybe', 'Kris')
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
class TestCallOpenAIChat(TransactionCase):
"""OpenAI client unit tests — mock urlopen, never hit the network."""
def test_empty_api_key_returns_empty_string(self):
# No key configured? Don't even try to call OpenAI.
self.assertEqual(call_openai_chat('', 'gpt-4o-mini', 'hi'), '')
def test_empty_prompt_returns_empty_string(self):
self.assertEqual(call_openai_chat('sk-x', 'gpt-4o-mini', ''), '')
def test_happy_path_returns_message_content(self):
fake_response = type('R', (), {})()
fake_response.read = lambda: (
b'{"choices":[{"message":{"content":"bullet one\\nbullet two"}}]}'
)
fake_response.__enter__ = lambda s: s
fake_response.__exit__ = lambda *a: None
with patch(
'odoo.addons.fusion_helpdesk_central.utils.urllib.request.urlopen',
return_value=fake_response,
):
out = call_openai_chat('sk-x', 'gpt-4o-mini', 'prompt')
self.assertEqual(out, 'bullet one\nbullet two')
def test_network_error_returns_empty(self):
# All exceptions must collapse to '' so the wizard's fallback fires.
import urllib.error
with patch(
'odoo.addons.fusion_helpdesk_central.utils.urllib.request.urlopen',
side_effect=urllib.error.URLError('boom'),
):
self.assertEqual(call_openai_chat('sk-x', 'm', 'p'), '')
def test_http_error_returns_empty(self):
import urllib.error
with patch(
'odoo.addons.fusion_helpdesk_central.utils.urllib.request.urlopen',
side_effect=urllib.error.HTTPError(
'url', 429, 'rate limited', {}, None,
),
):
self.assertEqual(call_openai_chat('sk-x', 'm', 'p'), '')
def test_malformed_json_returns_empty(self):
fake_response = type('R', (), {})()
fake_response.read = lambda: b'not json at all'
fake_response.__enter__ = lambda s: s
fake_response.__exit__ = lambda *a: None
with patch(
'odoo.addons.fusion_helpdesk_central.utils.urllib.request.urlopen',
return_value=fake_response,
):
self.assertEqual(call_openai_chat('sk-x', 'm', 'p'), '')
def test_empty_choices_returns_empty(self):
fake_response = type('R', (), {})()
fake_response.read = lambda: b'{"choices":[]}'
fake_response.__enter__ = lambda s: s
fake_response.__exit__ = lambda *a: None
with patch(
'odoo.addons.fusion_helpdesk_central.utils.urllib.request.urlopen',
return_value=fake_response,
):
self.assertEqual(call_openai_chat('sk-x', 'm', 'p'), '')
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
class TestPromptConstant(TransactionCase):
def test_summary_prompt_has_required_placeholders(self):
# The wizard calls .format(name=, description_plain=, messages_plain=)
# — silently dropping any of these would yield a useless prompt.
for placeholder in ('{name}', '{description_plain}', '{messages_plain}'):
self.assertIn(placeholder, SUMMARY_PROMPT)

View File

@@ -0,0 +1,168 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
"""Pure helpers for the owner-approval engagement flow.
No Odoo environment, no `request`, no network — just data in, data out.
Everything here is unit-testable in isolation, which is the same pattern
fusion_helpdesk/utils.py already follows. The OpenAI client lives in
`openai_client.py` so this file stays IO-free and trivially testable.
"""
import json
import logging
import urllib.error
import urllib.request
from markupsafe import Markup, escape
_logger = logging.getLogger(__name__)
# Frozen as a Python constant rather than an ICP key — the prompt is
# load-bearing on output quality and we want every change reviewed in git.
# Use {placeholders} to template; see build_summary_prompt for the fill.
SUMMARY_PROMPT = """You are summarising a customer support ticket for a busy executive
who needs to decide whether to approve the work.
Output rules:
- 4-6 short bullet points, plain text (no markdown).
- First bullet: the ask, in one sentence.
- Second bullet: the business impact if approved.
- Third bullet: the business impact if NOT approved (or "none material").
- Optional bullets: cost / effort signals if any are mentioned.
- Final bullet: open questions the approver should think about.
- Do not invent facts. If the thread doesn't say, write "not stated".
- No greetings, no sign-offs, no preamble.
Ticket title: {name}
Original report:
{description_plain}
Replies so far:
{messages_plain}
"""
# Bound the prompt sent to OpenAI so a runaway thread doesn't blow cost or
# context. 8000 chars * ~0.25 tokens/char ~= 2000 tokens, well under any model
# cap and cheap on gpt-4o-mini.
OPENAI_PROMPT_MAX_CHARS = 8000
def build_summary_prompt(ticket_name, description_plain, messages):
"""Render SUMMARY_PROMPT with ticket + chatter content.
`messages` is a list of dicts with at minimum {author, date, body_plain}.
We render one line per message so the model can attribute who said what.
Empty inputs collapse to "(none)" so the prompt never has bare blanks
that the model could interpret as a missing section.
"""
name = (ticket_name or '').strip() or '(untitled)'
desc = (description_plain or '').strip() or '(no description)'
if messages:
lines = []
for m in messages:
author = (m.get('author') or 'unknown').strip()
date = (m.get('date') or '').strip()
body = (m.get('body_plain') or '').strip() or '(empty)'
lines.append('%s (%s): %s' % (author, date, body))
msgs = '\n---\n'.join(lines)
else:
msgs = '(no replies yet)'
return SUMMARY_PROMPT.format(
name=name, description_plain=desc, messages_plain=msgs,
)
def truncate_for_openai(prompt, max_chars=OPENAI_PROMPT_MAX_CHARS):
"""Cap prompt length so a 50-message thread can't run up the bill.
Truncates from the END (keeps the title + description + earliest messages),
appending a marker so the model knows context was cut. Earliest replies
are usually the most relevant for an approval decision; later replies tend
to be operational chatter.
"""
if len(prompt) <= max_chars:
return prompt
marker = '\n\n[...truncated for length...]'
keep = max_chars - len(marker)
if keep <= 0:
return marker[:max_chars]
return prompt[:keep] + marker
def format_engagement_chatter(decision, owner_name, comment=None):
"""Build the chatter message posted on the ticket when the owner decides.
Returns Markup so message_post renders it as HTML (not escaped). Decision
must be 'approved' or 'rejected' — anything else raises ValueError so a
typo upstream fails loud instead of posting a half-rendered message.
"""
if decision == 'approved':
prefix, label = '', 'Approved'
elif decision == 'rejected':
prefix, label = '', 'Rejected'
else:
raise ValueError(
'format_engagement_chatter: decision must be "approved" or '
'"rejected", got %r' % (decision,)
)
name = escape((owner_name or '').strip() or 'the owner')
text = (comment or '').strip()
body = '<p><b>%s %s by %s</b></p>' % (prefix, label, name)
if text:
body += '<blockquote style="margin:6px 0 0 0; padding:6px 12px; ' \
'border-left:3px solid #d8dadd; color:#444;"><i>%s</i></blockquote>' \
% escape(text).replace('\n', '<br/>')
return Markup(body)
def call_openai_chat(api_key, model, prompt, timeout=15):
"""Single non-streaming call to OpenAI's chat-completions endpoint.
Returns the response text on success, or '' on any failure (network,
timeout, non-2xx, malformed JSON, empty choices). Never raises — the
caller treats '' as "AI unavailable" and shows the manual-summary
fallback in the wizard.
Uses urllib.request from the stdlib so we don't add a pip dependency
just for one HTTP call. JSON shape per OpenAI's chat-completions spec
as of 2026: messages, model, response choices[0].message.content.
"""
if not api_key or not prompt:
return ''
payload = json.dumps({
'model': model or 'gpt-4o-mini',
'messages': [{'role': 'user', 'content': prompt}],
'temperature': 0.2, # we want deterministic-ish summaries
}).encode('utf-8')
req = urllib.request.Request(
'https://api.openai.com/v1/chat/completions',
data=payload,
headers={
'Authorization': 'Bearer %s' % api_key,
'Content-Type': 'application/json',
},
method='POST',
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.loads(resp.read().decode('utf-8'))
except urllib.error.HTTPError as e:
_logger.warning(
'fusion_helpdesk_central: OpenAI returned HTTP %s', e.code,
)
return ''
except (urllib.error.URLError, TimeoutError, OSError) as e:
_logger.warning(
'fusion_helpdesk_central: OpenAI network error: %s', e,
)
return ''
except (ValueError, json.JSONDecodeError) as e:
_logger.warning(
'fusion_helpdesk_central: OpenAI returned malformed JSON: %s', e,
)
return ''
choices = (data or {}).get('choices') or []
if not choices:
return ''
content = (choices[0].get('message') or {}).get('content') or ''
return content.strip()

View File

@@ -0,0 +1,143 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1
Reporting dashboard for owner engagements. No new model — every view
is over helpdesk.ticket filtered to records where engagement_state is
not 'none'. The stored computed field x_fc_engagement_turnaround_hours
makes the pivot's average measure free.
-->
<odoo>
<!-- Pivot: rows = client, columns = state, measure = count + avg turnaround. -->
<record id="view_engagement_pivot" model="ir.ui.view">
<field name="name">fhc.engagement.pivot</field>
<field name="model">helpdesk.ticket</field>
<field name="arch" type="xml">
<pivot string="Owner Engagements" sample="1">
<field name="x_fc_client_label" type="row"/>
<field name="x_fc_engagement_state" type="col"/>
<field name="x_fc_engagement_turnaround_hours" type="measure"/>
</pivot>
</field>
</record>
<record id="view_engagement_graph" model="ir.ui.view">
<field name="name">fhc.engagement.graph</field>
<field name="model">helpdesk.ticket</field>
<field name="arch" type="xml">
<graph string="Owner Engagements" type="bar" sample="1">
<field name="x_fc_engagement_sent_at" interval="month"/>
<field name="x_fc_client_label" type="col"/>
</graph>
</field>
</record>
<record id="view_engagement_list" model="ir.ui.view">
<field name="name">fhc.engagement.list</field>
<field name="model">helpdesk.ticket</field>
<field name="arch" type="xml">
<list string="Owner Engagements" create="0" sample="1">
<field name="ticket_ref" optional="show"/>
<field name="name"/>
<field name="x_fc_client_label" string="Client"/>
<field name="x_fc_engagement_name" string="Owner"/>
<field name="x_fc_engagement_email" string="Owner Email" optional="show"/>
<field name="x_fc_engagement_state" widget="badge"
decoration-warning="x_fc_engagement_state == 'pending'"
decoration-success="x_fc_engagement_state == 'approved'"
decoration-danger="x_fc_engagement_state == 'rejected'"/>
<field name="x_fc_engagement_sent_at"/>
<field name="x_fc_engagement_reminded_at" optional="hide"/>
<field name="x_fc_engagement_decided_at" optional="show"/>
<field name="x_fc_engagement_turnaround_hours" string="Turnaround (h)"/>
</list>
</field>
</record>
<record id="view_engagement_kanban" model="ir.ui.view">
<field name="name">fhc.engagement.kanban</field>
<field name="model">helpdesk.ticket</field>
<field name="arch" type="xml">
<kanban default_group_by="x_fc_engagement_state" create="0"
sample="1" group_create="0" group_edit="0" group_delete="0">
<field name="name"/>
<field name="x_fc_client_label"/>
<field name="x_fc_engagement_name"/>
<field name="x_fc_engagement_sent_at"/>
<field name="x_fc_engagement_turnaround_hours"/>
<templates>
<t t-name="card">
<div class="o_kanban_record_top">
<strong><field name="name"/></strong>
</div>
<div>
<span class="text-muted"><field name="x_fc_client_label"/></span>
·
<field name="x_fc_engagement_name"/>
</div>
<div class="text-muted small">
sent <field name="x_fc_engagement_sent_at"/>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="view_engagement_search" model="ir.ui.view">
<field name="name">fhc.engagement.search</field>
<field name="model">helpdesk.ticket</field>
<field name="arch" type="xml">
<search string="Owner Engagements">
<field name="name"/>
<field name="x_fc_client_label"/>
<field name="x_fc_engagement_email"/>
<separator/>
<filter string="Pending" name="state_pending"
domain="[('x_fc_engagement_state', '=', 'pending')]"/>
<filter string="Approved" name="state_approved"
domain="[('x_fc_engagement_state', '=', 'approved')]"/>
<filter string="Rejected" name="state_rejected"
domain="[('x_fc_engagement_state', '=', 'rejected')]"/>
<separator/>
<filter string="Pending &gt; 7 days" name="stuck"
domain="[('x_fc_engagement_state', '=', 'pending'),
('x_fc_engagement_sent_at', '&lt;=',
(context_today() - relativedelta(days=7)).strftime('%Y-%m-%d %H:%M:%S'))]"/>
<separator/>
<!-- Odoo 19: <group expand="0">…</group> is no longer valid
in search views. Group-By filters work directly. -->
<filter string="Group by Client" name="group_client"
context="{'group_by': 'x_fc_client_label'}"/>
<filter string="Group by State" name="group_state"
context="{'group_by': 'x_fc_engagement_state'}"/>
<filter string="Group by Sent (month)" name="group_sent_month"
context="{'group_by': 'x_fc_engagement_sent_at:month'}"/>
</search>
</field>
</record>
<record id="action_owner_engagements" model="ir.actions.act_window">
<field name="name">Owner Engagements</field>
<field name="res_model">helpdesk.ticket</field>
<field name="view_mode">pivot,graph,list,kanban,form</field>
<field name="search_view_id" ref="view_engagement_search"/>
<field name="domain">[('x_fc_engagement_state', '!=', 'none')]</field>
<field name="context">{
'search_default_state_pending': 1
}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">No owner engagements yet.</p>
<p>Click "Request Owner Approval" on any in-app ticket to start one.</p>
</field>
</record>
<menuitem id="menu_owner_engagements"
name="Owner Engagements"
parent="helpdesk.helpdesk_ticket_report_menu_main"
action="action_owner_engagements"
sequence="50"/>
</odoo>

View File

@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1
The owner-approval engagement wizard, opened from the ticket form
button OR the list-view bulk server action. Branches on `mode` to
show either the single-ticket layout (one AI summary field) or the
bulk layout (an editable line per ticket).
-->
<odoo>
<record id="view_engagement_wizard_form" model="ir.ui.view">
<field name="name">fusion.helpdesk.engagement.wizard.form</field>
<field name="model">fusion.helpdesk.engagement.wizard</field>
<field name="arch" type="xml">
<form string="Request Owner Approval">
<field name="mode" invisible="1"/>
<field name="ticket_id" invisible="1"/>
<group>
<group>
<field name="owner_name_display" string="Owner"/>
<field name="owner_email_display" string="Owner Email" widget="email"/>
</group>
</group>
<div class="alert alert-warning" role="alert"
invisible="not ai_unavailable">
<strong>AI summary unavailable.</strong>
OpenAI didn't return a summary (no API key set, rate limit,
or network error). Write a quick brief below before sending —
everything else still works.
</div>
<!-- Single mode: one summary field for the one ticket. -->
<group invisible="mode != 'single'">
<field name="personal_note"
placeholder="One-line note that appears above the summary in the email…"/>
<field name="ai_summary" string="Summary to send"
placeholder="Bullet-point summary that the owner will read first." />
</group>
<!-- Bulk mode: per-ticket lines, each with its own summary. -->
<group invisible="mode != 'bulk'">
<field name="personal_note"
placeholder="One-line note that appears once at the top of the combined email…"/>
</group>
<field name="line_ids" invisible="mode != 'bulk'" nolabel="1">
<list editable="bottom" create="0" delete="0">
<field name="ticket_id" readonly="1"/>
<field name="ticket_name" readonly="1"/>
<field name="ai_summary"/>
</list>
</field>
<footer>
<button name="action_send" type="object"
string="Send Engagement"
class="btn-primary"/>
<button special="cancel" string="Cancel"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -27,8 +27,100 @@
<field name="x_fc_client_label"/>
<filter string="Client Deployment" name="group_client_label"
context="{'group_by': 'x_fc_client_label'}"/>
<separator/>
<filter string="Awaiting Owner Approval" name="fhc_pending_engagement"
domain="[('x_fc_engagement_state', '=', 'pending')]"/>
<filter string="Owner Approved" name="fhc_approved_engagement"
domain="[('x_fc_engagement_state', '=', 'approved')]"/>
<filter string="Owner Rejected" name="fhc_rejected_engagement"
domain="[('x_fc_engagement_state', '=', 'rejected')]"/>
</xpath>
</field>
</record>
<!--
Inherited ticket form. Adds:
* Header button "Request Owner Approval" (form-button entry into
the wizard). Disabled when there's no client_label OR no owner
contact on the client_key — see the action_open_engagement_wizard
method which raises a UserError with the same explanation.
* State pill in the title row showing pending / approved / rejected.
* Collapsible "Owner Engagement" group with the audit fields.
Inherited views in Odoo 19 cannot carry `groups`/`group_ids` on the
record (raises ParseError); per-node `groups=` attributes are fine.
-->
<record id="fhc_ticket_form_engagement" model="ir.ui.view">
<field name="name">fhc.helpdesk.ticket.form.engagement</field>
<field name="model">helpdesk.ticket</field>
<field name="inherit_id" ref="helpdesk.helpdesk_ticket_view_form"/>
<field name="arch" type="xml">
<!-- Header button + state badge. Placed at the top of the
header so it's the first thing support sees. -->
<xpath expr="//header" position="inside">
<button name="action_open_engagement_wizard"
type="object"
string="Request Owner Approval"
class="oe_highlight"
invisible="not x_fc_client_label or x_fc_engagement_state == 'pending'"
groups="base.group_user"/>
<button name="action_open_engagement_wizard"
type="object"
string="Re-engage Owner"
invisible="x_fc_engagement_state != 'pending'"
groups="base.group_user"/>
<field name="x_fc_engagement_state" widget="statusbar"
invisible="x_fc_engagement_state == 'none'"
statusbar_visible="pending,approved,rejected"/>
</xpath>
<!-- Collapsible Owner Engagement page on the notebook. -->
<xpath expr="//notebook" position="inside">
<page string="Owner Engagement"
name="fhc_engagement_page"
invisible="x_fc_engagement_state == 'none'">
<group>
<group>
<field name="x_fc_engagement_state" readonly="1"/>
<field name="x_fc_engagement_name" readonly="1"/>
<field name="x_fc_engagement_email" readonly="1" widget="email"/>
</group>
<group>
<field name="x_fc_engagement_sent_at" readonly="1"/>
<field name="x_fc_engagement_reminded_at" readonly="1"/>
<field name="x_fc_engagement_decided_at" readonly="1"/>
<field name="x_fc_engagement_turnaround_hours" readonly="1"/>
</group>
</group>
<separator string="AI Summary (snapshotted at engagement)"/>
<field name="x_fc_ai_summary" readonly="1" nolabel="1"/>
</page>
</xpath>
</field>
</record>
<!--
Kanban dot on the main Helpdesk kanban deferred — the underlying
view's structure varies between Helpdesk versions and reaching into
it with xpath is fragile. The Reporting → Owner Engagements kanban
(grouped by state) gives the same at-a-glance signal until we have
a stable hook in helpdesk's main kanban.
-->
<!--
Server action — bulk "Request Owner Approval" available from the
helpdesk.ticket list view. Validation happens in the wizard's
default_get; the server action just opens the wizard with the
selection in context.
-->
<record id="action_engagement_bulk" model="ir.actions.server">
<field name="name">Request Owner Approval (Bulk)</field>
<field name="model_id" ref="helpdesk.model_helpdesk_ticket"/>
<field name="binding_model_id" ref="helpdesk.model_helpdesk_ticket"/>
<field name="binding_view_types">list,kanban</field>
<field name="state">code</field>
<field name="code">action = model.action_open_engagement_wizard_bulk()</field>
</record>
</odoo>

View File

@@ -0,0 +1,132 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1
Minimal branded portal pages for the owner-approval flow. NOT using the
heavy portal.frontend_layout — the owner sees this once on their phone
and we want sub-200ms render, so each page is a self-contained HTML doc
with inline CSS. Branding kept consistent with the ack email's button
style (same blue, same border-radius).
-->
<odoo>
<!-- Shared shell: page wrapper, header strip, inline CSS reset. The
DOCTYPE is omitted — Odoo's QWeb XML parser rejects it inside
<template>, and modern browsers handle no-doctype pages fine for
a minimal confirmation page like this. Inline CSS keeps the
page self-contained, no external bundles to load. -->
<template id="engagement_layout" name="Owner Engagement Layout">
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Nexa Systems Support</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: Arial, Helvetica, sans-serif; color: #21252b; background: #f3f4f6; -webkit-font-smoothing: antialiased; }
.fhc-header { background: #1e3a5f; color: #fff; padding: 14px 22px; font-weight: 700; letter-spacing: 0.02em; }
.fhc-card { max-width: 640px; margin: 28px auto; background: #fff; border: 1px solid #d8dadd; border-radius: 8px; padding: 22px 26px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
.fhc-title { font-size: 1.15rem; font-weight: 700; margin: 0 0 6px 0; }
.fhc-meta { color: #6c757d; font-size: 0.85rem; margin-bottom: 14px; }
.fhc-section-h { font-size: 0.78rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #6c757d; margin: 16px 0 6px 0; }
.fhc-summary { white-space: pre-wrap; line-height: 1.5; background: #f9fafb; padding: 12px 14px; border-radius: 6px; border: 1px solid #e5e7eb; font-size: 0.92rem; }
.fhc-comment { width: 100%; min-height: 90px; padding: 8px 10px; font-family: inherit; font-size: 0.95rem; border: 1px solid #d8dadd; border-radius: 6px; resize: vertical; }
.fhc-btn { display: inline-block; padding: 12px 26px; font-size: 1rem; font-weight: 700; border-radius: 6px; border: 0; color: #fff; cursor: pointer; }
.fhc-btn-approve { background: linear-gradient(135deg, #5cc66f 0%, #28a745 100%); }
.fhc-btn-reject { background: linear-gradient(135deg, #e85d68 0%, #dc3545 100%); }
.fhc-actions { display: flex; gap: 10px; margin-top: 14px; }
.fhc-foot { color: #9aa3ad; font-size: 0.78rem; text-align: center; margin-top: 18px; }
.fhc-bad { color: #b02a37; }
.fhc-good { color: #1e7e34; }
</style>
</head>
<body>
<div class="fhc-header">Nexa Systems Support</div>
<div class="fhc-card">
<t t-out="0"/>
</div>
<div class="fhc-foot">If this link doesn't look right, contact <a href="mailto:support@nexasystems.ca">support@nexasystems.ca</a>.</div>
</body>
</html>
</template>
<!-- Confirmation page: owner has clicked the magic link and we want
one last confirmation + optional comment before recording the
decision. POST goes back to the same URL. -->
<template id="engagement_confirm" name="Engagement Confirm">
<t t-call="fusion_helpdesk_central.engagement_layout">
<div class="fhc-title" t-esc="ticket.name"/>
<div class="fhc-meta">
Confirm your decision on this request — your name and any
comment below will be posted on the ticket so the team sees
it immediately.
</div>
<div class="fhc-section-h">Summary</div>
<div class="fhc-summary" t-esc="ticket.x_fc_ai_summary or 'No AI summary was attached to this request.'"/>
<form method="POST" action="">
<div class="fhc-section-h">Comment (optional)</div>
<textarea class="fhc-comment" name="comment"
placeholder="Add a note for the team (e.g. 'go ahead, this fits Q2 budget')"/>
<div class="fhc-actions">
<t t-if="decision_state == 'approved'">
<button class="fhc-btn fhc-btn-approve" type="submit">✓ Confirm Approval</button>
</t>
<t t-else="">
<button class="fhc-btn fhc-btn-reject" type="submit">✗ Confirm Rejection</button>
</t>
</div>
</form>
</t>
</template>
<!-- Done: decision recorded. Owner sees a friendly receipt. -->
<template id="engagement_done" name="Engagement Done">
<t t-call="fusion_helpdesk_central.engagement_layout">
<t t-if="decision_state == 'approved'">
<div class="fhc-title fhc-good">✓ Approval recorded</div>
</t>
<t t-else="">
<div class="fhc-title fhc-bad">✗ Rejection recorded</div>
</t>
<div class="fhc-meta">
Thanks — your decision on
"<b><t t-esc="ticket.name"/></b>"
has been posted to the ticket and the team has been notified.
You can safely close this tab. If you also received a bulk
email with multiple requests, come back to it and decide on
the others independently.
</div>
</t>
</template>
<!-- Invalid: token unknown, already used, or wrong state. -->
<template id="engagement_invalid" name="Engagement Invalid">
<t t-call="fusion_helpdesk_central.engagement_layout">
<div class="fhc-title fhc-bad">Link no longer valid</div>
<div class="fhc-meta">
This approval link has already been used, was replaced by a
newer request, or is otherwise no longer active.
If you think this is a mistake, please contact
<a href="mailto:support@nexasystems.ca">support@nexasystems.ca</a>
and we'll sort it out.
</div>
</t>
</template>
<!-- Server-side error -->
<template id="engagement_error" name="Engagement Error">
<t t-call="fusion_helpdesk_central.engagement_layout">
<div class="fhc-title fhc-bad">Something went wrong</div>
<div class="fhc-meta">
We couldn't record your decision because of an unexpected
error on our side. Please try the link again in a minute —
if it still fails, reply to the original email or contact
<a href="mailto:support@nexasystems.ca">support@nexasystems.ca</a>.
</div>
</t>
</template>
</odoo>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,362 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Flowchart runtime - one fusion.repair.flowchart.run per CS troubleshooting
session, with one fusion.repair.flowchart.run.step per node the rep visited.
The transcript is the audit + handoff artefact: when CS resolves on-call we
post it to chatter; when they escalate we copy it to internal_notes so the
dispatched tech can see what was tried BEFORE arriving at the client.
"""
import logging
from markupsafe import Markup
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
RUN_OUTCOMES = [
('in_progress', 'In Progress'),
('resolved', 'Resolved on Call'),
('escalated', 'Escalated to Technician'),
('order_part', 'Captured Part Order'),
('abandoned', 'Abandoned'),
]
class FusionRepairFlowchartRun(models.Model):
_name = 'fusion.repair.flowchart.run'
_inherit = ['mail.thread']
_description = 'Flowchart Troubleshooting Run'
_order = 'started_at desc, id desc'
name = fields.Char(
compute='_compute_name',
store=True,
)
repair_order_id = fields.Many2one(
'repair.order',
required=True,
index=True,
ondelete='cascade',
)
flowchart_id = fields.Many2one(
'fusion.repair.flowchart',
required=True,
index=True,
ondelete='restrict',
)
flowchart_version = fields.Integer(
string='Chart Version Snapshot',
help='Version of the flowchart at the time the run was started; '
'so the transcript stays valid even if the chart is later edited.',
)
partner_id = fields.Many2one(
related='repair_order_id.partner_id', store=True, readonly=True,
)
started_at = fields.Datetime(default=fields.Datetime.now, required=True)
started_by_id = fields.Many2one(
'res.users', default=lambda self: self.env.user, required=True,
)
completed_at = fields.Datetime()
completed_by_id = fields.Many2one('res.users')
outcome = fields.Selection(
RUN_OUTCOMES,
default='in_progress',
required=True,
tracking=True,
)
step_ids = fields.One2many(
'fusion.repair.flowchart.run.step', 'run_id',
string='Steps',
)
step_count = fields.Integer(compute='_compute_step_count')
current_node_id = fields.Many2one(
'fusion.repair.flowchart.node',
compute='_compute_current_node',
store=True,
help='Where the runner is right now - last step\'s node if no edge '
'was chosen yet, otherwise the edge\'s target node.',
)
transcript_html = fields.Html(
compute='_compute_transcript',
sanitize=False, # we control the markup
)
company_id = fields.Many2one(
'res.company',
default=lambda self: self.env.company,
)
@api.depends('repair_order_id.name', 'flowchart_id.name')
def _compute_name(self):
for r in self:
r.name = _('%s on %s') % (
r.flowchart_id.name or '?', r.repair_order_id.name or '?',
)
@api.depends('step_ids')
def _compute_step_count(self):
for r in self:
r.step_count = len(r.step_ids)
@api.depends('step_ids.chosen_edge_id', 'step_ids.node_id',
'flowchart_id.start_node_id')
def _compute_current_node(self):
for r in self:
if not r.step_ids:
r.current_node_id = r.flowchart_id.start_node_id
continue
last = r.step_ids[-1]
# If they chose an edge on the last step, we should already be
# showing the target. If not (just landed on a card), it's the
# current node itself.
if last.chosen_edge_id:
r.current_node_id = last.chosen_edge_id.target_node_id
else:
r.current_node_id = last.node_id
@api.depends('step_ids.node_name_snapshot', 'step_ids.chosen_label_snapshot',
'step_ids.cs_note', 'outcome')
def _compute_transcript(self):
for r in self:
if not r.step_ids:
r.transcript_html = '<p><em>No steps recorded yet.</em></p>'
continue
lines = ['<ol style="margin:0 0 8px 1.2em;padding:0;">']
for s in r.step_ids:
line = f'<li><strong>{s.node_name_snapshot or "?"}</strong>'
if s.chosen_label_snapshot:
line += f' &rarr; <em>{s.chosen_label_snapshot}</em>'
if s.cs_note:
line += f' <span style="color:#666;">(note: {s.cs_note})</span>'
line += '</li>'
lines.append(line)
lines.append('</ol>')
outcome_label = dict(self._fields['outcome'].selection).get(r.outcome, r.outcome)
lines.append(f'<p><strong>Outcome:</strong> {outcome_label}</p>')
r.transcript_html = ''.join(lines)
# ------------------------------------------------------------------
# RUNTIME RPC API (called from the OWL runner component)
# ------------------------------------------------------------------
@api.model
def runtime_start_or_resume(self, repair_id):
"""Open or resume a troubleshooting run for the given repair.
- If an in-progress run exists, return it.
- Otherwise resolve the flowchart from the repair's (category, symptom)
and create a fresh run starting at the chart's start node.
Returns the dict shape that the OWL runner consumes.
"""
repair = self.env['repair.order'].browse(repair_id).exists()
if not repair:
raise UserError(_('Repair order not found.'))
existing = self.search([
('repair_order_id', '=', repair.id),
('outcome', '=', 'in_progress'),
], limit=1)
if existing:
return existing._runtime_dict()
if not repair.x_fc_symptom_class_id:
raise UserError(_('Pick a Symptom on the repair before starting '
'troubleshooting.'))
chart = self.env['fusion.repair.flowchart'].sudo().search([
('category_id', '=', repair.x_fc_repair_category_id.id),
('symptom_class_id', '=', repair.x_fc_symptom_class_id.id),
('published', '=', True),
('active', '=', True),
], limit=1)
if not chart:
raise UserError(_(
'No published troubleshooting flowchart found for '
'%(cat)s + %(sym)s. Ask a manager to publish one in '
'Configuration > Troubleshooting Flowcharts.'
) % {
'cat': repair.x_fc_repair_category_id.name or '?',
'sym': repair.x_fc_symptom_class_id.name or '?',
})
run = self.sudo().create({
'repair_order_id': repair.id,
'flowchart_id': chart.id,
'flowchart_version': chart.version,
})
return run._runtime_dict()
def runtime_choose(self, edge_id, cs_note=''):
"""CS clicked an answer button on the current card.
Records a step (current_node + chosen_edge + note) and returns the
updated run dict (which the OWL component uses to render the next card).
"""
self.ensure_one()
if self.outcome != 'in_progress':
raise UserError(_('This run has already been completed.'))
edge = self.env['fusion.repair.flowchart.edge'].browse(edge_id).exists()
if not edge:
raise UserError(_('Chosen answer not found.'))
if edge.source_node_id != self.current_node_id:
raise UserError(_(
'That answer does not belong to the current step. The chart '
'may have been edited - please reload the runner.'
))
self.env['fusion.repair.flowchart.run.step'].sudo().create({
'run_id': self.id,
'sequence': len(self.step_ids) + 1,
'node_id': self.current_node_id.id,
'node_name_snapshot': self.current_node_id.name,
'chosen_edge_id': edge.id,
'chosen_label_snapshot': edge.label,
'cs_note': cs_note or False,
})
# current_node_id auto-recomputes via depends
self.invalidate_recordset(['current_node_id'])
return self._runtime_dict()
def runtime_complete(self, outcome, cs_note=''):
"""CS hit a terminal outcome (resolved / escalate / order_part) OR
manually abandoned. Closes the run and triggers downstream effects."""
self.ensure_one()
valid = {'resolved', 'escalated', 'order_part', 'abandoned'}
if outcome not in valid:
raise UserError(_('Unknown outcome %s.') % outcome)
if self.outcome != 'in_progress':
return self._runtime_dict()
# Snapshot the final node as a step so the transcript shows it.
node = self.current_node_id
if node and (not self.step_ids or self.step_ids[-1].node_id != node):
self.env['fusion.repair.flowchart.run.step'].sudo().create({
'run_id': self.id,
'sequence': len(self.step_ids) + 1,
'node_id': node.id,
'node_name_snapshot': node.name,
'cs_note': cs_note or False,
})
self.write({
'outcome': outcome,
'completed_at': fields.Datetime.now(),
'completed_by_id': self.env.uid,
})
# Dispatch the side-effects on the repair (see _apply_outcome).
self._apply_outcome()
return self._runtime_dict()
def _apply_outcome(self):
"""Run the side-effects on the parent repair depending on outcome."""
for r in self:
repair = r.repair_order_id
transcript = Markup(r.transcript_html or '')
if r.outcome == 'resolved':
repair.x_fc_resolved_on_call = True
repair.message_post(body=Markup(_(
'<p><b>Resolved on call</b> via troubleshooting flowchart '
'<i>%(name)s</i>:</p>%(transcript)s'
)) % {'name': r.flowchart_id.name, 'transcript': transcript})
# Close the repair through the Odoo state machine. Reuse the
# visit-report wizard helper pattern.
try:
if repair.state == 'draft':
try:
repair.action_validate()
except Exception:
repair._action_repair_confirm()
if repair.state == 'confirmed':
repair.action_repair_start()
if repair.state == 'under_repair':
repair.action_repair_end()
except Exception as e:
_logger.warning(
'Could not auto-close repair %s after on-call '
'resolution: %s', repair.name, e,
)
elif r.outcome == 'escalated':
# Prepend the transcript to internal_notes so the tech sees
# what was tried at the top of the form.
existing = repair.internal_notes or ''
header = Markup(
'<p><b>CS troubleshooting summary (flowchart %s):</b></p>'
) % r.flowchart_id.name
repair.internal_notes = (header + transcript +
Markup('<hr/>') + Markup(existing))
repair.message_post(body=Markup(_(
'<p><b>Escalated to technician</b> via troubleshooting '
'flowchart <i>%(name)s</i>:</p>%(transcript)s'
)) % {'name': r.flowchart_id.name, 'transcript': transcript})
elif r.outcome == 'order_part':
repair.message_post(body=Markup(_(
'<p><b>Part order needed</b> per troubleshooting flowchart '
'<i>%(name)s</i>:</p>%(transcript)s'
)) % {'name': r.flowchart_id.name, 'transcript': transcript})
else: # abandoned
repair.message_post(body=Markup(_(
'<p>Troubleshooting run abandoned by %(user)s.</p>'
'%(transcript)s'
)) % {
'user': self.env.user.name,
'transcript': transcript,
})
def _runtime_dict(self):
"""Serialise the run + current node + available edges for the OWL UI."""
self.ensure_one()
node = self.current_node_id
outgoing = self.env['fusion.repair.flowchart.edge'].sudo().search([
('source_node_id', '=', node.id),
], order='sequence, id') if node else self.env['fusion.repair.flowchart.edge']
return {
'run_id': self.id,
'repair_id': self.repair_order_id.id,
'repair_name': self.repair_order_id.name,
'partner_name': self.partner_id.name or '',
'flowchart_id': self.flowchart_id.id,
'flowchart_name': self.flowchart_id.name,
'canvas_layout': self.flowchart_id.canvas_layout or '',
'outcome': self.outcome,
'current_node': node._designer_dict() if node else None,
'options': [{
'edge_id': e.id,
'label': e.label or _('Continue'),
'target_node_id': e.target_node_id.id,
'target_node_type': e.target_node_id.node_type,
'target_outcome_kind': e.target_node_id.outcome_kind or '',
} for e in outgoing],
'transcript_html': self.transcript_html,
'visited_node_ids': [s.node_id.id for s in self.step_ids if s.node_id],
'visited_edge_ids': [s.chosen_edge_id.id for s in self.step_ids if s.chosen_edge_id],
'step_count': self.step_count,
}
class FusionRepairFlowchartRunStep(models.Model):
_name = 'fusion.repair.flowchart.run.step'
_description = 'Flowchart Run Step'
_order = 'run_id, sequence, id'
run_id = fields.Many2one(
'fusion.repair.flowchart.run',
required=True,
index=True,
ondelete='cascade',
)
sequence = fields.Integer(default=10)
node_id = fields.Many2one(
'fusion.repair.flowchart.node',
ondelete='set null',
index=True,
)
node_name_snapshot = fields.Char(
help='Frozen at the moment the step was recorded so transcript '
'survives later chart edits.',
)
chosen_edge_id = fields.Many2one(
'fusion.repair.flowchart.edge',
ondelete='set null',
)
chosen_label_snapshot = fields.Char()
cs_note = fields.Text()
recorded_at = fields.Datetime(default=fields.Datetime.now)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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