Compare commits
4 Commits
52849777dd
...
c520803c84
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c520803c84 | ||
|
|
7349f3180d | ||
|
|
2414b6328e | ||
|
|
5605012245 |
@@ -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:
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
|
||||
@@ -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': """
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user