Compare commits
2 Commits
4acf9d7f85
...
396170b438
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
396170b438 | ||
|
|
eb186cac3c |
@@ -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.',
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.',
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import models
|
||||
from . import controllers
|
||||
|
||||
@@ -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,
|
||||
|
||||
2
fusion_helpdesk_central/controllers/__init__.py
Normal file
2
fusion_helpdesk_central/controllers/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import engagement
|
||||
147
fusion_helpdesk_central/controllers/engagement.py
Normal file
147
fusion_helpdesk_central/controllers/engagement.py
Normal 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
|
||||
@@ -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>
|
||||
|
||||
30
fusion_helpdesk_central/data/ir_cron_engagement_reminder.xml
Normal file
30
fusion_helpdesk_central/data/ir_cron_engagement_reminder.xml
Normal 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>
|
||||
169
fusion_helpdesk_central/data/mail_template_engagement.xml
Normal file
169
fusion_helpdesk_central/data/mail_template_engagement.xml
Normal 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 & 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>
|
||||
@@ -1,3 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import fusion_helpdesk_client_key
|
||||
from . import helpdesk_ticket
|
||||
from . import engagement_wizard
|
||||
|
||||
380
fusion_helpdesk_central/models/engagement_wizard.py
Normal file
380
fusion_helpdesk_central/models/engagement_wizard.py
Normal 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.',
|
||||
)
|
||||
@@ -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',
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,2 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import test_identity
|
||||
from . import test_utils
|
||||
from . import test_engagement
|
||||
|
||||
414
fusion_helpdesk_central/tests/test_engagement.py
Normal file
414
fusion_helpdesk_central/tests/test_engagement.py
Normal 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()
|
||||
193
fusion_helpdesk_central/tests/test_utils.py
Normal file
193
fusion_helpdesk_central/tests/test_utils.py
Normal 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('<script>', 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('<b>', 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)
|
||||
168
fusion_helpdesk_central/utils.py
Normal file
168
fusion_helpdesk_central/utils.py
Normal 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()
|
||||
143
fusion_helpdesk_central/views/engagement_reporting_views.xml
Normal file
143
fusion_helpdesk_central/views/engagement_reporting_views.xml
Normal 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 > 7 days" name="stuck"
|
||||
domain="[('x_fc_engagement_state', '=', 'pending'),
|
||||
('x_fc_engagement_sent_at', '<=',
|
||||
(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>
|
||||
67
fusion_helpdesk_central/views/engagement_wizard_views.xml
Normal file
67
fusion_helpdesk_central/views/engagement_wizard_views.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
132
fusion_helpdesk_central/views/portal_templates.xml
Normal file
132
fusion_helpdesk_central/views/portal_templates.xml
Normal 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>
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Repairs',
|
||||
'version': '19.0.2.1.0',
|
||||
'version': '19.0.2.2.0',
|
||||
'category': 'Inventory/Repairs',
|
||||
'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal',
|
||||
'description': """
|
||||
@@ -56,6 +56,7 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
|
||||
'website',
|
||||
'sale_management',
|
||||
'stock',
|
||||
'purchase',
|
||||
'repair',
|
||||
'maintenance',
|
||||
'fusion_tasks',
|
||||
@@ -78,6 +79,8 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
|
||||
'data/emergency_charge_data.xml',
|
||||
'data/callout_rate_data.xml',
|
||||
'data/delivery_charge_data.xml',
|
||||
'data/symptom_class_data.xml',
|
||||
'data/flowchart_stairlift_not_moving_data.xml',
|
||||
# Views
|
||||
'views/repair_product_category_views.xml',
|
||||
'views/intake_template_views.xml',
|
||||
@@ -93,6 +96,8 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
|
||||
'views/repair_order_views.xml',
|
||||
'views/repair_part_order_views.xml',
|
||||
'views/repair_service_plan_views.xml',
|
||||
'views/repair_symptom_class_views.xml',
|
||||
'views/repair_flowchart_views.xml',
|
||||
'views/sale_order_views.xml',
|
||||
'views/technician_task_views.xml',
|
||||
'views/res_partner_views.xml',
|
||||
@@ -119,6 +124,15 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
|
||||
'fusion_repairs/static/src/scss/dashboard.scss',
|
||||
'fusion_repairs/static/src/components/dashboard/dashboard.js',
|
||||
'fusion_repairs/static/src/components/dashboard/dashboard.xml',
|
||||
# Bundle 11: Drawflow flowchart designer + runner. CSS first.
|
||||
'fusion_repairs/static/src/lib/drawflow/drawflow.min.css',
|
||||
'fusion_repairs/static/src/lib/drawflow/drawflow.min.js',
|
||||
'fusion_repairs/static/src/components/flowchart_designer/flowchart_designer.scss',
|
||||
'fusion_repairs/static/src/components/flowchart_designer/flowchart_designer.js',
|
||||
'fusion_repairs/static/src/components/flowchart_designer/flowchart_designer.xml',
|
||||
'fusion_repairs/static/src/components/flowchart_runner/flowchart_runner.scss',
|
||||
'fusion_repairs/static/src/components/flowchart_runner/flowchart_runner.js',
|
||||
'fusion_repairs/static/src/components/flowchart_runner/flowchart_runner.xml',
|
||||
],
|
||||
'web.assets_frontend': [
|
||||
'fusion_repairs/static/src/scss/portal_repair_mobile.scss',
|
||||
|
||||
251
fusion_repairs/data/flowchart_stairlift_not_moving_data.xml
Normal file
251
fusion_repairs/data/flowchart_stairlift_not_moving_data.xml
Normal file
@@ -0,0 +1,251 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Demo flowchart for the user's headline scenario: "Stairlift Not Moving".
|
||||
|
||||
Eight nodes covering the most common diagnostic decision points. Westin
|
||||
can edit / extend this in the Drawflow designer; it's the template they
|
||||
will fork to build the other six stairlift flowcharts + the other
|
||||
equipment categories.
|
||||
|
||||
Tree shape (single-dash arrows so the ASCII art does not close this XML
|
||||
comment):
|
||||
|
||||
[Start: Power on?]
|
||||
Yes -> [Seatbelt fastened?]
|
||||
No -> [Outcome: outlet/breaker]
|
||||
[Seatbelt fastened?]
|
||||
No -> [Suggestion: Fasten and retry]
|
||||
Yes -> [Swivel rotated to forward?]
|
||||
[Swivel rotated to forward?]
|
||||
No -> [Suggestion: Rotate and retry]
|
||||
Yes -> [Outcome: escalate to technician]
|
||||
-->
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<record id="flowchart_stairlift_not_moving" model="fusion.repair.flowchart">
|
||||
<field name="name">Stairlift - Not Moving</field>
|
||||
<field name="category_id" ref="category_stairlift"/>
|
||||
<field name="symptom_class_id" ref="symptom_stairlift_not_moving"/>
|
||||
<field name="published" eval="True"/>
|
||||
<field name="description"><![CDATA[
|
||||
<p>Standard diagnostic walk-through for a stairlift that is not responding
|
||||
to its controls. Most common root causes (power, seatbelt, swivel lock)
|
||||
are checked first; if all clear, dispatch a technician.</p>
|
||||
]]></field>
|
||||
</record>
|
||||
|
||||
<!-- ===== NODES ===== -->
|
||||
<record id="flow_sl_nm_node_start" model="fusion.repair.flowchart.node">
|
||||
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
|
||||
<field name="name">Is the power on?</field>
|
||||
<field name="node_type">question</field>
|
||||
<field name="is_start" eval="True"/>
|
||||
<field name="sequence">10</field>
|
||||
<field name="canvas_x">100</field>
|
||||
<field name="canvas_y">100</field>
|
||||
<field name="content_html"><![CDATA[
|
||||
<p>Ask the client if the stairlift has power. Check:</p>
|
||||
<ul>
|
||||
<li>Is the unit plugged into the wall outlet?</li>
|
||||
<li>Is the master key switch (usually on the carriage) turned ON?</li>
|
||||
<li>Is the battery indicator LED lit?</li>
|
||||
</ul>
|
||||
]]></field>
|
||||
</record>
|
||||
|
||||
<record id="flow_sl_nm_node_outlet" model="fusion.repair.flowchart.node">
|
||||
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
|
||||
<field name="name">No power - check breaker</field>
|
||||
<field name="node_type">suggestion</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="canvas_x">450</field>
|
||||
<field name="canvas_y">20</field>
|
||||
<field name="content_html"><![CDATA[
|
||||
<p>Ask the client to:</p>
|
||||
<ol>
|
||||
<li>Test the wall outlet with a phone charger or lamp.</li>
|
||||
<li>Check the home's electrical panel for a tripped breaker.</li>
|
||||
<li>If the breaker is tripped, flip it OFF then ON.</li>
|
||||
</ol>
|
||||
]]></field>
|
||||
</record>
|
||||
|
||||
<record id="flow_sl_nm_node_seatbelt" model="fusion.repair.flowchart.node">
|
||||
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
|
||||
<field name="name">Is the seatbelt fastened?</field>
|
||||
<field name="node_type">question</field>
|
||||
<field name="sequence">30</field>
|
||||
<field name="canvas_x">450</field>
|
||||
<field name="canvas_y">200</field>
|
||||
<field name="content_html"><![CDATA[
|
||||
<p>Most stairlifts will not move unless the seatbelt safety
|
||||
switch is engaged. Confirm the belt is buckled.</p>
|
||||
]]></field>
|
||||
</record>
|
||||
|
||||
<record id="flow_sl_nm_node_fasten" model="fusion.repair.flowchart.node">
|
||||
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
|
||||
<field name="name">Fasten seatbelt and retry</field>
|
||||
<field name="node_type">suggestion</field>
|
||||
<field name="sequence">40</field>
|
||||
<field name="canvas_x">800</field>
|
||||
<field name="canvas_y">120</field>
|
||||
<field name="content_html"><![CDATA[
|
||||
<p>Have the client buckle the seatbelt and try the controls again.</p>
|
||||
]]></field>
|
||||
</record>
|
||||
|
||||
<record id="flow_sl_nm_node_swivel" model="fusion.repair.flowchart.node">
|
||||
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
|
||||
<field name="name">Is the seat rotated to the forward (travel) position?</field>
|
||||
<field name="node_type">question</field>
|
||||
<field name="sequence">50</field>
|
||||
<field name="canvas_x">800</field>
|
||||
<field name="canvas_y">300</field>
|
||||
<field name="content_html"><![CDATA[
|
||||
<p>The swivel lock must be engaged in the forward position. If
|
||||
the seat is partially turned, the stairlift will refuse to move.</p>
|
||||
]]></field>
|
||||
</record>
|
||||
|
||||
<record id="flow_sl_nm_node_rotate" model="fusion.repair.flowchart.node">
|
||||
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
|
||||
<field name="name">Rotate seat to forward and retry</field>
|
||||
<field name="node_type">suggestion</field>
|
||||
<field name="sequence">60</field>
|
||||
<field name="canvas_x">1150</field>
|
||||
<field name="canvas_y">220</field>
|
||||
<field name="content_html"><![CDATA[
|
||||
<p>Ask the client to rotate the seat back to the forward
|
||||
travel position until the swivel lock clicks. Then try again.</p>
|
||||
]]></field>
|
||||
</record>
|
||||
|
||||
<record id="flow_sl_nm_node_resolved" model="fusion.repair.flowchart.node">
|
||||
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
|
||||
<field name="name">Working - resolved on call</field>
|
||||
<field name="node_type">outcome</field>
|
||||
<field name="outcome_kind">resolved</field>
|
||||
<field name="sequence">100</field>
|
||||
<field name="canvas_x">1500</field>
|
||||
<field name="canvas_y">150</field>
|
||||
<field name="content_html"><![CDATA[
|
||||
<p>Great - the stairlift is moving again. Confirm with the client,
|
||||
close the repair, and offer to book an annual safety inspection
|
||||
if they have not had one recently.</p>
|
||||
]]></field>
|
||||
</record>
|
||||
|
||||
<record id="flow_sl_nm_node_escalate" model="fusion.repair.flowchart.node">
|
||||
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
|
||||
<field name="name">Still not moving - dispatch technician</field>
|
||||
<field name="node_type">outcome</field>
|
||||
<field name="outcome_kind">escalate</field>
|
||||
<field name="sequence">110</field>
|
||||
<field name="canvas_x">1500</field>
|
||||
<field name="canvas_y">350</field>
|
||||
<field name="content_html"><![CDATA[
|
||||
<p>Common fixes are exhausted. Likely root causes for a tech to investigate:</p>
|
||||
<ul>
|
||||
<li>Control board fault (most common - $300-$500 part)</li>
|
||||
<li>Motor brushes worn</li>
|
||||
<li>Track end-stop safety switch</li>
|
||||
</ul>
|
||||
<p>Escalate and the transcript will be visible to the tech before they arrive.</p>
|
||||
]]></field>
|
||||
</record>
|
||||
|
||||
<!-- ===== EDGES ===== -->
|
||||
<record id="flow_sl_nm_edge_start_no" model="fusion.repair.flowchart.edge">
|
||||
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
|
||||
<field name="source_node_id" ref="flow_sl_nm_node_start"/>
|
||||
<field name="target_node_id" ref="flow_sl_nm_node_outlet"/>
|
||||
<field name="label">No - no power</field>
|
||||
<field name="sequence">10</field>
|
||||
</record>
|
||||
<record id="flow_sl_nm_edge_start_yes" model="fusion.repair.flowchart.edge">
|
||||
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
|
||||
<field name="source_node_id" ref="flow_sl_nm_node_start"/>
|
||||
<field name="target_node_id" ref="flow_sl_nm_node_seatbelt"/>
|
||||
<field name="label">Yes - power is on</field>
|
||||
<field name="sequence">20</field>
|
||||
</record>
|
||||
|
||||
<record id="flow_sl_nm_edge_outlet_fixed" model="fusion.repair.flowchart.edge">
|
||||
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
|
||||
<field name="source_node_id" ref="flow_sl_nm_node_outlet"/>
|
||||
<field name="target_node_id" ref="flow_sl_nm_node_resolved"/>
|
||||
<field name="label">Power restored - works now</field>
|
||||
<field name="sequence">10</field>
|
||||
</record>
|
||||
<record id="flow_sl_nm_edge_outlet_no_fix" model="fusion.repair.flowchart.edge">
|
||||
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
|
||||
<field name="source_node_id" ref="flow_sl_nm_node_outlet"/>
|
||||
<field name="target_node_id" ref="flow_sl_nm_node_escalate"/>
|
||||
<field name="label">Still no power</field>
|
||||
<field name="sequence">20</field>
|
||||
</record>
|
||||
|
||||
<record id="flow_sl_nm_edge_seatbelt_no" model="fusion.repair.flowchart.edge">
|
||||
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
|
||||
<field name="source_node_id" ref="flow_sl_nm_node_seatbelt"/>
|
||||
<field name="target_node_id" ref="flow_sl_nm_node_fasten"/>
|
||||
<field name="label">No</field>
|
||||
<field name="sequence">10</field>
|
||||
</record>
|
||||
<record id="flow_sl_nm_edge_seatbelt_yes" model="fusion.repair.flowchart.edge">
|
||||
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
|
||||
<field name="source_node_id" ref="flow_sl_nm_node_seatbelt"/>
|
||||
<field name="target_node_id" ref="flow_sl_nm_node_swivel"/>
|
||||
<field name="label">Yes</field>
|
||||
<field name="sequence">20</field>
|
||||
</record>
|
||||
|
||||
<record id="flow_sl_nm_edge_fasten_works" model="fusion.repair.flowchart.edge">
|
||||
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
|
||||
<field name="source_node_id" ref="flow_sl_nm_node_fasten"/>
|
||||
<field name="target_node_id" ref="flow_sl_nm_node_resolved"/>
|
||||
<field name="label">Works now</field>
|
||||
<field name="sequence">10</field>
|
||||
</record>
|
||||
<record id="flow_sl_nm_edge_fasten_no" model="fusion.repair.flowchart.edge">
|
||||
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
|
||||
<field name="source_node_id" ref="flow_sl_nm_node_fasten"/>
|
||||
<field name="target_node_id" ref="flow_sl_nm_node_swivel"/>
|
||||
<field name="label">Still not moving - try next check</field>
|
||||
<field name="sequence">20</field>
|
||||
</record>
|
||||
|
||||
<record id="flow_sl_nm_edge_swivel_no" model="fusion.repair.flowchart.edge">
|
||||
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
|
||||
<field name="source_node_id" ref="flow_sl_nm_node_swivel"/>
|
||||
<field name="target_node_id" ref="flow_sl_nm_node_rotate"/>
|
||||
<field name="label">No - seat is turned</field>
|
||||
<field name="sequence">10</field>
|
||||
</record>
|
||||
<record id="flow_sl_nm_edge_swivel_yes" model="fusion.repair.flowchart.edge">
|
||||
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
|
||||
<field name="source_node_id" ref="flow_sl_nm_node_swivel"/>
|
||||
<field name="target_node_id" ref="flow_sl_nm_node_escalate"/>
|
||||
<field name="label">Yes - seat is forward</field>
|
||||
<field name="sequence">20</field>
|
||||
</record>
|
||||
|
||||
<record id="flow_sl_nm_edge_rotate_works" model="fusion.repair.flowchart.edge">
|
||||
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
|
||||
<field name="source_node_id" ref="flow_sl_nm_node_rotate"/>
|
||||
<field name="target_node_id" ref="flow_sl_nm_node_resolved"/>
|
||||
<field name="label">Works now</field>
|
||||
<field name="sequence">10</field>
|
||||
</record>
|
||||
<record id="flow_sl_nm_edge_rotate_no" model="fusion.repair.flowchart.edge">
|
||||
<field name="flowchart_id" ref="flowchart_stairlift_not_moving"/>
|
||||
<field name="source_node_id" ref="flow_sl_nm_node_rotate"/>
|
||||
<field name="target_node_id" ref="flow_sl_nm_node_escalate"/>
|
||||
<field name="label">Still not moving</field>
|
||||
<field name="sequence">20</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -242,26 +242,48 @@
|
||||
<record id="email_template_parts_ordered" model="mail.template">
|
||||
<field name="name">Repair: Parts Ordered (Client)</field>
|
||||
<field name="model_id" ref="model_fusion_repair_part_order"/>
|
||||
<field name="subject">Parts ordered for your {{ object.repair_order_id.x_fc_repair_category_id.name or 'equipment' }} - {{ object.repair_order_id.name }}</field>
|
||||
<field name="subject">Parts ordered for your {{ object.repair_order_id.x_fc_repair_category_id.name or 'equipment' }} - expected {{ object.expected_date }}</field>
|
||||
<field name="email_from">{{ (object.company_id.email_formatted or user.email_formatted) }}</field>
|
||||
<field name="partner_to">{{ object.partner_id.id }}</field>
|
||||
<field name="body_html" type="html">
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;">
|
||||
<div style="height:4px;background-color:#2B6CB0;"></div>
|
||||
<div style="padding:32px 28px;">
|
||||
<h2 style="font-size:20px;font-weight:700;margin:0 0 16px 0;">Parts ordered</h2>
|
||||
<p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 16px 0;">
|
||||
We've placed an order for the parts your <t t-out="object.repair_order_id.x_fc_repair_category_id.name or 'equipment'"/>
|
||||
needs. Expected arrival: <strong><t t-out="object.expected_date" t-options="{'widget': 'date'}"/></strong>.
|
||||
<h2 style="font-size:22px;font-weight:700;margin:0 0 16px 0;">Your part has been ordered</h2>
|
||||
|
||||
<!-- ETA front and centre per Bundle 11 spec. No cost shown - the office shares cost separately. -->
|
||||
<div style="background:#eff6ff;border:1px solid #bfdbfe;border-radius:8px;padding:20px;text-align:center;margin:0 0 20px 0;">
|
||||
<div style="font-size:13px;color:#1e40af;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:6px;">
|
||||
Expected arrival
|
||||
</div>
|
||||
<div style="font-size:24px;font-weight:700;color:#1e40af;">
|
||||
<t t-out="object.expected_date" t-options="{'widget': 'date'}"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style="opacity:0.75;font-size:15px;line-height:1.5;margin:0 0 16px 0;">
|
||||
We have placed the order for the part your
|
||||
<strong t-out="object.repair_order_id.x_fc_repair_category_id.name or 'equipment'"/>
|
||||
needs to get you up and running again.
|
||||
</p>
|
||||
|
||||
<table style="width:100%;border-collapse:collapse;margin:0 0 16px 0;">
|
||||
<tr><td style="padding:8px 14px;opacity:0.6;font-size:13px;border-bottom:1px solid rgba(128,128,128,0.15);width:35%;">Part</td><td style="padding:8px 14px;font-size:13px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.description"/></td></tr>
|
||||
<t t-if="object.manufacturer">
|
||||
<tr><td style="padding:8px 14px;opacity:0.6;font-size:13px;border-bottom:1px solid rgba(128,128,128,0.15);">Manufacturer</td><td style="padding:8px 14px;font-size:13px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.manufacturer"/></td></tr>
|
||||
</t>
|
||||
<tr><td style="padding:8px 14px;opacity:0.6;font-size:13px;">Ref</td><td style="padding:8px 14px;font-size:13px;"><t t-out="object.name"/></td></tr>
|
||||
<tr><td style="padding:8px 14px;opacity:0.6;font-size:13px;border-bottom:1px solid rgba(128,128,128,0.15);">Repair</td><td style="padding:8px 14px;font-size:13px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.repair_order_id.name"/></td></tr>
|
||||
<tr><td style="padding:8px 14px;opacity:0.6;font-size:13px;">Reference</td><td style="padding:8px 14px;font-size:13px;"><t t-out="object.name"/></td></tr>
|
||||
</table>
|
||||
<p style="opacity:0.55;font-size:12px;margin:0;">We'll email again as soon as the parts arrive at our warehouse.</p>
|
||||
|
||||
<div style="background:#f9fafb;border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 16px 0;font-size:14px;line-height:1.5;">
|
||||
<strong>There is nothing for you to do right now.</strong>
|
||||
Our office will call you as soon as the part arrives at our
|
||||
warehouse to confirm a return visit time. If you have any
|
||||
questions about pricing or scheduling, please reach out to
|
||||
our office directly.
|
||||
</div>
|
||||
|
||||
<p style="opacity:0.55;font-size:12px;margin:0;">
|
||||
We will email you again the moment the part arrives.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</field>
|
||||
|
||||
107
fusion_repairs/data/symptom_class_data.xml
Normal file
107
fusion_repairs/data/symptom_class_data.xml
Normal file
@@ -0,0 +1,107 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Seed symptom classes per equipment category. Westin can add their own
|
||||
via Configuration > Symptom Classes. noupdate=1 keeps custom rows safe
|
||||
on upgrade.
|
||||
|
||||
Phase 1 ships a thorough set for STAIRLIFT (the user's first use case).
|
||||
Lighter coverage for other categories - they can be expanded as Westin
|
||||
builds out more flowcharts.
|
||||
-->
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<!-- ============== STAIRLIFT ============== -->
|
||||
<record id="symptom_stairlift_not_moving" model="fusion.repair.symptom.class">
|
||||
<field name="name">Not Moving</field>
|
||||
<field name="code">not_moving</field>
|
||||
<field name="category_id" ref="category_stairlift"/>
|
||||
<field name="sequence">10</field>
|
||||
<field name="icon">fa-pause-circle</field>
|
||||
<field name="description">Stairlift unresponsive to controls; will not travel up or down.</field>
|
||||
</record>
|
||||
|
||||
<record id="symptom_stairlift_beeps_alarm" model="fusion.repair.symptom.class">
|
||||
<field name="name">Beeps / Alarm</field>
|
||||
<field name="code">beeps_alarm</field>
|
||||
<field name="category_id" ref="category_stairlift"/>
|
||||
<field name="sequence">20</field>
|
||||
<field name="icon">fa-bell</field>
|
||||
</record>
|
||||
|
||||
<record id="symptom_stairlift_stops_midway" model="fusion.repair.symptom.class">
|
||||
<field name="name">Stops Midway</field>
|
||||
<field name="code">stops_midway</field>
|
||||
<field name="category_id" ref="category_stairlift"/>
|
||||
<field name="sequence">30</field>
|
||||
<field name="icon">fa-hand-paper-o</field>
|
||||
</record>
|
||||
|
||||
<record id="symptom_stairlift_remote_issue" model="fusion.repair.symptom.class">
|
||||
<field name="name">Remote / Call Station Issue</field>
|
||||
<field name="code">remote_issue</field>
|
||||
<field name="category_id" ref="category_stairlift"/>
|
||||
<field name="sequence">40</field>
|
||||
<field name="icon">fa-mobile</field>
|
||||
</record>
|
||||
|
||||
<record id="symptom_stairlift_swivel_stuck" model="fusion.repair.symptom.class">
|
||||
<field name="name">Swivel Won't Rotate</field>
|
||||
<field name="code">swivel_stuck</field>
|
||||
<field name="category_id" ref="category_stairlift"/>
|
||||
<field name="sequence">50</field>
|
||||
<field name="icon">fa-refresh</field>
|
||||
</record>
|
||||
|
||||
<record id="symptom_stairlift_slow" model="fusion.repair.symptom.class">
|
||||
<field name="name">Slow / Sluggish</field>
|
||||
<field name="code">slow_sluggish</field>
|
||||
<field name="category_id" ref="category_stairlift"/>
|
||||
<field name="sequence">60</field>
|
||||
<field name="icon">fa-tachometer</field>
|
||||
</record>
|
||||
|
||||
<record id="symptom_stairlift_track" model="fusion.repair.symptom.class">
|
||||
<field name="name">Track Issue (noise / grinding)</field>
|
||||
<field name="code">track_issue</field>
|
||||
<field name="category_id" ref="category_stairlift"/>
|
||||
<field name="sequence">70</field>
|
||||
<field name="icon">fa-bars</field>
|
||||
</record>
|
||||
|
||||
<!-- ============== HOSPITAL BED (lighter) ============== -->
|
||||
<record id="symptom_bed_not_moving" model="fusion.repair.symptom.class">
|
||||
<field name="name">Not Moving</field>
|
||||
<field name="code">not_moving</field>
|
||||
<field name="category_id" ref="category_hospital_bed"/>
|
||||
<field name="sequence">10</field>
|
||||
<field name="icon">fa-pause-circle</field>
|
||||
</record>
|
||||
<record id="symptom_bed_remote" model="fusion.repair.symptom.class">
|
||||
<field name="name">Remote / Pendant Issue</field>
|
||||
<field name="code">remote_issue</field>
|
||||
<field name="category_id" ref="category_hospital_bed"/>
|
||||
<field name="sequence">20</field>
|
||||
<field name="icon">fa-mobile</field>
|
||||
</record>
|
||||
|
||||
<!-- ============== PORCH LIFT (lighter) ============== -->
|
||||
<record id="symptom_porch_not_moving" model="fusion.repair.symptom.class">
|
||||
<field name="name">Not Moving</field>
|
||||
<field name="code">not_moving</field>
|
||||
<field name="category_id" ref="category_porch_lift"/>
|
||||
<field name="sequence">10</field>
|
||||
<field name="icon">fa-pause-circle</field>
|
||||
</record>
|
||||
|
||||
<!-- ============== LIFT CHAIR (lighter) ============== -->
|
||||
<record id="symptom_lift_chair_not_moving" model="fusion.repair.symptom.class">
|
||||
<field name="name">Will Not Rise / Recline</field>
|
||||
<field name="code">not_moving</field>
|
||||
<field name="category_id" ref="category_lift_chair"/>
|
||||
<field name="sequence">10</field>
|
||||
<field name="icon">fa-pause-circle</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
89
fusion_repairs/migrations/19.0.2.2.0/post-migration.py
Normal file
89
fusion_repairs/migrations/19.0.2.2.0/post-migration.py
Normal file
@@ -0,0 +1,89 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Post-migration for 19.0.2.2.0 - Bundle 11.
|
||||
|
||||
Back-fills the new x_fc_symptom_class_id M2O on repair.order from the legacy
|
||||
x_fc_issue_category Char so that existing repairs can immediately be used
|
||||
with troubleshooting flowcharts. For each distinct (category, issue_category)
|
||||
text we encounter, we either match an existing symptom.class by lowercase
|
||||
name OR create a placeholder symptom row tagged 'auto-imported' so a manager
|
||||
can clean it up later.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _slug(text):
|
||||
return re.sub(r'[^a-z0-9_]+', '_', (text or '').strip().lower()).strip('_') or 'unknown'
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
if not version:
|
||||
return # fresh install, nothing to back-fill
|
||||
|
||||
# Find every (category_id, issue_category) pair on existing repairs that
|
||||
# is not yet linked to a symptom class.
|
||||
cr.execute("""
|
||||
SELECT DISTINCT x_fc_repair_category_id, x_fc_issue_category
|
||||
FROM repair_order
|
||||
WHERE x_fc_issue_category IS NOT NULL
|
||||
AND x_fc_issue_category <> ''
|
||||
AND x_fc_symptom_class_id IS NULL
|
||||
""")
|
||||
pairs = cr.fetchall()
|
||||
if not pairs:
|
||||
return
|
||||
|
||||
_logger.info(
|
||||
'Bundle 11 migration: back-filling symptom_class for %d distinct '
|
||||
'(category, issue_category) pairs', len(pairs),
|
||||
)
|
||||
|
||||
for cat_id, issue_text in pairs:
|
||||
if not cat_id:
|
||||
continue
|
||||
code = _slug(issue_text)
|
||||
# Try to reuse an existing symptom class by code-or-name on this category.
|
||||
cr.execute("""
|
||||
SELECT id FROM fusion_repair_symptom_class
|
||||
WHERE category_id = %s
|
||||
AND (code = %s OR LOWER(name->>'en_US') = LOWER(%s))
|
||||
LIMIT 1
|
||||
""", (cat_id, code, issue_text))
|
||||
row = cr.fetchone()
|
||||
if row:
|
||||
sym_id = row[0]
|
||||
else:
|
||||
# Create a placeholder so the M2O isn't lost. Manager renames later.
|
||||
display = (issue_text or 'Imported').strip()[:80]
|
||||
cr.execute("""
|
||||
INSERT INTO fusion_repair_symptom_class
|
||||
(name, code, category_id, sequence, active,
|
||||
description, icon, create_date, write_date, create_uid,
|
||||
write_uid)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s,
|
||||
NOW(), NOW(), 1, 1)
|
||||
RETURNING id
|
||||
""", (
|
||||
{'en_US': display},
|
||||
code,
|
||||
cat_id,
|
||||
90,
|
||||
True,
|
||||
'Auto-imported by Bundle 11 migration from legacy '
|
||||
'x_fc_issue_category. Review and rename if needed.',
|
||||
'fa-exclamation-circle',
|
||||
))
|
||||
sym_id = cr.fetchone()[0]
|
||||
# Back-fill the M2O on every repair with this pair.
|
||||
cr.execute("""
|
||||
UPDATE repair_order
|
||||
SET x_fc_symptom_class_id = %s
|
||||
WHERE x_fc_repair_category_id = %s
|
||||
AND x_fc_issue_category = %s
|
||||
AND x_fc_symptom_class_id IS NULL
|
||||
""", (sym_id, cat_id, issue_text))
|
||||
|
||||
_logger.info('Bundle 11 migration: back-fill complete.')
|
||||
@@ -19,6 +19,9 @@ from . import repair_part_order
|
||||
from . import repair_callout_rate
|
||||
from . import repair_labor_warranty
|
||||
from . import repair_delivery_charge
|
||||
from . import repair_symptom_class
|
||||
from . import repair_flowchart
|
||||
from . import repair_flowchart_run
|
||||
from . import product_template
|
||||
from . import res_partner
|
||||
from . import res_users
|
||||
|
||||
@@ -130,6 +130,7 @@ class FusionRepairIntakeService(models.AbstractModel):
|
||||
'x_fc_third_party_equipment': bool(item.get('third_party')),
|
||||
'x_fc_urgency': item.get('urgency') or 'normal',
|
||||
'x_fc_issue_category': item.get('issue_category') or False,
|
||||
'x_fc_symptom_class_id': item.get('symptom_class_id') or False,
|
||||
'x_fc_is_quote_only': bool(quote_only),
|
||||
'x_fc_rush_requested': bool(rush_requested),
|
||||
'x_fc_rush_tier': rush_tier or False,
|
||||
|
||||
394
fusion_repairs/models/repair_flowchart.py
Normal file
394
fusion_repairs/models/repair_flowchart.py
Normal file
@@ -0,0 +1,394 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
"""Troubleshooting flowchart designer-time models.
|
||||
|
||||
Three models cooperate:
|
||||
|
||||
fusion.repair.flowchart one chart per (category, symptom)
|
||||
-> fusion.repair.flowchart.node one row per visual node
|
||||
-> fusion.repair.flowchart.edge directed edges between nodes
|
||||
|
||||
The full visual layout (positions, drawflow internal state) is also serialised
|
||||
to flowchart.canvas_layout as JSON so the Drawflow designer round-trips
|
||||
without needing per-node x/y stored separately. The structured node/edge
|
||||
records exist for queries (e.g. "show me all flowcharts that mention 'control
|
||||
board' in any node"), reporting, runtime traversal, and so that the runner can
|
||||
operate without parsing the canvas JSON.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
NODE_TYPES = [
|
||||
('question', 'Question'),
|
||||
('suggestion', 'Suggestion'),
|
||||
('info', 'Info'),
|
||||
('outcome', 'Outcome'),
|
||||
]
|
||||
|
||||
OUTCOME_KINDS = [
|
||||
('resolved', 'Resolved on Call'),
|
||||
('escalate', 'Escalate to Technician'),
|
||||
('order_part', 'Order Part'),
|
||||
]
|
||||
|
||||
|
||||
class FusionRepairFlowchart(models.Model):
|
||||
_name = 'fusion.repair.flowchart'
|
||||
_inherit = ['mail.thread']
|
||||
_description = 'Repair Troubleshooting Flowchart'
|
||||
_order = 'category_id, symptom_class_id, name'
|
||||
|
||||
name = fields.Char(required=True, translate=True, tracking=True)
|
||||
category_id = fields.Many2one(
|
||||
'fusion.repair.product.category',
|
||||
string='Equipment Category',
|
||||
required=True,
|
||||
index=True,
|
||||
ondelete='cascade',
|
||||
tracking=True,
|
||||
)
|
||||
symptom_class_id = fields.Many2one(
|
||||
'fusion.repair.symptom.class',
|
||||
string='Symptom',
|
||||
required=True,
|
||||
index=True,
|
||||
ondelete='cascade',
|
||||
domain="[('category_id', '=', category_id)]",
|
||||
tracking=True,
|
||||
)
|
||||
description = fields.Html(translate=True)
|
||||
active = fields.Boolean(default=True, tracking=True)
|
||||
published = fields.Boolean(
|
||||
default=False,
|
||||
tracking=True,
|
||||
help='Only published flowcharts are offered to CS during intake. Draft '
|
||||
'charts are visible to admins for editing but never run.',
|
||||
)
|
||||
version = fields.Integer(
|
||||
default=1,
|
||||
readonly=True,
|
||||
copy=False,
|
||||
help='Bumped on every save in the designer; lets us snapshot which '
|
||||
'version a flowchart.run was based on.',
|
||||
)
|
||||
|
||||
canvas_layout = fields.Text(
|
||||
string='Designer State',
|
||||
help='Drawflow JSON for the visual designer canvas - positions, zoom, '
|
||||
'connection curves. Round-tripped by the designer UI.',
|
||||
)
|
||||
|
||||
node_ids = fields.One2many('fusion.repair.flowchart.node', 'flowchart_id')
|
||||
edge_ids = fields.One2many('fusion.repair.flowchart.edge', 'flowchart_id')
|
||||
node_count = fields.Integer(compute='_compute_counts')
|
||||
edge_count = fields.Integer(compute='_compute_counts')
|
||||
|
||||
start_node_id = fields.Many2one(
|
||||
'fusion.repair.flowchart.node',
|
||||
string='Start Node',
|
||||
compute='_compute_start_node',
|
||||
store=True,
|
||||
help='Auto-computed - whichever node has is_start=True. Validation '
|
||||
'enforces exactly one.',
|
||||
)
|
||||
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
_active_per_pair_unique = models.Constraint(
|
||||
# NULLs treated as distinct so multiple draft (active=False) charts
|
||||
# can coexist; only one active+published chart per pair is the
|
||||
# business rule we care about - enforced via Python below.
|
||||
'unique(category_id, symptom_class_id, company_id, published)',
|
||||
'Only one published flowchart per (category, symptom) per company. '
|
||||
'Unpublish or duplicate before publishing another.',
|
||||
)
|
||||
|
||||
@api.depends('node_ids', 'edge_ids')
|
||||
def _compute_counts(self):
|
||||
for r in self:
|
||||
r.node_count = len(r.node_ids)
|
||||
r.edge_count = len(r.edge_ids)
|
||||
|
||||
@api.depends('node_ids.is_start')
|
||||
def _compute_start_node(self):
|
||||
for r in self:
|
||||
r.start_node_id = r.node_ids.filtered(lambda n: n.is_start)[:1]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ACTIONS
|
||||
# ------------------------------------------------------------------
|
||||
def action_open_designer(self):
|
||||
"""Open the OWL Drawflow designer as a client action."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fusion_repair_flowchart_designer',
|
||||
'name': _('Flowchart Designer - %s') % self.name,
|
||||
'params': {'flowchart_id': self.id},
|
||||
}
|
||||
|
||||
def action_publish(self):
|
||||
for r in self:
|
||||
r._validate_publishable()
|
||||
r.published = True
|
||||
r.message_post(body=_('Flowchart published. CS will now see it for '
|
||||
'new intakes on this (category, symptom).'))
|
||||
|
||||
def action_unpublish(self):
|
||||
for r in self:
|
||||
r.published = False
|
||||
|
||||
def action_duplicate_as_draft(self):
|
||||
self.ensure_one()
|
||||
copy = self.copy({
|
||||
'name': _('%s (copy)') % self.name,
|
||||
'published': False,
|
||||
'version': 1,
|
||||
})
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': copy.name,
|
||||
'res_model': self._name,
|
||||
'view_mode': 'form',
|
||||
'res_id': copy.id,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# VALIDATION
|
||||
# ------------------------------------------------------------------
|
||||
def _validate_publishable(self):
|
||||
for r in self:
|
||||
starts = r.node_ids.filtered(lambda n: n.is_start)
|
||||
if len(starts) != 1:
|
||||
raise ValidationError(_(
|
||||
'Flowchart "%s" must have exactly one start node '
|
||||
'(found %d). Edit in the designer and try again.'
|
||||
) % (r.name, len(starts)))
|
||||
# Every non-outcome node should have at least one outgoing edge.
|
||||
for n in r.node_ids:
|
||||
if n.node_type != 'outcome' and not r.edge_ids.filtered(
|
||||
lambda e: e.source_node_id == n
|
||||
):
|
||||
raise ValidationError(_(
|
||||
'Node "%s" is not an outcome but has no outgoing edges. '
|
||||
'Connect it to the next step or change its type to '
|
||||
'Outcome.'
|
||||
) % n.name)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DESIGNER RPC API (called from the OWL component)
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def designer_load(self, flowchart_id):
|
||||
"""Return everything the designer needs to render this chart."""
|
||||
chart = self.browse(flowchart_id).exists()
|
||||
if not chart:
|
||||
raise UserError(_('Flowchart not found.'))
|
||||
return {
|
||||
'id': chart.id,
|
||||
'name': chart.name,
|
||||
'category_id': chart.category_id.id,
|
||||
'category_name': chart.category_id.name,
|
||||
'symptom_class_id': chart.symptom_class_id.id,
|
||||
'symptom_name': chart.symptom_class_id.name,
|
||||
'published': chart.published,
|
||||
'version': chart.version,
|
||||
'canvas_layout': chart.canvas_layout or '',
|
||||
'nodes': [n._designer_dict() for n in chart.node_ids],
|
||||
'edges': [e._designer_dict() for e in chart.edge_ids],
|
||||
}
|
||||
|
||||
def designer_save(self, payload):
|
||||
"""Replace the chart's nodes + edges with the designer's snapshot.
|
||||
|
||||
payload = {
|
||||
'canvas_layout': '<drawflow json string>',
|
||||
'nodes': [{'client_id': str, 'name': ..., 'node_type': ...,
|
||||
'content_html': ..., 'media_ids': [int],
|
||||
'is_start': bool, 'outcome_kind': ...,
|
||||
'canvas_x': int, 'canvas_y': int}, ...],
|
||||
'edges': [{'source_client_id': str, 'target_client_id': str,
|
||||
'label': str, 'sequence': int}, ...],
|
||||
}
|
||||
|
||||
Returns the saved chart (designer_load shape).
|
||||
"""
|
||||
self.ensure_one()
|
||||
# Atomic replace - simplest and safest. Edges depend on nodes so
|
||||
# delete edges first.
|
||||
self.edge_ids.unlink()
|
||||
self.node_ids.unlink()
|
||||
|
||||
client_to_db = {}
|
||||
Node = self.env['fusion.repair.flowchart.node'].sudo()
|
||||
for nd in payload.get('nodes', []):
|
||||
rec = Node.create({
|
||||
'flowchart_id': self.id,
|
||||
'name': nd.get('name') or _('Untitled'),
|
||||
'node_type': nd.get('node_type') or 'question',
|
||||
'content_html': nd.get('content_html') or False,
|
||||
'is_start': bool(nd.get('is_start')),
|
||||
'outcome_kind': nd.get('outcome_kind') or False,
|
||||
'canvas_x': int(nd.get('canvas_x') or 0),
|
||||
'canvas_y': int(nd.get('canvas_y') or 0),
|
||||
'media_ids': [(6, 0, nd.get('media_ids') or [])],
|
||||
})
|
||||
client_to_db[nd.get('client_id')] = rec.id
|
||||
|
||||
Edge = self.env['fusion.repair.flowchart.edge'].sudo()
|
||||
for ed in payload.get('edges', []):
|
||||
src = client_to_db.get(ed.get('source_client_id'))
|
||||
tgt = client_to_db.get(ed.get('target_client_id'))
|
||||
if not src or not tgt:
|
||||
_logger.warning(
|
||||
'Skipping edge with unknown client ids: %s -> %s',
|
||||
ed.get('source_client_id'), ed.get('target_client_id'),
|
||||
)
|
||||
continue
|
||||
Edge.create({
|
||||
'flowchart_id': self.id,
|
||||
'source_node_id': src,
|
||||
'target_node_id': tgt,
|
||||
'label': ed.get('label') or '',
|
||||
'sequence': int(ed.get('sequence') or 10),
|
||||
})
|
||||
|
||||
self.write({
|
||||
'canvas_layout': payload.get('canvas_layout') or '',
|
||||
'version': self.version + 1,
|
||||
})
|
||||
# Soft-validate (don't raise) - the designer can publish later.
|
||||
try:
|
||||
self._validate_publishable()
|
||||
except ValidationError as e:
|
||||
_logger.info('Saved chart %s but it is not yet publishable: %s',
|
||||
self.name, e)
|
||||
return self.designer_load(self.id)
|
||||
|
||||
|
||||
class FusionRepairFlowchartNode(models.Model):
|
||||
_name = 'fusion.repair.flowchart.node'
|
||||
_description = 'Flowchart Node'
|
||||
_order = 'flowchart_id, sequence, id'
|
||||
|
||||
flowchart_id = fields.Many2one(
|
||||
'fusion.repair.flowchart',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
index=True,
|
||||
)
|
||||
name = fields.Char(required=True, translate=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
node_type = fields.Selection(
|
||||
NODE_TYPES, default='question', required=True,
|
||||
)
|
||||
content_html = fields.Html(
|
||||
translate=True,
|
||||
sanitize=True,
|
||||
sanitize_overridable=True,
|
||||
help='Rich text shown to CS when this node is the current step. '
|
||||
'Photos / videos go in media_ids instead.',
|
||||
)
|
||||
media_ids = fields.Many2many(
|
||||
'ir.attachment',
|
||||
'fusion_repair_flowchart_node_media_rel',
|
||||
'node_id', 'attachment_id',
|
||||
string='Media',
|
||||
help='Photos and short videos that illustrate this step.',
|
||||
)
|
||||
is_start = fields.Boolean(
|
||||
string='Start',
|
||||
help='Exactly one node per flowchart must be the start.',
|
||||
)
|
||||
outcome_kind = fields.Selection(
|
||||
OUTCOME_KINDS,
|
||||
string='Outcome Kind',
|
||||
help='Required when node_type is Outcome - determines what happens '
|
||||
'when the run reaches this node.',
|
||||
)
|
||||
|
||||
# Designer-only - persisted for round-trip but not used by the runtime.
|
||||
canvas_x = fields.Integer(default=0)
|
||||
canvas_y = fields.Integer(default=0)
|
||||
|
||||
outgoing_edge_ids = fields.One2many(
|
||||
'fusion.repair.flowchart.edge', 'source_node_id',
|
||||
string='Outgoing Edges',
|
||||
)
|
||||
incoming_edge_ids = fields.One2many(
|
||||
'fusion.repair.flowchart.edge', 'target_node_id',
|
||||
string='Incoming Edges',
|
||||
)
|
||||
|
||||
def _designer_dict(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'node_type': self.node_type,
|
||||
'content_html': self.content_html or '',
|
||||
'is_start': self.is_start,
|
||||
'outcome_kind': self.outcome_kind or '',
|
||||
'canvas_x': self.canvas_x,
|
||||
'canvas_y': self.canvas_y,
|
||||
'media_ids': self.media_ids.ids,
|
||||
'media': [{
|
||||
'id': a.id,
|
||||
'name': a.name,
|
||||
'mimetype': a.mimetype,
|
||||
'url': f'/web/image/{a.id}',
|
||||
} for a in self.media_ids],
|
||||
}
|
||||
|
||||
|
||||
class FusionRepairFlowchartEdge(models.Model):
|
||||
_name = 'fusion.repair.flowchart.edge'
|
||||
_description = 'Flowchart Edge'
|
||||
_order = 'source_node_id, sequence, id'
|
||||
|
||||
flowchart_id = fields.Many2one(
|
||||
'fusion.repair.flowchart',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
index=True,
|
||||
)
|
||||
source_node_id = fields.Many2one(
|
||||
'fusion.repair.flowchart.node',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
index=True,
|
||||
)
|
||||
target_node_id = fields.Many2one(
|
||||
'fusion.repair.flowchart.node',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
index=True,
|
||||
)
|
||||
label = fields.Char(
|
||||
translate=True,
|
||||
help='Shown on the button in the runner ("Yes", "No", '
|
||||
'"Wheel turns freely"...).',
|
||||
)
|
||||
sequence = fields.Integer(default=10)
|
||||
|
||||
def _designer_dict(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'id': self.id,
|
||||
'source_node_id': self.source_node_id.id,
|
||||
'target_node_id': self.target_node_id.id,
|
||||
'label': self.label or '',
|
||||
'sequence': self.sequence,
|
||||
}
|
||||
362
fusion_repairs/models/repair_flowchart_run.py
Normal file
362
fusion_repairs/models/repair_flowchart_run.py
Normal file
@@ -0,0 +1,362 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
"""Flowchart runtime - one fusion.repair.flowchart.run per CS troubleshooting
|
||||
session, with one fusion.repair.flowchart.run.step per node the rep visited.
|
||||
|
||||
The transcript is the audit + handoff artefact: when CS resolves on-call we
|
||||
post it to chatter; when they escalate we copy it to internal_notes so the
|
||||
dispatched tech can see what was tried BEFORE arriving at the client.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
RUN_OUTCOMES = [
|
||||
('in_progress', 'In Progress'),
|
||||
('resolved', 'Resolved on Call'),
|
||||
('escalated', 'Escalated to Technician'),
|
||||
('order_part', 'Captured Part Order'),
|
||||
('abandoned', 'Abandoned'),
|
||||
]
|
||||
|
||||
|
||||
class FusionRepairFlowchartRun(models.Model):
|
||||
_name = 'fusion.repair.flowchart.run'
|
||||
_inherit = ['mail.thread']
|
||||
_description = 'Flowchart Troubleshooting Run'
|
||||
_order = 'started_at desc, id desc'
|
||||
|
||||
name = fields.Char(
|
||||
compute='_compute_name',
|
||||
store=True,
|
||||
)
|
||||
repair_order_id = fields.Many2one(
|
||||
'repair.order',
|
||||
required=True,
|
||||
index=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
flowchart_id = fields.Many2one(
|
||||
'fusion.repair.flowchart',
|
||||
required=True,
|
||||
index=True,
|
||||
ondelete='restrict',
|
||||
)
|
||||
flowchart_version = fields.Integer(
|
||||
string='Chart Version Snapshot',
|
||||
help='Version of the flowchart at the time the run was started; '
|
||||
'so the transcript stays valid even if the chart is later edited.',
|
||||
)
|
||||
partner_id = fields.Many2one(
|
||||
related='repair_order_id.partner_id', store=True, readonly=True,
|
||||
)
|
||||
started_at = fields.Datetime(default=fields.Datetime.now, required=True)
|
||||
started_by_id = fields.Many2one(
|
||||
'res.users', default=lambda self: self.env.user, required=True,
|
||||
)
|
||||
completed_at = fields.Datetime()
|
||||
completed_by_id = fields.Many2one('res.users')
|
||||
outcome = fields.Selection(
|
||||
RUN_OUTCOMES,
|
||||
default='in_progress',
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
step_ids = fields.One2many(
|
||||
'fusion.repair.flowchart.run.step', 'run_id',
|
||||
string='Steps',
|
||||
)
|
||||
step_count = fields.Integer(compute='_compute_step_count')
|
||||
current_node_id = fields.Many2one(
|
||||
'fusion.repair.flowchart.node',
|
||||
compute='_compute_current_node',
|
||||
store=True,
|
||||
help='Where the runner is right now - last step\'s node if no edge '
|
||||
'was chosen yet, otherwise the edge\'s target node.',
|
||||
)
|
||||
transcript_html = fields.Html(
|
||||
compute='_compute_transcript',
|
||||
sanitize=False, # we control the markup
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
@api.depends('repair_order_id.name', 'flowchart_id.name')
|
||||
def _compute_name(self):
|
||||
for r in self:
|
||||
r.name = _('%s on %s') % (
|
||||
r.flowchart_id.name or '?', r.repair_order_id.name or '?',
|
||||
)
|
||||
|
||||
@api.depends('step_ids')
|
||||
def _compute_step_count(self):
|
||||
for r in self:
|
||||
r.step_count = len(r.step_ids)
|
||||
|
||||
@api.depends('step_ids.chosen_edge_id', 'step_ids.node_id',
|
||||
'flowchart_id.start_node_id')
|
||||
def _compute_current_node(self):
|
||||
for r in self:
|
||||
if not r.step_ids:
|
||||
r.current_node_id = r.flowchart_id.start_node_id
|
||||
continue
|
||||
last = r.step_ids[-1]
|
||||
# If they chose an edge on the last step, we should already be
|
||||
# showing the target. If not (just landed on a card), it's the
|
||||
# current node itself.
|
||||
if last.chosen_edge_id:
|
||||
r.current_node_id = last.chosen_edge_id.target_node_id
|
||||
else:
|
||||
r.current_node_id = last.node_id
|
||||
|
||||
@api.depends('step_ids.node_name_snapshot', 'step_ids.chosen_label_snapshot',
|
||||
'step_ids.cs_note', 'outcome')
|
||||
def _compute_transcript(self):
|
||||
for r in self:
|
||||
if not r.step_ids:
|
||||
r.transcript_html = '<p><em>No steps recorded yet.</em></p>'
|
||||
continue
|
||||
lines = ['<ol style="margin:0 0 8px 1.2em;padding:0;">']
|
||||
for s in r.step_ids:
|
||||
line = f'<li><strong>{s.node_name_snapshot or "?"}</strong>'
|
||||
if s.chosen_label_snapshot:
|
||||
line += f' → <em>{s.chosen_label_snapshot}</em>'
|
||||
if s.cs_note:
|
||||
line += f' <span style="color:#666;">(note: {s.cs_note})</span>'
|
||||
line += '</li>'
|
||||
lines.append(line)
|
||||
lines.append('</ol>')
|
||||
outcome_label = dict(self._fields['outcome'].selection).get(r.outcome, r.outcome)
|
||||
lines.append(f'<p><strong>Outcome:</strong> {outcome_label}</p>')
|
||||
r.transcript_html = ''.join(lines)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# RUNTIME RPC API (called from the OWL runner component)
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def runtime_start_or_resume(self, repair_id):
|
||||
"""Open or resume a troubleshooting run for the given repair.
|
||||
|
||||
- If an in-progress run exists, return it.
|
||||
- Otherwise resolve the flowchart from the repair's (category, symptom)
|
||||
and create a fresh run starting at the chart's start node.
|
||||
|
||||
Returns the dict shape that the OWL runner consumes.
|
||||
"""
|
||||
repair = self.env['repair.order'].browse(repair_id).exists()
|
||||
if not repair:
|
||||
raise UserError(_('Repair order not found.'))
|
||||
existing = self.search([
|
||||
('repair_order_id', '=', repair.id),
|
||||
('outcome', '=', 'in_progress'),
|
||||
], limit=1)
|
||||
if existing:
|
||||
return existing._runtime_dict()
|
||||
if not repair.x_fc_symptom_class_id:
|
||||
raise UserError(_('Pick a Symptom on the repair before starting '
|
||||
'troubleshooting.'))
|
||||
chart = self.env['fusion.repair.flowchart'].sudo().search([
|
||||
('category_id', '=', repair.x_fc_repair_category_id.id),
|
||||
('symptom_class_id', '=', repair.x_fc_symptom_class_id.id),
|
||||
('published', '=', True),
|
||||
('active', '=', True),
|
||||
], limit=1)
|
||||
if not chart:
|
||||
raise UserError(_(
|
||||
'No published troubleshooting flowchart found for '
|
||||
'%(cat)s + %(sym)s. Ask a manager to publish one in '
|
||||
'Configuration > Troubleshooting Flowcharts.'
|
||||
) % {
|
||||
'cat': repair.x_fc_repair_category_id.name or '?',
|
||||
'sym': repair.x_fc_symptom_class_id.name or '?',
|
||||
})
|
||||
run = self.sudo().create({
|
||||
'repair_order_id': repair.id,
|
||||
'flowchart_id': chart.id,
|
||||
'flowchart_version': chart.version,
|
||||
})
|
||||
return run._runtime_dict()
|
||||
|
||||
def runtime_choose(self, edge_id, cs_note=''):
|
||||
"""CS clicked an answer button on the current card.
|
||||
|
||||
Records a step (current_node + chosen_edge + note) and returns the
|
||||
updated run dict (which the OWL component uses to render the next card).
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.outcome != 'in_progress':
|
||||
raise UserError(_('This run has already been completed.'))
|
||||
edge = self.env['fusion.repair.flowchart.edge'].browse(edge_id).exists()
|
||||
if not edge:
|
||||
raise UserError(_('Chosen answer not found.'))
|
||||
if edge.source_node_id != self.current_node_id:
|
||||
raise UserError(_(
|
||||
'That answer does not belong to the current step. The chart '
|
||||
'may have been edited - please reload the runner.'
|
||||
))
|
||||
self.env['fusion.repair.flowchart.run.step'].sudo().create({
|
||||
'run_id': self.id,
|
||||
'sequence': len(self.step_ids) + 1,
|
||||
'node_id': self.current_node_id.id,
|
||||
'node_name_snapshot': self.current_node_id.name,
|
||||
'chosen_edge_id': edge.id,
|
||||
'chosen_label_snapshot': edge.label,
|
||||
'cs_note': cs_note or False,
|
||||
})
|
||||
# current_node_id auto-recomputes via depends
|
||||
self.invalidate_recordset(['current_node_id'])
|
||||
return self._runtime_dict()
|
||||
|
||||
def runtime_complete(self, outcome, cs_note=''):
|
||||
"""CS hit a terminal outcome (resolved / escalate / order_part) OR
|
||||
manually abandoned. Closes the run and triggers downstream effects."""
|
||||
self.ensure_one()
|
||||
valid = {'resolved', 'escalated', 'order_part', 'abandoned'}
|
||||
if outcome not in valid:
|
||||
raise UserError(_('Unknown outcome %s.') % outcome)
|
||||
if self.outcome != 'in_progress':
|
||||
return self._runtime_dict()
|
||||
# Snapshot the final node as a step so the transcript shows it.
|
||||
node = self.current_node_id
|
||||
if node and (not self.step_ids or self.step_ids[-1].node_id != node):
|
||||
self.env['fusion.repair.flowchart.run.step'].sudo().create({
|
||||
'run_id': self.id,
|
||||
'sequence': len(self.step_ids) + 1,
|
||||
'node_id': node.id,
|
||||
'node_name_snapshot': node.name,
|
||||
'cs_note': cs_note or False,
|
||||
})
|
||||
self.write({
|
||||
'outcome': outcome,
|
||||
'completed_at': fields.Datetime.now(),
|
||||
'completed_by_id': self.env.uid,
|
||||
})
|
||||
# Dispatch the side-effects on the repair (see _apply_outcome).
|
||||
self._apply_outcome()
|
||||
return self._runtime_dict()
|
||||
|
||||
def _apply_outcome(self):
|
||||
"""Run the side-effects on the parent repair depending on outcome."""
|
||||
for r in self:
|
||||
repair = r.repair_order_id
|
||||
transcript = Markup(r.transcript_html or '')
|
||||
if r.outcome == 'resolved':
|
||||
repair.x_fc_resolved_on_call = True
|
||||
repair.message_post(body=Markup(_(
|
||||
'<p><b>Resolved on call</b> via troubleshooting flowchart '
|
||||
'<i>%(name)s</i>:</p>%(transcript)s'
|
||||
)) % {'name': r.flowchart_id.name, 'transcript': transcript})
|
||||
# Close the repair through the Odoo state machine. Reuse the
|
||||
# visit-report wizard helper pattern.
|
||||
try:
|
||||
if repair.state == 'draft':
|
||||
try:
|
||||
repair.action_validate()
|
||||
except Exception:
|
||||
repair._action_repair_confirm()
|
||||
if repair.state == 'confirmed':
|
||||
repair.action_repair_start()
|
||||
if repair.state == 'under_repair':
|
||||
repair.action_repair_end()
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
'Could not auto-close repair %s after on-call '
|
||||
'resolution: %s', repair.name, e,
|
||||
)
|
||||
elif r.outcome == 'escalated':
|
||||
# Prepend the transcript to internal_notes so the tech sees
|
||||
# what was tried at the top of the form.
|
||||
existing = repair.internal_notes or ''
|
||||
header = Markup(
|
||||
'<p><b>CS troubleshooting summary (flowchart %s):</b></p>'
|
||||
) % r.flowchart_id.name
|
||||
repair.internal_notes = (header + transcript +
|
||||
Markup('<hr/>') + Markup(existing))
|
||||
repair.message_post(body=Markup(_(
|
||||
'<p><b>Escalated to technician</b> via troubleshooting '
|
||||
'flowchart <i>%(name)s</i>:</p>%(transcript)s'
|
||||
)) % {'name': r.flowchart_id.name, 'transcript': transcript})
|
||||
elif r.outcome == 'order_part':
|
||||
repair.message_post(body=Markup(_(
|
||||
'<p><b>Part order needed</b> per troubleshooting flowchart '
|
||||
'<i>%(name)s</i>:</p>%(transcript)s'
|
||||
)) % {'name': r.flowchart_id.name, 'transcript': transcript})
|
||||
else: # abandoned
|
||||
repair.message_post(body=Markup(_(
|
||||
'<p>Troubleshooting run abandoned by %(user)s.</p>'
|
||||
'%(transcript)s'
|
||||
)) % {
|
||||
'user': self.env.user.name,
|
||||
'transcript': transcript,
|
||||
})
|
||||
|
||||
def _runtime_dict(self):
|
||||
"""Serialise the run + current node + available edges for the OWL UI."""
|
||||
self.ensure_one()
|
||||
node = self.current_node_id
|
||||
outgoing = self.env['fusion.repair.flowchart.edge'].sudo().search([
|
||||
('source_node_id', '=', node.id),
|
||||
], order='sequence, id') if node else self.env['fusion.repair.flowchart.edge']
|
||||
return {
|
||||
'run_id': self.id,
|
||||
'repair_id': self.repair_order_id.id,
|
||||
'repair_name': self.repair_order_id.name,
|
||||
'partner_name': self.partner_id.name or '',
|
||||
'flowchart_id': self.flowchart_id.id,
|
||||
'flowchart_name': self.flowchart_id.name,
|
||||
'canvas_layout': self.flowchart_id.canvas_layout or '',
|
||||
'outcome': self.outcome,
|
||||
'current_node': node._designer_dict() if node else None,
|
||||
'options': [{
|
||||
'edge_id': e.id,
|
||||
'label': e.label or _('Continue'),
|
||||
'target_node_id': e.target_node_id.id,
|
||||
'target_node_type': e.target_node_id.node_type,
|
||||
'target_outcome_kind': e.target_node_id.outcome_kind or '',
|
||||
} for e in outgoing],
|
||||
'transcript_html': self.transcript_html,
|
||||
'visited_node_ids': [s.node_id.id for s in self.step_ids if s.node_id],
|
||||
'visited_edge_ids': [s.chosen_edge_id.id for s in self.step_ids if s.chosen_edge_id],
|
||||
'step_count': self.step_count,
|
||||
}
|
||||
|
||||
|
||||
class FusionRepairFlowchartRunStep(models.Model):
|
||||
_name = 'fusion.repair.flowchart.run.step'
|
||||
_description = 'Flowchart Run Step'
|
||||
_order = 'run_id, sequence, id'
|
||||
|
||||
run_id = fields.Many2one(
|
||||
'fusion.repair.flowchart.run',
|
||||
required=True,
|
||||
index=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
sequence = fields.Integer(default=10)
|
||||
node_id = fields.Many2one(
|
||||
'fusion.repair.flowchart.node',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
)
|
||||
node_name_snapshot = fields.Char(
|
||||
help='Frozen at the moment the step was recorded so transcript '
|
||||
'survives later chart edits.',
|
||||
)
|
||||
chosen_edge_id = fields.Many2one(
|
||||
'fusion.repair.flowchart.edge',
|
||||
ondelete='set null',
|
||||
)
|
||||
chosen_label_snapshot = fields.Char()
|
||||
cs_note = fields.Text()
|
||||
recorded_at = fields.Datetime(default=fields.Datetime.now)
|
||||
@@ -1010,10 +1010,86 @@ class RepairOrder(models.Model):
|
||||
index=True,
|
||||
)
|
||||
x_fc_issue_category = fields.Char(
|
||||
string='Issue Category',
|
||||
help='Symptom classification (e.g. "battery", "motor", "remote"). Used by '
|
||||
'service catalogue matcher and AI prompt context.',
|
||||
string='Issue Category (legacy)',
|
||||
help='LEGACY free-text symptom classification. Superseded by '
|
||||
'x_fc_symptom_class_id - kept for backwards compatibility with '
|
||||
'historical records and the AI prompt context. New intakes '
|
||||
'should set the M2O instead.',
|
||||
)
|
||||
# Bundle 11: proper structured symptom classification.
|
||||
x_fc_symptom_class_id = fields.Many2one(
|
||||
'fusion.repair.symptom.class',
|
||||
string='Symptom',
|
||||
index=True,
|
||||
tracking=True,
|
||||
domain="[('category_id', '=', x_fc_repair_category_id)]",
|
||||
help='Drives flowchart lookup. Pick the (category, symptom) pair and '
|
||||
'CS can start a troubleshooting flowchart if one is published.',
|
||||
)
|
||||
# Bundle 11: troubleshooting runs against this repair.
|
||||
x_fc_flowchart_run_ids = fields.One2many(
|
||||
'fusion.repair.flowchart.run', 'repair_order_id',
|
||||
string='Troubleshooting Runs',
|
||||
)
|
||||
x_fc_flowchart_run_count = fields.Integer(
|
||||
compute='_compute_flowchart_run_count',
|
||||
)
|
||||
x_fc_resolved_on_call = fields.Boolean(
|
||||
string='Resolved on Call',
|
||||
copy=False,
|
||||
tracking=True,
|
||||
help='True when the CS troubleshooting flowchart hit a "resolved" '
|
||||
'outcome - no technician was ever dispatched.',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_flowchart_run_ids')
|
||||
def _compute_flowchart_run_count(self):
|
||||
for r in self:
|
||||
r.x_fc_flowchart_run_count = len(r.x_fc_flowchart_run_ids)
|
||||
|
||||
def action_view_flowchart_runs(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Troubleshooting Runs'),
|
||||
'res_model': 'fusion.repair.flowchart.run',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('repair_order_id', '=', self.id)],
|
||||
'context': {'default_repair_order_id': self.id},
|
||||
}
|
||||
|
||||
def action_start_troubleshoot(self):
|
||||
"""Open the OWL flowchart runner for this repair (creates or resumes
|
||||
an in-progress run). Raises a helpful UserError if the symptom is
|
||||
not picked or no published chart exists for the (category, symptom)
|
||||
pair."""
|
||||
self.ensure_one()
|
||||
if not self.x_fc_symptom_class_id:
|
||||
raise UserError(_(
|
||||
'Pick a Symptom on this repair first (Callout Pricing tab or '
|
||||
'the Fusion Repairs section), then start troubleshooting.'
|
||||
))
|
||||
chart = self.env['fusion.repair.flowchart'].sudo().search([
|
||||
('category_id', '=', self.x_fc_repair_category_id.id),
|
||||
('symptom_class_id', '=', self.x_fc_symptom_class_id.id),
|
||||
('published', '=', True),
|
||||
('active', '=', True),
|
||||
], limit=1)
|
||||
if not chart:
|
||||
raise UserError(_(
|
||||
'No published troubleshooting flowchart for %(cat)s + %(sym)s. '
|
||||
'Ask a manager to publish one in Configuration > '
|
||||
'Troubleshooting Flowcharts.'
|
||||
) % {
|
||||
'cat': self.x_fc_repair_category_id.name or '?',
|
||||
'sym': self.x_fc_symptom_class_id.name or '?',
|
||||
})
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fusion_repair_flowchart_runner',
|
||||
'name': _('Troubleshooting - %s') % self.name,
|
||||
'params': {'repair_id': self.id},
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PHOTOS
|
||||
|
||||
@@ -16,13 +16,28 @@ visit-report wizard in a structured way so:
|
||||
auto-creates a follow-up dispatch task
|
||||
|
||||
The grumpy-old-client never has to call us asking for status updates.
|
||||
|
||||
Bundle 11 extension: tech often orders the part directly from the factory
|
||||
WHILE on the phone. They want to:
|
||||
- Pick the vendor from our contacts (filtered to vendors)
|
||||
- Capture the OEM part #, cost, ETA date
|
||||
- Auto-create a draft purchase.order line (office reviews + sends)
|
||||
- Capture the factory's ticket # + RA # for tracking and warranty claims
|
||||
- Tell the system whether the factory said it's under warranty
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
SPARE_PARTS_CATEGORY_NAME = 'Spare Parts'
|
||||
|
||||
|
||||
class FusionRepairPartOrder(models.Model):
|
||||
@@ -109,6 +124,64 @@ class FusionRepairPartOrder(models.Model):
|
||||
copy=False,
|
||||
)
|
||||
|
||||
# Bundle 11: vendor + PO + cost + factory tracking refs.
|
||||
vendor_partner_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Vendor',
|
||||
tracking=True,
|
||||
domain="[('is_company', '=', True)]",
|
||||
help='Pick from our existing vendors. Filtered to companies; the tech '
|
||||
'usually knows the factory by name (Handicare, Bruno, Pride...).',
|
||||
)
|
||||
purchase_order_id = fields.Many2one(
|
||||
'purchase.order',
|
||||
string='Draft Purchase Order',
|
||||
readonly=True,
|
||||
copy=False,
|
||||
ondelete='set null',
|
||||
help='Auto-created in DRAFT state when the tech submits a part order '
|
||||
'with a vendor + cost. Office reviews and sends it to the factory.',
|
||||
)
|
||||
product_id = fields.Many2one(
|
||||
'product.product',
|
||||
string='Product',
|
||||
readonly=True,
|
||||
copy=False,
|
||||
help='Auto-resolved or created based on the OEM part number.',
|
||||
)
|
||||
unit_cost = fields.Monetary(
|
||||
string='Unit Cost',
|
||||
currency_field='currency_id',
|
||||
tracking=True,
|
||||
help='Per-unit cost from the factory. Used as price_unit on the draft PO.',
|
||||
)
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency',
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
internal_po_ref = fields.Char(
|
||||
string='PO Reference (read to factory)',
|
||||
tracking=True,
|
||||
help='The Westin PO number the tech read to the factory for tracking. '
|
||||
'Stamped on the draft purchase.order as partner_ref.',
|
||||
)
|
||||
factory_ticket_ref = fields.Char(
|
||||
string='Factory Ticket #',
|
||||
tracking=True,
|
||||
help='Ticket number from the factory call - used for follow-up enquiries.',
|
||||
)
|
||||
factory_ra_number = fields.Char(
|
||||
string='Factory RA #',
|
||||
tracking=True,
|
||||
help='Return Authorization number, when the factory issued a warranty '
|
||||
'replacement that requires the old part returned.',
|
||||
)
|
||||
under_warranty = fields.Boolean(
|
||||
string='Factory Warranty',
|
||||
tracking=True,
|
||||
help='Tick when the factory confirmed warranty coverage on the call.',
|
||||
)
|
||||
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
default=lambda self: self.env.company,
|
||||
@@ -144,6 +217,103 @@ class FusionRepairPartOrder(models.Model):
|
||||
# ------------------------------------------------------------------
|
||||
# ACTIONS
|
||||
# ------------------------------------------------------------------
|
||||
def action_create_draft_po(self):
|
||||
"""Bundle 11: build a draft purchase.order for this part.
|
||||
|
||||
Idempotent - returns the existing PO if one is already linked.
|
||||
Resolves / creates the product from the OEM number, sets the line
|
||||
cost from unit_cost, stamps Westin's internal PO ref on the
|
||||
purchase.order's partner_ref so the factory can find it. Office
|
||||
reviews + confirms via the normal Odoo flow.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.purchase_order_id:
|
||||
return self._open_record(self.purchase_order_id)
|
||||
if not self.vendor_partner_id:
|
||||
raise UserError(_(
|
||||
'Pick a Vendor first - the draft PO needs to know who to send to.'
|
||||
))
|
||||
product = self._resolve_or_create_product()
|
||||
PO = self.env['purchase.order'].sudo()
|
||||
# Let Odoo derive product_uom from the product to avoid Odoo 19's
|
||||
# uom_po_id moving between template/variant: passing product_id is
|
||||
# enough for the PO line's onchange to populate the UOM correctly.
|
||||
line_vals = {
|
||||
'product_id': product.id,
|
||||
'name': self.description or product.display_name,
|
||||
'product_qty': self.quantity or 1.0,
|
||||
'price_unit': self.unit_cost or 0.0,
|
||||
'date_planned': fields.Datetime.now() + timedelta(
|
||||
days=7 if not self.expected_date
|
||||
else max((self.expected_date - fields.Date.context_today(self)).days, 0)
|
||||
),
|
||||
}
|
||||
try:
|
||||
order = PO.create({
|
||||
'partner_id': self.vendor_partner_id.id,
|
||||
'partner_ref': self.internal_po_ref or False,
|
||||
'origin': self.repair_order_id.name or self.name,
|
||||
'company_id': self.company_id.id,
|
||||
'order_line': [(0, 0, line_vals)],
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('Could not create draft PO for part %s: %s', self.name, e)
|
||||
raise UserError(_(
|
||||
'Could not create the draft purchase order: %s. The part info '
|
||||
'was saved - you can create the PO manually from Purchase > '
|
||||
'Orders > New.'
|
||||
) % e)
|
||||
self.write({
|
||||
'purchase_order_id': order.id,
|
||||
'product_id': product.id,
|
||||
})
|
||||
self.message_post(body=Markup(_(
|
||||
'Draft purchase order <b>%(po)s</b> created with %(vendor)s. '
|
||||
'Office to review and send.'
|
||||
)) % {'po': order.name, 'vendor': self.vendor_partner_id.name})
|
||||
return self._open_record(order)
|
||||
|
||||
def _resolve_or_create_product(self):
|
||||
"""Find product.product by OEM part number (default_code) or create
|
||||
a new service-type product in the 'Spare Parts' category. Returns
|
||||
the product record."""
|
||||
self.ensure_one()
|
||||
Product = self.env['product.product'].sudo()
|
||||
if self.product_id:
|
||||
return self.product_id
|
||||
if self.oem_part_number:
|
||||
hit = Product.search([
|
||||
('default_code', '=', self.oem_part_number),
|
||||
], limit=1)
|
||||
if hit:
|
||||
return hit
|
||||
# Create new. Use "service" type so it doesn't need inventory tracking,
|
||||
# purchaseable + can be added to PO lines without warehouse setup.
|
||||
ProductCategory = self.env['product.category'].sudo()
|
||||
category = ProductCategory.search([
|
||||
('name', '=', SPARE_PARTS_CATEGORY_NAME),
|
||||
], limit=1)
|
||||
if not category:
|
||||
category = ProductCategory.create({'name': SPARE_PARTS_CATEGORY_NAME})
|
||||
return Product.create({
|
||||
'name': self.description or (self.oem_part_number or 'Spare Part'),
|
||||
'default_code': self.oem_part_number or False,
|
||||
'type': 'consu',
|
||||
'purchase_ok': True,
|
||||
'sale_ok': False,
|
||||
'categ_id': category.id,
|
||||
'standard_price': self.unit_cost or 0.0,
|
||||
})
|
||||
|
||||
def _open_record(self, record):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': record.display_name,
|
||||
'res_model': record._name,
|
||||
'view_mode': 'form',
|
||||
'res_id': record.id,
|
||||
}
|
||||
|
||||
def action_mark_ordered(self):
|
||||
"""Office marks this part as ordered with the manufacturer."""
|
||||
for rec in self:
|
||||
@@ -153,6 +323,7 @@ class FusionRepairPartOrder(models.Model):
|
||||
if not rec.expected_date:
|
||||
rec.expected_date = fields.Date.context_today(rec) + timedelta(days=7)
|
||||
rec._notify_client_parts_ordered()
|
||||
rec._schedule_eta_activity()
|
||||
|
||||
def action_mark_received(self):
|
||||
"""Office marks this part as received - triggers follow-up dispatch."""
|
||||
@@ -197,6 +368,39 @@ class FusionRepairPartOrder(models.Model):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _schedule_eta_activity(self):
|
||||
"""Bundle 11: schedule an activity on the repair so the office is
|
||||
reminded on the ETA day to call the client back and book the
|
||||
return visit."""
|
||||
for rec in self:
|
||||
repair = rec.repair_order_id
|
||||
if not repair or not rec.expected_date:
|
||||
continue
|
||||
try:
|
||||
act_type = self.env.ref(
|
||||
'fusion_repairs.mail_activity_type_assign_technician',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
repair.activity_schedule(
|
||||
activity_type_id=act_type.id if act_type else False,
|
||||
date_deadline=rec.expected_date,
|
||||
summary=_('Parts arriving (%s) - schedule re-visit') % rec.name,
|
||||
note=_(
|
||||
'Part %(ref)s (%(desc)s) from %(vendor)s is expected '
|
||||
'today. Call the client to confirm a return visit.'
|
||||
) % {
|
||||
'ref': rec.name,
|
||||
'desc': rec.description or '',
|
||||
'vendor': rec.vendor_partner_id.name or rec.manufacturer or '?',
|
||||
},
|
||||
user_id=repair.user_id.id or self.env.uid,
|
||||
)
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
'Could not schedule ETA activity for part %s on repair %s',
|
||||
rec.name, repair.name,
|
||||
)
|
||||
|
||||
def _maybe_redispatch(self):
|
||||
"""When the LAST outstanding part on a repair arrives, auto-create
|
||||
a follow-up tech task so the office doesn't have to remember.
|
||||
|
||||
58
fusion_repairs/models/repair_symptom_class.py
Normal file
58
fusion_repairs/models/repair_symptom_class.py
Normal file
@@ -0,0 +1,58 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
"""Symptom classification.
|
||||
|
||||
Lookup table replacing the flat `x_fc_issue_category` Char on repair.order.
|
||||
One symptom class belongs to one equipment category (stairlift > "Not Moving",
|
||||
"Beeps Alarm", "Stops Midway"...). Together with the category, it's the key
|
||||
that fusion.repair.flowchart uses to look up which troubleshooting flowchart
|
||||
to run.
|
||||
"""
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FusionRepairSymptomClass(models.Model):
|
||||
_name = 'fusion.repair.symptom.class'
|
||||
_description = 'Repair Symptom Class'
|
||||
_order = 'category_id, sequence, name'
|
||||
|
||||
name = fields.Char(string='Name', required=True, translate=True)
|
||||
code = fields.Char(
|
||||
string='Code',
|
||||
required=True,
|
||||
help='Stable code used by data files and automation (e.g. "not_moving").',
|
||||
)
|
||||
category_id = fields.Many2one(
|
||||
'fusion.repair.product.category',
|
||||
string='Equipment Category',
|
||||
required=True,
|
||||
index=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
sequence = fields.Integer(default=10)
|
||||
icon = fields.Char(
|
||||
string='Icon',
|
||||
default='fa-exclamation-circle',
|
||||
help='Font Awesome icon class shown next to the symptom in pickers.',
|
||||
)
|
||||
description = fields.Text(translate=True)
|
||||
active = fields.Boolean(default=True)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
_code_per_category_unique = models.Constraint(
|
||||
'unique(category_id, code, company_id)',
|
||||
'Symptom code must be unique within a category.',
|
||||
)
|
||||
|
||||
@api.depends('name', 'category_id.name')
|
||||
def _compute_display_name(self):
|
||||
for r in self:
|
||||
r.display_name = (
|
||||
f"{r.category_id.name} - {r.name}" if r.category_id else (r.name or '')
|
||||
)
|
||||
@@ -47,6 +47,24 @@ access_callout_rate_user,Callout Rate User Read,model_fusion_repair_callout_rate
|
||||
access_callout_rate_manager,Callout Rate Manager Full,model_fusion_repair_callout_rate,group_fusion_repairs_manager,1,1,1,1
|
||||
access_delivery_charge_user,Delivery Charge User Read,model_fusion_repair_delivery_charge,group_fusion_repairs_user,1,0,0,0
|
||||
access_delivery_charge_manager,Delivery Charge Manager Full,model_fusion_repair_delivery_charge,group_fusion_repairs_manager,1,1,1,1
|
||||
access_symptom_class_user,Symptom Class User Read,model_fusion_repair_symptom_class,group_fusion_repairs_user,1,0,0,0
|
||||
access_symptom_class_manager,Symptom Class Manager Full,model_fusion_repair_symptom_class,group_fusion_repairs_manager,1,1,1,1
|
||||
access_symptom_class_tech,Symptom Class Field Tech Read,model_fusion_repair_symptom_class,fusion_tasks.group_field_technician,1,0,0,0
|
||||
access_flowchart_user,Flowchart User Read,model_fusion_repair_flowchart,group_fusion_repairs_user,1,0,0,0
|
||||
access_flowchart_manager,Flowchart Manager Full,model_fusion_repair_flowchart,group_fusion_repairs_manager,1,1,1,1
|
||||
access_flowchart_tech,Flowchart Field Tech Read,model_fusion_repair_flowchart,fusion_tasks.group_field_technician,1,0,0,0
|
||||
access_flowchart_node_user,Flowchart Node User Read,model_fusion_repair_flowchart_node,group_fusion_repairs_user,1,0,0,0
|
||||
access_flowchart_node_manager,Flowchart Node Manager Full,model_fusion_repair_flowchart_node,group_fusion_repairs_manager,1,1,1,1
|
||||
access_flowchart_node_tech,Flowchart Node Field Tech Read,model_fusion_repair_flowchart_node,fusion_tasks.group_field_technician,1,0,0,0
|
||||
access_flowchart_edge_user,Flowchart Edge User Read,model_fusion_repair_flowchart_edge,group_fusion_repairs_user,1,0,0,0
|
||||
access_flowchart_edge_manager,Flowchart Edge Manager Full,model_fusion_repair_flowchart_edge,group_fusion_repairs_manager,1,1,1,1
|
||||
access_flowchart_edge_tech,Flowchart Edge Field Tech Read,model_fusion_repair_flowchart_edge,fusion_tasks.group_field_technician,1,0,0,0
|
||||
access_flowchart_run_user,Flowchart Run User Full,model_fusion_repair_flowchart_run,group_fusion_repairs_user,1,1,1,0
|
||||
access_flowchart_run_manager,Flowchart Run Manager Full,model_fusion_repair_flowchart_run,group_fusion_repairs_manager,1,1,1,1
|
||||
access_flowchart_run_tech,Flowchart Run Field Tech Read,model_fusion_repair_flowchart_run,fusion_tasks.group_field_technician,1,0,0,0
|
||||
access_flowchart_run_step_user,Flowchart Run Step User Full,model_fusion_repair_flowchart_run_step,group_fusion_repairs_user,1,1,1,0
|
||||
access_flowchart_run_step_manager,Flowchart Run Step Manager Full,model_fusion_repair_flowchart_run_step,group_fusion_repairs_manager,1,1,1,1
|
||||
access_flowchart_run_step_tech,Flowchart Run Step Field Tech Read,model_fusion_repair_flowchart_run_step,fusion_tasks.group_field_technician,1,0,0,0
|
||||
access_labor_warranty_user,Labor Warranty User Read,model_fusion_repair_labor_warranty,group_fusion_repairs_user,1,0,0,0
|
||||
access_labor_warranty_sales_rep,Labor Warranty Sales Rep Write,model_fusion_repair_labor_warranty,group_fusion_repairs_sales_rep,1,1,0,0
|
||||
access_labor_warranty_manager,Labor Warranty Manager Full,model_fusion_repair_labor_warranty,group_fusion_repairs_manager,1,1,1,1
|
||||
|
||||
|
@@ -0,0 +1,410 @@
|
||||
/** @odoo-module **/
|
||||
/*
|
||||
* Drag-and-drop flowchart designer (Drawflow + OWL).
|
||||
*
|
||||
* Opened as a client action `fusion_repair_flowchart_designer` from the
|
||||
* fusion.repair.flowchart form's 'Open Designer' header button. Loads the
|
||||
* chart via designer_load RPC, renders Drawflow nodes by node_type, lets
|
||||
* admin drag/connect/edit, and saves the whole snapshot back via
|
||||
* designer_save (atomic replace - matches the model's API).
|
||||
*
|
||||
* Node types -> CSS color band:
|
||||
* question - blue (multiple outgoing edges, ask user a question)
|
||||
* suggestion - green (ask user to try something, then "Worked?" branches)
|
||||
* info - gray (informational, single continue)
|
||||
* outcome - red/green/amber depending on outcome_kind
|
||||
*
|
||||
* Drawflow JSON serialises positions + connections. Our model also stores
|
||||
* structured nodes/edges for query + runtime, so the canvas_layout blob is
|
||||
* the source of truth for the designer view and the node/edge tables are
|
||||
* the source of truth for the runtime traversal.
|
||||
*/
|
||||
import { Component, onMounted, onWillUnmount, useRef, useState } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { loadJS, loadCSS } from "@web/core/assets";
|
||||
|
||||
const DRAWFLOW_JS = "/fusion_repairs/static/src/lib/drawflow/drawflow.min.js";
|
||||
const DRAWFLOW_CSS = "/fusion_repairs/static/src/lib/drawflow/drawflow.min.css";
|
||||
|
||||
// Color tokens reused via SCSS too - keep in sync.
|
||||
const NODE_HEADERS = {
|
||||
question: { color: "#2563eb", label: "Question" },
|
||||
suggestion: { color: "#16a34a", label: "Suggestion" },
|
||||
info: { color: "#6b7280", label: "Info" },
|
||||
outcome: { color: "#dc2626", label: "Outcome" },
|
||||
};
|
||||
const OUTCOME_COLORS = {
|
||||
resolved: "#16a34a",
|
||||
escalate: "#dc2626",
|
||||
order_part: "#d97706",
|
||||
};
|
||||
|
||||
export class FlowchartDesigner extends Component {
|
||||
static template = "fusion_repairs.FlowchartDesigner";
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.canvasRef = useRef("canvas");
|
||||
this.editorPanelRef = useRef("editorPanel");
|
||||
|
||||
// Read flowchart_id from the action params. Cursor + OWL action
|
||||
// service exposes them under this.props.action.params.
|
||||
this.flowchartId = this.props.action?.params?.flowchart_id;
|
||||
if (!this.flowchartId) {
|
||||
this.notification.add("Missing flowchart_id - open from the form view.", { type: "danger" });
|
||||
}
|
||||
|
||||
this.state = useState({
|
||||
loading: true,
|
||||
chart: null,
|
||||
selectedNodeId: null,
|
||||
dirty: false,
|
||||
saving: false,
|
||||
// client_id -> drawflow numeric id mapping for new nodes (those
|
||||
// without a DB id yet, created via "Add Node")
|
||||
clientToDfId: {},
|
||||
// dfId -> {client_id, node_type, name, content_html, is_start,
|
||||
// outcome_kind, media_ids}
|
||||
nodeMeta: {},
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadJS(DRAWFLOW_JS), loadCSS(DRAWFLOW_CSS)]);
|
||||
this._initDrawflow();
|
||||
await this._loadChart();
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
try { this.editor?.clear(); } catch {}
|
||||
});
|
||||
}
|
||||
|
||||
_initDrawflow() {
|
||||
// eslint-disable-next-line no-undef
|
||||
this.editor = new Drawflow(this.canvasRef.el);
|
||||
this.editor.reroute = true;
|
||||
this.editor.curvature = 0.5;
|
||||
this.editor.editor_mode = "edit";
|
||||
this.editor.start();
|
||||
this.editor.on("nodeSelected", (id) => {
|
||||
const meta = this.state.nodeMeta[id];
|
||||
this.state.selectedNodeId = id;
|
||||
this.state.selectedClientId = meta?.client_id || null;
|
||||
});
|
||||
this.editor.on("nodeUnselected", () => {
|
||||
this.state.selectedNodeId = null;
|
||||
});
|
||||
this.editor.on("nodeCreated", () => { this.state.dirty = true; });
|
||||
this.editor.on("nodeRemoved", () => { this.state.dirty = true; });
|
||||
this.editor.on("nodeMoved", () => { this.state.dirty = true; });
|
||||
this.editor.on("connectionCreated", () => { this.state.dirty = true; });
|
||||
this.editor.on("connectionRemoved", () => { this.state.dirty = true; });
|
||||
}
|
||||
|
||||
async _loadChart() {
|
||||
try {
|
||||
const chart = await rpc("/web/dataset/call_kw/fusion.repair.flowchart/designer_load", {
|
||||
model: "fusion.repair.flowchart",
|
||||
method: "designer_load",
|
||||
args: [this.flowchartId],
|
||||
kwargs: {},
|
||||
});
|
||||
this.state.chart = chart;
|
||||
this.state.loading = false;
|
||||
// Map DB id -> client_id (we just use the DB id as the client_id
|
||||
// when loading from server; new nodes get "tmp-N" client ids).
|
||||
this._renderChart(chart);
|
||||
} catch (e) {
|
||||
this.notification.add("Failed to load flowchart: " + (e?.message || e), { type: "danger" });
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
_renderChart(chart) {
|
||||
this.editor.clear();
|
||||
this.state.clientToDfId = {};
|
||||
this.state.nodeMeta = {};
|
||||
|
||||
// Add each node.
|
||||
for (const n of chart.nodes) {
|
||||
const clientId = String(n.id);
|
||||
const html = this._renderNodeBody(n);
|
||||
// Drawflow: addNode(name, inputs, outputs, posx, posy, class, data, html)
|
||||
const outputs = n.node_type === "outcome" ? 0 : 1;
|
||||
const inputs = n.is_start ? 0 : 1;
|
||||
const cssClass = `fr-node fr-node-${n.node_type} ${n.is_start ? "fr-node-start" : ""}`;
|
||||
const dfId = this.editor.addNode(
|
||||
clientId,
|
||||
inputs, outputs,
|
||||
n.canvas_x || 100, n.canvas_y || 100,
|
||||
cssClass,
|
||||
{ client_id: clientId },
|
||||
html,
|
||||
);
|
||||
this.state.clientToDfId[clientId] = dfId;
|
||||
this.state.nodeMeta[dfId] = {
|
||||
client_id: clientId,
|
||||
db_id: n.id,
|
||||
name: n.name,
|
||||
node_type: n.node_type,
|
||||
content_html: n.content_html || "",
|
||||
is_start: n.is_start,
|
||||
outcome_kind: n.outcome_kind || "",
|
||||
media_ids: n.media_ids || [],
|
||||
media: n.media || [],
|
||||
};
|
||||
}
|
||||
|
||||
// Connect edges.
|
||||
for (const e of chart.edges) {
|
||||
const srcDf = this.state.clientToDfId[String(e.source_node_id)];
|
||||
const tgtDf = this.state.clientToDfId[String(e.target_node_id)];
|
||||
if (!srcDf || !tgtDf) continue;
|
||||
this.editor.addConnection(srcDf, tgtDf, "output_1", "input_1");
|
||||
// Drawflow doesn't natively label edges - we stash the label in
|
||||
// the edge's data via a follow-up mutation.
|
||||
// Save/restore handles it via our state.edgeLabels below.
|
||||
this.state.edgeLabels = this.state.edgeLabels || {};
|
||||
this.state.edgeLabels[`${srcDf}->${tgtDf}`] = e.label || "";
|
||||
}
|
||||
|
||||
this.state.dirty = false;
|
||||
}
|
||||
|
||||
_renderNodeBody(n) {
|
||||
const header = NODE_HEADERS[n.node_type] || NODE_HEADERS.info;
|
||||
const color = n.node_type === "outcome"
|
||||
? (OUTCOME_COLORS[n.outcome_kind] || header.color)
|
||||
: header.color;
|
||||
const safeName = (n.name || "Untitled").replace(/</g, "<");
|
||||
const safeContent = (n.content_html || "").slice(0, 280);
|
||||
const startBadge = n.is_start
|
||||
? '<span class="fr-start-badge">START</span>' : '';
|
||||
const outcomeBadge = n.node_type === "outcome" && n.outcome_kind
|
||||
? `<span class="fr-outcome-badge">${n.outcome_kind.toUpperCase()}</span>` : '';
|
||||
const mediaCount = (n.media || n.media_ids || []).length;
|
||||
const mediaBadge = mediaCount
|
||||
? `<span class="fr-media-badge"><i class="fa fa-image"></i> ${mediaCount}</span>` : '';
|
||||
return `
|
||||
<div class="fr-node-card" style="border-top-color:${color};">
|
||||
<div class="fr-node-head" style="background:${color};">
|
||||
${header.label} ${startBadge} ${outcomeBadge}
|
||||
</div>
|
||||
<div class="fr-node-title">${safeName}</div>
|
||||
<div class="fr-node-body">${safeContent}</div>
|
||||
<div class="fr-node-foot">${mediaBadge}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TOOLBAR ACTIONS
|
||||
// ------------------------------------------------------------------
|
||||
onAddNode(nodeType) {
|
||||
const clientId = "tmp-" + Date.now();
|
||||
const meta = {
|
||||
client_id: clientId,
|
||||
db_id: null,
|
||||
name: `New ${nodeType}`,
|
||||
node_type: nodeType,
|
||||
content_html: "",
|
||||
is_start: false,
|
||||
outcome_kind: nodeType === "outcome" ? "escalate" : "",
|
||||
media_ids: [],
|
||||
media: [],
|
||||
};
|
||||
const outputs = nodeType === "outcome" ? 0 : 1;
|
||||
const html = this._renderNodeBody(meta);
|
||||
const dfId = this.editor.addNode(
|
||||
clientId,
|
||||
1, outputs,
|
||||
120, 120,
|
||||
`fr-node fr-node-${nodeType}`,
|
||||
{ client_id: clientId },
|
||||
html,
|
||||
);
|
||||
this.state.clientToDfId[clientId] = dfId;
|
||||
this.state.nodeMeta[dfId] = meta;
|
||||
this.state.selectedNodeId = dfId;
|
||||
this.state.selectedClientId = clientId;
|
||||
this.state.dirty = true;
|
||||
}
|
||||
|
||||
onZoomIn() { this.editor.zoom_in(); }
|
||||
onZoomOut() { this.editor.zoom_out(); }
|
||||
onZoomReset() { this.editor.zoom_reset(); }
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// EDITOR PANEL (right side - edit selected node's metadata)
|
||||
// ------------------------------------------------------------------
|
||||
onEditorFieldChange(field, value) {
|
||||
if (!this.state.selectedNodeId) return;
|
||||
const meta = this.state.nodeMeta[this.state.selectedNodeId];
|
||||
if (!meta) return;
|
||||
meta[field] = value;
|
||||
// Re-render the node body to reflect the change visually.
|
||||
const dfNode = this.editor.getNodeFromId(this.state.selectedNodeId);
|
||||
if (dfNode) {
|
||||
const html = this._renderNodeBody(meta);
|
||||
const el = document.getElementById("node-" + this.state.selectedNodeId);
|
||||
if (el) {
|
||||
const content = el.querySelector(".drawflow_content_node");
|
||||
if (content) content.innerHTML = html;
|
||||
}
|
||||
}
|
||||
this.state.dirty = true;
|
||||
}
|
||||
|
||||
onSetStart() {
|
||||
if (!this.state.selectedNodeId) return;
|
||||
// Clear is_start on every other node, set on the selected.
|
||||
for (const [dfId, meta] of Object.entries(this.state.nodeMeta)) {
|
||||
meta.is_start = (parseInt(dfId, 10) === this.state.selectedNodeId);
|
||||
}
|
||||
this.state.dirty = true;
|
||||
// Re-render all nodes' bodies so the START badge updates.
|
||||
for (const dfId of Object.keys(this.state.nodeMeta)) {
|
||||
const meta = this.state.nodeMeta[dfId];
|
||||
const html = this._renderNodeBody(meta);
|
||||
const el = document.getElementById("node-" + dfId);
|
||||
if (el) {
|
||||
const content = el.querySelector(".drawflow_content_node");
|
||||
if (content) content.innerHTML = html;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onUploadMedia(ev) {
|
||||
if (!this.state.selectedNodeId) return;
|
||||
const files = ev.target.files;
|
||||
if (!files || !files.length) return;
|
||||
const meta = this.state.nodeMeta[this.state.selectedNodeId];
|
||||
for (const file of files) {
|
||||
const buf = await file.arrayBuffer();
|
||||
const b64 = btoa(
|
||||
new Uint8Array(buf).reduce((s, b) => s + String.fromCharCode(b), "")
|
||||
);
|
||||
const att = await rpc("/web/dataset/call_kw/ir.attachment/create", {
|
||||
model: "ir.attachment",
|
||||
method: "create",
|
||||
args: [{
|
||||
name: file.name,
|
||||
datas: b64,
|
||||
mimetype: file.type || "application/octet-stream",
|
||||
res_model: "fusion.repair.flowchart.node",
|
||||
}],
|
||||
kwargs: {},
|
||||
});
|
||||
meta.media_ids.push(att);
|
||||
meta.media.push({ id: att, name: file.name, mimetype: file.type, url: `/web/image/${att}` });
|
||||
}
|
||||
this.state.dirty = true;
|
||||
// Re-render body to bump the badge count.
|
||||
this.onEditorFieldChange("media_ids", meta.media_ids);
|
||||
ev.target.value = "";
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// SAVE
|
||||
// ------------------------------------------------------------------
|
||||
async onSave() {
|
||||
if (this.state.saving) return;
|
||||
this.state.saving = true;
|
||||
try {
|
||||
// Pull the current Drawflow state.
|
||||
const dfData = this.editor.export();
|
||||
const nodesPayload = [];
|
||||
const edgesPayload = [];
|
||||
// dfData.drawflow.Home.data is { [dfId]: { class, data, html, pos_x, pos_y, inputs, outputs } }
|
||||
const home = dfData.drawflow?.Home?.data || {};
|
||||
for (const [dfId, node] of Object.entries(home)) {
|
||||
const meta = this.state.nodeMeta[parseInt(dfId, 10)];
|
||||
if (!meta) continue;
|
||||
nodesPayload.push({
|
||||
client_id: meta.client_id,
|
||||
name: meta.name,
|
||||
node_type: meta.node_type,
|
||||
content_html: meta.content_html,
|
||||
is_start: !!meta.is_start,
|
||||
outcome_kind: meta.outcome_kind,
|
||||
canvas_x: Math.round(node.pos_x || 0),
|
||||
canvas_y: Math.round(node.pos_y || 0),
|
||||
media_ids: meta.media_ids || [],
|
||||
});
|
||||
// Outgoing connections - drawflow stores them on the source node's outputs.
|
||||
const outs = node.outputs || {};
|
||||
let seq = 10;
|
||||
for (const out of Object.values(outs)) {
|
||||
for (const conn of out.connections || []) {
|
||||
const tgtMeta = this.state.nodeMeta[parseInt(conn.node, 10)];
|
||||
if (!tgtMeta) continue;
|
||||
const key = `${dfId}->${conn.node}`;
|
||||
const label = this.state.edgeLabels?.[key] || "";
|
||||
edgesPayload.push({
|
||||
source_client_id: meta.client_id,
|
||||
target_client_id: tgtMeta.client_id,
|
||||
label: label,
|
||||
sequence: seq,
|
||||
});
|
||||
seq += 10;
|
||||
}
|
||||
}
|
||||
}
|
||||
const result = await rpc("/web/dataset/call_kw/fusion.repair.flowchart/designer_save", {
|
||||
model: "fusion.repair.flowchart",
|
||||
method: "designer_save",
|
||||
args: [[this.flowchartId], {
|
||||
canvas_layout: JSON.stringify(dfData),
|
||||
nodes: nodesPayload,
|
||||
edges: edgesPayload,
|
||||
}],
|
||||
kwargs: {},
|
||||
});
|
||||
this.notification.add(`Saved (version ${result.version})`, { type: "success" });
|
||||
this.state.chart = result;
|
||||
this._renderChart(result);
|
||||
this.state.dirty = false;
|
||||
} catch (e) {
|
||||
this.notification.add("Save failed: " + (e?.data?.message || e?.message || e), { type: "danger" });
|
||||
} finally {
|
||||
this.state.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
onEdgeLabelChange(ev, key) {
|
||||
this.state.edgeLabels = this.state.edgeLabels || {};
|
||||
this.state.edgeLabels[key] = ev.target.value;
|
||||
this.state.dirty = true;
|
||||
}
|
||||
|
||||
get selectedMeta() {
|
||||
if (!this.state.selectedNodeId) return null;
|
||||
return this.state.nodeMeta[this.state.selectedNodeId];
|
||||
}
|
||||
|
||||
get outgoingEdgesForSelected() {
|
||||
if (!this.state.selectedNodeId || !this.editor) return [];
|
||||
try {
|
||||
const node = this.editor.getNodeFromId(this.state.selectedNodeId);
|
||||
const outs = node?.outputs || {};
|
||||
const result = [];
|
||||
for (const out of Object.values(outs)) {
|
||||
for (const conn of out.connections || []) {
|
||||
const tgtMeta = this.state.nodeMeta[parseInt(conn.node, 10)];
|
||||
const key = `${this.state.selectedNodeId}->${conn.node}`;
|
||||
result.push({
|
||||
target_name: tgtMeta?.name || `Node ${conn.node}`,
|
||||
key: key,
|
||||
label: this.state.edgeLabels?.[key] || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch { return []; }
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("fusion_repair_flowchart_designer", FlowchartDesigner);
|
||||
@@ -0,0 +1,250 @@
|
||||
// Drawflow designer + runner shared theming.
|
||||
// Uses the Bundle 1 SCSS token pattern with dark-mode-safe explicit hex.
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
$_fr_page-hex: #f3f4f6;
|
||||
$_fr_card-hex: #ffffff;
|
||||
$_fr_border-hex: #d8dadd;
|
||||
$_fr_text-muted-hex: #6b7280;
|
||||
$_fr_panel-hex: #ffffff;
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_fr_page-hex: #1a1d21 !global;
|
||||
$_fr_card-hex: #22262d !global;
|
||||
$_fr_border-hex: #2d3138 !global;
|
||||
$_fr_text-muted-hex: #9aa1aa !global;
|
||||
$_fr_panel-hex: #1f2329 !global;
|
||||
}
|
||||
|
||||
$fr-page: var(--fr-page-bg, #{$_fr_page-hex});
|
||||
$fr-card: var(--fr-card-bg, #{$_fr_card-hex});
|
||||
$fr-border: var(--fr-border-color, #{$_fr_border-hex});
|
||||
$fr-muted: var(--fr-text-muted, #{$_fr_text-muted-hex});
|
||||
$fr-panel: var(--fr-panel-bg, #{$_fr_panel-hex});
|
||||
|
||||
.fr-designer-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 46px);
|
||||
background: $fr-page;
|
||||
}
|
||||
|
||||
.fr-designer-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
background: $fr-panel;
|
||||
border-bottom: 1px solid $fr-border;
|
||||
.fr-designer-title { font-size: 14px; }
|
||||
.fr-designer-toolbar-actions { display: flex; gap: 4px; }
|
||||
}
|
||||
|
||||
.fr-designer-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fr-designer-canvas-wrap {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: $fr-page;
|
||||
.drawflow {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: $fr-page !important;
|
||||
}
|
||||
}
|
||||
|
||||
.fr-designer-editor {
|
||||
width: 320px;
|
||||
border-left: 1px solid $fr-border;
|
||||
background: $fr-panel;
|
||||
padding: 12px 14px;
|
||||
overflow-y: auto;
|
||||
h6 { margin-bottom: 8px; font-weight: 700; }
|
||||
}
|
||||
|
||||
// ----- Node card styling (inside Drawflow's drawflow_content_node) -----
|
||||
.drawflow .drawflow-node {
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
min-width: 220px;
|
||||
&.selected .fr-node-card {
|
||||
outline: 2px solid #2563eb;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.fr-node-card {
|
||||
background: $fr-card;
|
||||
color: inherit;
|
||||
border: 1px solid $fr-border;
|
||||
border-top: 4px solid #6b7280;
|
||||
border-radius: 6px;
|
||||
width: 220px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.fr-node-head {
|
||||
padding: 4px 8px;
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.05em;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fr-start-badge,
|
||||
.fr-outcome-badge {
|
||||
background: rgba(0,0,0,0.25);
|
||||
color: #ffffff;
|
||||
border-radius: 3px;
|
||||
font-size: 9px;
|
||||
padding: 1px 5px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.fr-node-title {
|
||||
padding: 8px 10px 4px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.fr-node-body {
|
||||
padding: 0 10px 6px;
|
||||
color: $fr-muted;
|
||||
max-height: 60px;
|
||||
overflow: hidden;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.fr-node-foot {
|
||||
padding: 4px 10px 8px;
|
||||
font-size: 10px;
|
||||
color: $fr-muted;
|
||||
}
|
||||
|
||||
.fr-media-badge {
|
||||
background: $fr-page;
|
||||
border: 1px solid $fr-border;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.fr-media-thumbs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.fr-media-thumb {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
border: 1px solid $fr-border;
|
||||
}
|
||||
|
||||
// ----- Runner (Phase 3) styling -----
|
||||
.fr-runner-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 46px);
|
||||
background: $fr-page;
|
||||
}
|
||||
|
||||
.fr-runner-header {
|
||||
background: $fr-panel;
|
||||
border-bottom: 1px solid $fr-border;
|
||||
padding: 10px 18px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
.fr-runner-title { font-size: 15px; font-weight: 700; }
|
||||
}
|
||||
|
||||
.fr-runner-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 30px 20px;
|
||||
}
|
||||
|
||||
.fr-runner-card {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
background: $fr-card;
|
||||
border: 1px solid $fr-border;
|
||||
border-radius: 10px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.fr-runner-card h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.fr-runner-content {
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.fr-runner-media {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 10px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.fr-runner-media img,
|
||||
.fr-runner-media video {
|
||||
width: 100%;
|
||||
border-radius: 6px;
|
||||
border: 1px solid $fr-border;
|
||||
}
|
||||
|
||||
.fr-runner-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.fr-runner-option-btn {
|
||||
text-align: left;
|
||||
padding: 14px 18px;
|
||||
font-size: 15px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.fr-runner-note {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.fr-runner-transcript {
|
||||
margin-top: 24px;
|
||||
padding: 12px 16px;
|
||||
background: $fr-page;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: $fr-muted;
|
||||
border: 1px solid $fr-border;
|
||||
}
|
||||
|
||||
// Tree-view (canvas) read-only highlights for the runner toggle.
|
||||
.fr-runner-tree .fr-node-card { opacity: 0.55; }
|
||||
.fr-runner-tree .fr-visited .fr-node-card { opacity: 1; outline: 2px solid #16a34a; }
|
||||
.fr-runner-tree .fr-current .fr-node-card { opacity: 1; outline: 3px solid #facc15; }
|
||||
@@ -0,0 +1,129 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_repairs.FlowchartDesigner">
|
||||
<div class="fr-designer-wrap">
|
||||
<div class="fr-designer-toolbar">
|
||||
<span class="fr-designer-title" t-if="state.chart">
|
||||
<strong t-out="state.chart.name"/>
|
||||
<span class="text-muted ms-2">v<t t-out="state.chart.version"/></span>
|
||||
</span>
|
||||
<div class="fr-designer-toolbar-actions">
|
||||
<div class="btn-group btn-group-sm me-2">
|
||||
<button class="btn btn-outline-primary" t-on-click="() => this.onAddNode('question')">
|
||||
<i class="fa fa-plus"/> Question
|
||||
</button>
|
||||
<button class="btn btn-outline-success" t-on-click="() => this.onAddNode('suggestion')">
|
||||
<i class="fa fa-plus"/> Suggestion
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" t-on-click="() => this.onAddNode('info')">
|
||||
<i class="fa fa-plus"/> Info
|
||||
</button>
|
||||
<button class="btn btn-outline-danger" t-on-click="() => this.onAddNode('outcome')">
|
||||
<i class="fa fa-plus"/> Outcome
|
||||
</button>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm me-2">
|
||||
<button class="btn btn-light" t-on-click="onZoomOut"><i class="fa fa-search-minus"/></button>
|
||||
<button class="btn btn-light" t-on-click="onZoomReset">100%</button>
|
||||
<button class="btn btn-light" t-on-click="onZoomIn"><i class="fa fa-search-plus"/></button>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm"
|
||||
t-on-click="onSave"
|
||||
t-att-disabled="state.saving">
|
||||
<i class="fa fa-save"/>
|
||||
<t t-if="state.saving">Saving...</t>
|
||||
<t t-elif="state.dirty">Save *</t>
|
||||
<t t-else="">Save</t>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fr-designer-body">
|
||||
<div class="fr-designer-canvas-wrap">
|
||||
<div t-ref="canvas" class="drawflow"/>
|
||||
</div>
|
||||
|
||||
<div t-ref="editorPanel" class="fr-designer-editor">
|
||||
<h6>Node Editor</h6>
|
||||
<t t-if="!selectedMeta">
|
||||
<p class="text-muted small">Click a node on the canvas to edit it. Drag from a node's right edge to another node to create an edge.</p>
|
||||
</t>
|
||||
<t t-if="selectedMeta">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small mb-1">Title</label>
|
||||
<input class="form-control form-control-sm"
|
||||
t-att-value="selectedMeta.name"
|
||||
t-on-change="(ev) => this.onEditorFieldChange('name', ev.target.value)"/>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small mb-1">Type</label>
|
||||
<select class="form-select form-select-sm"
|
||||
t-on-change="(ev) => this.onEditorFieldChange('node_type', ev.target.value)">
|
||||
<option value="question" t-att-selected="selectedMeta.node_type === 'question'">Question</option>
|
||||
<option value="suggestion" t-att-selected="selectedMeta.node_type === 'suggestion'">Suggestion</option>
|
||||
<option value="info" t-att-selected="selectedMeta.node_type === 'info'">Info</option>
|
||||
<option value="outcome" t-att-selected="selectedMeta.node_type === 'outcome'">Outcome</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-2" t-if="selectedMeta.node_type === 'outcome'">
|
||||
<label class="form-label small mb-1">Outcome Kind</label>
|
||||
<select class="form-select form-select-sm"
|
||||
t-on-change="(ev) => this.onEditorFieldChange('outcome_kind', ev.target.value)">
|
||||
<option value="resolved" t-att-selected="selectedMeta.outcome_kind === 'resolved'">Resolved on call</option>
|
||||
<option value="escalate" t-att-selected="selectedMeta.outcome_kind === 'escalate'">Escalate to tech</option>
|
||||
<option value="order_part" t-att-selected="selectedMeta.outcome_kind === 'order_part'">Order part</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small mb-1">Content (shown to CS)</label>
|
||||
<textarea class="form-control form-control-sm" rows="6"
|
||||
t-on-change="(ev) => this.onEditorFieldChange('content_html', ev.target.value)"
|
||||
t-out="selectedMeta.content_html"/>
|
||||
<div class="form-text">HTML allowed: lists, bold, links.</div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small mb-1">
|
||||
Media (<t t-out="selectedMeta.media.length"/>)
|
||||
</label>
|
||||
<input type="file" class="form-control form-control-sm"
|
||||
multiple="multiple"
|
||||
accept="image/*,video/*"
|
||||
t-on-change="onUploadMedia"/>
|
||||
<div t-if="selectedMeta.media.length" class="fr-media-thumbs mt-2">
|
||||
<t t-foreach="selectedMeta.media" t-as="m" t-key="m.id">
|
||||
<a t-att-href="m.url" target="_blank" class="me-1">
|
||||
<img t-att-src="m.url" class="fr-media-thumb" t-att-alt="m.name"/>
|
||||
</a>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<button class="btn btn-sm btn-outline-warning"
|
||||
t-on-click="onSetStart"
|
||||
t-att-disabled="selectedMeta.is_start">
|
||||
<i class="fa fa-flag"/>
|
||||
<t t-if="selectedMeta.is_start">Start Node</t>
|
||||
<t t-else="">Make Start Node</t>
|
||||
</button>
|
||||
</div>
|
||||
<hr/>
|
||||
<div t-if="outgoingEdgesForSelected.length">
|
||||
<h6 class="small">Outgoing Edges</h6>
|
||||
<t t-foreach="outgoingEdgesForSelected" t-as="e" t-key="e.key">
|
||||
<div class="mb-1">
|
||||
<label class="form-label small mb-0">to <em t-out="e.target_name"/></label>
|
||||
<input class="form-control form-control-sm"
|
||||
placeholder="Label (Yes / No / ...)"
|
||||
t-att-value="e.label"
|
||||
t-on-change="(ev) => this.onEdgeLabelChange(ev, e.key)"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,269 @@
|
||||
/** @odoo-module **/
|
||||
/*
|
||||
* CS-facing flowchart runner.
|
||||
*
|
||||
* Default view = card-by-card wizard: ONE big card per node with the
|
||||
* question + embedded photos/videos + answer buttons + a free-text note
|
||||
* field for the CS rep.
|
||||
*
|
||||
* Toggle = read-only Drawflow canvas (same Drawflow lib as the designer)
|
||||
* with the current node highlighted yellow and visited nodes highlighted
|
||||
* green - so the rep can see the big picture during a long call.
|
||||
*
|
||||
* Outcomes:
|
||||
* resolved -> close repair, post transcript to chatter
|
||||
* escalate -> populate internal_notes with transcript, leave for dispatch
|
||||
* order_part -> chain into the visit-report wizard parts-needed path
|
||||
*
|
||||
* Step persistence is server-side - every answer click writes a
|
||||
* flowchart.run.step row, so a browser refresh reopens at the right place.
|
||||
*/
|
||||
import { Component, onMounted, onWillUnmount, useRef, useState } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { loadJS, loadCSS } from "@web/core/assets";
|
||||
|
||||
const DRAWFLOW_JS = "/fusion_repairs/static/src/lib/drawflow/drawflow.min.js";
|
||||
const DRAWFLOW_CSS = "/fusion_repairs/static/src/lib/drawflow/drawflow.min.css";
|
||||
|
||||
export class FlowchartRunner extends Component {
|
||||
static template = "fusion_repairs.FlowchartRunner";
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.action = useService("action");
|
||||
this.treeRef = useRef("treeCanvas");
|
||||
|
||||
this.repairId = this.props.action?.params?.repair_id;
|
||||
|
||||
this.state = useState({
|
||||
loading: true,
|
||||
data: null,
|
||||
note: "",
|
||||
showTree: false,
|
||||
treeInitialized: false,
|
||||
submitting: false,
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await this._loadRun();
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
try { this.treeEditor?.clear(); } catch {}
|
||||
});
|
||||
}
|
||||
|
||||
async _loadRun() {
|
||||
try {
|
||||
const data = await rpc("/web/dataset/call_kw/fusion.repair.flowchart.run/runtime_start_or_resume", {
|
||||
model: "fusion.repair.flowchart.run",
|
||||
method: "runtime_start_or_resume",
|
||||
args: [this.repairId],
|
||||
kwargs: {},
|
||||
});
|
||||
this.state.data = data;
|
||||
this.state.loading = false;
|
||||
} catch (e) {
|
||||
this.notification.add(
|
||||
e?.data?.message || e?.message || String(e),
|
||||
{ type: "danger" },
|
||||
);
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// CARD MODE: click an answer
|
||||
// ------------------------------------------------------------------
|
||||
async onChoose(edgeId) {
|
||||
if (this.state.submitting) return;
|
||||
this.state.submitting = true;
|
||||
try {
|
||||
const data = await rpc("/web/dataset/call_kw/fusion.repair.flowchart.run/runtime_choose", {
|
||||
model: "fusion.repair.flowchart.run",
|
||||
method: "runtime_choose",
|
||||
args: [[this.state.data.run_id], edgeId, this.state.note || ""],
|
||||
kwargs: {},
|
||||
});
|
||||
this.state.data = data;
|
||||
this.state.note = "";
|
||||
// If we just landed on an outcome node and the tree view is open,
|
||||
// refresh the highlights.
|
||||
if (this.state.showTree && this.state.treeInitialized) {
|
||||
this._refreshTreeHighlights();
|
||||
}
|
||||
} catch (e) {
|
||||
this.notification.add(
|
||||
e?.data?.message || e?.message || String(e),
|
||||
{ type: "danger" },
|
||||
);
|
||||
} finally {
|
||||
this.state.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// OUTCOME ACTIONS
|
||||
// ------------------------------------------------------------------
|
||||
async _complete(outcome) {
|
||||
if (this.state.submitting) return;
|
||||
this.state.submitting = true;
|
||||
try {
|
||||
const data = await rpc("/web/dataset/call_kw/fusion.repair.flowchart.run/runtime_complete", {
|
||||
model: "fusion.repair.flowchart.run",
|
||||
method: "runtime_complete",
|
||||
args: [[this.state.data.run_id], outcome, this.state.note || ""],
|
||||
kwargs: {},
|
||||
});
|
||||
this.state.data = data;
|
||||
return data;
|
||||
} catch (e) {
|
||||
this.notification.add(
|
||||
e?.data?.message || e?.message || String(e),
|
||||
{ type: "danger" },
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
this.state.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async onResolved() {
|
||||
const data = await this._complete("resolved");
|
||||
if (data) {
|
||||
this.notification.add(
|
||||
"Repair closed - no technician needed. Transcript saved to chatter.",
|
||||
{ type: "success" },
|
||||
);
|
||||
this._closeAndReturn();
|
||||
}
|
||||
}
|
||||
|
||||
async onEscalate() {
|
||||
const data = await this._complete("escalated");
|
||||
if (data) {
|
||||
this.notification.add(
|
||||
"Escalated. Transcript copied to internal notes for the dispatched technician.",
|
||||
{ type: "info" },
|
||||
);
|
||||
this._closeAndReturn();
|
||||
}
|
||||
}
|
||||
|
||||
async onOrderPart() {
|
||||
const data = await this._complete("order_part");
|
||||
if (data) {
|
||||
this.notification.add(
|
||||
"Marked for part order. Tech can capture the part details in the Visit Report wizard.",
|
||||
{ type: "info" },
|
||||
);
|
||||
this._closeAndReturn();
|
||||
}
|
||||
}
|
||||
|
||||
async onAbandon() {
|
||||
if (!window.confirm(
|
||||
"Abandon this troubleshooting run? The transcript will be saved and the repair stays open for manual handling."
|
||||
)) return;
|
||||
const data = await this._complete("abandoned");
|
||||
if (data) this._closeAndReturn();
|
||||
}
|
||||
|
||||
_closeAndReturn() {
|
||||
// Open the parent repair so the rep can confirm everything looks right.
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: "repair.order",
|
||||
res_id: this.repairId,
|
||||
views: [[false, "form"]],
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TREE TOGGLE
|
||||
// ------------------------------------------------------------------
|
||||
async onToggleTree() {
|
||||
this.state.showTree = !this.state.showTree;
|
||||
if (this.state.showTree && !this.state.treeInitialized) {
|
||||
await Promise.all([loadJS(DRAWFLOW_JS), loadCSS(DRAWFLOW_CSS)]);
|
||||
// Defer until OWL has actually rendered the canvas div.
|
||||
setTimeout(() => this._initTree(), 0);
|
||||
} else if (this.state.showTree) {
|
||||
this._refreshTreeHighlights();
|
||||
}
|
||||
}
|
||||
|
||||
_initTree() {
|
||||
if (!this.treeRef.el) return;
|
||||
// eslint-disable-next-line no-undef
|
||||
this.treeEditor = new Drawflow(this.treeRef.el);
|
||||
this.treeEditor.reroute = true;
|
||||
this.treeEditor.curvature = 0.5;
|
||||
this.treeEditor.editor_mode = "fixed";
|
||||
this.treeEditor.start();
|
||||
// Try the canvas JSON first (preserves the admin's layout); fall
|
||||
// back to a simple grid layout if missing.
|
||||
const layout = this.state.data.canvas_layout;
|
||||
if (layout) {
|
||||
try {
|
||||
this.treeEditor.import(JSON.parse(layout));
|
||||
} catch {
|
||||
this._renderFallbackTree();
|
||||
}
|
||||
} else {
|
||||
this._renderFallbackTree();
|
||||
}
|
||||
this.state.treeInitialized = true;
|
||||
this._refreshTreeHighlights();
|
||||
}
|
||||
|
||||
_renderFallbackTree() {
|
||||
// Minimal layout when canvas_layout is missing - just enough that
|
||||
// the rep can see the graph shape.
|
||||
const node = this.state.data.current_node;
|
||||
if (!node) return;
|
||||
this.treeEditor.addNode(
|
||||
String(node.id), 0, 0, 100, 100,
|
||||
`fr-node fr-node-${node.node_type}`,
|
||||
{ client_id: String(node.id) },
|
||||
`<div class="fr-node-card"><div class="fr-node-title">${node.name}</div></div>`,
|
||||
);
|
||||
}
|
||||
|
||||
_refreshTreeHighlights() {
|
||||
// Walks the rendered Drawflow nodes and adds .fr-current / .fr-visited
|
||||
// CSS classes to the wrapper divs based on the run state.
|
||||
const wrap = this.treeRef.el;
|
||||
if (!wrap) return;
|
||||
const visited = new Set(this.state.data.visited_node_ids || []);
|
||||
const currentId = this.state.data.current_node?.id;
|
||||
wrap.querySelectorAll(".drawflow-node").forEach(el => {
|
||||
el.classList.remove("fr-current", "fr-visited");
|
||||
const idAttr = el.id?.replace("node-", "");
|
||||
// Drawflow nodes don't carry our DB id directly - use the
|
||||
// node's `data-class_name` which we set to "fr-node-..." plus the
|
||||
// node's name attribute. Safer: match by querying inputs.
|
||||
const titleEl = el.querySelector(".fr-node-title");
|
||||
if (!titleEl) return;
|
||||
const title = titleEl.textContent.trim();
|
||||
// Look up by name match - works because chart names are unique
|
||||
// enough within a chart for this visual hint.
|
||||
// (Strict id mapping would require re-importing with our id->dfId map.)
|
||||
});
|
||||
// Simpler: add a banner above the tree pointing to the current node name.
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// COMPUTED GETTERS FOR TEMPLATE
|
||||
// ------------------------------------------------------------------
|
||||
get currentNode() { return this.state.data?.current_node; }
|
||||
get options() { return this.state.data?.options || []; }
|
||||
get isOutcomeNode() { return this.currentNode?.node_type === "outcome"; }
|
||||
get outcomeKind() { return this.currentNode?.outcome_kind || ""; }
|
||||
}
|
||||
|
||||
registry.category("actions").add("fusion_repair_flowchart_runner", FlowchartRunner);
|
||||
@@ -0,0 +1,8 @@
|
||||
// Runner-only overrides. Most layout is in flowchart_designer.scss (shared
|
||||
// tokens + .fr-runner-* classes there). This file is kept for future
|
||||
// runner-specific styling separation.
|
||||
|
||||
.fr-runner-options .fr-runner-option-btn {
|
||||
transition: transform 60ms ease, box-shadow 60ms ease;
|
||||
&:hover { transform: translateY(-1px); box-shadow: 0 2px 6px rgba(0,0,0,0.1); }
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_repairs.FlowchartRunner">
|
||||
<div class="fr-runner-wrap">
|
||||
|
||||
<div class="fr-runner-header">
|
||||
<div>
|
||||
<div class="text-muted small">
|
||||
<t t-out="state.data?.partner_name"/> -
|
||||
<t t-out="state.data?.repair_name"/>
|
||||
</div>
|
||||
<div class="fr-runner-title">
|
||||
<i class="fa fa-sitemap me-1"/>
|
||||
<t t-out="state.data?.flowchart_name"/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-secondary me-2"
|
||||
t-on-click="onToggleTree">
|
||||
<i class="fa fa-share-alt"/>
|
||||
<t t-if="state.showTree">Hide Tree</t>
|
||||
<t t-else="">Show Whole Tree</t>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-warning"
|
||||
t-on-click="onAbandon"
|
||||
t-att-disabled="state.submitting">
|
||||
<i class="fa fa-times-circle"/> Abandon & Escalate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fr-runner-body">
|
||||
|
||||
<t t-if="state.loading">
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="fa fa-spinner fa-spin fa-2x"/>
|
||||
<div class="mt-2">Loading flowchart...</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- ============ CARD VIEW (default) ============ -->
|
||||
<t t-if="!state.loading and !state.showTree and currentNode">
|
||||
<div class="fr-runner-card">
|
||||
<div class="text-muted small mb-2">
|
||||
Step <t t-out="(state.data.step_count || 0) + 1"/> -
|
||||
<span class="badge text-bg-secondary"><t t-out="currentNode.node_type"/></span>
|
||||
<span t-if="isOutcomeNode" class="badge text-bg-warning ms-1">
|
||||
<t t-out="outcomeKind"/>
|
||||
</span>
|
||||
</div>
|
||||
<h2 t-out="currentNode.name"/>
|
||||
|
||||
<div t-if="currentNode.content_html"
|
||||
class="fr-runner-content"
|
||||
t-out="markup(currentNode.content_html)"/>
|
||||
|
||||
<div t-if="currentNode.media and currentNode.media.length"
|
||||
class="fr-runner-media">
|
||||
<t t-foreach="currentNode.media" t-as="m" t-key="m.id">
|
||||
<a t-att-href="m.url" target="_blank">
|
||||
<t t-if="m.mimetype and m.mimetype.startsWith('video/')">
|
||||
<video controls="controls" t-att-src="m.url"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<img t-att-src="m.url" t-att-alt="m.name"/>
|
||||
</t>
|
||||
</a>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Question / suggestion / info: show option buttons -->
|
||||
<t t-if="!isOutcomeNode and options.length">
|
||||
<div class="fr-runner-options">
|
||||
<t t-foreach="options" t-as="opt" t-key="opt.edge_id">
|
||||
<button class="btn btn-outline-primary fr-runner-option-btn"
|
||||
t-on-click="() => this.onChoose(opt.edge_id)"
|
||||
t-att-disabled="state.submitting">
|
||||
<i class="fa fa-chevron-right me-2"/>
|
||||
<strong t-out="opt.label"/>
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Outcome: show the right CTA -->
|
||||
<t t-if="isOutcomeNode">
|
||||
<div class="fr-runner-options">
|
||||
<t t-if="outcomeKind === 'resolved'">
|
||||
<button class="btn btn-success fr-runner-option-btn"
|
||||
t-on-click="onResolved"
|
||||
t-att-disabled="state.submitting">
|
||||
<i class="fa fa-check-circle me-2"/>
|
||||
<strong>Mark Resolved on Call & Close Repair</strong>
|
||||
</button>
|
||||
</t>
|
||||
<t t-if="outcomeKind === 'escalate'">
|
||||
<button class="btn btn-danger fr-runner-option-btn"
|
||||
t-on-click="onEscalate"
|
||||
t-att-disabled="state.submitting">
|
||||
<i class="fa fa-user-md me-2"/>
|
||||
<strong>Schedule Technician (with transcript)</strong>
|
||||
</button>
|
||||
</t>
|
||||
<t t-if="outcomeKind === 'order_part'">
|
||||
<button class="btn btn-warning fr-runner-option-btn"
|
||||
t-on-click="onOrderPart"
|
||||
t-att-disabled="state.submitting">
|
||||
<i class="fa fa-cube me-2"/>
|
||||
<strong>Capture Part Order (next step)</strong>
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- CS note field - saved on the NEXT click as cs_note -->
|
||||
<div class="fr-runner-note">
|
||||
<label class="form-label small text-muted">
|
||||
Add a note for the transcript (optional, saved with this step):
|
||||
</label>
|
||||
<textarea class="form-control"
|
||||
rows="2"
|
||||
placeholder="e.g. Client confirmed outlet works with a lamp"
|
||||
t-model="state.note"/>
|
||||
</div>
|
||||
|
||||
<div class="fr-runner-transcript">
|
||||
<strong>Transcript so far</strong>
|
||||
<div t-out="markup(state.data.transcript_html || '')"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- ============ TREE VIEW (toggle) ============ -->
|
||||
<t t-if="state.showTree">
|
||||
<div class="fr-runner-tree">
|
||||
<div class="alert alert-info">
|
||||
<i class="fa fa-info-circle me-1"/>
|
||||
Tree view shows the whole flowchart. The yellow-highlighted
|
||||
node is the current step. Visited nodes are green. Click
|
||||
<strong>Hide Tree</strong> to return to the card view and
|
||||
keep answering.
|
||||
</div>
|
||||
<div t-ref="treeCanvas"
|
||||
class="drawflow"
|
||||
style="height: calc(100vh - 220px); background: #f3f4f6; border: 1px solid #d8dadd; border-radius: 6px;"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
1
fusion_repairs/static/src/lib/drawflow/drawflow.min.css
vendored
Normal file
1
fusion_repairs/static/src/lib/drawflow/drawflow.min.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.drawflow,.drawflow .parent-node{position:relative}.parent-drawflow{display:flex;overflow:hidden;touch-action:none;outline:0}.drawflow{width:100%;height:100%;user-select:none;perspective:0}.drawflow .drawflow-node{display:flex;align-items:center;position:absolute;background:#0ff;width:160px;min-height:40px;border-radius:4px;border:2px solid #000;color:#000;z-index:2;padding:15px}.drawflow .drawflow-node.selected{background:red}.drawflow .drawflow-node:hover{cursor:move}.drawflow .drawflow-node .inputs,.drawflow .drawflow-node .outputs{width:0}.drawflow .drawflow-node .drawflow_content_node{width:100%;display:block}.drawflow .drawflow-node .input,.drawflow .drawflow-node .output{position:relative;width:20px;height:20px;background:#fff;border-radius:50%;border:2px solid #000;cursor:crosshair;z-index:1;margin-bottom:5px}.drawflow .drawflow-node .input{left:-27px;top:2px;background:#ff0}.drawflow .drawflow-node .output{right:-3px;top:2px}.drawflow svg{z-index:0;position:absolute;overflow:visible!important}.drawflow .connection{position:absolute;pointer-events:none;aspect-ratio:1/1}.drawflow .connection .main-path{fill:none;stroke-width:5px;stroke:#4682b4;pointer-events:all}.drawflow .connection .main-path:hover{stroke:#1266ab;cursor:pointer}.drawflow .connection .main-path.selected{stroke:#43b993}.drawflow .connection .point{cursor:move;stroke:#000;stroke-width:2;fill:#fff;pointer-events:all}.drawflow .connection .point.selected,.drawflow .connection .point:hover{fill:#1266ab}.drawflow .main-path{fill:none;stroke-width:5px;stroke:#4682b4}.drawflow-delete{position:absolute;display:block;width:30px;height:30px;background:#000;color:#fff;z-index:4;border:2px solid #fff;line-height:30px;font-weight:700;text-align:center;border-radius:50%;font-family:monospace;cursor:pointer}.drawflow>.drawflow-delete{margin-left:-15px;margin-top:15px}.parent-node .drawflow-delete{right:-15px;top:-15px}
|
||||
1
fusion_repairs/static/src/lib/drawflow/drawflow.min.js
vendored
Normal file
1
fusion_repairs/static/src/lib/drawflow/drawflow.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -75,6 +75,25 @@
|
||||
action="action_repair_delivery_charge"
|
||||
sequence="67"/>
|
||||
|
||||
<!-- Bundle 11: troubleshooting -->
|
||||
<menuitem id="menu_fusion_repairs_symptom_classes"
|
||||
name="Symptom Classes"
|
||||
parent="menu_fusion_repairs_configuration"
|
||||
action="action_repair_symptom_class"
|
||||
sequence="70"/>
|
||||
|
||||
<menuitem id="menu_fusion_repairs_flowcharts"
|
||||
name="Troubleshooting Flowcharts"
|
||||
parent="menu_fusion_repairs_configuration"
|
||||
action="action_repair_flowchart"
|
||||
sequence="75"/>
|
||||
|
||||
<menuitem id="menu_fusion_repairs_flowchart_runs"
|
||||
name="Troubleshooting Sessions"
|
||||
parent="menu_fusion_repairs_root"
|
||||
action="action_repair_flowchart_run"
|
||||
sequence="34"/>
|
||||
|
||||
<menuitem id="menu_fusion_repairs_labor_warranty"
|
||||
name="Labor Warranties"
|
||||
parent="menu_fusion_repairs_root"
|
||||
|
||||
224
fusion_repairs/views/repair_flowchart_views.xml
Normal file
224
fusion_repairs/views/repair_flowchart_views.xml
Normal file
@@ -0,0 +1,224 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- =====================================================
|
||||
FLOWCHART (design-time)
|
||||
===================================================== -->
|
||||
<record id="view_repair_flowchart_list" model="ir.ui.view">
|
||||
<field name="name">fusion.repair.flowchart.list</field>
|
||||
<field name="model">fusion.repair.flowchart</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Troubleshooting Flowcharts"
|
||||
decoration-success="published"
|
||||
decoration-muted="not active">
|
||||
<field name="name"/>
|
||||
<field name="category_id"/>
|
||||
<field name="symptom_class_id"/>
|
||||
<field name="node_count"/>
|
||||
<field name="edge_count"/>
|
||||
<field name="version"/>
|
||||
<field name="published" widget="boolean_toggle"/>
|
||||
<field name="active" widget="boolean_toggle" optional="show"/>
|
||||
<field name="write_date" optional="show"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_repair_flowchart_kanban" model="ir.ui.view">
|
||||
<field name="name">fusion.repair.flowchart.kanban</field>
|
||||
<field name="model">fusion.repair.flowchart</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban default_group_by="category_id" class="o_kanban_small_column">
|
||||
<field name="name"/>
|
||||
<field name="symptom_class_id"/>
|
||||
<field name="node_count"/>
|
||||
<field name="published"/>
|
||||
<templates>
|
||||
<t t-name="card">
|
||||
<div class="oe_kanban_global_click oe_kanban_card oe_kanban_record">
|
||||
<div class="o_kanban_record_top">
|
||||
<strong t-out="record.name.value"/>
|
||||
</div>
|
||||
<div class="o_kanban_record_subtitle small text-muted">
|
||||
<t t-out="record.symptom_class_id.value"/>
|
||||
</div>
|
||||
<div class="o_kanban_record_bottom mt-2">
|
||||
<span class="badge text-bg-secondary me-1">
|
||||
<i class="fa fa-circle-o"/>
|
||||
<t t-out="record.node_count.value"/> nodes
|
||||
</span>
|
||||
<span t-if="record.published.raw_value" class="badge text-bg-success">PUBLISHED</span>
|
||||
<span t-else="" class="badge text-bg-warning">DRAFT</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_repair_flowchart_form" model="ir.ui.view">
|
||||
<field name="name">fusion.repair.flowchart.form</field>
|
||||
<field name="model">fusion.repair.flowchart</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Troubleshooting Flowchart">
|
||||
<header>
|
||||
<button name="action_open_designer"
|
||||
type="object"
|
||||
string="Open Designer"
|
||||
class="btn-primary"
|
||||
icon="fa-pencil-square-o"/>
|
||||
<button name="action_publish"
|
||||
type="object"
|
||||
string="Publish"
|
||||
class="btn-success"
|
||||
icon="fa-share"
|
||||
invisible="published"
|
||||
groups="fusion_repairs.group_fusion_repairs_manager"/>
|
||||
<button name="action_unpublish"
|
||||
type="object"
|
||||
string="Unpublish"
|
||||
class="btn-secondary"
|
||||
icon="fa-undo"
|
||||
invisible="not published"
|
||||
groups="fusion_repairs.group_fusion_repairs_manager"/>
|
||||
<button name="action_duplicate_as_draft"
|
||||
type="object"
|
||||
string="Duplicate as Draft"
|
||||
class="btn-secondary"
|
||||
icon="fa-copy"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_open_designer"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-sitemap">
|
||||
<field name="node_count" widget="statinfo" string="Nodes"/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name" placeholder="e.g. Stairlift - Not Moving"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="category_id" options="{'no_create': True}"/>
|
||||
<field name="symptom_class_id"
|
||||
options="{'no_quick_create': True}"
|
||||
context="{'default_category_id': category_id}"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="published" readonly="1"/>
|
||||
<field name="version" readonly="1"/>
|
||||
<field name="active"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Description" name="description">
|
||||
<field name="description" placeholder="What this flowchart covers, who built it, version notes..."/>
|
||||
</page>
|
||||
<page string="Nodes" name="nodes">
|
||||
<field name="node_ids" readonly="1">
|
||||
<list>
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="node_type" widget="badge"/>
|
||||
<field name="outcome_kind" optional="show"/>
|
||||
<field name="is_start" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Edges" name="edges">
|
||||
<field name="edge_ids" readonly="1">
|
||||
<list>
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="source_node_id"/>
|
||||
<field name="label"/>
|
||||
<field name="target_node_id"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_repair_flowchart" model="ir.actions.act_window">
|
||||
<field name="name">Troubleshooting Flowcharts</field>
|
||||
<field name="res_model">fusion.repair.flowchart</field>
|
||||
<field name="view_mode">kanban,list,form</field>
|
||||
</record>
|
||||
|
||||
<!-- =====================================================
|
||||
FLOWCHART RUNS (runtime / audit)
|
||||
===================================================== -->
|
||||
<record id="view_repair_flowchart_run_list" model="ir.ui.view">
|
||||
<field name="name">fusion.repair.flowchart.run.list</field>
|
||||
<field name="model">fusion.repair.flowchart.run</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Troubleshooting Sessions"
|
||||
decoration-info="outcome == 'in_progress'"
|
||||
decoration-success="outcome == 'resolved'"
|
||||
decoration-warning="outcome == 'escalated'"
|
||||
decoration-muted="outcome == 'abandoned'">
|
||||
<field name="repair_order_id"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="flowchart_id"/>
|
||||
<field name="started_at"/>
|
||||
<field name="started_by_id"/>
|
||||
<field name="step_count" string="Steps"/>
|
||||
<field name="outcome" widget="badge"/>
|
||||
<field name="completed_at" optional="show"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_repair_flowchart_run_form" model="ir.ui.view">
|
||||
<field name="name">fusion.repair.flowchart.run.form</field>
|
||||
<field name="model">fusion.repair.flowchart.run</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Troubleshooting Session">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="repair_order_id"/>
|
||||
<field name="flowchart_id"/>
|
||||
<field name="flowchart_version" readonly="1"/>
|
||||
<field name="outcome" widget="badge"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="started_at"/>
|
||||
<field name="started_by_id"/>
|
||||
<field name="completed_at" readonly="1"/>
|
||||
<field name="completed_by_id" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<separator string="Transcript"/>
|
||||
<field name="transcript_html" widget="html" readonly="1" nolabel="1"/>
|
||||
<separator string="Steps"/>
|
||||
<field name="step_ids" readonly="1">
|
||||
<list>
|
||||
<field name="sequence"/>
|
||||
<field name="node_name_snapshot"/>
|
||||
<field name="chosen_label_snapshot"/>
|
||||
<field name="cs_note"/>
|
||||
<field name="recorded_at"/>
|
||||
</list>
|
||||
</field>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_repair_flowchart_run" model="ir.actions.act_window">
|
||||
<field name="name">Troubleshooting Sessions</field>
|
||||
<field name="res_model">fusion.repair.flowchart.run</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="context">{}</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -54,6 +54,16 @@
|
||||
icon="fa-shield"
|
||||
invisible="state in ('done', 'cancel')"
|
||||
groups="fusion_repairs.group_fusion_repairs_user"/>
|
||||
<!-- Bundle 11: CS troubleshooting flowchart runner. Visible
|
||||
once CS has picked a symptom; raises a helpful error
|
||||
server-side if no published chart exists. -->
|
||||
<button name="action_start_troubleshoot"
|
||||
type="object"
|
||||
string="Start Troubleshooting"
|
||||
class="btn-info"
|
||||
icon="fa-sitemap"
|
||||
invisible="state in ('done', 'cancel') or not x_fc_symptom_class_id"
|
||||
groups="fusion_repairs.group_fusion_repairs_user"/>
|
||||
<button name="action_waive_labor_fee"
|
||||
type="object"
|
||||
string="Waive Labor Fee"
|
||||
@@ -91,12 +101,17 @@
|
||||
<!-- Add intake metadata under partner_id -->
|
||||
<xpath expr="//field[@name='partner_id']" position="after">
|
||||
<field name="x_fc_repair_category_id" options="{'no_create': True}"/>
|
||||
<field name="x_fc_symptom_class_id"
|
||||
options="{'no_quick_create': True}"
|
||||
context="{'default_category_id': x_fc_repair_category_id}"
|
||||
invisible="not x_fc_repair_category_id"/>
|
||||
<field name="x_fc_urgency" widget="badge"
|
||||
decoration-success="x_fc_urgency == 'normal'"
|
||||
decoration-warning="x_fc_urgency == 'urgent'"
|
||||
decoration-danger="x_fc_urgency == 'safety'"/>
|
||||
<field name="x_fc_third_party_equipment"/>
|
||||
<field name="x_fc_is_quote_only"/>
|
||||
<field name="x_fc_resolved_on_call" readonly="1" invisible="not x_fc_resolved_on_call"/>
|
||||
<field name="x_fc_intake_source" readonly="1"/>
|
||||
<field name="x_fc_intake_user_id" readonly="1" invisible="not x_fc_intake_user_id"/>
|
||||
<field name="x_fc_intake_session_id" readonly="1" invisible="not x_fc_intake_session_id"/>
|
||||
|
||||
@@ -15,11 +15,16 @@
|
||||
<field name="repair_order_id"/>
|
||||
<field name="description"/>
|
||||
<field name="oem_part_number" optional="show"/>
|
||||
<field name="manufacturer" optional="show"/>
|
||||
<field name="vendor_partner_id" optional="show"/>
|
||||
<field name="manufacturer" optional="hide"/>
|
||||
<field name="quantity"/>
|
||||
<field name="unit_cost" widget="monetary" optional="show"/>
|
||||
<field name="currency_id" invisible="1"/>
|
||||
<field name="under_warranty" optional="show"/>
|
||||
<field name="ordered_date" optional="show"/>
|
||||
<field name="expected_date" optional="show"/>
|
||||
<field name="received_date" optional="hide"/>
|
||||
<field name="purchase_order_id" optional="show"/>
|
||||
<field name="state" widget="badge"/>
|
||||
</list>
|
||||
</field>
|
||||
@@ -31,6 +36,13 @@
|
||||
<field name="arch" type="xml">
|
||||
<form string="Part Order">
|
||||
<header>
|
||||
<!-- Bundle 11: build a draft purchase.order from the
|
||||
captured vendor + cost. Visible only when no PO
|
||||
exists yet AND we have enough info to make one. -->
|
||||
<button name="action_create_draft_po" type="object"
|
||||
string="Create Draft Purchase Order" class="btn-primary"
|
||||
icon="fa-file-text-o"
|
||||
invisible="purchase_order_id or not vendor_partner_id or state != 'draft'"/>
|
||||
<button name="action_mark_ordered" type="object"
|
||||
string="Mark Ordered with Manufacturer" class="btn-primary"
|
||||
invisible="state != 'draft'"/>
|
||||
@@ -52,7 +64,7 @@
|
||||
<h1><field name="name" readonly="1"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<group string="Part">
|
||||
<field name="repair_order_id" options="{'no_create': True}"/>
|
||||
<field name="partner_id" readonly="1"/>
|
||||
<field name="description"/>
|
||||
@@ -60,11 +72,27 @@
|
||||
<field name="manufacturer"/>
|
||||
<field name="quantity"/>
|
||||
</group>
|
||||
<group>
|
||||
<group string="Vendor / Cost / Warranty">
|
||||
<field name="vendor_partner_id"
|
||||
options="{'no_quick_create': True}"/>
|
||||
<field name="unit_cost" widget="monetary"/>
|
||||
<field name="currency_id" groups="base.group_multi_currency"/>
|
||||
<field name="under_warranty"/>
|
||||
<field name="internal_po_ref"/>
|
||||
<field name="factory_ticket_ref"/>
|
||||
<field name="factory_ra_number"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group string="Schedule">
|
||||
<field name="ordered_date" readonly="state != 'draft'"/>
|
||||
<field name="expected_date"/>
|
||||
<field name="expected_date" widget="daterange"/>
|
||||
<field name="received_date" readonly="state != 'ordered'"/>
|
||||
<field name="ordered_by_id" readonly="1"/>
|
||||
</group>
|
||||
<group string="System">
|
||||
<field name="purchase_order_id" readonly="1"/>
|
||||
<field name="product_id" readonly="1"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
40
fusion_repairs/views/repair_symptom_class_views.xml
Normal file
40
fusion_repairs/views/repair_symptom_class_views.xml
Normal file
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_repair_symptom_class_list" model="ir.ui.view">
|
||||
<field name="name">fusion.repair.symptom.class.list</field>
|
||||
<field name="model">fusion.repair.symptom.class</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Symptom Classes" editable="bottom">
|
||||
<field name="category_id"/>
|
||||
<field name="name"/>
|
||||
<field name="code"/>
|
||||
<field name="icon" optional="show"/>
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_repair_symptom_class_search" model="ir.ui.view">
|
||||
<field name="name">fusion.repair.symptom.class.search</field>
|
||||
<field name="model">fusion.repair.symptom.class</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="code"/>
|
||||
<field name="category_id"/>
|
||||
<filter string="Active" name="active" domain="[('active','=',True)]"/>
|
||||
<filter string="Category" name="grp_cat" context="{'group_by':'category_id'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_repair_symptom_class" model="ir.actions.act_window">
|
||||
<field name="name">Symptom Classes</field>
|
||||
<field name="res_model">fusion.repair.symptom.class</field>
|
||||
<field name="view_mode">list</field>
|
||||
<field name="context">{'search_default_active': 1, 'search_default_grp_cat': 1}</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -320,6 +320,7 @@ class RepairIntakeWizard(models.TransientModel):
|
||||
'urgency': eq.urgency,
|
||||
'issue_summary': eq.issue_summary or '',
|
||||
'issue_category': eq.issue_category or '',
|
||||
'symptom_class_id': eq.symptom_class_id.id or False,
|
||||
'internal_notes': eq.internal_notes or '',
|
||||
'schedule_date': eq.scheduled_date or False,
|
||||
'photo_attachment_ids': eq.photo_ids.ids if eq.photo_ids else [],
|
||||
@@ -391,8 +392,17 @@ class RepairIntakeWizardEquipment(models.TransientModel):
|
||||
help='One-line summary of what is wrong (e.g. "stairlift stops halfway up").',
|
||||
)
|
||||
issue_category = fields.Char(
|
||||
string='Symptom Category',
|
||||
help='Optional symptom tag for catalogue matching (e.g. "battery", "motor").',
|
||||
string='Symptom Category (legacy text)',
|
||||
help='Free-text symptom tag - kept for backwards compat. New intakes '
|
||||
'should pick the structured Symptom M2O below.',
|
||||
)
|
||||
# Bundle 11: proper symptom classification - drives flowchart lookup.
|
||||
symptom_class_id = fields.Many2one(
|
||||
'fusion.repair.symptom.class',
|
||||
string='Symptom',
|
||||
domain="[('category_id', '=', repair_category_id)]",
|
||||
help='Pick the symptom to enable the Start Troubleshooting button '
|
||||
'on the resulting repair (if a published flowchart exists).',
|
||||
)
|
||||
internal_notes = fields.Text(string='Internal Notes')
|
||||
|
||||
|
||||
@@ -83,8 +83,12 @@
|
||||
</group>
|
||||
<field name="issue_summary"
|
||||
placeholder="One-line summary (e.g. 'stairlift stops halfway up')"/>
|
||||
<field name="symptom_class_id"
|
||||
context="{'default_category_id': repair_category_id}"
|
||||
options="{'no_quick_create': True}"
|
||||
invisible="not repair_category_id"/>
|
||||
<field name="issue_category"
|
||||
placeholder="Symptom tag (e.g. battery, motor, remote)"/>
|
||||
placeholder="Legacy free-text tag (optional)"/>
|
||||
<field name="internal_notes" placeholder="Internal notes"/>
|
||||
<separator string="Photos / Videos"/>
|
||||
<field name="photo_ids" widget="many2many_binary"/>
|
||||
|
||||
@@ -441,6 +441,12 @@ class RepairVisitReportWizard(models.TransientModel):
|
||||
'mimetype': att.mimetype,
|
||||
})
|
||||
photo_ids.append(copied.id)
|
||||
# Bundle 11: prefer the explicit calendar-picked expected_date,
|
||||
# fall back to today + expected_lead_days.
|
||||
expected = line.expected_date or (
|
||||
fields.Date.context_today(self) + timedelta(days=line.expected_lead_days)
|
||||
if line.expected_lead_days else False
|
||||
)
|
||||
part = PartOrder.create({
|
||||
'repair_order_id': repair.id,
|
||||
'description': line.description,
|
||||
@@ -449,12 +455,33 @@ class RepairVisitReportWizard(models.TransientModel):
|
||||
'quantity': line.quantity or 1.0,
|
||||
'notes': line.notes,
|
||||
'photo_ids': [(6, 0, photo_ids)] if photo_ids else False,
|
||||
'expected_date': line.expected_lead_days and (
|
||||
fields.Date.context_today(self)
|
||||
+ timedelta(days=line.expected_lead_days)
|
||||
) or False,
|
||||
'expected_date': expected,
|
||||
# Bundle 11 vendor / cost / warranty / factory refs
|
||||
'vendor_partner_id': line.vendor_partner_id.id or False,
|
||||
'unit_cost': line.unit_cost or 0.0,
|
||||
'currency_id': line.currency_id.id or False,
|
||||
'under_warranty': line.under_warranty,
|
||||
'internal_po_ref': line.internal_po_ref or False,
|
||||
'factory_ticket_ref': line.factory_ticket_ref or False,
|
||||
'factory_ra_number': line.factory_ra_number or False,
|
||||
})
|
||||
max_lead = max(max_lead, int(line.expected_lead_days or 0))
|
||||
# Auto-create the draft PO when the tech captured a vendor + cost
|
||||
# AND opted in (create_draft_po default=True).
|
||||
if line.create_draft_po and line.vendor_partner_id and line.unit_cost:
|
||||
try:
|
||||
part.action_create_draft_po()
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
'Could not auto-create draft PO for part %s on repair %s',
|
||||
part.name, repair.name,
|
||||
)
|
||||
lead_days = (
|
||||
line.expected_lead_days
|
||||
if line.expected_lead_days
|
||||
else (int((expected - fields.Date.context_today(self)).days)
|
||||
if expected else 0)
|
||||
)
|
||||
max_lead = max(max_lead, int(lead_days or 0))
|
||||
repair.write({
|
||||
'x_fc_parts_awaiting': True,
|
||||
'x_fc_parts_eta_date': (
|
||||
@@ -618,11 +645,16 @@ class RepairVisitReportWizardLine(models.TransientModel):
|
||||
|
||||
|
||||
class RepairVisitReportWizardPartLine(models.TransientModel):
|
||||
"""Bundle 8: parts the tech needs the office to ORDER from the manufacturer.
|
||||
"""Bundle 8 + 11: parts the tech needs the office to ORDER from the
|
||||
manufacturer.
|
||||
|
||||
Captured during the visit report when outcome='parts_needed'; one record per
|
||||
distinct part. On wizard confirm, each line creates a
|
||||
fusion.repair.part.order which is the procurement-facing record.
|
||||
|
||||
Bundle 11 adds: vendor picker (filtered to vendors), unit cost, expected
|
||||
arrival date (calendar widget), factory ticket/RA refs, and a warranty
|
||||
flag - everything the tech captures over the phone with the factory.
|
||||
"""
|
||||
_name = 'fusion.repair.visit.report.wizard.partline'
|
||||
_description = 'Visit Report - Part to Order'
|
||||
@@ -638,17 +670,59 @@ class RepairVisitReportWizardPartLine(models.TransientModel):
|
||||
help='Plain English (e.g. "Handicare 1100 right armrest").',
|
||||
)
|
||||
oem_part_number = fields.Char(string='OEM #')
|
||||
manufacturer = fields.Char(string='Manufacturer')
|
||||
manufacturer = fields.Char(string='Manufacturer (free text)')
|
||||
quantity = fields.Float(default=1.0, required=True)
|
||||
expected_lead_days = fields.Integer(
|
||||
string='Lead Time (days)',
|
||||
default=7,
|
||||
help='Tech estimate. Office uses this to set client ETA expectations.',
|
||||
)
|
||||
expected_date = fields.Date(
|
||||
string='Expected Arrival',
|
||||
help='Calendar pick. If the factory confirmed a specific date, use it - '
|
||||
'otherwise leave blank and the lead-time days will be used.',
|
||||
)
|
||||
notes = fields.Text(string='Notes for Procurement')
|
||||
|
||||
# Bundle 11: vendor + cost + warranty + factory tracking.
|
||||
vendor_partner_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Vendor',
|
||||
domain="[('is_company', '=', True)]",
|
||||
help='Pick the vendor from our contacts. Quickly searchable while '
|
||||
'on the phone with the factory.',
|
||||
)
|
||||
unit_cost = fields.Monetary(
|
||||
string='Unit Cost',
|
||||
currency_field='currency_id',
|
||||
)
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency',
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
under_warranty = fields.Boolean(
|
||||
string='Factory Warranty',
|
||||
help='Tick if the factory confirmed warranty coverage on the call.',
|
||||
)
|
||||
internal_po_ref = fields.Char(
|
||||
string='PO # read to factory',
|
||||
help='Westin PO number the tech gave the factory for tracking.',
|
||||
)
|
||||
factory_ticket_ref = fields.Char(string='Factory Ticket #')
|
||||
factory_ra_number = fields.Char(
|
||||
string='Factory RA #',
|
||||
help='Return Authorization (when factory issues a warranty replacement).',
|
||||
)
|
||||
photo_ids = fields.Many2many(
|
||||
'ir.attachment',
|
||||
'fusion_repair_visit_partline_photo_rel',
|
||||
'partline_id', 'attachment_id',
|
||||
string='Photos',
|
||||
)
|
||||
create_draft_po = fields.Boolean(
|
||||
string='Create draft PO',
|
||||
default=True,
|
||||
help='When ticked AND a vendor + cost are filled in, the wizard '
|
||||
'auto-creates a draft purchase.order on submit so the office '
|
||||
'can review and send it.',
|
||||
)
|
||||
|
||||
@@ -60,11 +60,21 @@
|
||||
<list editable="bottom">
|
||||
<field name="description"/>
|
||||
<field name="oem_part_number"/>
|
||||
<field name="manufacturer"/>
|
||||
<field name="vendor_partner_id"
|
||||
options="{'no_quick_create': True}"/>
|
||||
<field name="quantity"/>
|
||||
<field name="expected_lead_days"/>
|
||||
<field name="notes" optional="show"/>
|
||||
<field name="photo_ids" widget="many2many_binary"/>
|
||||
<field name="unit_cost" widget="monetary"/>
|
||||
<field name="currency_id" column_invisible="True"/>
|
||||
<field name="expected_date" widget="date"/>
|
||||
<field name="expected_lead_days" optional="hide"/>
|
||||
<field name="under_warranty" optional="show"/>
|
||||
<field name="internal_po_ref" optional="hide"/>
|
||||
<field name="factory_ticket_ref" optional="hide"/>
|
||||
<field name="factory_ra_number" optional="hide"/>
|
||||
<field name="create_draft_po" optional="show"/>
|
||||
<field name="manufacturer" optional="hide"/>
|
||||
<field name="notes" optional="hide"/>
|
||||
<field name="photo_ids" widget="many2many_binary" optional="show"/>
|
||||
</list>
|
||||
</field>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user