Compare commits

...

4 Commits

Author SHA1 Message Date
gsinghpal
c520803c84 feat(fusion_helpdesk_central): findings-first wizard, explicit Generate button
The old flow fired OpenAI on wizard open with just ticket + chatter,
so the AI summary was just a paraphrase of what the user originally
reported — your engineering analysis (scope, limitations, recommended
approach) never made it to the owner. Restructure to a two-step flow:

  1. Open wizard → empty findings + empty summary, NO OpenAI call
  2. You write findings: scope / effort / approach / risk
  3. Click 'Generate Summary from Findings' → OpenAI runs with
     ticket + chatter + findings, where the prompt explicitly tells
     the model to weight findings MORE THAN the original report
  4. Review/edit, then Send

Bulk wizard mirrors the flow per line: each row gets its own
findings + summary, one 'Generate All Summaries' button fans out
parallel OpenAI calls using each line's own findings.

Updated SUMMARY_PROMPT to:
- Tell the model the support engineer's findings are authoritative
- Emit a bullet structure that leads with the recommendation, not
  the user's restated ask
- Side with findings over the original report when they conflict

New tests cover:
- default_get does NOT fire OpenAI (regression guard for auto-AI)
- Findings text actually reaches the OpenAI prompt
- Send works with a manually-typed summary (no AI in the loop)
- Existing bulk + validation paths still pass with the new shape

Also folds in the deferred code-review #7: ThreadPoolExecutor now
explicitly cancels pending futures on timeout via
shutdown(wait=False, cancel_futures=True) so a slow OpenAI day can't
hold the wizard open for ceil(N/workers)*15s.

Bumps fusion_helpdesk_central to 19.0.2.3.0.

Smoke-tested live on nexa: opening the wizard makes zero OpenAI calls;
clicking Generate with findings='My findings: scope is XL, ~8h' makes
exactly one call and the findings text is verifiably in the prompt
body received by call_openai_chat.
2026-05-27 13:49:02 -04:00
gsinghpal
7349f3180d docs(billing): note flip-day usage-API subscription-id mapping for 2b 2026-05-27 13:45:23 -04:00
gsinghpal
2414b6328e fix(fusion_repairs): designer setup() scope - onMounted/onWillUnmount were stranded outside, broke entire backend bundle
REGRESSION FROM b22bb11b (Wysiwyg integration).

While inserting the new Wysiwyg methods (wysiwygConfig getter, onWysiwygLoad,
onToggleSource) between setup() and the existing onMounted / onWillUnmount
hook calls, I accidentally closed setup() early with the new
`this.wysiwygEditors = {};` assignment. That left the original
`onMounted(async () => {...});` and `onWillUnmount(...);` calls dangling
INSIDE the class body but OUTSIDE any method - which is invalid JS.

JavaScript's class-body parser sees the bare `onMounted(async () => ...)`
and tries to interpret it as a method declaration where `onMounted` is the
name and the parens are the parameter list. `async () => {...}` is not a
valid parameter, so the bundle fails with:

  Uncaught SyntaxError: Unexpected token '('
  web.assets_web.min.js:28807

That single parse failure tanks the entire backend asset bundle, leaving
users with a completely blank screen on /odoo (and any other backend
route). Frontend bundle was unaffected.

FIX

Move the onMounted / onWillUnmount calls back inside setup() where they
belong. Add a load-bearing comment explaining why they must stay there so
this regression cannot silently come back during a future refactor.

