Compare commits

..

3 Commits

Author SHA1 Message Date
gsinghpal
15470426eb refactor(fusion_helpdesk): owner contact is a res.partner, not two text fields
Smaller UX simplification on the client side: the owner is already a
contact in entech's address book, so picking one is faster + safer than
re-typing their email and name (and avoids typos creeping into the
approval-email To: header).

What changed:
- Entech settings: drop fhd_owner_email + fhd_owner_name char fields;
  add fhd_owner_partner_id Many2one to res.partner exposed in the
  same "Owner Approval" block as a single partner selector. Quick-create
  + create-and-edit kept enabled so admins can spin up a new partner
  inline if the owner isn't already in the system.
- controllers/main.py::_read_config: derives owner_email + owner_name
  from the selected partner via the new _resolve_owner_contact helper.
  Missing / dangling partner id → blank email + name → central simply
  won't see the keys and the Engage button stays disabled (correct
  "not configured" behaviour).
- Nexa side: ZERO changes. Still receives owner_email + owner_name
  strings on the ticket payload, still upserts client_key.owner_email/
  name. The partner abstraction stops at the entech boundary.
- migrations/19.0.2.1.0/post-migration.py auto-resolves the legacy
  fusion_helpdesk.owner_email ICP value to an existing res.partner
  (lowest-id match on lowercased email), writes the new
  fusion_helpdesk.owner_partner_id key, and deletes the obsolete
  owner_email + owner_name ICP rows so a future reader doesn't trip
  over stale config.

Verified live on entech: kris@enplating.ca → res.partner #2308 ("Kris
Pathinather"), legacy keys purged, controller._resolve_owner_contact
returns the expected (email, name). The piggyback payload is unchanged
so existing client_key sync continues to work without a central
redeploy.

Bumps fusion_helpdesk to 19.0.2.1.0. fusion_helpdesk_central stays at
19.0.2.0.0 (no central-side changes required).
2026-05-27 13:21:08 -04:00
gsinghpal
b22bb11b31 feat(fusion_repairs): flowchart designer node content uses Odoo Wysiwyg
Replace the plain <textarea> in the flowchart designer's node-editor
right-panel with Odoo 19's native rich text editor so admins write
formatted prose / lists / bold / links / inline images without typing
HTML tags. The raw <textarea> stays available behind a toggle for the
power-user case (pasting markup from elsewhere, debugging).

CHANGES

manifest:
  - depends += 'html_editor' (provides @html_editor/wysiwyg)
  - bumped to 19.0.2.2.1

components/flowchart_designer/flowchart_designer.js:
  - import { Wysiwyg } from '@html_editor/wysiwyg'
  - import { MAIN_PLUGINS } from '@html_editor/plugin_sets'
  - register Wysiwyg in static components
  - state.sourceMode boolean (default false = rich text mode)
  - wysiwygConfig getter builds the EditorConfig for the SELECTED node;
    onChange reads editor.getContent() and writes back into the same
    selectedMeta.content_html the rest of the designer already uses,
    so the save path is unchanged
  - onWysiwygLoad(editor) captures the editor instance per dfId so the
    onChange callback can resolve the right one when nodes switch
  - onToggleSource flushes the current editor's content before flipping
    modes so unsaved keystrokes don't get lost

components/flowchart_designer/flowchart_designer.xml:
  - replaced <textarea>...</textarea> with a conditional block:
      sourceMode == false -> <Wysiwyg t-key="'wysiwyg-' + selectedNodeId"
                                       config="wysiwygConfig"
                                       onLoad="onWysiwygLoad.bind(this)"/>
      sourceMode == true  -> <textarea class="font-monospace" rows="10"/>
  - t-key forces the editor to re-mount with the freshly-selected node's
    content; otherwise switching nodes would keep showing the first
    selected node's HTML
  - new toolbar row above the editor has a "HTML Source" / "Rich Text"
    toggle button (eye / code icons) so the user can flip at will
  - hint text updated to reflect what each mode supports

components/flowchart_designer/flowchart_designer.scss:
  - widened the right editor panel from 320px to 360px to give the
    Wysiwyg toolbar room to breathe
  - new .fr-wysiwyg-shell rule frames the embedded editor with the same
    border + background as the other form-controls in the panel, with
    a min-height of 180px and max-height 320px so it scrolls when the
    content grows. Pins .o-we-toolbar inside the shell so it stays in
    view as the user scrolls long content.

