Compare commits
3 Commits
fc8963da99
...
1f818096db
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f818096db | ||
|
|
bb873e8a7a | ||
|
|
d4ef4d55e0 |
@@ -7,9 +7,9 @@ home-grown Stripe billing into one customer ledger and one accounting system.
|
|||||||
|
|
||||||
> **Design spec:** [`docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md`](../docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md)
|
> **Design spec:** [`docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md`](../docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md)
|
||||||
>
|
>
|
||||||
> **Status:** **SCAFFOLD.** Models + security + the API auth shell are in place and the
|
> **Status:** Core engine (sub-project #1) and the **NexaCloud importer (sub-project #2a)**
|
||||||
> module installs. The usage engine, full inbound API, and webhook processor are stubs
|
> are implemented and tested on odoo-trial Enterprise. 2b (usage wiring), 2c (control loop),
|
||||||
> to be implemented from the writing-plans output.
|
> and 2d (reconciliation) are pending.
|
||||||
|
|
||||||
## Why this module is small
|
## Why this module is small
|
||||||
|
|
||||||
@@ -59,6 +59,42 @@ cost into margin reporting; reuse its daily-rollup aggregation pattern.
|
|||||||
|
|
||||||
`account_accountant`, `sale_subscription`, `sale_management`, `payment_stripe`.
|
`account_accountant`, `sale_subscription`, `sale_management`, `payment_stripe`.
|
||||||
|
|
||||||
|
## Running the NexaCloud import (2a)
|
||||||
|
|
||||||
|
Exposed as **Fusion Billing → Import from NexaCloud** (a wizard). It runs entirely
|
||||||
|
read-only against NexaCloud, and everything it creates in Odoo is shadow-safe (draft
|
||||||
|
subscriptions, no payment token, charges with NULL `plan_id`) so it cannot charge or post
|
||||||
|
during the dual-run.
|
||||||
|
|
||||||
|
**1. Create a least-privilege read-only role in the NexaCloud Postgres (LXC 201):**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE ROLE odoo_billing_ro WITH LOGIN PASSWORD '<choose-a-strong-password>';
|
||||||
|
GRANT CONNECT ON DATABASE nexacloud TO odoo_billing_ro;
|
||||||
|
GRANT USAGE ON SCHEMA public TO odoo_billing_ro;
|
||||||
|
GRANT SELECT ON users, plans, subscriptions, deployments TO odoo_billing_ro;
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Point Odoo at it** via the system parameter (Settings → Technical → System Parameters,
|
||||||
|
or odoo-shell). psycopg2 wants a **libpq DSN** — i.e. NexaCloud's SQLAlchemy URL *without*
|
||||||
|
`+asyncpg`:
|
||||||
|
|
||||||
|
```
|
||||||
|
key: fusion_billing.nexacloud_dsn
|
||||||
|
value: postgresql://odoo_billing_ro:<password>@<lxc201-host>:5432/nexacloud
|
||||||
|
```
|
||||||
|
|
||||||
|
(Odoo on nexa / VM 315 must have a network route to the LXC 201 Postgres port.)
|
||||||
|
|
||||||
|
**3. Validate → dry-run → run for real:**
|
||||||
|
|
||||||
|
- **Test Connection** — confirms reachability + schema and reports row counts; imports nothing.
|
||||||
|
- **Run Import** with **Dry run** ticked — computes the whole import inside a rolled-back
|
||||||
|
savepoint and reports created / updated / **skipped** / **failed** counts; writes nothing.
|
||||||
|
A red/amber banner flags any failures — investigate them before proceeding.
|
||||||
|
- Untick **Dry run** and **Run Import** to persist the shadow copy. Re-running is safe and
|
||||||
|
idempotent (upserts, never duplicates).
|
||||||
|
|
||||||
## Local dev
|
## Local dev
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -243,3 +243,9 @@ class TestImporterReadGuard(TransactionCase):
|
|||||||
wiz = self.env['fusion.billing.import.wizard'].sudo().create({'dry_run': True})
|
wiz = self.env['fusion.billing.import.wizard'].sudo().create({'dry_run': True})
|
||||||
with self.assertRaises(UserError):
|
with self.assertRaises(UserError):
|
||||||
wiz._read_nexacloud_rows()
|
wiz._read_nexacloud_rows()
|
||||||
|
|
||||||
|
def test_test_connection_guards_missing_dsn(self):
|
||||||
|
self.env['ir.config_parameter'].sudo().set_param('fusion_billing.nexacloud_dsn', '')
|
||||||
|
wiz = self.env['fusion.billing.import.wizard'].sudo().create({'dry_run': True})
|
||||||
|
with self.assertRaises(UserError):
|
||||||
|
wiz.action_test_connection()
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
<field name="result_summary" nolabel="1" widget="text"/>
|
<field name="result_summary" nolabel="1" widget="text"/>
|
||||||
</group>
|
</group>
|
||||||
<footer>
|
<footer>
|
||||||
|
<button name="action_test_connection" type="object"
|
||||||
|
string="Test Connection" class="btn-secondary"/>
|
||||||
<button name="action_run_import" type="object" string="Run Import"
|
<button name="action_run_import" type="object" string="Run Import"
|
||||||
class="btn-primary"/>
|
class="btn-primary"/>
|
||||||
<button string="Close" class="btn-secondary" special="cancel"/>
|
<button string="Close" class="btn-secondary" special="cancel"/>
|
||||||
|
|||||||
@@ -60,6 +60,22 @@ class FusionBillingImportWizard(models.TransientModel):
|
|||||||
"target": "new",
|
"target": "new",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def action_test_connection(self):
|
||||||
|
"""Read-only connectivity + schema check: connect, read the source tables, and
|
||||||
|
report row counts WITHOUT importing anything. The safe first step before a
|
||||||
|
dry-run — surfaces a bad DSN, no network route, or a schema drift up front."""
|
||||||
|
self.ensure_one()
|
||||||
|
data = self._read_nexacloud_rows()
|
||||||
|
msg = "Connected. Read %s user(s), %s plan(s), %s subscription(s)." % (
|
||||||
|
len(data.get("users", [])), len(data.get("plans", [])),
|
||||||
|
len(data.get("subscriptions", [])))
|
||||||
|
return {
|
||||||
|
"type": "ir.actions.client",
|
||||||
|
"tag": "display_notification",
|
||||||
|
"params": {"title": "NexaCloud connection OK", "message": msg,
|
||||||
|
"type": "success", "sticky": False},
|
||||||
|
}
|
||||||
|
|
||||||
# ----- read side (the ONLY code that touches NexaCloud) ------------------
|
# ----- read side (the ONLY code that touches NexaCloud) ------------------
|
||||||
def _read_nexacloud_rows(self):
|
def _read_nexacloud_rows(self):
|
||||||
"""Open a READ-ONLY psycopg2 connection to the nexacloud Postgres (DSN in
|
"""Open a READ-ONLY psycopg2 connection to the nexacloud Postgres (DSN in
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1
|
# License OPL-1
|
||||||
{
|
{
|
||||||
'name': 'Fusion Helpdesk Central — Client API Keys',
|
'name': 'Fusion Helpdesk Central — Client API Keys',
|
||||||
'version': '19.0.2.3.1',
|
'version': '19.0.2.3.2',
|
||||||
'category': 'Productivity',
|
'category': 'Productivity',
|
||||||
'summary': 'Admin UI on the central Odoo for issuing per-client API '
|
'summary': 'Admin UI on the central Odoo for issuing per-client API '
|
||||||
'keys used by fusion_helpdesk client deployments.',
|
'keys used by fusion_helpdesk client deployments.',
|
||||||
|
|||||||
@@ -18,11 +18,16 @@
|
|||||||
<field name="mode" invisible="1"/>
|
<field name="mode" invisible="1"/>
|
||||||
<field name="ticket_id" invisible="1"/>
|
<field name="ticket_id" invisible="1"/>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Compact "who + note" header in a normal 2-col group.
|
||||||
|
Owner is read-only display, Personal Note is short.
|
||||||
|
-->
|
||||||
<group>
|
<group>
|
||||||
<group>
|
<field name="owner_name_display" string="Owner" readonly="1"/>
|
||||||
<field name="owner_name_display" string="Owner"/>
|
<field name="owner_email_display" string="Owner Email"
|
||||||
<field name="owner_email_display" string="Owner Email" widget="email"/>
|
widget="email" readonly="1"/>
|
||||||
</group>
|
<field name="personal_note"
|
||||||
|
placeholder="One-line note that appears above the summary in the email…"/>
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
<div class="alert alert-warning" role="alert"
|
<div class="alert alert-warning" role="alert"
|
||||||
@@ -34,63 +39,60 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Single mode. The user fills findings first, clicks
|
Wide textareas live DIRECTLY at the form level (not inside
|
||||||
Generate Summary, reviews the AI output, edits if needed,
|
a <group>) so they take the full sheet width — the same
|
||||||
then Sends. We deliberately don't auto-fire OpenAI on
|
pattern Odoo's mail.compose.message wizard uses for the
|
||||||
open — the AI needs the engineer's analysis to be useful.
|
`body` HTML editor. Section titles use <separator>, which
|
||||||
|
renders as a horizontal divider with a label above the
|
||||||
|
next field. This is the only Odoo 19 idiom that reliably
|
||||||
|
gives a full-width textarea inside a wizard dialog.
|
||||||
-->
|
-->
|
||||||
<group invisible="mode != 'single'">
|
<separator string="1. Your Findings"
|
||||||
<field name="personal_note"
|
invisible="mode != 'single'"/>
|
||||||
placeholder="One-line note that appears above the summary in the email…"/>
|
<field name="findings"
|
||||||
</group>
|
invisible="mode != 'single'"
|
||||||
<!--
|
nolabel="1"
|
||||||
`col="1"` on these single-column groups makes the
|
widget="text"
|
||||||
enclosed field span the full group width. The default
|
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."/>
|
||||||
`col="2"` reserves a label column even with nolabel=1,
|
|
||||||
which is what was squeezing the textareas into half
|
<separator string="2. Summary to Send"
|
||||||
width before. Findings + summary are wide-form inputs
|
invisible="mode != 'single'"/>
|
||||||
that deserve all the real estate the dialog has.
|
<div class="d-flex align-items-center mb-2"
|
||||||
-->
|
invisible="mode != 'single'">
|
||||||
<group invisible="mode != 'single'" string="1. Your Findings" col="1">
|
<button name="action_generate_summary" type="object"
|
||||||
<field name="findings" nolabel="1"
|
string="Generate Summary from Findings"
|
||||||
widget="text"
|
icon="fa-magic"
|
||||||
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."/>
|
class="btn-secondary"/>
|
||||||
</group>
|
<span class="text-muted ms-2">
|
||||||
<group invisible="mode != 'single'" string="2. Summary to Send" col="1">
|
← click after writing your findings, then review & edit below
|
||||||
<div class="d-flex align-items-center">
|
</span>
|
||||||
<button name="action_generate_summary" type="object"
|
</div>
|
||||||
string="Generate Summary from Findings"
|
<field name="ai_summary"
|
||||||
icon="fa-magic"
|
invisible="mode != 'single'"
|
||||||
class="btn-secondary"/>
|
nolabel="1"
|
||||||
<span class="text-muted ms-2">
|
widget="text"
|
||||||
← click after writing your findings, then review & edit below
|
placeholder="Click Generate Summary above, or write the brief yourself. The owner reads this first."/>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<field name="ai_summary" nolabel="1"
|
|
||||||
widget="text"
|
|
||||||
placeholder="Click Generate Summary above, or write the brief yourself. The owner reads this first."/>
|
|
||||||
</group>
|
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Bulk mode: same two-step flow per ticket. Findings +
|
Bulk mode: same two-step flow, per-line. The list view
|
||||||
Summary editable inline; a single Generate All button
|
is wide by default because list views fill the form
|
||||||
fans out OpenAI in parallel using each line's findings.
|
sheet — no special layout work needed.
|
||||||
-->
|
-->
|
||||||
<group invisible="mode != 'bulk'">
|
<separator string="Per-Ticket Findings & Summaries"
|
||||||
<field name="personal_note"
|
invisible="mode != 'bulk'"/>
|
||||||
placeholder="One-line note that appears once at the top of the combined email…"/>
|
<div class="d-flex align-items-center mb-2"
|
||||||
</group>
|
|
||||||
<div class="o_row" colspan="2"
|
|
||||||
invisible="mode != 'bulk'">
|
invisible="mode != 'bulk'">
|
||||||
<button name="action_generate_all_summaries" type="object"
|
<button name="action_generate_all_summaries" type="object"
|
||||||
string="Generate All Summaries"
|
string="Generate All Summaries"
|
||||||
icon="fa-magic"
|
icon="fa-magic"
|
||||||
class="btn-secondary"/>
|
class="btn-secondary"/>
|
||||||
<span class="o_form_label text-muted ms-2">
|
<span class="text-muted ms-2">
|
||||||
← fill in findings per row first, then click to regenerate all summaries in parallel
|
← fill in findings per row first, then click to regenerate all summaries in parallel
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<field name="line_ids" invisible="mode != 'bulk'" nolabel="1">
|
<field name="line_ids"
|
||||||
|
invisible="mode != 'bulk'"
|
||||||
|
nolabel="1">
|
||||||
<list editable="bottom" create="0" delete="0">
|
<list editable="bottom" create="0" delete="0">
|
||||||
<field name="ticket_id" readonly="1"/>
|
<field name="ticket_id" readonly="1"/>
|
||||||
<field name="ticket_name" readonly="1"/>
|
<field name="ticket_name" readonly="1"/>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Repairs',
|
'name': 'Fusion Repairs',
|
||||||
'version': '19.0.2.2.3',
|
'version': '19.0.2.2.4',
|
||||||
'category': 'Inventory/Repairs',
|
'category': 'Inventory/Repairs',
|
||||||
'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal',
|
'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
* the source of truth for the designer view and the node/edge tables are
|
* the source of truth for the designer view and the node/edge tables are
|
||||||
* the source of truth for the runtime traversal.
|
* the source of truth for the runtime traversal.
|
||||||
*/
|
*/
|
||||||
import { Component, onMounted, onWillUnmount, useRef, useState } from "@odoo/owl";
|
import { Component, markup, onMounted, onWillUnmount, useRef, useState } from "@odoo/owl";
|
||||||
import { registry } from "@web/core/registry";
|
import { registry } from "@web/core/registry";
|
||||||
import { rpc } from "@web/core/network/rpc";
|
import { rpc } from "@web/core/network/rpc";
|
||||||
import { useService } from "@web/core/utils/hooks";
|
import { useService } from "@web/core/utils/hooks";
|
||||||
@@ -106,8 +106,13 @@ export class FlowchartDesigner extends Component {
|
|||||||
*/
|
*/
|
||||||
get wysiwygConfig() {
|
get wysiwygConfig() {
|
||||||
const meta = this.selectedMeta;
|
const meta = this.selectedMeta;
|
||||||
|
// CRITICAL: wrap the HTML string with markup() so the editor's
|
||||||
|
// setElementContent() takes the innerHTML branch instead of the
|
||||||
|
// textContent branch - otherwise the editor renders our tags as
|
||||||
|
// literal text instead of formatted prose / lists.
|
||||||
|
const rawHtml = meta?.content_html || "";
|
||||||
return {
|
return {
|
||||||
content: meta?.content_html || "",
|
content: rawHtml ? markup(rawHtml) : markup(""),
|
||||||
Plugins: MAIN_PLUGINS,
|
Plugins: MAIN_PLUGINS,
|
||||||
// onChange fires after each edit step - read the current HTML
|
// onChange fires after each edit step - read the current HTML
|
||||||
// from the editor and persist it to selectedMeta + dirty flag.
|
// from the editor and persist it to selectedMeta + dirty flag.
|
||||||
|
|||||||
Reference in New Issue
Block a user