# 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//static/src/ ``` 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. 6. **res.groups**: NO `users` field, NO `category_id` field. **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.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`. 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%'`. ## 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//...`). 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 ## Cursor-Managed Modules - **fusion_clock** is currently being modified in Cursor — always read files fresh before editing, don't assume you know the current state ## Workflow - Local dev: `docker exec odoo-modsdev-app odoo -d fusion-dev -u --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 / \ -u --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 --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