The save path, the runtime renderer, and the data model are unchanged -
content_html is still sanitised HTML stored on fusion.repair.flowchart.node.

Verified on local westin-v19:
  - upgrade clean (no errors, no warnings)
  - login serves 200 after restart
  - 4 stale asset bundles flushed; Drawflow JS still served 46KB at
    /fusion_repairs/static/src/lib/drawflow/drawflow.min.js
  - Wysiwyg export confirmed at
    /usr/lib/python3/dist-packages/odoo/addons/html_editor/static/src/wysiwyg.js:25
  - MAIN_PLUGINS export confirmed at plugin_sets.js:103

Bumped to 19.0.2.2.1.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 13:18:27 -04:00
gsinghpal
134c94fc6c docs(billing): design spec for sub-project #2a NexaCloud→Odoo importer
One-time, re-runnable, read-only importer that backfills NexaCloud
customers/plans/deployments into Odoo as a shadow copy for dual-run
reconciliation. Locks the brainstorming decisions: per-deployment
granularity, flat+overage billing, cpu_seconds metric, CPU-only v1,
Odoo-side psycopg2 reader, and shadow-safety by construction (draft
subs + no payment token + charges with NULL plan_id).
2026-05-27 13:18:26 -04:00
10 changed files with 437 additions and 41 deletions

View File