VERIFIED

  - line 51: setup() opens
  - lines 87, 93: onMounted, onWillUnmount calls INSIDE setup
  - line 142: _initDrawflow as a normal class method (outside setup)
  - upgrade clean
  - bundle 10029245 bytes, exactly one onMounted( occurrence in
    FlowchartDesigner class body
  - node --check on the freshly-rendered web.assets_web.min.js -> PARSE_OK

Bumped to 19.0.2.2.3.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 13:44:58 -04:00
gsinghpal
5605012245 fix(billing): importer review fixes — surface failures, validate, dedupe
Resolves findings from the post-build review:
- C1: a partial import was indistinguishable from success. action_run_import
  now logs failed rows at ERROR (survives nexa's log_level=warn) and the
  wizard shows red/amber banners with failed/skipped counts.
- H3: an unrecognized billing_cycle silently fell back to monthly (wrong
  plan AND price). Now raised per-row -> failed[], never silently mis-billed.
- M5: a NULL plan price silently became a $0 line. Prices now preserve
  NULL-vs-0.0; a missing price for the subscription's cycle is failed[].
- H2: post-connect query/schema errors now become a clean UserError, not a
  raw SQL traceback (matches the connection-error path).
- M4: per-row failures now record the exception type and log a traceback.
- MED#3: charge plan_id set explicitly False so re-runs re-assert the
  shadow-safe NULL even if it was changed between runs.
- HIGH-edge: re-run only rewrites x_fc_* on existing subs; partner_id/plan_id/
  line are set at creation only (never rewrite immutable fields).
- account_link: partner email match is now case-insensitive (=ilike) to avoid
  duplicate partners against a differently-cased pre-existing partner.

Shadow-safety invariant unchanged and re-confirmed. 52/52 green on odoo-trial.
2026-05-27 13:44:51 -04:00
14 changed files with 421 additions and 114 deletions

View File

@@ -183,6 +183,13 @@ until this is granted.
- Importing historical NexaCloud invoices / `usage_records` (2d reads NexaCloud actuals).
- Add-ons (`deployment_addons`) as recurring lines — revisit if material.
> **Flip-day note (carry into 2b):** the inbound `/usage` API resolves a subscription by
> its **Odoo integer id** (`int(subscription_external_id)`), but imported shadow subs are
> keyed by NexaCloud's UUID in `x_fc_nexacloud_subscription_id`. Before NexaCloud can push
> usage (2b), decide how it learns the Odoo id (return the mapping from the importer, or
> extend the usage API to also resolve by `x_fc_nexacloud_subscription_id`). Not a 2a bug
> (2a is read-only), but it must be resolved before the flip.
## 11. Verify at implementation (do NOT code from memory — CLAUDE rule #1)
Confirm on odoo-trial Enterprise before relying on them:

View File

@@ -46,7 +46,9 @@ class FusionBillingAccountLink(models.Model):
return existing
partner = self.env['res.partner']
if email:
partner = partner.search([('email', '=', email)], limit=1)
# case-insensitive so a pre-existing partner with a differently-cased email
# (created via the web UI or another sync) is reused, not duplicated.
partner = partner.search([('email', '=ilike', email)], limit=1)
if not partner:
partner = partner.create({'name': name or external_id, 'email': email, **(extra or {})})
return self.create({

View File

@@ -211,6 +211,29 @@ class TestImporterErrorIsolation(TransactionCase):
self.assertTrue(summary['failed'], "the bad row must be recorded in failed[]")
self.assertTrue(any(f['kind'] == 'user' for f in summary['failed']))
def test_unknown_billing_cycle_is_failed_not_silently_monthly(self):
data = _fixture()
data['subscriptions'][0]['billing_cycle'] = 'annual' # not monthly/yearly
summary = self.Wizard._import_rows(data)
self.assertFalse(self.env['sale.order'].search(
[('x_fc_nexacloud_subscription_id', '=', 's-1')]),
"an unrecognized billing_cycle must NOT silently create a monthly sub")
self.assertTrue(any(f['kind'] == 'subscription' and f['id'] == 's-1'
for f in summary['failed']))
def test_missing_price_for_cycle_is_failed_not_zero(self):
data = _fixture()
data['plans'][0]['price_yearly'] = None # s-2 is yearly -> no price for it
summary = self.Wizard._import_rows(data)
# the yearly sub fails (no silent $0 line); the monthly one still imports
self.assertFalse(self.env['sale.order'].search(
[('x_fc_nexacloud_subscription_id', '=', 's-2')]),
"a missing price for the cycle must NOT silently create a $0 line")
self.assertTrue(self.env['sale.order'].search(
[('x_fc_nexacloud_subscription_id', '=', 's-1')]))
self.assertTrue(any(f['kind'] == 'subscription' and f['id'] == 's-2'
for f in summary['failed']))
@tagged('post_install', '-at_install')
class TestImporterReadGuard(TransactionCase):

View File

@@ -5,6 +5,13 @@
<field name="model">fusion.billing.import.wizard</field>
<field name="arch" type="xml">
<form string="Import from NexaCloud">
<div class="alert alert-danger" role="alert" invisible="failed_count == 0">
<strong>Import completed with errors: </strong>
<field name="failed_count" class="oe_inline" readonly="1"/> row(s) failed — see Result below.
</div>
<div class="alert alert-warning" role="alert" invisible="skipped_count == 0">
<field name="skipped_count" class="oe_inline" readonly="1"/> row(s) skipped (unresolved customer/plan) — see Result below.
</div>
<group>
<field name="dry_run"/>
</group>

View File

@@ -33,12 +33,25 @@ class FusionBillingImportWizard(models.TransientModel):
default=True,
help="Read and report what would be imported, without writing anything.")
result_summary = fields.Text(readonly=True)
failed_count = fields.Integer(readonly=True)
skipped_count = fields.Integer(readonly=True)
def action_run_import(self):
self.ensure_one()
data = self._read_nexacloud_rows()
summary = self._import_rows(data, dry_run=self.dry_run)
failed = summary.get("failed") or []
skipped = summary.get("skipped") or []
self.result_summary = json.dumps(summary, indent=2, default=str)
self.failed_count = len(failed)
self.skipped_count = len(skipped)
# A partial billing import must be loud, not buried in the JSON. Log at ERROR
# so it survives nexa's log_level=warn (INFO is suppressed there).
if failed:
_logger.error("NexaCloud import: %s row(s) FAILED%s: %s",
len(failed), " (dry-run)" if self.dry_run else "", failed)
if skipped:
_logger.warning("NexaCloud import: %s row(s) skipped: %s", len(skipped), skipped)
return {
"type": "ir.actions.act_window",
"res_model": self._name,
@@ -83,6 +96,13 @@ class FusionBillingImportWizard(models.TransientModel):
"current_period_start, current_period_end FROM subscriptions")
data["subscriptions"] = [dict(r) for r in cur.fetchall()]
return data
except psycopg2.Error as e:
# A query/schema error (e.g. a renamed/missing column) gets the same clean
# operator message as a connection failure — not a raw SQL traceback. We
# never return a partial `data` (the return is the last statement in `try`).
raise UserError(
"Failed reading from the NexaCloud database — the source schema may "
"have changed. Underlying error:\n%s" % e)
finally:
conn.close()
@@ -127,8 +147,10 @@ class FusionBillingImportWizard(models.TransientModel):
partner_by_user[str(u["id"])] = link.partner_id
self._bump(summary, created, "partners")
except Exception as e: # noqa: BLE001 - per-row isolation
_logger.exception("NexaCloud import: user row %s failed", u.get("id"))
summary["failed"].append(
{"kind": "user", "id": str(u.get("id")), "error": str(e)})
{"kind": "user", "id": str(u.get("id")),
"error": "%s: %s" % (type(e).__name__, e)})
for p in data.get("plans", []):
try:
@@ -137,8 +159,10 @@ class FusionBillingImportWizard(models.TransientModel):
plan_ctx_by_id[str(p["id"])] = ctx
self._bump(summary, created, "plans")
except Exception as e: # noqa: BLE001
_logger.exception("NexaCloud import: plan row %s failed", p.get("id"))
summary["failed"].append(
{"kind": "plan", "id": str(p.get("id")), "error": str(e)})
{"kind": "plan", "id": str(p.get("id")),
"error": "%s: %s" % (type(e).__name__, e)})
for s in data.get("subscriptions", []):
partner = partner_by_user.get(str(s.get("user_id") or ""))
@@ -154,8 +178,10 @@ class FusionBillingImportWizard(models.TransientModel):
service, partner, ctx, recurrence_plans, s)
self._bump(summary, created, "subscriptions")
except Exception as e: # noqa: BLE001
_logger.exception("NexaCloud import: subscription row %s failed", s.get("id"))
summary["failed"].append(
{"kind": "subscription", "id": str(s.get("id")), "error": str(e)})
{"kind": "subscription", "id": str(s.get("id")),
"error": "%s: %s" % (type(e).__name__, e)})
_logger.info("NexaCloud import summary: %s", summary)
return summary
@@ -234,8 +260,12 @@ class FusionBillingImportWizard(models.TransientModel):
Charge = self.env["fusion.billing.charge"]
plan_code = str(prow["id"])
name = prow.get("name") or plan_code
price_monthly = float(prow.get("price_monthly") or 0.0)
price_yearly = float(prow.get("price_yearly") or 0.0)
# Preserve NULL vs 0.0: a missing price must NOT silently become a $0 line.
# The subscription import raises on a missing price for its cycle (-> failed[]).
raw_monthly = prow.get("price_monthly")
raw_yearly = prow.get("price_yearly")
price_monthly = float(raw_monthly) if raw_monthly is not None else None
price_yearly = float(raw_yearly) if raw_yearly is not None else None
created = False
sub_code = "NC-PLAN-%s" % plan_code
@@ -244,7 +274,7 @@ class FusionBillingImportWizard(models.TransientModel):
sub_product = Product.create({
"name": "NexaCloud %s" % name, "default_code": sub_code,
"type": "service", "recurring_invoice": True,
"list_price": price_monthly})
"list_price": price_monthly or 0.0})
created = True
ov_code = "NC-CPU-OVG-%s" % plan_code
@@ -261,7 +291,10 @@ class FusionBillingImportWizard(models.TransientModel):
"price_per_unit": CPU_RATE_PER_CORE_HOUR,
"unit_batch": CPU_SECONDS_PER_CORE_HOUR,
"charge_model": "standard",
# plan_id intentionally omitted (NULL) — shadow safety guarantee #3
# Shadow safety guarantee #3: plan_id MUST stay NULL so the rating cron
# never auto-mutates order lines. Set it explicitly (not just omitted) so a
# re-run re-asserts NULL even if someone set it on the charge between runs.
"plan_id": False,
}
charge = Charge.search(
[("plan_code", "=", plan_code), ("metric_id", "=", metric.id)], limit=1)
@@ -280,20 +313,29 @@ class FusionBillingImportWizard(models.TransientModel):
SaleOrder = self.env["sale.order"]
SaleOrderLine = self.env["sale.order.line"]
sub_ext = str(srow["id"])
cycle = (srow.get("billing_cycle") or "monthly").lower()
cycle = (srow.get("billing_cycle") or "").strip().lower()
if cycle not in ("monthly", "yearly"):
raise UserError(
"Subscription %s has an unrecognized billing_cycle %r — cannot pick a "
"plan/price." % (sub_ext, srow.get("billing_cycle")))
rec_plan = recurrence_plans["yearly"] if cycle == "yearly" else recurrence_plans["monthly"]
price = plan_ctx["price_yearly"] if cycle == "yearly" else plan_ctx["price_monthly"]
if price is None:
raise UserError(
"Subscription %s is billed %s but its plan has no %s price." % (
sub_ext, cycle, cycle))
product = plan_ctx["sub_product"]
order_vals = {
"partner_id": partner.id, "plan_id": rec_plan.id,
"x_fc_nexacloud_subscription_id": sub_ext,
# x_fc_* are always (re-)written; identity fields (partner_id/plan_id/order_line)
# are set ONLY at creation, so a re-run never rewrites immutable fields on an
# order that may since have been confirmed.
shadow_vals = {
"x_fc_nexacloud_deployment_id": str(srow.get("deployment_id") or ""),
"x_fc_billing_service_id": service.id, "x_fc_shadow": True,
}
existing = SaleOrder.search(
[("x_fc_nexacloud_subscription_id", "=", sub_ext)], limit=1)
if existing:
existing.write(order_vals)
existing.write(shadow_vals)
line = existing.order_line.filtered(lambda l: l.product_id == product)
line_vals = {"product_uom_qty": 1, "price_unit": price}
if line:
@@ -304,9 +346,13 @@ class FusionBillingImportWizard(models.TransientModel):
order = existing
created = False
else:
order_vals["order_line"] = [(0, 0, {
"product_id": product.id, "product_uom_qty": 1, "price_unit": price})]
order = SaleOrder.create(order_vals)
order = SaleOrder.create({
"partner_id": partner.id, "plan_id": rec_plan.id,
"x_fc_nexacloud_subscription_id": sub_ext,
"order_line": [(0, 0, {
"product_id": product.id, "product_uom_qty": 1, "price_unit": price})],
**shadow_vals,
})
created = True
# guarantee the explicit price stuck (a pricelist compute may have overwritten it)
line = order.order_line.filtered(lambda l: l.product_id == product)

View File

@@ -3,7 +3,7 @@
# License OPL-1
{
'name': 'Fusion Helpdesk Central — Client API Keys',
'version': '19.0.2.2.0',
'version': '19.0.2.3.0',
'category': 'Productivity',
'summary': 'Admin UI on the central Odoo for issuing per-client API '
'keys used by fusion_helpdesk client deployments.',

View File

@@ -64,11 +64,20 @@ class FusionHelpdeskEngagementWizard(models.TransientModel):
help='One-line note from you, prepended above the AI summary in the '
'email body. Optional. Skip if the summary speaks for itself.',
)
findings = fields.Text(
string='Your Findings',
help='Your engineering analysis: scope, limitations, recommended '
'approach, effort, risks — anything the original reporter '
'would not have known. The AI weighs these MORE HEAVILY than '
'the ticket description when generating the owner summary. '
'Optional but strongly recommended: without it, the summary '
'is just the AI restating the user\'s report.',
)
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.',
string='Summary to Send',
help='Brief shown to the owner in the approval email. Either '
'generated from your findings + ticket (click "Generate '
'Summary") or written by hand. Edit freely before sending.',
)
owner_email_display = fields.Char(
@@ -129,34 +138,35 @@ class FusionHelpdeskEngagementWizard(models.TransientModel):
return vals
def _default_get_single(self, vals, ticket_id):
# No AI on open — user writes findings first, then clicks
# "Generate Summary" to fire OpenAI. Summary starts empty so the
# view's Send button can be disabled until either Generate runs
# or the user types one manually.
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),
'ai_summary': '',
'findings': '',
'ai_unavailable': False,
})
return vals
def _default_get_bulk(self, vals, ticket_ids):
# Same as single: no AI on open. User fills findings per ticket
# then hits "Generate Summary" on each line (or "Generate All" —
# see action_generate_all_summaries).
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, '')})
(0, 0, {'ticket_id': t.id, 'findings': '', 'ai_summary': ''})
for t in tickets
],
'ai_unavailable': not any_ok,
'ai_unavailable': False,
})
return vals
@@ -227,9 +237,13 @@ class FusionHelpdeskEngagementWizard(models.TransientModel):
msg_data,
)
def _generate_summary(self, ticket):
def _generate_summary(self, ticket, findings=''):
"""Single-ticket summary. Returns '' on any failure — the wizard
treats empty as "AI unavailable" and shows the manual fallback."""
treats empty as "AI unavailable" and shows the manual fallback.
`findings` is the user's free-text analysis from the wizard. The
prompt explicitly tells the model to weight it more than the
original user report — see SUMMARY_PROMPT in utils.py."""
ICP = self.env['ir.config_parameter'].sudo()
api_key = (ICP.get_param(
'fusion_helpdesk_central.openai_api_key') or '').strip()
@@ -238,35 +252,39 @@ class FusionHelpdeskEngagementWizard(models.TransientModel):
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))
prompt = truncate_for_openai(
build_summary_prompt(name, desc, msgs, findings=findings)
)
return call_openai_chat(api_key, model, prompt)
def _generate_summaries_parallel(self, tickets):
def _generate_summaries_parallel(self, ticket_findings):
"""{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 ''."""
`ticket_findings` is a dict {ticket_id: (ticket_recordset, findings_str)}
so each parallel call uses its own per-ticket findings.
"""
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}
return {tid: '' for tid in ticket_findings}
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))
for tid, (ticket, findings) in ticket_findings.items():
name, desc, msgs = self._summary_inputs(ticket)
inputs[tid] = truncate_for_openai(
build_summary_prompt(name, desc, msgs, findings=findings))
results = {t.id: '' for t in tickets}
with concurrent.futures.ThreadPoolExecutor(
max_workers=_BULK_AI_WORKERS) as pool:
results = {tid: '' for tid in ticket_findings}
# Cancel pending futures on overall timeout so a slow OpenAI day
# doesn't block the wizard for ceil(N/workers) * 15s.
pool = concurrent.futures.ThreadPoolExecutor(
max_workers=_BULK_AI_WORKERS)
try:
futures = {
pool.submit(call_openai_chat, api_key, model, p): tid
for tid, p in inputs.items()
@@ -288,8 +306,67 @@ class FusionHelpdeskEngagementWizard(models.TransientModel):
'out after %ss; remaining tickets will get empty '
'summaries.', _BULK_AI_TIMEOUT,
)
finally:
# py3.9+: cancel_futures stops queued tasks from starting;
# in-flight urlopen calls still finish their 15s per-call cap
# and drop their result on the floor.
pool.shutdown(wait=False, cancel_futures=True)
return results
# ------------------------------------------------------------------
# User-driven AI trigger — explicit "Generate" buttons in the view.
# ------------------------------------------------------------------
def action_generate_summary(self):
"""Single mode: fire OpenAI with the current findings, drop the
result into ai_summary. Returns an action to keep the wizard open
(replaces the current view instead of closing it).
"""
self.ensure_one()
if self.mode != 'single' or not self.ticket_id:
raise UserError(_(
'Generate Summary only works in single-ticket mode. Use '
'the per-line Generate buttons on the bulk wizard.'
))
summary = self._generate_summary(
self.ticket_id, findings=self.findings or '',
)
self.ai_summary = summary
self.ai_unavailable = not bool(summary)
return {
'type': 'ir.actions.act_window',
'res_model': 'fusion.helpdesk.engagement.wizard',
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
}
def action_generate_all_summaries(self):
"""Bulk mode: fire OpenAI per-ticket in parallel using each line's
own findings. Lines that already have a non-empty ai_summary get
regenerated too (the user clicked the button — they meant it).
"""
self.ensure_one()
if self.mode != 'bulk' or not self.line_ids:
raise UserError(_(
'Generate All only works in bulk mode.'
))
ticket_findings = {
line.ticket_id.id: (line.ticket_id, line.findings or '')
for line in self.line_ids
}
results = self._generate_summaries_parallel(ticket_findings)
for line in self.line_ids:
line.ai_summary = results.get(line.ticket_id.id, '')
any_ok = any(results.values())
self.ai_unavailable = not any_ok
return {
'type': 'ir.actions.act_window',
'res_model': 'fusion.helpdesk.engagement.wizard',
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
}
# ------------------------------------------------------------------
# Send: write engagement state + queue mail
# ------------------------------------------------------------------
@@ -374,7 +451,14 @@ class FusionHelpdeskEngagementWizardLine(models.TransientModel):
ticket_name = fields.Char(
related='ticket_id.name', readonly=True,
)
ai_summary = fields.Text(
string='AI Summary',
help='Per-ticket summary — edit before send.',
findings = fields.Text(
string='Your Findings',
help='Per-ticket engineering findings. The Generate button on this '
'line uses these as the most authoritative input for the AI '
'summary.',
)
ai_summary = fields.Text(
string='Summary to Send',
help='Per-ticket summary — generated from findings + ticket, or '
'written by hand. Edit before send.',
)

View File

@@ -172,40 +172,98 @@ class TestEngagementReset(TestEngagementBase):
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
class TestEngagementWizard(TestEngagementBase):
def test_single_send_via_wizard(self):
def test_default_get_does_not_fire_openai(self):
# Two-step flow: opening the wizard must NOT call OpenAI — the user
# has to write findings first, then click Generate. Wrap call_openai_chat
# in a mock that fails the test if invoked.
from unittest.mock import patch
t = self._make_ticket()
with _patch_openai():
called = {'n': 0}
def _spy(*a, **kw):
called['n'] += 1
return 'should-not-appear'
with patch(
'odoo.addons.fusion_helpdesk_central.models.engagement_wizard.'
'call_openai_chat',
side_effect=_spy,
):
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(called['n'], 0,
'default_get must not auto-fire OpenAI.')
self.assertEqual(wizard.ai_summary, '')
self.assertEqual(wizard.findings, '')
def test_single_send_via_wizard(self):
t = self._make_ticket()
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')
# User writes findings, clicks Generate Summary
wizard.findings = 'Scope is small; ~2 hours of work.'
with _patch_openai():
wizard.action_generate_summary()
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.
def test_send_with_manual_summary_no_ai(self):
# Skipping Generate altogether: user types the summary by hand.
# Send should work without ever invoking OpenAI.
t = self._make_ticket()
with _patch_openai():
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
default_ticket_id=t.id,
).create({})
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
default_ticket_id=t.id,
).create({})
wizard.ai_summary = 'Manually written brief; no AI involved.'
wizard.action_send()
self.assertEqual(t.x_fc_engagement_state, 'pending')
self.assertEqual(t.x_fc_ai_summary,
'Manually written brief; no AI involved.')
def test_generate_summary_passes_findings_to_ai(self):
# Pin the prompt-construction wiring: findings field must reach
# build_summary_prompt, which must reach call_openai_chat.
from unittest.mock import patch
t = self._make_ticket()
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
default_ticket_id=t.id,
).create({})
wizard.findings = 'UNIQUE-FINDINGS-MARKER-XYZ'
captured = {}
def _spy(api_key, model, prompt, timeout=15):
captured['prompt'] = prompt
return '• generated'
with patch(
'odoo.addons.fusion_helpdesk_central.models.engagement_wizard.'
'call_openai_chat',
side_effect=_spy,
):
wizard.action_generate_summary()
self.assertIn('UNIQUE-FINDINGS-MARKER-XYZ', captured['prompt'])
def test_single_send_uses_current_client_key_owner(self):
# Send must read the FRESH owner contact from client_key, not a
# stale snapshot from default_get.
t = self._make_ticket()
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
default_ticket_id=t.id,
).create({})
wizard.ai_summary = 'manual summary'
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):
with self.assertRaises(UserError):
self.env['fusion.helpdesk.engagement.wizard'].with_context(
default_ticket_id=t.id,
).create({})
@@ -213,17 +271,18 @@ class TestEngagementWizard(TestEngagementBase):
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):
with 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):
def test_generate_marks_ai_unavailable_when_empty(self):
t = self._make_ticket()
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
default_ticket_id=t.id,
).create({})
with _patch_openai(return_value=''):
wizard = self.env['fusion.helpdesk.engagement.wizard'].with_context(
default_ticket_id=t.id,
).create({})
wizard.action_generate_summary()
self.assertTrue(wizard.ai_unavailable)
self.assertEqual(wizard.ai_summary, '')
@@ -231,34 +290,37 @@ class TestEngagementWizard(TestEngagementBase):
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({})
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)
# User fills findings + generates summaries (or writes by hand)
for line in wizard.line_ids:
line.findings = 'Scope: small. Effort: low.'
with _patch_openai():
wizard.action_generate_all_summaries()
for line in wizard.line_ids:
self.assertIn('summary bullet', line.ai_summary)
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):
with self.assertRaises(UserError):
self.env['fusion.helpdesk.engagement.wizard'].with_context(
default_ticket_ids=[t1.id, t2.id],
fhc_bulk=True,
@@ -268,7 +330,7 @@ class TestEngagementWizard(TestEngagementBase):
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):
with self.assertRaises(UserError):
self.env['fusion.helpdesk.engagement.wizard'].with_context(
default_ticket_ids=[t1.id, t2.id],
fhc_bulk=True,

View File

@@ -54,6 +54,19 @@ class TestBuildSummaryPrompt(TransactionCase):
# survives; '(empty)' marker keeps the model honest.
self.assertIn('(empty)', prompt)
def test_findings_included_in_prompt(self):
# Findings is the support engineer's analysis — must reach OpenAI.
prompt = build_summary_prompt(
't', 'd', [], findings='Scope is small. ~2h of work.',
)
self.assertIn('Scope is small. ~2h of work.', prompt)
def test_findings_absent_uses_explicit_marker(self):
# Empty findings collapses to an explicit marker so the model
# doesn't read a blank section as "missing".
prompt = build_summary_prompt('t', 'd', [], findings='')
self.assertIn('none provided', prompt)
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
class TestTruncateForOpenAI(TransactionCase):

View File

@@ -23,22 +23,35 @@ _logger = logging.getLogger(__name__)
SUMMARY_PROMPT = """You are summarising a customer support ticket for a busy executive
who needs to decide whether to approve the work.
The support engineer has reviewed this ticket and written their findings
below (scope, limitations, recommended approach, anything the original
reporter wouldn't have known). Treat those findings as authoritative —
they reflect the actual engineering reality, not just the user's
description of the problem. If the findings contradict the original
report, side with the findings and call out the gap.
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.
- First bullet: the ask, in one sentence, framed in the support
engineer's terms (not just a paraphrase of the original report).
- Second bullet: the support engineer's recommendation, if they made one.
- Third bullet: business impact if approved.
- Fourth bullet: business impact if NOT approved (or "none material").
- Optional bullets: scope / effort / risk signals from the findings.
- Final bullet: open questions the approver should think about.
- Do not invent facts. If the thread doesn't say, write "not stated".
- Do not invent facts. If the findings or thread don't say, write
"not stated".
- No greetings, no sign-offs, no preamble.
Ticket title: {name}
Original report:
Original report from the user:
{description_plain}
Replies so far:
Replies in the ticket thread:
{messages_plain}
Support engineer's findings (your most important input):
{findings_plain}
"""
# Bound the prompt sent to OpenAI so a runaway thread doesn't blow cost or
@@ -47,13 +60,17 @@ Replies so far:
OPENAI_PROMPT_MAX_CHARS = 8000
def build_summary_prompt(ticket_name, description_plain, messages):
"""Render SUMMARY_PROMPT with ticket + chatter content.
def build_summary_prompt(ticket_name, description_plain, messages,
findings=''):
"""Render SUMMARY_PROMPT with ticket + chatter + support findings.
`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.
`findings` is the support engineer's free-text notes from the wizard —
their scope/effort/recommendation that the AI should weight more
heavily than the original user description.
Empty inputs collapse to explicit markers so the prompt never has
bare blanks the model could misread as missing sections.
"""
name = (ticket_name or '').strip() or '(untitled)'
desc = (description_plain or '').strip() or '(no description)'
@@ -67,8 +84,13 @@ def build_summary_prompt(ticket_name, description_plain, messages):
msgs = '\n---\n'.join(lines)
else:
msgs = '(no replies yet)'
findings_text = (findings or '').strip() or (
'(none provided — base the summary on the user\'s report and '
'thread alone)'
)
return SUMMARY_PROMPT.format(
name=name, description_plain=desc, messages_plain=msgs,
findings_plain=findings_text,
)

View File

@@ -29,28 +29,65 @@
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.
or network error). Write a quick brief in the Summary
field below before sending — everything else still works.
</div>
<!-- Single mode: one summary field for the one ticket. -->
<!--
Single mode. The user fills findings first, clicks
Generate Summary, reviews the AI output, edits if needed,
then Sends. We deliberately don't auto-fire OpenAI on
open — the AI needs the engineer's analysis to be useful.
-->
<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>
<group invisible="mode != 'single'" string="1. Your Findings">
<field name="findings" nolabel="1"
placeholder="What did your investigation surface? Scope, limitations, recommended approach, effort, risks — anything the owner needs to know that the original reporter wouldn't have."/>
</group>
<group invisible="mode != 'single'" string="2. Summary to Send">
<div class="o_row" colspan="2">
<button name="action_generate_summary" type="object"
string="Generate Summary from Findings"
icon="fa-magic"
class="btn-secondary"/>
<span class="o_form_label text-muted ms-2">
← click after writing your findings, then review &amp; edit below
</span>
</div>
<field name="ai_summary" nolabel="1"
placeholder="Click Generate Summary above, or write the brief yourself. The owner reads this first."/>
</group>
<!-- Bulk mode: per-ticket lines, each with its own summary. -->
<!--
Bulk mode: same two-step flow per ticket. Findings +
Summary editable inline; a single Generate All button
fans out OpenAI in parallel using each line's findings.
-->
<group invisible="mode != 'bulk'">
<field name="personal_note"
placeholder="One-line note that appears once at the top of the combined email…"/>
</group>
<div class="o_row" colspan="2"
invisible="mode != 'bulk'">
<button name="action_generate_all_summaries" type="object"
string="Generate All Summaries"
icon="fa-magic"
class="btn-secondary"/>
<span class="o_form_label text-muted ms-2">
← fill in findings per row first, then click to regenerate all summaries in parallel
</span>
</div>
<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"/>
<field name="findings"
placeholder="Your engineering findings for this ticket…"/>
<field name="ai_summary"
placeholder="(empty — click Generate All above, or write by hand)"/>
</list>
</field>

View File

@@ -4,7 +4,7 @@
{
'name': 'Fusion Repairs',
'version': '19.0.2.2.1',
'version': '19.0.2.2.3',
'category': 'Inventory/Repairs',
'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal',
'description': """

View File

@@ -78,6 +78,21 @@ export class FlowchartDesigner extends Component {
// Per-selected-node Wysiwyg editor instance captured via onLoad.
// Keyed by dfId so switching nodes correctly reads the right editor.
this.wysiwygEditors = {};
// Drawflow init + chart load happen on mount; cleanup on unmount.
// These onMounted / onWillUnmount hooks MUST stay inside setup() -
// they are not class methods. Leaving them outside throws
// "Unexpected token (" because JS parses bare function calls in a
// class body as malformed method declarations.
onMounted(async () => {
await Promise.all([loadJS(DRAWFLOW_JS), loadCSS(DRAWFLOW_CSS)]);
this._initDrawflow();
await this._loadChart();
});
onWillUnmount(() => {
try { this.editor?.clear(); } catch {}
});
}
// ------------------------------------------------------------------
@@ -124,17 +139,6 @@ export class FlowchartDesigner extends Component {
this.state.sourceMode = !this.state.sourceMode;
}
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);

View File

@@ -91,7 +91,7 @@
<div class="fr-wysiwyg-shell">
<Wysiwyg t-key="'wysiwyg-' + state.selectedNodeId"
config="wysiwygConfig"
onLoad="onWysiwygLoad.bind(this)"
onLoad.bind="onWysiwygLoad"
contentClass="'fr-wysiwyg-content'"/>
</div>
<div class="form-text">Bold, italic, lists, links and inline images supported. Click <em>HTML Source</em> to paste raw markup.</div>