289 lines
32 KiB
Markdown
289 lines
32 KiB
Markdown
# Odoo Modules — Claude Code Instructions
|
|
|
|
## Project
|
|
27 custom Odoo 19 modules for Fusion Central (Westin Healthcare + NEXA Systems).
|
|
|
|
## Critical Rules — Odoo 19
|
|
1. **NEVER code from memory** — Always read a reference file from Docker first:
|
|
```bash
|
|
docker exec odoo-dev-app cat /usr/lib/python3/dist-packages/odoo/addons/<module>/static/src/<path>
|
|
```
|
|
2. **Frontend JS**: Use `Interaction` class from `@web/public/interaction`, registered via `registry.category("public.interactions")`. NOT IIFE/DOMContentLoaded.
|
|
3. **Backend OWL**: Use standalone `rpc()` from `@web/core/network/rpc`. NOT `useService("rpc")`. `static props = []` not `{}`.
|
|
4. **HTTP routes**: `type="jsonrpc"` — NOT `type="json"` (deprecated).
|
|
5. **res.config.settings**: Only boolean/integer/float/char/selection/many2one/datetime. NO Date fields.
|
|
**`config_parameter=` Boolean fields don't round-trip `False` as a string.** Odoo's `set_values()` calls `IrConfigParameter.set_param(key, value)`, and `set_param` deletes the row when `value` is falsy (False / None / empty). So writing `False` to a Boolean config field means the param no longer exists in `ir_config_parameter`; a subsequent `get_param(key)` returns the *default* (Python `False`), not `'False'`. Test like `self.assertFalse(ICP.get_param('...'))` — never `assertEqual(..., 'False')`. (Integer/Float/Char go through `repr(value)` / strip, so they DO persist as strings — `'90'`, `'0'`, etc.) Source: `odoo/addons/base/models/res_config.py::set_values` and `ir_config_parameter.py::set_param`.
|
|
6. **res.groups**: NO `users` field, NO `category_id` field. **The Odoo 19 replacement for `category_id` is `res.groups.privilege`.** To make a module's groups appear as application-access dropdowns on the user form (Settings → Users → *Application Accesses*) instead of only in developer mode: define an `ir.module.category`, a `res.groups.privilege` (with `category_id` → that category), and set each group's `privilege_id` → that privilege. Groups under one privilege that form an `implied_ids` chain render as a single role dropdown; a standalone group in its own privilege renders as a separate row under the same category header. Verified in `fusion_clock/security/security.xml`; mirrors `fusion_plating`/`fusion_tasks`.
|
|
**res.users**: field was renamed `groups_id` → `group_ids` (also `all_group_ids` for implied). The plural form is gone; using `groups_id` raises `ValueError: Invalid field 'groups_id' in 'res.users'`.
|
|
**`ir.ui.view`**: same rename — view-level visibility gating uses `group_ids`, not `groups_id`. A record like `<field name="groups_id" eval="[(4, ref('base.group_system'))]"/>` on an `ir.ui.view` raises `ValueError: Invalid field 'groups_id' in 'ir.ui.view'` at module install. (The XML *attribute* `groups="base.group_system"` on form elements like `<page>`, `<button>`, `<field>` is unrelated and still works.)
|
|
**`ir.rule` `groups` field is additive, not restrictive.** A rule with `groups=[some_group]` applies ONLY to users in that group — it does NOT restrict non-members. So `domain_force=[(1,'=',1)]` + `groups=[base.group_system]` does NOT mean "only admins see rows"; it means "admins see all rows (and the rule is silent on everyone else)". Non-admins are gated by the ACL (`ir.model.access.csv`), not the rule. To truly restrict by group at the rule layer, pair a global rule (`groups=[]`, `domain_force=[(0,'=',1)]` = block-all baseline) with a group-scoped allow rule. Default to letting the ACL do the gating; use rules for row-level filters that ACLs cannot express.
|
|
7. **Search views**: NO `group expand="0"` syntax.
|
|
8. **SCSS imports**: `@import "./partial"` is FORBIDDEN in Odoo 19 custom SCSS. It prints a warning and silently falls back to the old cached bundle. Register every SCSS file (including `_partial.scss` tokens) as a separate entry in `web.assets_backend`. Put tokens first; Odoo concatenates bundle files so SCSS variables/mixins from the first file are visible to every later file.
|
|
9. **SQL constraints & indexes**: Odoo 19 dropped `_sql_constraints = [(name, def, msg), ...]` and the `init()`/raw-SQL pattern. Both still parse but only emit a warning and are silently ignored. Use declarative class attributes instead:
|
|
```python
|
|
_check_qty_positive = models.Constraint('CHECK (qty > 0)', 'Quantity must be positive.')
|
|
_user_time_idx = models.Index('(user_id, event_time DESC)')
|
|
```
|
|
The attribute name after the leading underscore becomes the SQL object name suffix (`{table}_{suffix}`). `models.Index` accepts `DESC`, `WHERE` predicates, and `USING btree (...)`. Sources: `odoo/orm/model_classes.py` (warns at registry build), `odoo/orm/table_objects.py` (Constraint + Index classes).
|
|
10. **`res.users._login` is an instance method in Odoo 19**, not a classmethod as in earlier versions. Signature is `def _login(self, credential, user_agent_env)` — there is no `db` parameter. Override it like any normal instance method (`super()._login(credential, user_agent_env)`). When called via `authenticate()` on an empty recordset, `self` carries the right env. Older recipes that build a separate `api.Environment` from `odoo.modules.registry.Registry(db)` no longer apply. Source: `odoo/addons/base/models/res_users.py:760`.
|
|
11. **Inherited `ir.ui.view` records cannot have `groups`/`group_ids` on the record itself.** Odoo 19 raises `ParseError: Inherited view cannot have 'groups' defined on the record. Use 'groups' attributes inside the view definition` at install time. Move the gate to the inner XML nodes — every `<button>`, `<page>`, `<field>`, `<xpath>`, `<group>` etc. supports a `groups="base.group_system"` attribute. For an inherited form with a smart button + admin tab, put `groups=` on the button and the page individually; leave the `<record model="ir.ui.view">` clean.
|
|
12. **`mail.template` QWeb/inline_template `ctx` IS `self.env.context`** — not a nested dict you can pass. `MailRenderMixin._render_eval_context()` sets `ctx = self.env.context`, so `ctx.get('foo')` in subject/body resolves to `env.context.get('foo')`. To pass dynamic data to a template, spread keys directly into the context: `tmpl.with_context(**my_data).send_mail(res_id, ...)`. Calling `tmpl.with_context(ctx=my_data)` puts the dict at `env.context['ctx']`, and the template's `ctx.get('foo')` becomes `env.context.get('foo')` → `None` (looks like a silent rendering bug — subject ends up blank).
|
|
13. **`ir.cron` dropped `numbercall`** in Odoo 19. Old recipes set `<field name="numbercall">-1</field>` for "run forever"; that now raises `ValueError: Invalid field 'numbercall' in 'ir.cron'` at install time. Just omit the field — recurring crons keep running as long as `active=True`. Source: `odoo/addons/base/models/ir_cron.py` field list.
|
|
14. **`cr.commit()` / `cr.rollback()` raise AssertionError inside `TransactionCase`** — they are NOT silent no-ops in Odoo 19. The test cursor explicitly refuses both ("Cannot commit or rollback a cursor from inside a test, this will lead to a broken cursor when trying to rollback the test. Please rollback to a specific savepoint instead..."). For cron/worker code that needs per-row isolation so one bad row doesn't roll back the whole batch, use `with self.env.cr.savepoint(): ...` inside the loop instead of `cr.commit()`. Savepoints work in both prod (under the outer cron transaction) and tests (under the outer test transaction). The cron transaction commits the whole batch when the method returns; in tests everything rolls back cleanly. Source: `odoo/sql_db.py::TestCursor.commit` and `Cursor.savepoint()`.
|
|
|
|
15. **There is NO `sale.subscription` model in Odoo 19** (Enterprise `sale_subscription`). A subscription is a **`sale.order`** with `is_subscription=True`, `plan_id` → **`sale.subscription.plan`** (the recurrence), plus `subscription_state` / `next_invoice_date` / `recurring_monthly`. Any Many2one or relation that targets "a subscription" must point at `sale.order` (filter `domain=[('is_subscription','=',True)]`) — **not** `sale.subscription`, which does not exist and fails at install. The surviving `sale.subscription.*` records are only the plan + wizards/reports (`sale.subscription.plan`, `sale.subscription.report`, `sale.subscription.change.customer.wizard`, `sale.subscription.close.reason.wizard`). Verified on live `nexamain` (odoo-nexa, 19.0): `SELECT model FROM ir_model WHERE model LIKE 'sale.subscription%'`.
|
|
|
|
16. **Renaming a module's technical name needs a DB rename, not just a folder rename.** The technical name is baked into the database: `ir_module_module.name`, every external ID in `ir_model_data.module`, each view's `ir_ui_view.key` prefix, and the `ir_module_module_dependency.name` rows of every module that depends on it. Rename only the folder + in-code references and Odoo treats the new name as a fresh uninstalled module — installing it **duplicates** groups/templates/menus and **orphans** all existing data. On every DB that already has it installed, run an in-place SQL rename (the 4 tables above) **before** `-u <newname>`; a fresh DB needs nothing. Reference script + full rationale: [`fusion_portal/rename_module.sql`](fusion_portal/rename_module.sql) (written for the `fusion_authorizer_portal` → `fusion_portal` rename). Also update cross-module `depends`, `inherit_id="<old>.view"`, `t-call`, `env.ref('<old>.xmlid')`, asset paths (`<old>/static/...`), and `from odoo.addons.<old>... import`.
|
|
|
|
17. **`url_encode` (and werkzeug url helpers) are NOT available in the Odoo 19 `mail.template` QWeb render context.** Using `url_encode({...})` inside a template `body_html` (e.g. to build a fallback link) makes the template fail Odoo's save-time render validation **at install**, surfacing as the opaque `ParseError: ... Oops! We couldn't save your template due to an issue with this value: <the entire body html>` (the real `NameError` is hidden, and `--log-handler odoo.tools.convert:DEBUG` does NOT reveal it). Build URLs with plain string methods instead: `'https://…?q=' + (value or '').replace(' ', '+')`. Found installing `fusion_repairs` (post-visit NPS template). **That same opaque "issue with this value" error wraps ANY render failure in a mail.template body** — when you see it, suspect an undefined name / bad field reference in the template, not malformed XML.
|
|
|
|
## Card Styling — Copy Odoo's Kanban Pattern
|
|
Don't rely on `var(--bs-border-color)` or `var(--bs-body-bg)` for card surfaces — they drift between themes/addons and often render **invisible**. Odoo's own kanban (`.o_kanban_record`) uses **explicit hex** values:
|
|
```css
|
|
background-color: white;
|
|
border: 1px solid #d8dadd;
|
|
```
|
|
For custom OWL dashboards / client actions use the same approach:
|
|
- Define a `_tokens.scss` partial with explicit hex values wrapped in a CSS custom property:
|
|
```scss
|
|
$fp-card: var(--fp-card-bg, #ffffff);
|
|
$fp-border: var(--fp-border-color, #d8dadd);
|
|
```
|
|
- Reference those tokens everywhere (never `var(--bs-border-color)` directly)
|
|
- Three-layer contrast: **page** (grayest) → **container/column** (mid) → **card** (brightest). That's what makes cards pop.
|
|
- Reference implementation: `fusion_plating_shopfloor/static/src/scss/_fp_shopfloor_tokens.scss`.
|
|
|
|
## Dark Mode — Branch on `$o-webclient-color-scheme` at SCSS Compile Time
|
|
Odoo 19 does NOT flip dark mode via a runtime DOM class. It compiles TWO asset bundles:
|
|
- `web.assets_backend` — compiled with `$o-webclient-color-scheme: bright`
|
|
- `web.assets_web_dark` — compiled with `$o-webclient-color-scheme: dark` (dark variant primary variables loaded first)
|
|
|
|
Your SCSS file is compiled into BOTH bundles. To make the dark bundle have different colors, **branch at compile time** using the SCSS variable Odoo sets:
|
|
|
|
```scss
|
|
$o-webclient-color-scheme: bright !default;
|
|
|
|
$_my-page-hex: #f3f4f6;
|
|
$_my-card-hex: #ffffff;
|
|
|
|
@if $o-webclient-color-scheme == dark {
|
|
$_my-page-hex: #1a1d21 !global;
|
|
$_my-card-hex: #22262d !global;
|
|
}
|
|
|
|
$my-page: var(--my-page-bg, $_my-page-hex);
|
|
$my-card: var(--my-card-bg, $_my-card-hex);
|
|
```
|
|
|
|
**Do NOT use** `.o_dark_mode` class selectors, `[data-bs-theme="dark"]`, or `@media (prefers-color-scheme: dark)` — none of those fire reliably in Odoo 19. The user toggles dark mode via the user profile, which sets a `color_scheme` cookie and reloads the page; Odoo then serves the dark bundle. Your SCSS `@if` handles the rest at compile time.
|
|
|
|
Verify by inspecting the attachments — you should see two files with different URLs for the two bundles:
|
|
```python
|
|
env['ir.qweb']._get_asset_bundle('web.assets_backend').css() # light
|
|
env['ir.qweb']._get_asset_bundle('web.assets_web_dark').css() # dark
|
|
```
|
|
|
|
## Asset Bundle Cache Busting
|
|
Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS changes but the hash doesn't update, the browser serves the old bundle. Fixes in order of escalation:
|
|
1. Bump the module `version` in `__manifest__.py`
|
|
2. `DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';` then restart odoo
|
|
3. Call `env['ir.qweb']._get_asset_bundle('web.assets_backend').css()` in odoo-shell to force regeneration
|
|
4. Hard-refresh browser with cache clear (DevTools → right-click refresh → *Empty Cache and Hard Reload*); on mobile clear website data
|
|
|
|
## Naming
|
|
- New fields: `x_fc_*` prefix
|
|
- Legacy fields: `x_studio_*`
|
|
- Canadian English for all user-facing text
|
|
- Currency: `$` sign with Monetary fields + currency_id
|
|
|
|
## Module-Specific Notes
|
|
- **fusion_clock** — developed in **Claude Code** (no longer Cursor; no concurrent-editing conflicts). Changed a lot recently (NFC kiosk: tap-to-clock, enrollment + program-from-unknown-tap, manager page, sounds, screen lock, guided profile-photo capture, faster animations). Still read files fresh before editing rather than assuming the layout. Live on entech (`odoo-entech` / LXC 111 on `pve-worker5`).
|
|
- **fusion_repairs** — read [`fusion_repairs/cloud.md`](fusion_repairs/cloud.md) before feature work. **Version `19.0.2.3.0`** (Plan-1 maintenance foundation added 2026-06-02). **NOT Community-installable** — it transitively pulls in Enterprise `ai` + `knowledge` (`fusion_repairs → fusion_portal → fusion_claims → ai`; `fusion_portal → knowledge`), so it can NOT be installed or tested on local `odoo-modsdev` (Community) — the old `-d fusion-dev -u fusion_repairs` recipe does NOT work. **Test on Enterprise:** an isolated `westin-fr-test` DB on the `odoo-westin` host (clone of prod `westin-v19`; a fresh-DB clone install also needs a one-time orphaned-FK cleanup because prod has orphaned account/tax m2m rows). First-ever clean install surfaced + fixed 2 bugs (url_encode → rule 17; menu parent defined after its children) in commit `903ceb10`. **Not production-deployed** to Westin yet. **Test-runner gotchas on that prod-config container:** `--test-enable` SILENTLY SKIPS all tests without `--workers 0`; the conf's `log_level=warn` hides test output (add `--log-level=test`); the post_install phase also trips on a pre-existing module, so verify behaviour via `odoo shell` rather than the test runner. `mail_template_data.xml` is `noupdate=1` → template edits load on a FRESH install (the prod deploy) but NOT on `-u` of an already-installed DB. Outstanding: maintenance booking (Plan 2), visit log (Plan 3), backfill wizard (Plan 4), office follow-up crons (Plan 5), RingCentral SMS.
|
|
- **fusion_portal** (formerly `fusion_authorizer_portal`) — authorizer/sales-rep portal; **ENTERPRISE-only** (depends `knowledge` → cannot run on local Community; verify on a westin clone, see *Westin Prod* below). **Assessment-visit flow LIVE on westin, v19.0.2.10.1.** A `fusion.assessment.visit` bundles the assessments from one home visit and, on completion (`action_complete_visit`), groups them by funding workflow (`x_fc_sale_type`) into ONE draft sale order per workflow (MoD/ADP/ODSP/WSIB/private/hardship/insurance) — never one combined SO, never one-per-item-within-a-funding. ADP devices group into one order (combination guard: ≤1 seated {wheelchair/powerchair/scooter} + ≤1 walker); accessibility items group per funding. Reps enter via the "Start a Visit" dashboard tile → `/my/visit/new`; the express/accessibility forms carry `?visit_id=` and defer SO creation to the visit. Renaming the technical name needs a DB rename — see [`fusion_portal/rename_module.sql`](fusion_portal/rename_module.sql).
|
|
|
|
## Workflow
|
|
- Local dev: `docker exec odoo-modsdev-app odoo -d fusion-dev -u <module> --stop-after-init`
|
|
- Local URL: http://localhost:8082
|
|
- **Running module tests requires ephemeral ports.** The dev container's main Odoo process holds 8069 and 8072; a `docker exec ... odoo --test-enable` will die with `Address already in use` unless you also pass `--http-port=0 --gevent-port=0`. This is because Odoo 19 forces `http_spawn()` when `--test-enable` is set, even when `--no-http` is passed. Canonical test invocation:
|
|
```bash
|
|
docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /<module> \
|
|
-u <module> --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
|
|
```
|
|
- **`fusion_centralize_billing` tests run on odoo-trial (VM 316).** Local dev is Community and cannot install this module. Use `bash scripts/fcb_test_on_trial.sh` from the repo root. The script uses `--http-port 8070` to avoid the port 8069 conflict with the live odoo-trial-app container. Pass = `FCB_EXIT=0`. Takes ~1-2 min.
|
|
- **Python deps not bundled with `odoo:19` image:** `user_agents` (used by `fusion_login_audit`), and likely others. Install ephemerally with `docker exec -u 0 odoo-modsdev-app pip install <pkg> --break-system-packages`. The install is LOST when the container is recreated (e.g. `docker compose up -d` after a compose edit). When this happens, the symptom is `ModuleNotFoundError` deep in the auth or report code. Re-run the pip install. A persistent fix would be a custom Dockerfile or a startup hook on the compose service — not done yet.
|
|
- Test before deploying. Edit existing files — don't create unnecessary new ones.
|
|
|
|
## PDF Preview — Prefer fusion_pdf_preview Over Downloads/New-Tab
|
|
When a Python action opens an attachment, route it through `fusion_pdf_preview` instead of returning `ir.actions.act_url` with `download=true` or `target=new`. The preview dialog gives operators preview + print + download in one place and writes an audit log; non-PDF attachments fall back to the legacy download path automatically.
|
|
|
|
The drop-in replacement is the new helper on `ir.attachment`:
|
|
```python
|
|
return att.action_fusion_preview(title='My Doc')
|
|
# vs. the old pattern:
|
|
# return {'type': 'ir.actions.act_url',
|
|
# 'url': '/web/content/%s?download=true' % att.id,
|
|
# 'target': 'new'}
|
|
```
|
|
|
|
The helper auto-detects mimetype: PDFs go to the dialog, everything else (ZPL, CSV, XML, images) stays on download. So a callsite that today serves CSV today and a PDF tomorrow doesn't need a code change — same call, different routing.
|
|
|
|
If you need to invoke the client action directly (rare — only when you don't have a recordset handy), the tag is `fusion_pdf_preview.open_attachment` and the params are `{attachment_id, title, model_name, record_ids, report_name}`. See `fusion_pdf_preview/static/src/js/open_attachment_action.js`.
|
|
|
|
Existing reports (`ir.actions.report` of type `qweb-pdf`) are intercepted automatically by `fusion_pdf_preview/static/src/js/pdf_preview.js`; the helper above is for the *other* pattern — attachments opened by custom buttons.
|
|
|
|
## Supabase Knowledge Base
|
|
Before starting unfamiliar work, check Supabase for context:
|
|
```bash
|
|
PGPASSWORD='a09e12e0995dc29446631fa458f3d4b3' psql -h 100.74.28.73 -p 5433 -U postgres -d postgres
|
|
```
|
|
- `fusionapps.decisions` — past architecture decisions
|
|
- `fusionapps.issues` — known issues and fixes
|
|
- `fusionapps.code_snippets` — reference code
|
|
- `fusionapps.quick_commands` — deployment and admin commands
|
|
|
|
## Westin Prod — Deploy & Clone-Verify (fusion_portal et al.)
|
|
|
|
Westin prod: host `odoo-westin`, app container `odoo-dev-app`, db container `odoo-dev-db`, DB `westin-v19` (user `odoo`, pw `DevSecure2025!`), addons `/opt/odoo/custom-addons` → `/mnt/extra-addons`, Enterprise `/mnt/enterprise-addons`, conf `/etc/odoo/odoo.conf`. ENTERPRISE env — modules depending on `knowledge` (fusion_portal → fusion_claims) cannot run on local Community, so verify on a clone before prod.
|
|
|
|
**Clone-verify a change (prod-safe, isolated — prod files + live DB untouched):**
|
|
1. Clone online: `docker exec -e PGPASSWORD='DevSecure2025!' odoo-dev-db sh -c 'dropdb -U odoo --if-exists westin-v19-visittest; createdb -U odoo -O odoo westin-v19-visittest && pg_dump -U odoo westin-v19 | psql -U odoo -q -d westin-v19-visittest'` (~2 min, ~152M -Fc).
|
|
2. Stage the branch module into an isolated dir INSIDE the addons path: `/opt/odoo/custom-addons/_test/<module>`, then `-u <module> --stop-after-init --no-http --db_host db --db_port 5432 --db_user odoo --db_password 'DevSecure2025!' --addons-path=/usr/lib/python3/dist-packages/odoo/addons,/usr/lib/python3/dist-packages/addons,/mnt/extra-addons/_test,/mnt/enterprise-addons,/mnt/extra-addons`. The `/mnt/extra-addons/_test` prefix SHADOWS prod's copy (first matching path wins); deps load from the real `/mnt/extra-addons`.
|
|
3. Smoke-test via `odoo shell -d westin-v19-visittest` (same addons-path); `env.cr.rollback()` at the end. To exercise email paths WITHOUT sending: `UPDATE ir_mail_server SET active=false;` AND in the shell `env['ir.mail_server'].__class__.send_email = lambda self, message, *a, **k: 'noop'` (`odoo shell` rejects `--smtp-server`).
|
|
|
|
**THE ORPHANED-TAX-FK TRAP** (cost real diagnosis time): westin-v19 has ~3300 orphaned rows in `product_taxes_rel` + ~3300 in `product_supplier_taxes_rel` (`tax_id` → deleted `account_tax`), under FKs that are `convalidated=true` (taxes deleted via an FK-bypassing path; PG never re-checks a validated constraint). A plain `pg_dump | psql` clone can't recreate a *validating* FK over orphaned data → the FK is lost on the clone → Odoo `check_foreign_keys` tries to add it → `ForeignKeyViolation: Key (tax_id)=(N) is not present in account_tax` → "Failed to load registry". **Fix ON THE CLONE only:** `DELETE FROM <t> WHERE tax_id NOT IN (SELECT id FROM account_tax)` across every `%_rel` table with a tax column. **Prod `-u` is SAFE without touching the orphans** — prod's FK already exists, so Odoo skips it (it never re-validates a present FK); proven empirically by replicating FK-present+orphan on a clone and running `-u` (exit 0, orphan untouched). Owner is auditing the orphans — do NOT delete them on prod without sign-off.
|
|
|
|
**Deploy:** backup (`docker exec ... pg_dump -Fc -U odoo westin-v19 > /opt/odoo/backups/<name>.dump` + `cp -r` the module dir to `/opt/odoo/backups/` — OUTSIDE the addons path, never a `*.bak` dir inside it) → `scp` branch to `/opt/odoo/staging/<module>` → swap into `/opt/odoo/custom-addons/<module>` → `-u <module>` → `DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%'` → `docker restart odoo-dev-app`. **Gate the restart on `-u` exit 0**; on failure restore the dir backup and do NOT restart. When a feature branch predates main's other merges, merge to `main` **surgically** (temp worktree off `origin/main` + `git checkout <branch> -- <module>` → commit → fast-forward push) so you don't revert parallel sessions' work.
|
|
|
|
## Fusion Helpdesk — Customer Follow-up + Embedded Inbox (deployment + handoff)
|
|
|
|
Two modules: **`fusion_helpdesk`** (client — runs on each client deployment, e.g. entech)
|
|
and **`fusion_helpdesk_central`** (runs on the central Odoo = nexa). The client forwards
|
|
tickets to central over **XML-RPC**; central find-or-creates the customer partner +
|
|
follower; the client shows a server-side-scoped "My Tickets" inbox + systray unread badge.
|
|
|
|
### Where each runs / how to deploy
|
|
- **Central = nexa** (`erp.nexasystems.ca`, VM 315 on pve-worker1, Docker, DB `nexamain`).
|
|
Source on host: `/opt/odoo/custom-addons/fusion_helpdesk_central`. Upgrade (brief downtime):
|
|
```bash
|
|
ssh pve-worker1 "qm guest exec 315 --timeout 590 -- bash -c 'docker stop odoo-nexa-app; docker run --rm --network odoo_odoo-network -v odoo_odoo-data:/var/lib/odoo -v /opt/odoo/custom-addons:/mnt/extra-addons -v /opt/odoo/enterprise-addons:/mnt/enterprise-addons -v /opt/odoo/odoo.conf:/etc/odoo/odoo.conf odoo-nexa:19 odoo -d nexamain -u fusion_helpdesk_central --stop-after-init --http-port=0 --gevent-port=0 > /tmp/up.log 2>&1; docker start odoo-nexa-app'"
|
|
```
|
|
Use `;` (not `&&`) before `docker start` so the app ALWAYS restarts even if the upgrade
|
|
fails. nexa `odoo.conf` has `log_level=warn`, so test/INFO lines are suppressed — verify
|
|
the result via DB query, not the upgrade log.
|
|
- **Client = entech** (LXC 111 on pve-worker5, **native systemd `odoo.service`**, DB `admin`,
|
|
config `/etc/odoo/odoo.conf`, source `/mnt/extra-addons/custom/fusion_helpdesk`). No host
|
|
bind mount — get files in with `scp` to pve-worker5 then `pct push 111 <file> <dest>`.
|
|
Upgrade as the `odoo` user (NOT root):
|
|
```bash
|
|
pct exec 111 -- bash -lc "systemctl stop odoo; runuser -u odoo -- /usr/bin/odoo --config /etc/odoo/odoo.conf -d admin -u fusion_helpdesk --stop-after-init --http-port=0 --gevent-port=0 --logfile=/tmp/up.log; systemctl start odoo"
|
|
```
|
|
**Backup dir MUST live OUTSIDE the addons path** (e.g. `/root/`). A dir named `*.bak.*`
|
|
*inside* `/mnt/extra-addons/custom` makes Odoo try to load it as a module →
|
|
`FileNotFoundError: Invalid module name: fusion_helpdesk.bak.predeploy` → whole registry
|
|
load fails. (Learned the hard way; auto-rollback restored it.) Current rollback copy:
|
|
`/root/fh_bak_predeploy`.
|
|
|
|
### REQUIRED prerequisite on the central service account (easy to miss)
|
|
The keystone passes `partner_email`, so central find-or-creates the partner. The XML-RPC
|
|
service account (**`support@nexasystems.ca`, uid 33** on nexa) MUST have the **Contact
|
|
Creation** group (`base.group_partner_manager`). Without it, `helpdesk.ticket.create`
|
|
faults with *"not allowed to create 'Contact' (res.partner)"* for any reporter who isn't
|
|
already a contact. Granted on nexa 2026-05-27. **Every new client deployment needs this
|
|
grant on the central account.**
|
|
|
|
### Testing lesson
|
|
Client logic (scope domain, seen model, vals, `_norm_email`) is unit-tested in
|
|
`fusion_helpdesk/tests/` and runs on local Community (`-d modsdev`). **Smoke tests must
|
|
call the controller endpoints, not re-implement their logic** — the Phase 6 smoke test
|
|
replicated `build_scope_domain` directly and so missed a `NameError` (`_norm_email`
|
|
referenced but never imported) that broke every inbox endpoint. Run
|
|
`docker exec odoo-modsdev-app python3 -m pyflakes <file>` after editing controllers — it
|
|
catches undefined names instantly.
|
|
|
|
### Two non-obvious gotchas the first ship hit (fixed 2026-05-27 afternoon)
|
|
1. **`group_reporter_admin` had zero members on install** — `res.groups` doesn't auto-grant
|
|
to the deployment admin, so the "All (deployment)" toggle never appeared and admins were
|
|
stuck with the per-user `partner_email` filter. Fix lives in
|
|
`fusion_helpdesk/security/fusion_helpdesk_groups.xml`: extend `base.group_system.implied_ids`
|
|
with `(4, ref('fusion_helpdesk.group_reporter_admin'))`. The (4, id) tuple is additive — it
|
|
never replaces base's existing implied groups. Verified live: all six entech
|
|
`base.group_system` members now return True for
|
|
`has_group('fusion_helpdesk.group_reporter_admin')` after the upgrade.
|
|
2. **Historical tickets had NULL `x_fc_client_label` + NULL `partner_email`** — anything
|
|
created before the customer-followup ship was invisible in "My Tickets" because the scope
|
|
filter requires both fields. The reporter identity was preserved only in the description
|
|
HTML (the diag block's "User" row). Backfill recipe (50 ENTECH + 1 WESTIN, all in one
|
|
transaction):
|
|
```sql
|
|
UPDATE helpdesk_ticket
|
|
SET x_fc_client_label = substring(name from '^\[([A-Z]+)\]'),
|
|
partner_email = lower(substring(
|
|
substring(description from 'User</td><td[^>]*><code>([^<]+)</code>')
|
|
from ', ([^)]+)\)')),
|
|
partner_name = regexp_replace(
|
|
substring(description from 'User</td><td[^>]*><code>([^<]+)</code>'),
|
|
' \(#\d+, [^)]+\)$', '')
|
|
WHERE name ~ '^\[[A-Z]+\]'
|
|
AND description ~ 'User</td>'
|
|
AND x_fc_client_label IS NULL;
|
|
```
|
|
Safe: SQL UPDATE bypasses the central `helpdesk.ticket.create` override, so no duplicate
|
|
ack emails. Per-deployment label inferred from the `[XXX]` name prefix the old code was
|
|
already adding. Note: users whose `login != email` (e.g. uid=2 on entech has login
|
|
`gsinghpal@outlook.com` and email `gs@nexasystems.ca`) get tagged with their *login* in
|
|
backfill — they won't see their old tickets in "Mine", only in "All". New tickets are
|
|
tagged with the profile email (`user.email` first, `user.login` fallback).
|
|
|
|
### STATUS (handoff 2026-05-27 afternoon)
|
|
- **Merged to `main`** as squash commit `6c15a7b1` (initial ship). Today's followup is the
|
|
group/backfill fix described above — committed separately.
|
|
- **Deployed live**: nexa `fusion_helpdesk_central` **19.0.1.1.0**; entech `fusion_helpdesk`
|
|
**19.0.1.5.0** (bumped from 19.0.1.4.1 for the implied_ids fix). Both services healthy.
|
|
- **Historical entech tickets backfilled** on nexa (51 rows: 50 ENTECH + 1 WESTIN).
|
|
- **Smoke-tested live end-to-end** (entech→nexa): partner resolved + follower + `ENTECH`
|
|
label, branded ack email queued, support reply visible in thread, inbox scope finds own
|
|
ticket, no cross-deployment leak. The "Mine" view for non-admins and the "All" view for
|
|
the entech owner both populate as expected.
|
|
- **Browser confirmation**: hard-refresh entech (DevTools → Empty Cache and Hard Reload),
|
|
open the systray helpdesk dialog. The Mine/All toggle appears for the owner; "All" shows
|
|
all 50 ENTECH tickets, "Mine" shows the count matching the owner's profile email.
|
|
Tracebacks live in `/var/log/odoo/odoo-server.log` on entech (LXC 111 / pve-worker5).
|
|
|
|
## Fusion Centralized Billing (`fusion_centralize_billing`) — engine + test harness
|
|
|
|
Odoo (`odoo-nexa`, live DB `nexamain`) is being made the single billing brain for every
|
|
NexaSystems app (NexaCloud, NexaDesk/Fusion-Chat, NexaMaps), **superseding Lago**. The
|
|
module adds only the metering + integration layer (service registry, identity links,
|
|
metric/charge catalog, aggregate-push usage engine, inbound Lago-shaped REST API at
|
|
`/api/billing/v1/*`, outbound HMAC webhooks, dual-run reconciliation); all financial
|
|
behaviour is native Odoo **Enterprise** (`sale_subscription` + `payment_stripe` +
|
|
`account_accountant`). Design + rollout live in `docs/superpowers/specs/`
|
|
(`2026-05-27-nexa-billing-centralized-design.md` = architecture;
|
|
`2026-06-02-nexacloud-odoo-billing-cutover-design.md` = NexaCloud pilot: build → import →
|
|
dual-run → gated flip) and `docs/superpowers/plans/`.
|
|
|
|
**Testing it — NOT on local `odoo-modsdev` (community) and NEVER `-u` against live `nexamain`.**
|
|
It needs Enterprise deps, so tests run on `odoo-nexa` in an **isolated throwaway container**
|
|
against a **fresh** DB with the Canadian localization:
|
|
```
|
|
ssh odoo-nexa
|
|
# fresh DB (inside odoo-nexa-db): dropdb --if-exists fcb_test; createdb fcb_test
|
|
cp -a /opt/odoo/custom-addons /opt/odoo/custom-addons-staging # edit/sync HERE, never the live module dir
|
|
docker run --rm --network odoo_odoo-network \
|
|
-v /opt/odoo/custom-addons-staging:/mnt/extra-addons:ro -v /opt/odoo/enterprise-addons:/mnt/enterprise-addons:ro \
|
|
-v /opt/odoo/odoo.conf:/etc/odoo/odoo.conf:ro -v /opt/odoo/staging-data:/var/lib/odoo \
|
|
odoo-nexa:19 -c /etc/odoo/odoo.conf -d fcb_test --db_host=db --db_user=odoo \
|
|
--addons-path=/usr/lib/python3/dist-packages/odoo/addons,/mnt/extra-addons,/mnt/enterprise-addons \
|
|
--without-demo=all --test-enable --test-tags /fusion_centralize_billing \
|
|
-i l10n_ca,fusion_centralize_billing --stop-after-init --no-http
|
|
```
|
|
Iterate with `-u fusion_centralize_billing` (reuse fcb_test). Gotchas that cost hours:
|
|
- **`l10n_ca` is required** — the ledger tests need a Canadian CoA + active CAD + 13% HST.
|
|
- A **prod clone is the wrong base** — its existing rows collide with fixed-code test fixtures
|
|
(`nexacloud` service / `cpu_seconds` metric) across 5 test files.
|
|
- odoo.conf sets `log_level=warn`, so **passing tests log nothing** — exit 0 alone does NOT
|
|
prove tests ran (a tag matching zero tests is also exit 0). Confirm execution with
|
|
`--log-handler=odoo.addons.fusion_centralize_billing.tests:INFO` (look for `Starting
|
|
<Class>.<method>`). The **exit code is authoritative** (1 on any failure).
|
|
- Do **NOT** pass `--workers=0` (blanks captured stdout) or `--logfile=/dev/stdout` (errors out).
|