@@ -0,0 +1,205 @@
# Sub-project #2a — NexaCloud → Odoo Billing Importer (Design)
- **Date:** 2026-05-27
- **Status:** Design approved (brainstorming session) — implementation in progress
- **Module:** `fusion_centralize_billing` (Odoo 19 Enterprise, host odoo-nexa / tested on odoo-trial)
- **Parent:** Sub-project #2 (NexaCloud adapter + dual-run reconciliation). This spec covers **chunk 2a only** — the read-only importer/backfill. 2b (usage wiring), 2c (control loop), 2d (reconciliation) are separate specs.
- **Depends on:** the core engine (sub-project #1, on `main` at `d770c0c3`): service registry, `_resolve_or_create_partner`, `fusion.billing.charge._compute_billable`, `fusion.billing.usage`, the inbound API, the webhook engine.
## 1. Goal
Backfill the **existing** NexaCloud customers, plans, and deployments into Odoo so the
central billing engine has a complete shadow copy to run dual-run reconciliation (2d)
against. The importer is a **one-time, re-runnable** migration — *not* a continuous sync.
New NexaCloud signups after the cutover already flow through the live inbound API built in
sub-project #1.
The importer must be **safe by construction**: while NexaCloud is still the live biller,
nothing the importer creates in Odoo may charge, post, or email a customer.
## 2. Decisions locked in brainstorming (2026-05-27)
1. **Per-deployment granularity.** NexaCloud's own `subscriptions` table carries
`deployment_id` + `plan_id`, so the natural mapping is **one Odoo subscription
`sale.order` per deployment**. (Confirms spec §15 Q2.)
2. **Billing model = flat plan price + metered overage.** Customers pay a fixed
monthly/yearly plan price PLUS per-unit charges for usage above the plan's quota.
(Confirms the original §6 quota+overage assumption.)
3. **CPU metric standardized to `cpu_seconds`.** The NexaCloud plan quota
(`plans.cpu_seconds_quota`) is already in seconds, so it maps to `charge.included_quota`
with no conversion. NexaCloud's CPU rate ($0.0075/core-hour) maps to
`price_per_unit = 0.0075`, `unit_batch = 3600` (one core-hour = 3600 cpu-seconds).
4. **CPU is the only metered-overage metric in v1.** It is the only resource with a plan
quota. RAM / disk / bandwidth are treated as bundled in the flat plan price for now,
addable later as more metrics if NexaCloud actually bills them as overage. (YAGNI.)
5. **Importer = Odoo-side read-only reader** (Approach A). An Odoo wizard connects
read-only to the `nexacloud` Postgres, reads its tables, and writes only into Odoo via
the existing model methods. No NexaCloud code is touched.
6. **Idempotent / re-runnable.** Every created entity is upserted on a stable key, so the
importer can run each cycle during the dual-run and update rather than duplicate.
## 3. Source data (NexaCloud, read-only)
Confirmed by reading `/Users/gurpreet/Github/Nexa-Cloud/backend/app/models`. FastAPI +
async SQLAlchemy on Postgres. Relevant tables/columns:
- **`users`** — `id` (UUID), `email`, `full_name`, `company`, `billing_email`,
`billing_address`/`_city`/`_state`/`_postal_code`/`_country`, `tax_id`,
`stripe_customer_id`.
- **`plans`** — `id` (UUID), `product_id`, `name`, `price_monthly`, `price_yearly`,
`stripe_price_id`, `cpu_seconds_quota` (BigInteger), `is_active`.
- **`deployments`** — `id` (UUID), `user_id`, `product_id`, `plan_id`, `name`, `status`,
`billing_cycle`, `next_due_date`.
- **`subscriptions`** — `id` (UUID), `user_id`, `deployment_id`, `plan_id`, `status`
(active/cancelled/past_due/trialing/paused), `billing_cycle` (monthly/yearly),
`current_period_start`, `current_period_end`, `stripe_subscription_id`.
(The `usage_records`, `invoices`, `addons` tables are out of scope for 2a — usage wiring
is 2b; reconciliation against NexaCloud invoice/usage totals is 2d.)
## 4. Data mapping
| NexaCloud (read) | Odoo (upsert) | Idempotency key |
|---|---|---|
| `users` | `res.partner` + `fusion.billing.account.link` (service=`nexacloud`, external_id=`user.id`) | `account.link (service_id, external_id)` (existing unique constraint) |
| `plans` | one subscription `product.template` (flat price) + one CPU-overage `product.product` + one `fusion.billing.charge` | `charge.plan_code = plan.id` (UUID string) |
| `subscriptions`/`deployments` | one **draft** `sale.order(is_subscription)` per deployment | `sale.order.x_fc_nexacloud_subscription_id` |
| (constant) | `fusion.billing.metric` `cpu_seconds` | `metric.code` (existing unique) |
| (constant) | `sale.subscription.plan` Monthly + Yearly recurrences | `(billing_period_value, billing_period_unit)` |
### 4.1 Identity (`users` → partner + link)
Reuse `account_link._resolve_or_create_partner(service, external_id, name, email, extra)`.
- `external_id` = `str(user.id)`, `email` = `user.billing_email or user.email`,
`name` = `user.full_name or user.company or email`.
- `extra` carries billing address fields → `res.partner` (`street`, `city`, `country_id`
resolved from the ISO/name, `vat` from `tax_id`).
- Stash `user.stripe_customer_id` on `res.partner.x_fc_stripe_customer_id` so the eventual
flip (not 2a) can reuse the existing Stripe customer instead of creating a new one.
### 4.2 Catalog (`plans` → product + charge)
For each active NexaCloud plan:
- **Subscription product** (`product.template`, `type='service'`, `recurring_invoice=True`)
named after the plan. `recurring_invoice=True` is what makes Odoo treat an order using
it as a subscription (verified pattern from the core engine's `_api_create_subscription`).
- **CPU-overage product** (`product.product`, `type='service'`) — the product the rating
math attaches the overage amount to (`charge.product_id`).
- **`fusion.billing.charge`**: `plan_code=str(plan.id)`, `metric_id=cpu_seconds`,
`product_id=`overage product, `included_quota=plan.cpu_seconds_quota`,
`price_per_unit=0.0075`, `unit_batch=3600`, `charge_model='standard'`, CAD.
**`plan_id` is left NULL on purpose** (see §6) — the hourly auto-rating cron skips
charges with no `plan_id`, so importing charges never auto-mutates shadow subscriptions.
### 4.3 Subscription (`deployment` → draft shadow sale.order)
For each deployment that has a NexaCloud subscription:
- `partner_id` = the mapped partner.
- `plan_id` = the Monthly or Yearly `sale.subscription.plan` per `subscription.billing_cycle`.
- `order_line` = one line: the plan's subscription product, qty 1, **`price_unit` set
explicitly** to `plan.price_monthly` or `plan.price_yearly` (matching the cycle). Setting
the price explicitly makes Odoo's computed amount match NexaCloud's by construction —
it does not depend on Odoo subscription-pricing internals or a pricelist.
- `x_fc_nexacloud_subscription_id` = `str(subscription.id)` (upsert key),
`x_fc_nexacloud_deployment_id` = `str(deployment.id)`,
`x_fc_billing_service_id` = the nexacloud service, `x_fc_shadow = True`.
- **Left in draft** (`action_confirm()` is NOT called). No payment token is attached.
## 5. Architecture / mechanism
A new transient model **`fusion.billing.import.wizard`** with one button, but the logic
lives in two model methods so it is unit-testable headless (the core-engine pattern —
logic in model methods, thin UI):
- **`_read_nexacloud_rows()`** — opens a **read-only `psycopg2`** connection using a DSN
from `ir.config_parameter` (`fusion_billing.nexacloud_dsn`), runs `SELECT`s, and returns
a plain dict: `{'users': [...], 'plans': [...], 'subscriptions': [...]}` (rows as dicts).
This is the *only* code that touches NexaCloud, and it only reads.
- **`_import_rows(data, dry_run=False)`** — pure Odoo writes. Consumes the dict, upserts in
FK order (metric+recurrences → partners → catalog → subscriptions), returns a summary
`{'created': {...}, 'updated': {...}, 'skipped': [...], 'failed': [...]}`. With
`dry_run=True` it computes the summary inside a rolled-back savepoint and writes nothing.
`action_run_import()` on the wizard wires them: `self._import_rows(self._read_nexacloud_rows(), dry_run=self.dry_run)`.
## 6. Shadow-mode safety (the critical property)
While NexaCloud is the live biller, the importer must not produce any customer-visible
billing in Odoo. Three independent guarantees, any one of which is sufficient:
1. **Subscriptions are imported in `draft`.** Odoo's native recurring-invoice cron only
invoices confirmed (`3_progress`) subscriptions, so draft imports are never auto-invoiced,
posted, or emailed.
2. **No payment token is imported.** Even a posted invoice could not be auto-charged,
because Odoo has no saved Stripe payment method for the partner. Charging is physically
impossible.
3. **Charges are imported with `plan_id = NULL`.** The hourly `_cron_rate_open_periods`
skips charges without a `plan_id`, so importing the catalog never mutates any order line.
`x_fc_shadow=True` marks every imported subscription for later identification. The flip
(out of scope here) is: set `charge.plan_id`, attach payment tokens, `action_confirm()`.
## 7. Error handling
- **Per-row `savepoint`** (`with self.env.cr.savepoint():`) around each entity write
(CLAUDE rule #14 — no `cr.commit()` in tests). One malformed row (missing email, unknown
plan, bad country) is recorded in `failed` with its reason and skipped; the batch
continues.
- Rows that reference an unresolved parent (subscription whose user/plan failed) are
`skipped` with a reason, not failed.
- `_read_nexacloud_rows()` raises a clear `UserError` if the DSN config param is missing or
the connection fails — the wizard surfaces it; nothing is half-written (read happens
before any write).
## 8. Testing
Split mirrors §5 so the Odoo logic is fully testable without a foreign DB:
- **`_import_rows(data)` unit tests** (`TransactionCase`, run on odoo-trial Enterprise via
`bash scripts/fcb_test_on_trial.sh`) with hand-built fixture dicts:
- partners + links created; re-run updates, does not duplicate (idempotency).
- catalog: `cpu_seconds` metric, product, and a `charge` with `included_quota` = quota,
`unit_batch=3600`, `price_per_unit=0.0075`, **`plan_id` NULL**.
- subscription: one **draft** `sale.order` per deployment, `is_subscription=True`,
`price_unit` = the cycle's NexaCloud price, `x_fc_shadow=True`, no confirm.
- shadow safety: imported subscription is `draft`/not `3_progress`; no `account.move`
is created; partner has no payment token.
- malformed rows land in `failed`/`skipped` without aborting the batch.
- `dry_run=True` writes nothing (counts only).
- The `psycopg2` read path is verified manually against the real `nexacloud` DB once
access is granted (cannot be unit-tested against a foreign DB).
## 9. Prerequisite (flagged, not blocking the build)
Odoo on nexa (VM 315) needs network reachability + a **read-only credential** to the
`nexacloud` Postgres (LXC 201), stored as `ir.config_parameter` `fusion_billing.nexacloud_dsn`.
The build and all unit tests proceed with fixtures; only the live import run is blocked
until this is granted.
## 10. Out of scope (YAGNI / later chunks)
- RAM / disk / bandwidth overage metrics (only if NexaCloud bills them — add as metrics).
- The **flip** to live billing (confirm subs, attach tokens, set `charge.plan_id`).
- Usage metering wiring (2b), control-loop webhooks (2c), reconciliation compute (2d).
- Importing historical NexaCloud invoices / `usage_records` (2d reads NexaCloud actuals).
- Add-ons (`deployment_addons`) as recurring lines — revisit if material.
## 11. Verify at implementation (do NOT code from memory — CLAUDE rule #1)
Confirm on odoo-trial Enterprise before relying on them:
- A **draft** `sale.order` with `plan_id` + a `recurring_invoice=True` product line reports
`is_subscription=True` (so `fusion.billing.usage.subscription_id`'s domain accepts it).
- `product.template.recurring_invoice` is the correct field name in this build.
- `sale.subscription.plan` fields `billing_period_value` / `billing_period_unit` (used by
the core tests) are the right find-or-create keys.
- `res.partner` country resolution field (`country_id`) and `vat` for `tax_id`.
## 12. Success criteria
- Running `_import_rows(fixture)` produces, per the mapping in §4, partners+links, a
`cpu_seconds`-based charge catalog (`plan_id` NULL), and one **draft** shadow subscription
per deployment with the correct flat `price_unit` — and re-running it changes nothing
(pure idempotency).
- No `account.move` and no payment token exist for any imported partner after an import
(shadow safety, asserted in tests).
- Full suite green on odoo-trial (`FCB_EXIT=0`); no `_sql_constraints`, no bare
`sale.subscription` model references.

View File

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

View File

@@ -228,12 +228,29 @@ class FusionHelpdeskController(http.Controller):
'client_label': (
ICP.get_param('fusion_helpdesk.client_label') or ''
).strip(),
'owner_email': (
ICP.get_param('fusion_helpdesk.owner_email') or ''
).strip(),
'owner_name': (
ICP.get_param('fusion_helpdesk.owner_name') or ''
).strip(),
# Owner contact is configured as a res.partner reference; we
# derive email + name on read so the rest of the submit path
# doesn't have to know about the partner. Missing / dangling
# partner id → blank email + name → central simply won't see
# the keys and the Engage button stays disabled (which is the
# correct "not configured" behaviour).
**self._resolve_owner_contact(ICP),
}
def _resolve_owner_contact(self, ICP):
raw = (ICP.get_param('fusion_helpdesk.owner_partner_id') or '').strip()
try:
partner_id = int(raw)
except (TypeError, ValueError):
return {'owner_email': '', 'owner_name': ''}
if partner_id <= 0:
return {'owner_email': '', 'owner_name': ''}
partner = request.env['res.partner'].sudo().browse(partner_id).exists()
if not partner:
return {'owner_email': '', 'owner_name': ''}
return {
'owner_email': (partner.email or '').strip(),
'owner_name': (partner.name or '').strip(),
}
def _authenticate(self, cfg):

View File

@@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
"""Migrate owner contact settings from email/name strings to a partner ref.
Pre-19.0.2.1.0, fusion_helpdesk stored two ICP keys:
fusion_helpdesk.owner_email
fusion_helpdesk.owner_name
19.0.2.1.0 replaces them with a single Many2one to res.partner exposed
as a partner-id string in:
fusion_helpdesk.owner_partner_id
This script auto-resolves the email to an existing res.partner (best
effort — exact case-insensitive match), so admins don't have to re-pick
the owner contact after the upgrade. The old keys are then deleted to
avoid stale-config confusion. If no matching partner is found, the
owner_partner_id is left blank — admin reconfigures via Settings.
"""
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
cr.execute(
"SELECT value FROM ir_config_parameter "
"WHERE key = 'fusion_helpdesk.owner_email'"
)
row = cr.fetchone()
email = (row[0] or '').strip().lower() if row else ''
if not email:
_logger.info(
'fusion_helpdesk: no legacy owner_email to migrate, skipping.'
)
_purge_legacy_keys(cr)
return
# Exact (lowercased) match on res_partner.email. The legacy ICP value
# was free text — could be uppercase, have trailing spaces, etc. —
# so normalise both sides. If multiple partners share the email,
# pick the oldest (lowest id) — same convention used elsewhere in
# the customer-followup partner backfill.
cr.execute(
"SELECT id, name FROM res_partner "
"WHERE lower(email) = %s "
"ORDER BY id ASC LIMIT 1",
(email,),
)
match = cr.fetchone()
if not match:
_logger.warning(
'fusion_helpdesk: legacy owner_email %s does not match any '
'res.partner — owner_partner_id left blank, admin must '
're-pick under Settings → Fusion Helpdesk → Owner Approval.',
email,
)
_purge_legacy_keys(cr)
return
partner_id, partner_name = match
cr.execute(
"INSERT INTO ir_config_parameter (key, value) "
"VALUES ('fusion_helpdesk.owner_partner_id', %s) "
"ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value",
(str(partner_id),),
)
_logger.info(
'fusion_helpdesk: migrated owner_email "%s" → res.partner #%s '
'("%s"). Legacy keys purged.', email, partner_id, partner_name,
)
_purge_legacy_keys(cr)
def _purge_legacy_keys(cr):
"""Delete the obsolete owner_email / owner_name ICP rows so a future
reader doesn't trip over stale config. Safe — the new partner_id key
is the only source of truth after this migration."""
cr.execute(
"DELETE FROM ir_config_parameter "
"WHERE key IN ('fusion_helpdesk.owner_email', "
" 'fusion_helpdesk.owner_name')"
)

View File

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

View File

@@ -45,15 +45,12 @@
</block>
<block title="Owner Approval"
name="fhd_owner_approval">
<setting id="fhd_owner_name"
string="Owner Name"
help="Display name of the real decision-maker at your company. Used in approval emails and chatter attribution.">
<field name="fhd_owner_name" placeholder="Jane Doe"/>
</setting>
<setting id="fhd_owner_email"
string="Owner Email"
help="Email of the real decision-maker. Used when central support requests approval for a ticket that needs sign-off. Leave blank to disable approval requests for this deployment.">
<field name="fhd_owner_email" placeholder="owner@yourcompany.com"/>
<setting id="fhd_owner_partner_id"
string="Owner Contact"
help="Pick the real decision-maker at your company from your existing contacts. Used when central support requests approval for a ticket that needs sign-off. Their email and name are sent automatically. Leave blank to disable approval requests for this deployment.">
<field name="fhd_owner_partner_id"
options="{'no_create_edit': False, 'no_quick_create': False}"
placeholder="Pick an owner contact…"/>
</setting>
</block>
</app>

View File

@@ -4,7 +4,7 @@
{
'name': 'Fusion Repairs',
'version': '19.0.2.2.0',
'version': '19.0.2.2.1',
'category': 'Inventory/Repairs',
'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal',
'description': """
@@ -52,6 +52,7 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
'depends': [
'base',
'mail',
'html_editor',
'portal',
'website',
'sale_management',

View File

@@ -24,6 +24,8 @@ import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
import { loadJS, loadCSS } from "@web/core/assets";
import { Wysiwyg } from "@html_editor/wysiwyg";
import { MAIN_PLUGINS } from "@html_editor/plugin_sets";
const DRAWFLOW_JS = "/fusion_repairs/static/src/lib/drawflow/drawflow.min.js";
const DRAWFLOW_CSS = "/fusion_repairs/static/src/lib/drawflow/drawflow.min.css";
@@ -43,6 +45,7 @@ const OUTCOME_COLORS = {
export class FlowchartDesigner extends Component {
static template = "fusion_repairs.FlowchartDesigner";
static components = { Wysiwyg };
static props = ["*"];
setup() {
@@ -63,6 +66,8 @@ export class FlowchartDesigner extends Component {
selectedNodeId: null,
dirty: false,
saving: false,
// Default to rich-text mode; the user can toggle to raw HTML.
sourceMode: false,
// client_id -> drawflow numeric id mapping for new nodes (those
// without a DB id yet, created via "Add Node")
clientToDfId: {},
@@ -70,6 +75,54 @@ export class FlowchartDesigner extends Component {
// outcome_kind, media_ids}
nodeMeta: {},
});
// Per-selected-node Wysiwyg editor instance captured via onLoad.
// Keyed by dfId so switching nodes correctly reads the right editor.
this.wysiwygEditors = {};
}
// ------------------------------------------------------------------
// RICH TEXT EDITOR INTEGRATION
// ------------------------------------------------------------------
/**
* Build the config object the <Wysiwyg> component expects for the
* currently selected node. Recomputed each render so switching nodes
* re-mounts the editor with the right content (via the t-key on the
* template).
*/
get wysiwygConfig() {
const meta = this.selectedMeta;
return {
content: meta?.content_html || "",
Plugins: MAIN_PLUGINS,
// onChange fires after each edit step - read the current HTML
// from the editor and persist it to selectedMeta + dirty flag.
onChange: () => {
if (!this.state.selectedNodeId) return;
const editor = this.wysiwygEditors[this.state.selectedNodeId];
if (!editor) return;
const html = editor.getContent();
this.onEditorFieldChange("content_html", html);
},
};
}
onWysiwygLoad(editor) {
if (this.state.selectedNodeId) {
this.wysiwygEditors[this.state.selectedNodeId] = editor;
}
}
onToggleSource() {
// Before flipping modes, persist the current editor's content so we
// don't lose unsaved keystrokes.
if (!this.state.sourceMode && this.state.selectedNodeId) {
const editor = this.wysiwygEditors[this.state.selectedNodeId];
if (editor) {
this.onEditorFieldChange("content_html", editor.getContent());
}
}
this.state.sourceMode = !this.state.sourceMode;
}
onMounted(async () => {
await Promise.all([loadJS(DRAWFLOW_JS), loadCSS(DRAWFLOW_CSS)]);

View File

@@ -60,7 +60,7 @@ $fr-panel: var(--fr-panel-bg, #{$_fr_panel-hex});
}
.fr-designer-editor {
width: 320px;
width: 360px;
border-left: 1px solid $fr-border;
background: $fr-panel;
padding: 12px 14px;
@@ -68,6 +68,26 @@ $fr-panel: var(--fr-panel-bg, #{$_fr_panel-hex});
h6 { margin-bottom: 8px; font-weight: 700; }
}
// Frame the embedded Odoo Wysiwyg so it visually matches the other
// form-control fields in the right panel (border, padding, scrollable).
.fr-wysiwyg-shell {
border: 1px solid $fr-border;
border-radius: 4px;
background: $fr-card;
min-height: 180px;
max-height: 320px;
overflow-y: auto;
.odoo-editor-editable, .fr-wysiwyg-content {
min-height: 160px;
padding: 8px 10px;
outline: none;
}
.o-we-toolbar, .o_we_toolbar {
// Pin the Wysiwyg toolbar inside the shell so it scrolls with content.
z-index: 2;
}
}
// ----- Node card styling (inside Drawflow's drawflow_content_node) -----
.drawflow .drawflow-node {
background: transparent !important;

View File

@@ -75,12 +75,35 @@
<option value="order_part" t-att-selected="selectedMeta.outcome_kind === 'order_part'">Order part</option>
</select>
</div>
<div class="mb-2">
<label class="form-label small mb-1">Content (shown to CS)</label>
<textarea class="form-control form-control-sm" rows="6"
t-on-change="(ev) => this.onEditorFieldChange('content_html', ev.target.value)"
t-out="selectedMeta.content_html"/>
<div class="form-text">HTML allowed: lists, bold, links.</div>
<div class="mb-2 fr-content-editor">
<div class="d-flex justify-content-between align-items-center mb-1">
<label class="form-label small mb-0">Content (shown to CS)</label>
<button type="button"
class="btn btn-link btn-sm p-0"
t-on-click="onToggleSource">
<i t-att-class="state.sourceMode ? 'fa fa-eye' : 'fa fa-code'"/>
<t t-if="state.sourceMode"> Rich Text</t>
<t t-else=""> HTML Source</t>
</button>
</div>
<!-- Rich text (default) - re-mounts when selecting a different node via t-key. -->
<t t-if="!state.sourceMode">
<div class="fr-wysiwyg-shell">
<Wysiwyg t-key="'wysiwyg-' + state.selectedNodeId"
config="wysiwygConfig"
onLoad="onWysiwygLoad.bind(this)"
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>
</t>
<!-- HTML source mode (toggle) - power user escape hatch. -->
<t t-else="">
<textarea class="form-control form-control-sm font-monospace"
rows="10"
t-on-change="(ev) => this.onEditorFieldChange('content_html', ev.target.value)"
t-out="selectedMeta.content_html"/>
<div class="form-text">Raw HTML mode - what you type is rendered as-is.</div>
</t>
</div>
<div class="mb-2">
<label class="form-label small mb-1">