Compare commits

...

19 Commits

Author SHA1 Message Date
gsinghpal
71f4c41d5c merge: NexaCloud->Odoo billing cutover (spec + plan00 hermetic suite + plan01 cancel endpoint) 2026-06-02 09:17:43 -04:00
gsinghpal
2f6a8b33a9 docs(billing): CLAUDE.md centralized-billing + test-harness section; plan-01 note
Document fusion_centralize_billing as the Lago-superseding billing engine and the
isolated odoo-nexa test recipe (fresh DB + l10n_ca; never -u against live nexamain;
log_level/workers gotchas). Plan-01 doc: corrected the unsafe test command + added the
harness section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:17:41 -04:00
gsinghpal
4b832e7445 Update 2026-06-02-nexacloud-cutover-01-odoo-cancel-endpoint.md 2026-06-02 09:13:35 -04:00
gsinghpal
f67cefc213 feat(billing): _api_cancel_subscription service method + unit tests
Plan 01 (NexaCloud cutover) Task 1: cancel/close a subscription with the same
service-scoped authorization as _api_record_usage (resolve via
_fc_resolve_subscription; partner must be linked to this service). Idempotent
(no-op if already 6_churn). 5 unit tests, verified green on fcb_test
(fresh + l10n_ca). DELETE route + HttpCase follow in Task 2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:08:37 -04:00
gsinghpal
658611457e docs(CLAUDE.md): westin clone-verify recipe + orphaned-tax-FK trap + fusion_portal note
Capture the operational knowledge from the fusion_portal assessment-visit deploys:
the isolated _test addons-path clone-verify technique, the orphaned-tax-FK restore
trap (and the proof that prod -u is safe without touching the orphans because Odoo
skips a present FK), the backup/stage/swap/-u/cache-bust deploy flow with restart
gating, the surgical branch->main merge for branches that predate other merges, and
a fusion_portal module note (ENTERPRISE-only; visit funding-grouping architecture).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:08:33 -04:00
gsinghpal
4df35448c2 docs(fusion_plating): partial-order rollout fixes + open-items handoff
Consolidated handoff added to the Partial Order Handling section: the bugs
that only live tablet testing surfaced (phantom stage cards, scan-button
icons/labels, dark-mode undefined --bs-* vars, from-step predecessor block,
seeded-stage auto-finish on drain, gating fall-forward) and the open items
(discoverability badges, Scrap/Rework standalone buttons, automated tests
not written, dark-mode chip polish). Docs only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:06:53 -04:00
gsinghpal
1d6797f0d2 Merge fusion_repairs maintenance foundation (Plan 1) + 2 install fixes + CLAUDE.md rule 17 into main 2026-06-02 09:03:17 -04:00
gsinghpal
4622521729 docs(CLAUDE.md): Odoo 19 url_encode-in-mail-template rule + corrected fusion_repairs note
Rule 17: url_encode (and werkzeug url helpers) are not in the Odoo 19 mail.template QWeb render context -> opaque 'issue with this value' ParseError at install. fusion_repairs note corrected: NOT Community-installable (Enterprise ai+knowledge via fusion_portal->fusion_claims); test on the westin-fr-test Enterprise sandbox; --workers 0 + log_level=warn test-runner gotchas; noupdate templates load on fresh install only. Version 19.0.2.3.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:02:27 -04:00
gsinghpal
40a29081bf fix(fusion_portal): readable gradient for Start-a-Visit tile (live westin 19.0.2.10.1)
Inline tile gradient (no !important) was overridden by the theme .card rule,
rendering near-white with invisible white text. Dedicated .portal-visit-card
class (blue->indigo, distinct from the green New Assessment tile).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:59:59 -04:00
gsinghpal
11ab261ad9 test(billing): make fusion_centralize_billing suite hermetic (green baseline)
- test_usage / test_webhook setUp: get-or-create the cpu_seconds metric and
  nexacloud service so the suite no longer collides with existing rows.
- test_invoice_ledger: add _fc_ensure_ca_billing_env (activate CAD + a 13%
  sale tax matching _fc_tax_for) so the ledger tests pass on a clean DB.

Canonical test DB: a FRESH db with l10n_ca installed (a prod clone collides
on fixed-code fixtures across 5 test files). Full suite now exits 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:59:31 -04:00
gsinghpal
859a327738 fix(fusion_plating_jobs): gating steps fall forward to next stage's column
A "Ready for X" gating step (fp.step.kind code='gating') maps to
area_kind='receiving' in the taxonomy. For a MID-recipe gate (e.g.
"Ready for processing" between Racking and Plating) that snapped the
job's card back to the far-left Receiving column when work advanced into
it — the job looked like it vanished from the board.

_compute_area_kind now detects gating via the stable kind code and
resolves a gating step's column to the NEXT non-gating step's area (so
"Ready for processing" shows in Plating), keeping cards flowing
left→right. Falls back to the last real stage for a trailing gate.
Non-gating steps unchanged. Helpers: _fp_is_gating_step / _fp_raw_area_kind
(no recursion) / _fp_resolve_area_kind.

area_kind is a stored compute — recomputed all 537 live steps on entech.
Verified: WO-30061 "Ready for processing" area receiving→plating, card now
renders in the Plating column.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:53:47 -04:00
gsinghpal
a52f2bbebd fix(fusion_plating_jobs): gating steps fall forward to next stage's column
A "Ready for X" gating step (fp.step.kind code='gating') maps to
area_kind='receiving' in the taxonomy. For a MID-recipe gate (e.g.
"Ready for processing" between Racking and Plating) that snapped the
job's card back to the far-left Receiving column when work advanced into
it — the job looked like it vanished from the board.

_compute_area_kind now detects gating via the stable kind code and
resolves a gating step's column to the NEXT non-gating step's area (so
"Ready for processing" shows in Plating), keeping cards flowing
left→right. Falls back to the last real stage for a trailing gate.
Non-gating steps unchanged. Helpers: _fp_is_gating_step / _fp_raw_area_kind
(no recursion) / _fp_resolve_area_kind.

area_kind is a stored compute — recomputed all 537 live steps on entech.
Verified: WO-30061 "Ready for processing" area receiving→plating, card now
renders in the Plating column.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:51:24 -04:00
gsinghpal
9a8e1d7ab5 feat(fusion_portal): ADP/express->visit wiring, visit entry tile, email consolidation (live on westin 19.0.2.10.0)
- express save captures visit_id; visit-linked submit defers SO creation
  (saves draft + signature) and returns to the visit for grouping.
- portal dashboard 'Start a Visit' tile for sales reps.
- fix duplicate-authorizer completion email; visit grouped SOs email once per SO.
- define visit._assessment_sale_type (ADP grouping key) - fixes AttributeError.

Verified on a westin-v19 clone (load + ADP-grouping + combination-guard smoke
test, mail neutralised) then deployed to westin prod 19.0.2.10.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:50:25 -04:00
gsinghpal
451fc5eafd docs(billing): NexaCloud->Odoo cutover spec + plan 01 (cancel endpoint)
Increment design (phase #2 of the approved 2026-05-27 centralized-billing
spec) to make Odoo fusion_centralize_billing the system of record for
NexaCloud billing: build -> import -> dual-run -> gated flip, NexaCloud first,
one subscription per deployment, go-forward billing only. Plan 01 = the Odoo
subscription-cancel endpoint (test-first).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:38:48 -04:00
gsinghpal
7fcf38ca82 fix(fusion_plating_jobs): first/seeded stage never auto-finished on drain
_fp_try_autofinish_on_drain guarded on _fp_has_real_incoming() — the WRONG
direction. The first stage (e.g. Racking) is fed by the qty_at_step seed,
not an incoming move, so it never auto-finished when all its parts were
sent forward (operator sent everything out of Racking, step stayed
in_progress at qty 0). Now guards on a real OUTGOING move (parts left),
which covers the seeded first stage.

Still best-effort + gated: button_finish runs the required-input / sign-off
gates, so a step with an unrecorded required input (Racking's "Count the
Parts") won't auto-finish — it stays in_progress for a manual finish after
the input is recorded. Verified on entech.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:37:29 -04:00
gsinghpal
64a202ff6e fix(fusion_plating_shopfloor): partial advance blocked by from-step predecessor
The Move dialog's predecessor check flagged every unfinished step before
the destination — including the from_step itself, which is in-progress by
definition when advancing partial parts out of it. So any "Send → next"
to a not-yet-started step showed a hard "Predecessor not done: <from_step>"
blocker and greyed out SEND (reproduced on WO-30061: Racking → Ready for
processing). This broke partial advance for ALL quantities, not just
1-part orders.

Fix: _blockers_for_move only blocks unfinished steps STRICTLY BETWEEN
from_step and to_step (you'd be skipping an incomplete intermediate
stage). Immediate-next advance is allowed; skip-ahead still blocked;
backward (rework) moves unblocked. Verified on entech: blocker no longer
fires for Racking → Ready for processing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:30:12 -04:00
gsinghpal
13fabb0e79 feat(fusion_portal): assessment-visit redesign - live on westin 19.0.2.9.0
Bundles multiple assessments per home visit; on completion groups them by
funding workflow (x_fc_sale_type) into one draft sale order per workflow
(March of Dimes / ADP / ODSP / WSIB / private / hardship / insurance).
Adds the mobility scooter ADP device type, the power-mobility home-access
rule, ADP multi-device combination guard, and the portal visit workspace.

Verified on a westin-v19 clone (clean registry load + funding-grouping
smoke test) then deployed to westin prod (fusion_portal 19.0.2.9.0).
Prod's pre-existing orphaned tax links were preserved (Odoo skips existing
FKs), pending a later audit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:23:43 -04:00
gsinghpal
319de06ca6 fix(fusion_plating_shopfloor): finish-dialog text readable in dark mode (real fix)
Root cause (verified against the live compiled bundle): Odoo's backend
CSS never DEFINES --bs-body-color / --bs-secondary-color / --bs-*-bg as
custom properties (0 definitions; they're only referenced). So every
color: var(--bs-body-color, #1d1d1f) — and the earlier --bs-secondary-color
swap — resolved to the dark hex fallback in BOTH light and dark mode.
That's why the prior swaps never worked. Backend dark mode here is runtime
[data-bs-theme=dark] + SCSS literals, not those vars.

Fix: the finish-block dialog text now INHERITS the modal's theme-correct
colour (same as the readable title + "Count the Parts" list items) — the
broken line was the only one setting an explicit var() colour. Tinted
banners use translucent rgba() instead of color-mix-with-undefined-var.
Verified in the served bundle: o_fp_finish_block_msg{font-weight:500;}
(no colour override).

CLAUDE.md dark-mode guidance corrected (it had wrongly recommended those
undefined vars).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 02:31:09 -04:00
gsinghpal
0499a1ad2e fix(fusion_plating_shopfloor): finish-dialog message readable in dark mode
The "N required input(s) haven't been recorded yet" line still read as
dark/dim in dark mode after the --text-secondary→--bs-secondary-color
swap, because --bs-secondary-color is muted/low-opacity. That line is
primary instruction text, so use the full-contrast var(--bs-body-color)
instead (+ font-weight 500). Reserve --bs-secondary-color for genuinely
secondary text.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 02:11:42 -04:00
29 changed files with 1555 additions and 65 deletions

View File

@@ -35,6 +35,8 @@
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
@@ -96,7 +98,8 @@ Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS
## 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.2.4`.** Bundles 111 shipped in repo (intake, portals, dashboard, pricing, flowcharts, parts/PO). **Not production-deployed** to Westin as of 2026-05-27. Local: `docker exec odoo-modsdev-app odoo -d fusion-dev -u fusion_repairs --stop-after-init`. Outstanding: RingCentral SMS, C2 history sidebar UI, office follow-up crons (config keys only), `tests/`, more flowchart content, sales-rep dashboard tile in `fusion_portal`.
- **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`
@@ -138,6 +141,19 @@ PGPASSWORD='a09e12e0995dc29446631fa458f3d4b3' psql -h 100.74.28.73 -p 5433 -U po
- `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)
@@ -232,3 +248,41 @@ catches undefined names instantly.
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).

View File

@@ -0,0 +1,298 @@
# NexaCloud→Odoo Cutover — Plan 01: Odoo subscription-cancel endpoint
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add the one inbound endpoint NexaCloud's deprovision path needs — cancel (close) a subscription — to `fusion_centralize_billing`, with the same auth model the other endpoints already use.
**Architecture:** New `fusion.billing.service._api_cancel_subscription(external_ref)` resolves the subscription via the existing `_fc_resolve_subscription`, enforces the same "partner must be linked to this service" authorization as `_api_record_usage`, and closes it with Odoo 19's native `set_close()` (→ `subscription_state='6_churn'`). A `DELETE /api/billing/v1/subscriptions/<ref>` route wraps it.
**Tech Stack:** Odoo 19 Enterprise (`sale_subscription`), Python, Odoo `TransactionCase` tests.
**Spec:** [`2026-06-02-nexacloud-odoo-billing-cutover-design.md`](../specs/2026-06-02-nexacloud-odoo-billing-cutover-design.md) §4.1.3
---
## ⚠ Test harness (supersedes any `-d nexamain` command below)
**NEVER run `-u` / `--test-enable` against the live `nexamain` DB.** Tests run in an **isolated throwaway container** against a dedicated DB, reading a **separate** addons copy so the live module is never touched:
```
# 1) edit files on branch feat/nexacloud-odoo-billing-cutover, then sync the changed
# module files to the staging addons copy on odoo-nexa:
# /opt/odoo/custom-addons-staging/fusion_centralize_billing/...
# 2) run (ssh odoo-nexa):
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 \
--test-enable --test-tags /fusion_centralize_billing:TestSubscriptionCancel \
-u fusion_centralize_billing --stop-after-init --no-http
```
- `fcb_test` is a **fresh** install DB (not a prod clone). `nexamain_staging` is a prod clone kept for later integration/importer plans.
- **Scope each step's run to the relevant test class** (`:TestSubscriptionCancel`, `:TestSubscriptionCancelHttp`). The wider suite is **not hermetic yet** (see Plan 00) — `test_invoice_ledger` needs a configured Canadian CoA/active CAD/HST; `test_usage`/`test_webhook` collide with cloned prod data. Don't gate this plan on those.
- The per-step `Run:` blocks below that mention `-d nexamain` are **illustrative only — use this harness instead.**
> **Prerequisite — Plan 00 (make the suite hermetic):** before green-baseline TDD, fix fixtures so the whole suite passes on `fcb_test`: `setUp` should get-or-create the `nexacloud`/`cpu_seconds` records (idempotent), and a test-setup helper must ensure an active CAD currency + a Canadian CoA + a 13% HST sale tax. Tracked as its own plan; recommended before Plan 01 execution.
---
## Increment plan sequence (this is Plan 01 of 6)
Each is its own plan doc + its own working, testable deliverable. Order reflects dependencies:
1. **Odoo: subscription-cancel endpoint***this doc* (unblocked; no external decisions).
2. **Odoo: NexaCloud charge catalog** — products + `sale.subscription.plan` (`NC-PLAN-*`) + `fusion.billing.charge` (cpu_seconds quota/overage). **Blocked on confirming real NexaCloud plan pricing/quotas** (open review Q#1) before it can be written placeholder-free.
3. **Odoo: importer go-forward subscriptions** — extend `wizards/import_wizard.py` to create one shadow `sale.order` per active deployment with go-forward `next_invoice_date`; the safety test that asserts **no past-period invoice** is the centrepiece (guards against the 2026-05-27 Lago re-bill).
4. **NexaCloud: adapter activation** — config (`odoo_billing_base_url`/`api_key`/staged enable), customer + subscription create/cancel calls, reconciliation-amount push.
5. **NexaCloud: control-loop receiver** — activate `/billing/webhooks/central` HMAC verify → suspend/restore/deprovision via `network_isolation`/`throttle_checker`/`resource_manager`.
6. **Dual-run + gated flip** — operational runbook: shadow ≥1 cycle, reconcile to cent, then the reversible flip flag.
---
## File structure (this plan)
- Modify: `fusion_centralize_billing/models/service.py` — add `_api_cancel_subscription`.
- Modify: `fusion_centralize_billing/controllers/api.py` — add `DELETE /subscriptions/<ref>`.
- Create: `fusion_centralize_billing/tests/test_subscription_cancel.py` — service-method + authorization tests.
- Modify: `fusion_centralize_billing/tests/__init__.py` — import the new test module.
Run tests (from `K:\Github\CLAUDE.md` workflow, adapted to odoo-nexa):
```
ssh odoo-nexa "docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing -u fusion_centralize_billing --stop-after-init"
```
---
### Task 1: `_api_cancel_subscription` service method
**Files:**
- Modify: `fusion_centralize_billing/models/service.py` (add method after `_api_create_subscription`, ~line 250)
- Create: `fusion_centralize_billing/tests/test_subscription_cancel.py`
- Modify: `fusion_centralize_billing/tests/__init__.py`
- [ ] **Step 0: Verify the Odoo 19 close method (do NOT code from memory — per `K:\Github\CLAUDE.md`)**
Run:
```
ssh odoo-nexa "docker exec odoo-nexa-app grep -nE 'def set_close|def set_open|6_churn' /mnt/enterprise-addons/sale_subscription/models/sale_order.py | head"
```
Expected: a `def set_close(self...)` exists and sets `subscription_state='6_churn'`. If the method name differs in this build, use the actual name in Step 3 and the assertion in Step 1.
- [ ] **Step 1: Write the failing test**
Create `fusion_centralize_billing/tests/test_subscription_cancel.py`:
```python
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestSubscriptionCancel(TransactionCase):
def setUp(self):
super().setUp()
self.plan = self.env['sale.subscription.plan'].sudo().create(
{'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'})
self.product = self.env['product.product'].sudo().create(
{'name': 'NexaCloud Plan', 'type': 'service',
'recurring_invoice': True, 'list_price': 49.0})
self.svc_a = self.env['fusion.billing.service'].sudo().create(
{'name': 'NexaCloud', 'code': 'nexacloud'})
self.svc_b = self.env['fusion.billing.service'].sudo().create(
{'name': 'Other', 'code': 'other'})
self.svc_a._api_upsert_customer({'external_id': 'user-1', 'name': 'Acme'})
res = self.svc_a._api_create_subscription({
'external_customer_id': 'user-1', 'plan_id': self.plan.id,
'lines': [{'product_id': self.product.id, 'quantity': 1}]})
self.sub = self.env['sale.order'].browse(res['subscription_id'])
def test_cancel_closes_subscription(self):
self.assertEqual(self.sub.subscription_state, '3_progress')
res = self.svc_a._api_cancel_subscription(str(self.sub.id))
self.assertEqual(res['status'], 'ok')
self.assertEqual(self.sub.subscription_state, '6_churn')
def test_cancel_is_idempotent(self):
self.svc_a._api_cancel_subscription(str(self.sub.id))
res = self.svc_a._api_cancel_subscription(str(self.sub.id))
self.assertEqual(res['status'], 'ok')
self.assertEqual(self.sub.subscription_state, '6_churn')
def test_cancel_unknown_subscription_rejected(self):
res = self.svc_a._api_cancel_subscription('999999999')
self.assertEqual(res['status'], 'error')
self.assertEqual(res['error'], 'unknown subscription')
def test_cancel_cross_service_rejected(self):
# svc_b is not linked to the customer that owns self.sub
res = self.svc_b._api_cancel_subscription(str(self.sub.id))
self.assertEqual(res['status'], 'error')
self.assertEqual(res['error'], 'unknown subscription')
self.assertEqual(self.sub.subscription_state, '3_progress')
def test_cancel_missing_id_rejected(self):
res = self.svc_a._api_cancel_subscription('')
self.assertEqual(res['status'], 'error')
```
Append to `fusion_centralize_billing/tests/__init__.py`:
```python
from . import test_subscription_cancel
```
- [ ] **Step 2: Run the test to verify it fails**
Run:
```
ssh odoo-nexa "docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing:TestSubscriptionCancel -u fusion_centralize_billing --stop-after-init"
```
Expected: FAIL — `AttributeError: 'fusion.billing.service' object has no attribute '_api_cancel_subscription'`.
- [ ] **Step 3: Implement the method**
In `fusion_centralize_billing/models/service.py`, add immediately after `_api_create_subscription`:
```python
def _api_cancel_subscription(self, external_ref):
"""Cancel (close) the subscription identified by ``external_ref``.
Authorization mirrors ``_api_record_usage``: the resolved sale.order must
exist, be a subscription, and belong to a customer THIS service is linked
to. Idempotent — closing an already-churned subscription returns ok.
Validation (C3): an empty ref returns a 4xx-shaped error, never raises.
"""
self.ensure_one()
if external_ref in (None, ''):
return {'status': 'error', 'error': 'subscription id required'}
sub = self._fc_resolve_subscription(external_ref)
linked_partners = self.account_link_ids.mapped('partner_id')
if not sub.exists() or not sub.is_subscription \
or sub.partner_id not in linked_partners:
return {'status': 'error', 'error': 'unknown subscription'}
if sub.subscription_state != '6_churn':
sub.set_close()
return {'status': 'ok', 'subscription_id': sub.id,
'subscription_state': sub.subscription_state}
```
- [ ] **Step 4: Run the test to verify it passes**
Run:
```
ssh odoo-nexa "docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing:TestSubscriptionCancel -u fusion_centralize_billing --stop-after-init"
```
Expected: PASS — 5 tests, 0 failures. (If `set_close()` was a different name in Step 0, use that name here and re-run.)
- [ ] **Step 5: Commit**
```bash
git add fusion_centralize_billing/models/service.py fusion_centralize_billing/tests/test_subscription_cancel.py fusion_centralize_billing/tests/__init__.py
git commit -m "feat(billing): add _api_cancel_subscription (close sub, service-scoped authz)"
```
---
### Task 2: `DELETE /subscriptions/<ref>` route
**Files:**
- Modify: `fusion_centralize_billing/controllers/api.py` (add route after `post_subscription`, ~line 95)
- Modify: `fusion_centralize_billing/tests/test_subscription_cancel.py` (add an HTTP-layer test)
- [ ] **Step 1: Write the failing test (HTTP layer)**
Append to `tests/test_subscription_cancel.py` a class that exercises the route through Odoo's test client. Add the import at the top of the file:
```python
from odoo.tests import HttpCase
```
Then append:
```python
@tagged('post_install', '-at_install')
class TestSubscriptionCancelHttp(HttpCase):
def setUp(self):
super().setUp()
self.plan = self.env['sale.subscription.plan'].sudo().create(
{'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'})
self.product = self.env['product.product'].sudo().create(
{'name': 'NexaCloud Plan', 'type': 'service',
'recurring_invoice': True, 'list_price': 49.0})
self.svc = self.env['fusion.billing.service'].sudo().create(
{'name': 'NexaCloud', 'code': 'nexacloud'})
self.raw_key = self.svc.action_generate_api_key()
self.svc._api_upsert_customer({'external_id': 'user-1', 'name': 'Acme'})
res = self.svc._api_create_subscription({
'external_customer_id': 'user-1', 'plan_id': self.plan.id,
'lines': [{'product_id': self.product.id, 'quantity': 1}]})
self.sub_id = res['subscription_id']
self.env.cr.commit()
self.addCleanup(self._cleanup)
def _cleanup(self):
self.env['sale.order'].browse(self.sub_id).sudo().unlink()
def test_delete_requires_auth(self):
resp = self.url_open(
"/api/billing/v1/subscriptions/%s" % self.sub_id,
method='DELETE')
self.assertEqual(resp.status_code, 401)
def test_delete_cancels_with_valid_key(self):
resp = self.url_open(
"/api/billing/v1/subscriptions/%s" % self.sub_id,
method='DELETE',
headers={'Authorization': 'Bearer %s' % self.raw_key})
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json()['subscription_state'], '6_churn')
```
- [ ] **Step 2: Run the test to verify it fails**
Run:
```
ssh odoo-nexa "docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing:TestSubscriptionCancelHttp -u fusion_centralize_billing --stop-after-init"
```
Expected: FAIL — the DELETE route returns 404 (route not registered) so the assertions fail.
- [ ] **Step 3: Implement the route**
In `fusion_centralize_billing/controllers/api.py`, add after `post_subscription`:
```python
@http.route(f"{API_BASE}/subscriptions/<sub_ref>", type="http", auth="none",
methods=["DELETE"], csrf=False)
def delete_subscription(self, sub_ref, **kw):
service = self._authenticate()
if not service:
return self._json({"error": "unauthorized"}, status=401)
result = service._api_cancel_subscription(sub_ref)
if result.get("status") == "error":
status = 404 if result.get("error") == "unknown subscription" else 400
return self._json(result, status=status)
return self._json(result)
```
- [ ] **Step 4: Run the test to verify it passes**
Run:
```
ssh odoo-nexa "docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing:TestSubscriptionCancelHttp -u fusion_centralize_billing --stop-after-init"
```
Expected: PASS — 2 tests, 0 failures.
- [ ] **Step 5: Commit**
```bash
git add fusion_centralize_billing/controllers/api.py fusion_centralize_billing/tests/test_subscription_cancel.py
git commit -m "feat(billing): DELETE /api/billing/v1/subscriptions/<ref> cancel route"
```
---
## Self-review
- **Spec coverage:** §4.1.3 "add subscription cancel (`DELETE /subscriptions/:id`)" → Tasks 1+2. ✔
- **Placeholder scan:** none — all code is concrete; Step 0 verifies the one Odoo-internal name (`set_close`) against the live container instead of assuming.
- **Type consistency:** `_api_cancel_subscription` returns the same `{'status','subscription_id','subscription_state'}` shape as `_api_create_subscription`; error shape matches `_api_record_usage` (`{'status':'error','error':...}`); resolver reused (`_fc_resolve_subscription`) so cross-service rejection is identical to `/usage`. ✔
- **Authorization parity:** cancel uses the exact `not sub.exists() or not sub.is_subscription or sub.partner_id not in linked_partners` guard as `_api_record_usage`. ✔

View File

@@ -0,0 +1,101 @@
# NexaCloud → Odoo Centralized Billing — Cutover (build-out · dual-run · gated flip)
- **Date:** 2026-06-02
- **Status:** Design approved — pending written-spec review
- **Author:** Design session (Claude + Gurpreet)
- **Parent spec:** [`2026-05-27-nexa-billing-centralized-design.md`](2026-05-27-nexa-billing-centralized-design.md) (architecture; this doc is its **phase #2** — the NexaCloud pilot)
- **Repos:** `K:\Github\Odoo-Modules\fusion_centralize_billing` (engine) + `K:\Github\Nexa-Cloud` (the NexaCloud adapter)
- **Hosts:** `odoo-nexa` (VM 315, Odoo 19 Enterprise, live DB `nexamain`); NexaCloud (LXC 102, app `192.168.1.250`, DB `192.168.1.50`)
## 1. Goal
Make Odoo (`fusion_centralize_billing` on `odoo-nexa`) the system of record for **NexaCloud** billing: build the engine pieces NexaCloud needs, import NexaCloud's active deployments as Odoo subscriptions, run Odoo in **shadow** alongside NexaCloud's existing Stripe billing for ≥1 cycle, reconcile to the cent, and then **flip** NexaCloud onto Odoo behind an explicit go/no-go gate. NexaCloud is the pilot; NexaDesk and NexaMaps follow in later increments. This does not touch Lago.
## 2. Decisions locked (this session, 2026-06-02)
1. **Sequence: NexaCloud first** (per parent spec), then NexaDesk, then NexaMaps.
2. **Granularity: one Odoo subscription per NexaCloud deployment** (mirrors `nexacloud` `subscriptions.deployment_id`; the existing usage-push and `fusion.billing.reconciliation` code already key per deployment via `x_fc_nexacloud_subscription_id`).
3. **Approach A: build → import → dual-run → gated flip**, all in this increment; the flip executes only after ≥1 green reconciliation cycle **and** explicit operator go-ahead.
4. **Go-forward billing only.** The importer sets each subscription's `next_invoice_date` so Odoo bills only future periods. Past NexaCloud periods are **never re-issued** (this is the exact failure mode of the 2026-05-27 Lago incident — see `lago-doublecharge-incident-2026-06` memory).
## 3. Current state (recon, 2026-06-02)
Engine is **installed** on `nexamain` (`fusion_centralize_billing` v19.0.1.1.0; deps `sale_subscription`, `payment_stripe`, `account_accountant` installed). Runtime rows:
| Table | Rows | Read |
|---|---|---|
| `fusion_billing_service` | 1 | only `nexacloud`; **`webhook_url` empty** |
| `fusion_billing_account_link` | 7 | identities imported |
| `fusion_billing_metric` | 1 | (cpu_seconds) |
| `fusion_billing_charge` | **0** | no quota/overage pricing yet |
| `fusion_billing_usage` | **0** | nothing ingested |
| `fusion_billing_reconciliation` | **0** | dual-run never run |
| `fusion_billing_webhook` | **0** | control loop never fired |
| `sale_order` (`is_subscription`) | **0** | no subscriptions exist |
Engine code status: `webhook.py` delivery engine (HMAC + backoff + dead-letter) is **complete** (its "TODO §8" header comment is stale); `usage.py` (idempotent upsert + pre-invoice rating cron + aggregation) and `reconciliation.py` (NexaCloud dual-run) are **complete**. `controllers/api.py` implements `/health`, `POST /customers`, `POST /usage`, `GET /plans`, `POST /subscriptions` only — the rest of parent-spec §7 is unimplemented (needed by NexaDesk, **not** NexaCloud).
NexaCloud adapter is present but **INERT**: `config.py` `odoo_billing_enabled=False`, `odoo_billing_base_url`/`odoo_billing_api_key` empty; `usage_metering.py` pushes `cpu_seconds` only when enabled; `routers/odoo_billing.py` `/billing/webhooks/central` returns 404 when disabled; `services/odoo_billing_integration.py` is the (inert) receiver. Lago is paused (worker+clock stopped) and out of scope here.
## 4. Scope
### 4.1 Odoo side (`fusion_centralize_billing` + catalog data on `nexamain`)
1. **Charge catalog (the main gap — currently 0).**
- NexaCloud plans/products → `product.template` + `sale.subscription.plan` (monthly), each tagged `plan_code` and a `product.default_code` of `NC-PLAN-<slug>` (reconciliation already filters plan lines on `default_code LIKE 'NC-PLAN-%'`).
- `cpu_seconds` metric (exists) → one `fusion.billing.charge` per plan: `included_quota` = the plan's bundled CPU-seconds, `price_per_unit`/`unit_batch` for overage derived from `usage_metering.HOURLY_RATES` (`cpu_per_core=$0.0075/core-hr` → per-cpu-second rate). Memory/disk are part of the flat plan today (not metered) — keep them flat unless a plan meters them.
- Throttle-removal fee and the CPU/RAM/disk/daily-backup **add-ons** → one-off invoice products / optional recurring add-on products tagged `NC-ADDON-<slug>`.
- HST: reuse native `account.tax` (13% ON); confirm the tax code matches what NexaCloud invoices apply today.
2. **Run the importer** (`wizards/import_wizard.py`): read the `nexacloud` DB → ensure `res.partner` + `account.link` for each active customer (7 exist; backfill any missing), and create **one shadow `sale.order` (`is_subscription=True`) per active deployment**, setting `x_fc_nexacloud_subscription_id`, `x_fc_nexacloud_plan_id`, the `NC-PLAN-*` line, and **`next_invoice_date` = the deployment's next real billing date** (go-forward only). Subscriptions start in shadow (draft/not auto-charging).
3. **Inbound API — add only what NexaCloud needs.** `POST /customers`, `POST /subscriptions`, `POST /usage`, `GET /plans` already exist. Add **subscription cancel** (`DELETE /subscriptions/:id` → terminate the `sale.order`) for NexaCloud's deprovision path. All other parent-spec §7 endpoints stay deferred to the NexaDesk increment.
4. **Wire the control loop:** set the `nexacloud` `fusion.billing.service.webhook_url``https://api.vps.nexasystems.ca/api/v1/billing/webhooks/central`, and confirm `cron` schedules for `usage._cron_rate_open_periods` and `webhook._cron_dispatch` are enabled.
### 4.2 NexaCloud side (`Nexa-Cloud` repo)
4. **Configure + activate the adapter:** set `odoo_billing_base_url=https://erp.nexasystems.ca/api/billing/v1`, `odoo_billing_api_key=<nexacloud service key>`. Keep `odoo_billing_enabled` staged so usage push + the webhook receiver activate for shadow without yet disabling local Stripe.
5. **Identity + subscription sync:** on deployment create / cancel, call Odoo `POST /customers` and `POST /subscriptions` / cancel (usage push already exists in `usage_metering.py`). Send a stable `external_id` (NexaCloud user id) and `subscription_external_id` (deployment/subscription id) — namespaced, to avoid the cross-app `external_id` collision noted in `nexa-billing-architecture`.
6. **Reconciliation feed:** push NexaCloud's **actual** charged amount per (deployment, period) so `reconciliation._reconcile_rows` can diff Odoo-computed vs NexaCloud-actual. (Source: NexaCloud's own invoices/`usage_records`.)
7. **Activate the control-loop receiver:** `routers/odoo_billing.py` `/billing/webhooks/central``services/odoo_billing_integration.py` maps `invoice.payment_failed`→suspend (existing `network_isolation`/`throttle_checker`/`resource_manager`), `invoice.payment_succeeded`/`subscription.reactivated`→restore, `subscription.terminated`→deprovision. Verify HMAC against the `nexacloud` service `webhook_secret`.
### 4.3 Dual-run (shadow, ≥1 billing cycle)
NexaCloud keeps charging via its own Stripe. Odoo computes **draft, uncharged** invoices from imported subscriptions + pushed `cpu_seconds`. `fusion.billing.reconciliation` upserts one row per `(service, deployment, period)` with `odoo_amount` vs `external_amount` and a cent-level `delta`. Operators investigate every `delta` row until a full cycle is `match` within tolerance (default $0.01).
### 4.4 Gated flip (after ≥1 green cycle + explicit go)
1. NexaCloud **stops its own Stripe charging** (disable the charge path in `billing_service.py` / scheduler `billing_payment` + invoice generation) and treats Odoo as SoR.
2. Odoo subscriptions move from shadow → active; native subscription invoicing charges the **shared** Stripe account `acct_1ShlA9IkwUB1dVox` (saved cards carry over — no re-collection).
3. Webhooks drive suspend/restore/deprovision. Past NexaCloud invoices remain archived (PDF/opening balance) — **not** re-issued.
4. Rollback: re-enable NexaCloud local billing + set Odoo subs back to shadow (no data destroyed).
## 5. Out of scope (YAGNI for this increment)
- NexaDesk and NexaMaps adapters (later increments) and the inbound-API endpoints only they need (`/invoices` family, `/credit_notes`, `/catalog`, `/checkout_url`, `PUT /subscriptions` plan-change/upgrade).
- Lago changes or decommission (Lago stays paused; its remediation is tracked separately).
- Customer-portal redesign — use native Odoo portal as-is.
- Metering memory/disk/bandwidth (stay flat unless a NexaCloud plan already meters them).
## 6. Success criteria
- A NexaCloud deployment is created as an Odoo subscription `sale.order` (`is_subscription=True`) via `POST /subscriptions`, resolving one `res.partner` through `account.link`.
- `cpu_seconds` counters pushed to `/usage` aggregate (idempotent) into a **draft** invoice with quota → free, overage priced, HST applied — matching NexaCloud's own computed amount within $0.01.
- A simulated `invoice.payment_failed` webhook reaches `/billing/webhooks/central` (valid HMAC) and triggers a NexaCloud suspend; `invoice.payment_succeeded` restores.
- `fusion.billing.reconciliation` is `match` for **every** active deployment across ≥1 full cycle before any flip.
- Re-sending the same usage counter (same `idempotency_key`) does **not** double-bill (constraint + upsert verified by test).
- Post-flip: Odoo charges go-forward periods only; **zero** past-period re-issues.
## 7. Risks & open items
- **Re-billing regression (highest):** the importer MUST set `next_invoice_date` go-forward and must not finalize/charge historical periods. Add an explicit test asserting no invoice is generated for any period earlier than import time. (Direct mitigation of the 2026-05-27 Lago incident.)
- **Odoo 19 correctness:** read live reference files from the container (`docker exec odoo-nexa-app cat …`) for `sale.order` subscription flow, `account.move`, `payment_stripe` before coding internals — never from memory (per `K:\Github\CLAUDE.md`).
- **Idempotency:** `fusion.billing.usage` unique `(subscription, metric, idempotency_key)` already enforces it; the NexaCloud key is `nexacloud:cpu_seconds:<sub>:<period>` — keep it stable across retries.
- **external_id namespacing:** NexaCloud must send namespaced ids so it can never collide with NexaDesk/NexaMaps in the shared Odoo identity space.
- **Reconciliation source:** confirm where NexaCloud's "actual amount" comes from (its `invoices`/`usage_records`) and that it's net of the same HST basis Odoo uses.
- **Flip switch safety:** disabling NexaCloud's local Stripe must be a single, reversible config flag, and the `billing_payment` scheduler job must be guarded so it can't charge once Odoo is SoR.
- **Spec/branch target:** `Odoo-Modules` is on `feat/fusion-login-audit` with `-wt-portal`/`-wt-fm` worktrees; confirm the branch for engine changes; NexaCloud changes land on its own branch (note: pushing `Nexa-Cloud` `main` auto-deploys to prod).
## 8. Test plan
- Odoo unit tests (extend `fusion_centralize_billing/tests/`): catalog→charge mapping; usage aggregation + quota/overage; idempotent re-push; reconciliation match/delta; webhook HMAC sign/verify + backoff; **importer go-forward `next_invoice_date` assertion**.
- NexaCloud tests: adapter customer/subscription calls; `/billing/webhooks/central` HMAC verify + suspend/restore/deprovision dispatch; reconciliation-amount push.
- Dual-run acceptance: a full cycle of `match` reconciliation on real (or staged) deployments before the flip gate.

View File

@@ -247,3 +247,24 @@ class FusionBillingService(models.Model):
sub.action_confirm()
return {'status': 'ok', 'subscription_id': sub.id,
'subscription_state': sub.subscription_state}
def _api_cancel_subscription(self, external_ref):
"""Cancel (close) the subscription identified by ``external_ref``.
Authorization mirrors ``_api_record_usage``: the resolved sale.order must
exist, be a subscription, and belong to a customer THIS service is linked
to. Idempotent — closing an already-churned subscription returns ok.
Validation (C3): an empty ref returns a 4xx-shaped error, never raises.
"""
self.ensure_one()
if external_ref in (None, ''):
return {'status': 'error', 'error': 'subscription id required'}
sub = self._fc_resolve_subscription(external_ref)
linked_partners = self.account_link_ids.mapped('partner_id')
if not sub.exists() or not sub.is_subscription \
or sub.partner_id not in linked_partners:
return {'status': 'error', 'error': 'unknown subscription'}
if sub.subscription_state != '6_churn':
sub.set_close()
return {'status': 'ok', 'subscription_id': sub.id,
'subscription_state': sub.subscription_state}

View File

@@ -6,3 +6,4 @@ from . import test_webhook
from . import test_importer
from . import test_reconciliation
from . import test_invoice_ledger
from . import test_subscription_cancel

View File

@@ -18,11 +18,26 @@ def _inv_fixture():
}]
def _fc_ensure_ca_billing_env(env):
"""Prod (`nexamain`) is a fully-configured Canadian company; a bare test DB is not.
Give it the two things the ledger needs: an active CAD currency and a 13% sale tax
matching invoice.ledger.wizard._fc_tax_for (type_tax_use=sale, percent, amount=13)."""
cad = env.ref('base.CAD')
if not cad.active:
cad.sudo().write({'active': True})
Tax = env['account.tax'].sudo()
if not Tax.search([('type_tax_use', '=', 'sale'),
('amount_type', '=', 'percent'), ('amount', '=', 13.0)], limit=1):
Tax.create({'name': 'HST 13%', 'type_tax_use': 'sale',
'amount_type': 'percent', 'amount': 13.0})
@tagged('post_install', '-at_install')
class TestLedgerFamily(TransactionCase):
def setUp(self):
super().setUp()
_fc_ensure_ca_billing_env(self.env)
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
def test_family_classification(self):
@@ -47,6 +62,7 @@ class TestLedgerTax(TransactionCase):
def setUp(self):
super().setUp()
_fc_ensure_ca_billing_env(self.env)
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
def test_tax_for_13pct_is_a_13_percent_sale_tax(self):
@@ -68,6 +84,7 @@ class TestLedgerIngest(TransactionCase):
def setUp(self):
super().setUp()
_fc_ensure_ca_billing_env(self.env)
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
self.Move = self.env['account.move']
@@ -174,6 +191,7 @@ class TestLedgerVerifiedSync(TransactionCase):
def setUp(self):
super().setUp()
_fc_ensure_ca_billing_env(self.env)
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
self.Move = self.env['account.move']
ICP = self.env['ir.config_parameter'].sudo()

View File

@@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestSubscriptionCancel(TransactionCase):
def _service(self, code, name):
Svc = self.env['fusion.billing.service'].sudo()
return Svc.search([('code', '=', code)], limit=1) or Svc.create(
{'name': name, 'code': code})
def setUp(self):
super().setUp()
self.plan = self.env['sale.subscription.plan'].sudo().create(
{'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'})
self.product = self.env['product.product'].sudo().create(
{'name': 'NexaCloud Plan', 'type': 'service',
'recurring_invoice': True, 'list_price': 49.0})
self.svc_a = self._service('nexacloud', 'NexaCloud')
self.svc_b = self._service('other_app', 'Other App')
self.svc_a._api_upsert_customer({'external_id': 'user-1', 'name': 'Acme'})
res = self.svc_a._api_create_subscription({
'external_customer_id': 'user-1', 'plan_id': self.plan.id,
'lines': [{'product_id': self.product.id, 'quantity': 1}]})
self.sub = self.env['sale.order'].browse(res['subscription_id'])
def test_cancel_closes_subscription(self):
self.assertEqual(self.sub.subscription_state, '3_progress')
res = self.svc_a._api_cancel_subscription(str(self.sub.id))
self.assertEqual(res['status'], 'ok')
self.assertEqual(self.sub.subscription_state, '6_churn')
def test_cancel_is_idempotent(self):
self.svc_a._api_cancel_subscription(str(self.sub.id))
res = self.svc_a._api_cancel_subscription(str(self.sub.id))
self.assertEqual(res['status'], 'ok')
self.assertEqual(self.sub.subscription_state, '6_churn')
def test_cancel_unknown_subscription_rejected(self):
res = self.svc_a._api_cancel_subscription('999999999')
self.assertEqual(res['status'], 'error')
self.assertEqual(res['error'], 'unknown subscription')
def test_cancel_cross_service_rejected(self):
# svc_b is not linked to the customer that owns self.sub
res = self.svc_b._api_cancel_subscription(str(self.sub.id))
self.assertEqual(res['status'], 'error')
self.assertEqual(res['error'], 'unknown subscription')
self.assertEqual(self.sub.subscription_state, '3_progress')
def test_cancel_missing_id_rejected(self):
res = self.svc_a._api_cancel_subscription('')
self.assertEqual(res['status'], 'error')

View File

@@ -9,7 +9,8 @@ class TestRatingCron(TransactionCase):
def setUp(self):
super().setUp()
self.metric = self.env['fusion.billing.metric'].sudo().create(
Metric = self.env['fusion.billing.metric'].sudo()
self.metric = Metric.search([('code', '=', 'cpu_seconds')], limit=1) or Metric.create(
{'name': 'CPU seconds', 'code': 'cpu_seconds', 'aggregation': 'sum'})
self.plan_a = self.env['sale.subscription.plan'].sudo().create(
{'name': 'Plan A', 'billing_period_value': 1, 'billing_period_unit': 'month'})
@@ -67,7 +68,8 @@ class TestUsageIngestion(TransactionCase):
def setUp(self):
super().setUp()
self.metric = self.env['fusion.billing.metric'].sudo().create(
Metric = self.env['fusion.billing.metric'].sudo()
self.metric = Metric.search([('code', '=', 'cpu_seconds')], limit=1) or Metric.create(
{'name': 'CPU seconds', 'code': 'cpu_seconds', 'aggregation': 'sum'})
self.plan = self.env['sale.subscription.plan'].sudo().create(
{'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'})

View File

@@ -13,11 +13,17 @@ class TestWebhookEngine(TransactionCase):
def setUp(self):
super().setUp()
self.service = self.env['fusion.billing.service'].sudo().create({
Service = self.env['fusion.billing.service'].sudo()
vals = {
'name': 'NexaCloud', 'code': 'nexacloud',
'webhook_url': 'https://api.vps.nexasystems.ca/billing/webhook',
'webhook_secret': 'whsec_test',
})
}
self.service = Service.search([('code', '=', 'nexacloud')], limit=1)
if self.service:
self.service.write(vals)
else:
self.service = Service.create(vals)
self.Webhook = self.env['fusion.billing.webhook'].sudo()
def test_enqueue_signs_payload(self):

View File

@@ -630,8 +630,27 @@ De-Racking → Final inspection → Shipping`
Columns are first-class — they always render in this exact order, never
reorder, never collapse when empty. Driven by `fp.work.centre.area_kind`
Selection (added 2026-05-23). Each `fp.job.step.area_kind` is computed
(stored) from `work_centre.area_kind` with a fallback to a step-kind
dispatch table (`_STEP_KIND_TO_AREA` in `fusion_plating_jobs/models/fp_job_step.py`).
(stored) in `_compute_area_kind` (`fusion_plating_jobs/models/fp_job_step.py`):
`work_centre.area_kind` → else `recipe_node.kind_id.area_kind` (the
`fp.step.kind` taxonomy is authoritative; the legacy `_STEP_KIND_TO_AREA`
dict is gone) → else catch-all `'plating'`.
**Gating/"Ready for X" marker steps fall FORWARD (fixed 2026-06-02).** The
`fp.step.kind` named *Gating* has `code='gating'` **and `area_kind='receiving'`**.
A gating step is a non-physical "ready for the next stage" marker, so
mapping it to Receiving made a *mid-recipe* gate snap the job's card back
to the first column (Racking → "Ready for processing" jumped to Receiving,
so the job looked like it vanished). `_compute_area_kind` therefore detects
a gating step via the **stable `kind_id.code == 'gating'`** (never the
display name) and resolves its column to the **next non-gating step's** raw
area (so "Ready for processing" before plating shows in the **Plating**
column); if nothing real follows, it falls back to the last real stage.
Helpers: `_fp_is_gating_step`, `_fp_raw_area_kind` (own work_centre/kind
only — no look-ahead, avoids recursion), `_fp_resolve_area_kind`. **NB:**
`area_kind` is a STORED compute, so after changing this logic you must
force-recompute existing rows (`env['fp.job.step'].search([])._compute_area_kind()`
+ `flush_recordset(['area_kind'])` + commit) — a `-u`/restart alone leaves
old values stale.
**Spec D3:** all wet-line steps (Soak Clean, Electroclean, Acid Dip,
Etch, Desmut, Zincate, Rinse, E-Nickel, Chrome, Anodize, Black Oxide,
@@ -1847,20 +1866,42 @@ A 50-part job can have parts at several stages at once (10 Masking, 20 Plating,
3. **The Move Parts dialog was only wired into the DEPRECATED `shopfloor_tablet.js`** — the live `fp_job_workspace` had no move/advance action, so operators literally could not move partial parts. The "Send → <next>" action now lives in `job_workspace.js` (`getStepActions` advance descriptor → `onAdvanceStep` → `FpMovePartsDialog`). The dialog itself was slimmed (qty steppers, no keyboard; Transfer Type + To Location collapsed behind "More options"). If you add another operator surface, wire the advance action there too.
4. **Partial-flow "light up" lives in `move_controller._do_move_parts_commit` / `_do_move_rack_commit`:** a forward (`transfer_type='step'`) move (a) flips the destination step `pending → ready` so the receiving operator gets an actionable card with no action by anyone, and (b) calls `from_step._fp_try_autofinish_on_drain()` (best-effort, swallows finish-gate UserErrors). It does **not** auto-START the destination — `button_start` stays explicit to keep the labour timer accurate (S16). No auto-ready/auto-finish for hold/scrap/rework moves.
4. **Partial-flow "light up" lives in `move_controller._do_move_parts_commit` / `_do_move_rack_commit`:** a forward (`transfer_type='step'`) move (a) flips the destination step `pending → ready` so the receiving operator gets an actionable card with no action by anyone, and (b) calls `from_step._fp_try_autofinish_on_drain()` (best-effort, swallows finish-gate UserErrors). It does **not** auto-START the destination — `button_start` stays explicit to keep the labour timer accurate (S16). No auto-ready/auto-finish for hold/scrap/rework moves. **Two non-obvious traps in `_fp_try_autofinish_on_drain` (both fixed 2026-06-02):** (1) it must guard on a real **OUTGOING** move (`move_ids` to a different step, `qty_moved > 0`), NOT `_fp_has_real_incoming()` — the FIRST/seeded stage (e.g. Racking) is fed by the `qty_at_step` seed, has no incoming move, and so never auto-finished when all its parts were sent forward. (2) It is **best-effort and gated**: `button_finish` still runs the required-step-input / sign-off / contract-review gates, so a step with an unrecorded required input (e.g. Racking's "Count the Parts") will NOT auto-finish on drain — it stays `in_progress` with `qty_at_step=0` ("running, 0 here → finish me") until the operator records the input and finishes. That's correct (can't complete a step missing compliance data); don't try to force auto-finish past the gates.
5. **The predecessor gate is qty-aware: `_fp_should_block_predecessors()` returns False once `_fp_has_real_incoming()` is true** (an incoming move from a different step with `qty_moved > 0`). A step with parts physically parked at it is startable regardless of whether upstream steps are fully done. This is the single source of truth shared by `can_start`, `_compute_blocker`, `button_start`, and the Move dialog's `_blockers_for_move`. **Don't "fix" the predecessor gate back to pure sequence-based** — it would re-lock the next stage while the rest of the batch is still upstream.
5. **The predecessor gate is qty-aware: `_fp_should_block_predecessors()` returns False once `_fp_has_real_incoming()` is true** (an incoming move from a different step with `qty_moved > 0`). A step with parts physically parked at it is startable regardless of whether upstream steps are fully done. This is the single source of truth shared by `can_start`, `_compute_blocker`, `button_start`, and the Move dialog's `_blockers_for_move`. **Don't "fix" the predecessor gate back to pure sequence-based** — it would re-lock the next stage while the rest of the batch is still upstream. **Second, distinct trap (fixed 2026-06-02): the Move dialog's `_blockers_for_move` predecessor check must only flag unfinished steps STRICTLY BETWEEN `from_step` and `to_step` (`from_step.sequence < s.sequence < to_step.sequence`), NOT all steps before `to_step`.** The original `s.sequence < to_step.sequence` filter counted the `from_step` itself (which is in-progress *by definition* when you advance partial parts out of it) as an "unfinished predecessor" of the destination — so EVERY partial advance to a not-yet-started next step showed a hard "Predecessor not done: \<from_step\>" blocker and greyed out SEND (hit on WO-30061). The between-only rule allows the immediate-next advance, still blocks skip-ahead moves over incomplete intermediate stages, and leaves backward (rework) moves unblocked (empty range).
6. **Move-based scrap (`transfer_type='scrap'`) does NOT touch `job.qty_scrapped`.** At close, `button_mark_done` calls `_fp_scrapped_via_moves()` and folds it into `qty_scrapped`, then auto-fills `qty_done = qty qty_scrapped` (was: blindly `= job.qty`, which over-counted when parts were scrapped). The reconciliation gate is still the safety net.
**Verification:** the plating modules can't be installed on the local Community dev DB (missing enterprise deps — same reason `fusion_plating` shows `installed=0` in `modsdev`/`fusion-dev`). Static checks done: pyflakes (Python), lxml parse (XML), `node --check` as `.mjs` (JS — `node --check` on a `.js` errors with "Cannot use import statement outside a module"; copy to `/tmp/x.mjs` first). Dynamic tests + browser check require an installed env (entech / odoo-trial).
### Rollout fixes + open items (live operator testing, 2026-06-02)
Bugs that only real tablet testing surfaced (all fixed, deployed to entech, on main):
- **Phantom future-stage cards** — a job showed in every not-yet-started `ready` stage. Presence keys off parked qty / `in_progress`, never `ready` (gotcha 1).
- **Scan buttons** — camera button rendered two icons; "Scan Code" vs "Camera" was confusing. `QrScanner` keeps its single icon; now **"Scan QR"** (camera) + **"Enter Code"** (wedge/manual). Don't pass an emoji in the `QrScanner` label — it doubles the icon.
- **Dark-mode invisible text** — `var(--bs-body-color)` / `var(--bs-secondary-color)` are UNDEFINED in Odoo's backend CSS → always fall back to the dark hex. Use inherit / translucent `rgba()` (see the Dark-mode SCSS section).
- **Partial advance blocked by the from-step's own predecessor** — `_blockers_for_move` now blocks only steps STRICTLY BETWEEN from/to (gotcha 5).
- **First/seeded stage never auto-finished on drain** — `_fp_try_autofinish_on_drain` guards on a real OUTGOING move, not incoming.
- **Gating "Ready for X" steps zig-zagged the card back to Receiving** — gating steps fall FORWARD to the next real stage's column (see the Plant-View `area_kind` note).
Open / deferred (next session):
- **Discoverability (not built):** show a "N here" qty badge on step rows + the count on the Send button; add a "✓ all sent — record inputs to finish" hint when a step is drained-to-0 but still has a pending required input (answers operators' "why is it still active?").
- **Scrap / Rework as standalone intent buttons** — currently under the Move dialog's "More options"; only Hold has its own button.
- **Automated tests NOT written** — modules need enterprise deps (can't install on local Community); validated via pyflakes/lxml + live odoo-shell verification on entech. A `bt_s*`-style battle test is the recommended next step.
- **Plant-card status chips** read fine but bright in dark mode (deferred).
---
## Dark-mode SCSS gotchas — shop-floor dialogs/components (fixed 2026-06-02)
Operators reported invisible (dark-on-dark) text in the workspace + "Cannot Finish Step" dialog under Odoo dark mode. Root causes + the rules:
1. **`var(--text-secondary, #333)` is a MADE-UP variable — it does not exist in Odoo, so it ALWAYS falls back to the hardcoded dark hex → invisible on dark backgrounds.** It was used 33× across `job_workspace.scss` + 5 component stylesheets. The real, dark-aware secondary-text variable is **`var(--bs-secondary-color)`** (CLAUDE.md rule 9 lists it). Never use `--text-secondary` / `--text-primary` / `--card-bg` etc. — those aren't Odoo vars.
1. **Odoo's compiled backend CSS does NOT define the Bootstrap colour custom-properties — `var(--bs-body-color)`, `var(--bs-secondary-color)`, `var(--bs-tertiary-bg)`, `var(--bs-body-bg)` are REFERENCED but never DEFINED (verified 2026-06-02: 0 definitions for `--bs-body-color`/`--bs-secondary-color` in the live `web.assets_backend` text).** So **any `color: var(--bs-body-color, #hex)` resolves to the `#hex` fallback in BOTH light and dark mode** — a dark hex → invisible on a dark surface. (`var(--text-secondary, …)` is even worse — that var name is entirely made-up.) Odoo themes the backend via **runtime `[data-bs-theme="dark"]`** (Bootstrap 5.3) + SCSS literals, NOT via those CSS vars, and NOT via `prefers-color-scheme`. Do NOT colour custom text with `var(--bs-*)`. **Correct, verified options:**
- **Inherit** — omit `color:` entirely so the element takes the dialog/page theme colour. Proven: the finish-block dialog's title + `.o_fp_finish_block_list` items have no colour and ARE readable in both modes; the `.o_fp_finish_block_msg` line was the ONLY broken one because it set `color: var(--bs-body-color,…)`. Removing that one line fixed it. This is the simplest fix for dialog/modal text.
- **Translucent `rgba()` for tinted boxes** — e.g. `background: rgba(245,158,11,0.16)` (warning) / `rgba(128,128,128,0.12)` (neutral). Works over whatever the live theme background is. (`color-mix(…, var(--bs-body-bg))` does NOT work — `--bs-body-bg` is undefined, so the whole `color-mix` is invalid and dropped.)
- **Explicit `[data-bs-theme="dark"] .my-class { color: … }`** override with literal hex when you genuinely need a different value per theme.
- **Compile-time `$o-webclient-color-scheme == dark`** literals only work if the **dark bundle is actually served**; on entech the active mechanism is runtime `[data-bs-theme]`, so prefer inherit / rgba / `[data-bs-theme=dark]` selectors over the two-bundle approach for backend dialogs.
NOTE: ~33 muted-text usages across `job_workspace.scss` + 5 component stylesheets still use `var(--bs-secondary-color, #hex)` (undefined → dark hex). They're muted/secondary so less glaring, but technically wrong in dark mode — sweep them to one of the patterns above when touched.
2. **Odoo's bootstrap does NOT define the Bootstrap 5.3 `--bs-{color}-bg-subtle` / `--bs-{color}-text-emphasis` family.** Verified by grepping `web/static/lib/bootstrap/scss/_root.scss`: `--bs-tertiary-bg` and `--bs-secondary-color` exist; `--bs-warning-bg-subtle`, `--bs-danger-bg-subtle`, `--bs-warning-text-emphasis` are MISSING. So `var(--bs-warning-bg-subtle, #fef3c7)` just yields the bright hex fallback — useless for dark mode. **For tinted status banners (warning/danger/info), use `color-mix` over the live theme bg instead:** `background-color: color-mix(in srgb, #f59e0b 14%, var(--bs-body-bg)); color: var(--bs-body-color);` — pale in light mode, dark-tinted in dark mode, readable in both, graceful-degrades to no-bg on ancient browsers. (`color-mix` works in `background-color` per the rule-8 note; keep it out of shorthands.) Solid accent elements (selected pills, priority dots) with `color: white` are fine as-is in both modes.
3. **Confirmed-present, dark-aware Odoo vars to reach for:** `--bs-body-color` (primary text), `--bs-secondary-color` (muted text), `--bs-body-bg` / `--bs-tertiary-bg` (surfaces), `--bs-border-color`. The deliberate color-coded plant-card status chips (`_plant_card.scss` `.kind-*` / `.tag-*`) are light-bg + dark-text (readable in both modes, just bright on a dark card) — intentionally left as a color-coded set.

View File

@@ -157,33 +157,71 @@ class FpJobStep(models.Model):
@api.depends(
'work_centre_id.area_kind',
'recipe_node_id.kind_id.area_kind',
'recipe_node_id.kind_id.code',
'sequence',
'job_id.step_ids.sequence',
'job_id.step_ids.work_centre_id.area_kind',
'job_id.step_ids.recipe_node_id.kind_id.area_kind',
'job_id.step_ids.recipe_node_id.kind_id.code',
)
def _compute_area_kind(self):
"""Resolve the plant-view column this step belongs in.
Priority chain:
Priority chain (non-gating steps):
1. work_centre.area_kind (explicit operator setup wins)
2. recipe_node.kind_id.area_kind (kind taxonomy authoritative)
3. catch-all 'plating' (data integrity issue if we land here)
The legacy _STEP_KIND_TO_AREA dict was removed — fp.step.kind
now self-declares its area_kind, so the kind taxonomy IS the
source of truth. See spec
2026-05-24-shopfloor-live-step-fix-design.md Change 6.
Gating/marker steps (kind `code == 'gating'` — the "Ready for X"
steps) have NO physical location; the taxonomy maps them to
'receiving', which made a mid-recipe gate snap the job's card back
to the first column (Racking -> "Ready for processing" jumped to
Receiving, so the job looked like it vanished — 2026-06-02). A
gating step now FALLS FORWARD to the next non-gating step's column
(it's "ready for [that stage]"), keeping the card moving
left->right. If nothing real follows, it falls back to the last
real stage.
"""
for step in self:
# 1. Explicit work_centre wins
if step.work_centre_id and step.work_centre_id.area_kind:
step.area_kind = step.work_centre_id.area_kind
continue
# 2. Kind taxonomy
node = step.recipe_node_id
if node and node.kind_id and node.kind_id.area_kind:
step.area_kind = node.kind_id.area_kind
continue
# 3. Catch-all — only reached for orphaned steps (no
# work_centre AND no recipe_node).
step.area_kind = 'plating'
step.area_kind = step._fp_resolve_area_kind()
def _fp_raw_area_kind(self):
"""Area from this step's OWN work_centre / kind only — no look-ahead
and no dependence on the computed `area_kind` field (so the gating
fall-forward below can't recurse)."""
self.ensure_one()
if self.work_centre_id and self.work_centre_id.area_kind:
return self.work_centre_id.area_kind
node = self.recipe_node_id
if node and node.kind_id and node.kind_id.area_kind:
return node.kind_id.area_kind
return 'plating'
def _fp_is_gating_step(self):
"""True for a 'Ready for X' marker step (no physical location).
Detected via the STABLE kind code, never the display name."""
self.ensure_one()
node = self.recipe_node_id
return bool(node and node.kind_id and node.kind_id.code == 'gating')
def _fp_resolve_area_kind(self):
"""Column for this step: its own raw area, EXCEPT a gating marker
falls forward to the next non-gating step's column."""
self.ensure_one()
if not self._fp_is_gating_step():
return self._fp_raw_area_kind()
siblings = self.job_id.step_ids
later = siblings.filtered(
lambda s: s.sequence > self.sequence and not s._fp_is_gating_step()
).sorted('sequence')
if later:
return later[0]._fp_raw_area_kind()
earlier = siblings.filtered(
lambda s: s.sequence < self.sequence and not s._fp_is_gating_step()
).sorted('sequence')
if earlier:
return earlier[-1]._fp_raw_area_kind()
return self._fp_raw_area_kind()
last_activity_at = fields.Datetime(
string='Last Activity',
@@ -698,19 +736,29 @@ class FpJobStep(models.Model):
operator to finish manually (the board will show it "running, 0
here", which reads as "finish me").
Only fires for steps that had REAL incoming parts — never an
untouched first-step seed. Returns True if the step finished.
Fires for any step that actually moved parts OUT and drained to
zero — INCLUDING the first/seeded stage (its qty comes from the
qty_at_step seed, not a real incoming move). Returns True if the
step finished.
"""
self.ensure_one()
if self.state != 'in_progress':
return False
if not self._fp_has_real_incoming():
return False
# qty_at_step is a non-stored compute off the move rows — force a
# re-read so we see the just-committed outgoing move.
self.invalidate_recordset(['qty_at_step'])
if self.qty_at_step != 0:
return False
# Guard: only auto-finish a step that genuinely moved parts OUT (a
# real outgoing move, excluding self-loop measurement moves). The
# earlier guard checked _fp_has_real_incoming() — the WRONG
# direction: the first/seeded stage (e.g. Racking) is fed by the
# qty_at_step seed, not an incoming move, so it never auto-finished
# when all its parts were sent forward. Checking for a real
# OUTGOING move covers the seeded first stage correctly.
if not self.move_ids.filtered(
lambda m: m.to_step_id != self and (m.qty_moved or 0) > 0):
return False
try:
self.button_finish()
return True

View File

@@ -124,8 +124,18 @@ class FpTabletMoveController(http.Controller):
hasattr(to_step, '_fp_should_block_predecessors')
and to_step._fp_should_block_predecessors()
):
# Partial-flow (2026-06-02): only an unfinished step STRICTLY
# BETWEEN from_step and to_step blocks the move (you'd be skipping
# an incomplete intermediate stage). The from_step itself is
# in-progress BY DEFINITION when advancing partial parts out of
# it — counting it (or any earlier step) as an "unfinished
# predecessor" blocked every partial advance to a not-yet-started
# next step. Steps before from_step are irrelevant: the parts
# being moved are physically at from_step, ready for the next
# stage. Backward moves (rework: from > to) yield an empty range
# and are never predecessor-blocked.
unfinished = to_step.job_id.step_ids.filtered(
lambda s: s.sequence < to_step.sequence
lambda s: from_step.sequence < s.sequence < to_step.sequence
and s.state not in ('done', 'skipped', 'cancelled')
)
if unfinished:

View File

@@ -806,20 +806,25 @@ $_ws-text-hex: #1d1d1f;
gap: 1rem;
}
// NOTE: Odoo's backend CSS does NOT define --bs-body-color /
// --bs-secondary-color / --bs-*-bg as custom properties (verified: 0
// definitions in the compiled bundle — they're SCSS literals + two
// bundles + [data-bs-theme]). So var(--bs-body-color, #hex) ALWAYS
// resolves to the dark #hex fallback, in light AND dark mode. The fix
// for dialog text is to INHERIT the modal's theme-correct colour (the
// dialog title and the "Count the Parts" list items do exactly this and
// are readable in both modes). Tinted boxes use translucent rgba() so
// they work over whatever the live theme background is.
.o_fp_finish_block_step {
font-size: 1.1rem;
color: var(--bs-body-color);
// Amber wash over the live theme bg — pale in light mode, dark-amber
// in dark mode (the -bg-subtle/-text-emphasis BS vars aren't defined
// in Odoo's bootstrap, so color-mix is the dark-aware path).
background-color: color-mix(in srgb, #f59e0b 14%, var(--bs-body-bg));
background-color: rgba(245, 158, 11, 0.16);
padding: 0.7rem 1rem;
border-radius: 6px;
border-left: 4px solid #f59e0b;
}
.o_fp_finish_block_msg {
color: var(--bs-secondary-color, #333);
font-weight: 500;
}
.o_fp_finish_block_list {
@@ -834,9 +839,9 @@ $_ws-text-hex: #1d1d1f;
}
.o_fp_finish_block_action_note {
color: var(--bs-secondary-color, #555);
// Inherit text colour; translucent neutral box works in both themes.
font-style: italic;
padding: 0.6rem 0.8rem;
background: var(--bs-tertiary-bg, #f3f4f6);
background: rgba(128, 128, 128, 0.12);
border-radius: 4px;
}

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
{
'name': 'Fusion Authorizer & Sales Portal',
'version': '19.0.2.8.0',
'version': '19.0.2.10.1',
'category': 'Sales/Portal',
'summary': 'Portal for Authorizers (OTs) and Sales Reps with Assessment Forms',
'description': """
@@ -64,12 +64,14 @@ This module provides external portal access for:
'data/portal_menu_data.xml',
'data/ir_actions_server_data.xml',
'data/welcome_articles.xml',
'data/visit_data.xml',
# Views
'views/res_partner_views.xml',
'views/sale_order_views.xml',
'views/assessment_views.xml',
'views/loaner_checkout_views.xml',
'views/pdf_template_views.xml',
'views/visit_views.xml',
# Portal Templates
'views/portal_templates.xml',
'views/portal_assessment_express.xml',
@@ -79,6 +81,7 @@ This module provides external portal access for:
'views/portal_technician_templates.xml',
'views/portal_book_assessment.xml',
'views/portal_page11_sign_templates.xml',
'views/portal_visit.xml',
],
'assets': {
'web.assets_backend': [

View File

@@ -458,6 +458,7 @@ class AssessmentPortal(CustomerPortal):
'current_page': 1,
'total_pages': 2,
'assessment': None,
'visit_id': kw.get('visit_id', ''),
'google_maps_api_key': google_maps_api_key,
}
@@ -516,6 +517,7 @@ class AssessmentPortal(CustomerPortal):
'partner': partner,
'user': user,
'assessment': assessment,
'visit_id': kw.get('visit_id') or (assessment.visit_id.id if assessment.visit_id else ''),
'authorizers': authorizers,
'authorizers_json': authorizers_json,
'clients': clients,
@@ -630,6 +632,30 @@ class AssessmentPortal(CustomerPortal):
except Exception as e:
_logger.error(f"Error saving Page 11 signature: {e}")
# ===== Visit-linked: defer SO creation to visit completion =====
# Started from a visit workspace: do NOT complete into a standalone
# sale order. Leave it as a draft linked to the visit so
# visit.action_complete_visit() groups the visit's ADP devices
# (combination-checked) into ONE ADP order. The Page 11 signature is
# already saved above; pre-generate its PDF so it is ready.
if assessment.visit_id and action == 'submit':
if assessment.signature_page_11 and assessment.consent_declaration_accepted:
try:
pdf_bytes = assessment.generate_template_pdf('Page 11')
if pdf_bytes:
import base64 as b64
assessment.write({
'signed_page_11_pdf': b64.b64encode(pdf_bytes),
'signed_page_11_pdf_filename': f'ADP_Page11_{assessment.reference}.pdf',
})
except Exception as pdf_e:
_logger.warning(f"Visit-linked Page 11 PDF generation failed (non-blocking): {pdf_e}")
_logger.info(
f"Express assessment {assessment.reference} saved to visit "
f"{assessment.visit_id.name} (completion deferred to visit)"
)
return request.redirect(f'/my/visit/{assessment.visit_id.id}')
# Handle navigation
if action == 'submit':
# If already completed, we just saved consent/signature above -- redirect with success
@@ -803,6 +829,13 @@ class AssessmentPortal(CustomerPortal):
def _build_express_assessment_vals(self, kw):
"""Build values dict from express form POST data"""
vals = {}
# Visit linkage (assessment started from a visit workspace)
if kw.get('visit_id'):
try:
vals['visit_id'] = int(kw.get('visit_id'))
except (ValueError, TypeError):
pass
# Equipment type
if kw.get('equipment_type'):
@@ -815,7 +848,15 @@ class AssessmentPortal(CustomerPortal):
vals['wheelchair_type'] = kw.get('wheelchair_type')
if kw.get('powerchair_type'):
vals['powerchair_type'] = kw.get('powerchair_type')
if kw.get('scooter_type'):
vals['scooter_type'] = kw.get('scooter_type')
if kw.get('scooter_max_range'):
vals['scooter_max_range'] = float(kw.get('scooter_max_range') or 0)
if kw.get('x_fc_power_home_accessible'):
vals['x_fc_power_home_accessible'] = kw.get('x_fc_power_home_accessible')
if kw.get('x_fc_power_home_access_notes'):
vals['x_fc_power_home_access_notes'] = kw.get('x_fc_power_home_access_notes')
# Float measurements
float_fields = [
'rollator_handle_height', 'rollator_seat_height',

View File

@@ -2479,6 +2479,56 @@ class AuthorizerPortal(CustomerPortal):
template = template_map.get(assessment_type, 'fusion_portal.portal_accessibility_selector')
return request.render(template, values)
# ==========================================================================
# ASSESSMENT VISIT WORKSPACE (Phase 1b/3)
# ==========================================================================
@http.route('/my/visit/new', type='http', auth='user', website=True)
def visit_new(self, **kw):
"""Start a new assessment visit and open its workspace."""
partner = request.env.user.partner_id
if not partner.is_sales_rep_portal and not partner.is_authorizer:
return request.redirect('/my')
visit = request.env['fusion.assessment.visit'].sudo().create({
'sales_rep_id': request.env.user.id,
})
return request.redirect('/my/visit/%s' % visit.id)
@http.route('/my/visit/<int:visit_id>', type='http', auth='user', website=True)
def visit_workspace(self, visit_id, **kw):
visit = request.env['fusion.assessment.visit'].sudo().browse(visit_id)
if not visit.exists():
return request.redirect('/my')
return request.render('fusion_portal.portal_visit_workspace', {
'visit': visit,
'page_name': 'visit',
'error': kw.get('error'),
})
@http.route('/my/visit/<int:visit_id>/save', type='http', auth='user', methods=['POST'], website=True, csrf=True)
def visit_save_client(self, visit_id, **post):
visit = request.env['fusion.assessment.visit'].sudo().browse(visit_id)
if visit.exists():
visit.write({
'client_name': (post.get('client_name') or '').strip(),
'client_phone': (post.get('client_phone') or '').strip(),
'client_email': (post.get('client_email') or '').strip(),
'client_address': (post.get('client_address') or '').strip(),
'x_fc_income_under_mod_threshold': post.get('x_fc_income_under_mod_threshold') or 'unknown',
})
return request.redirect('/my/visit/%s' % visit_id)
@http.route('/my/visit/<int:visit_id>/complete', type='http', auth='user', methods=['POST'], website=True, csrf=True)
def visit_complete(self, visit_id, **post):
visit = request.env['fusion.assessment.visit'].sudo().browse(visit_id)
if not visit.exists():
return request.redirect('/my')
try:
visit.action_complete_visit()
except Exception as e:
_logger.warning("Visit %s completion failed: %s", visit_id, e)
return request.redirect('/my/visit/%s?error=%s' % (visit_id, str(e)))
return request.redirect('/my/visit/%s' % visit_id)
@http.route('/my/accessibility/save', type='json', auth='user', methods=['POST'], csrf=True)
def accessibility_assessment_save(self, **post):
"""Save an accessibility assessment and optionally create a Sale Order"""
@@ -2547,6 +2597,13 @@ class AuthorizerPortal(CustomerPortal):
if partner.is_authorizer:
vals['authorizer_id'] = partner.id
# Link to a visit if this form was launched from the workspace.
if post.get('visit_id'):
try:
vals['visit_id'] = int(post.get('visit_id'))
except (TypeError, ValueError):
pass
# Create the assessment
assessment = Assessment.create(vals)
_logger.info(f"Created accessibility assessment {assessment.reference} by {request.env.user.name}")
@@ -2572,8 +2629,12 @@ class AuthorizerPortal(CustomerPortal):
if video_data:
self._attach_accessibility_video(assessment, video_data, video_filename)
# Complete the assessment and create Sale Order if requested
# Complete the assessment and create Sale Order if requested.
# When launched from a visit, always save as a draft linked to the
# visit — the VISIT completion creates the grouped sale order(s).
create_sale_order = post.get('create_sale_order', True)
if vals.get('visit_id'):
create_sale_order = False
if create_sale_order:
sale_order = assessment.action_complete()
return {
@@ -2586,12 +2647,13 @@ class AuthorizerPortal(CustomerPortal):
'redirect_url': f'/my/sales/case/{sale_order.id}',
}
else:
redirect_url = ('/my/visit/%s' % vals['visit_id']) if vals.get('visit_id') else '/my/accessibility/list'
return {
'success': True,
'assessment_id': assessment.id,
'assessment_ref': assessment.reference,
'message': f'Assessment {assessment.reference} saved as draft.',
'redirect_url': '/my/accessibility/list',
'message': f'Assessment {assessment.reference} saved.',
'redirect_url': redirect_url,
}
except Exception as e:

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Reference sequence for assessment visits (VISIT/2026/0001) -->
<record id="seq_fusion_assessment_visit" model="ir.sequence">
<field name="name">Assessment Visit</field>
<field name="code">fusion.assessment.visit</field>
<field name="prefix">VISIT/%(year)s/</field>
<field name="padding">4</field>
<field name="company_id" eval="False"/>
</record>
</data>
</odoo>

View File

@@ -7,5 +7,6 @@ from . import adp_document
from . import assessment
from . import accessibility_assessment
from . import sale_order
from . import visit
from . import loaner_checkout
from . import pdf_template

View File

@@ -157,7 +157,14 @@ class FusionAccessibilityAssessment(models.Model):
readonly=True,
copy=False,
)
visit_id = fields.Many2one(
'fusion.assessment.visit',
string='Assessment Visit',
ondelete='set null',
index=True,
help='The home visit this accessibility assessment belongs to.',
)
# Dates
assessment_date = fields.Date(
string='Assessment Date',

View File

@@ -45,6 +45,7 @@ class FusionAssessment(models.Model):
('rollator', 'Rollator'),
('wheelchair', 'Wheelchair'),
('powerchair', 'Powerchair'),
('scooter', 'Mobility Scooter'),
], string='Equipment Type', tracking=True, index=True)
# Rollator Types
@@ -69,6 +70,31 @@ class FusionAssessment(models.Model):
('type_2', 'Adult Power Base Type 2'),
('type_3', 'Adult Power Base Type 3'),
], string='Powerchair Type')
# ===== MOBILITY SCOOTER (ADP) — 2026-06 Phase 2 =====
scooter_type = fields.Selection([
('travel_3', '3-Wheel Travel/Portable'),
('travel_4', '4-Wheel Travel/Portable'),
('standard_3', '3-Wheel Standard'),
('standard_4', '4-Wheel Standard'),
('heavy_duty', 'Heavy-Duty / Bariatric'),
], string='Scooter Type')
scooter_max_range = fields.Float(
string='Maximum Range Needed (km)', digits=(10, 1),
help='Maximum distance the client needs the scooter to travel on a charge.',
)
# ===== POWER-MOBILITY HOME ACCESSIBILITY (ADP hard rule) =====
# Applies to scooter + power wheelchair: ADP funds power mobility only if the
# device can enter and be used at the residence independently, without lifting.
x_fc_power_home_accessible = fields.Selection([
('yes', 'Yes — usable inside and outside independently'),
('no', 'No — home needs accessibility work'),
], string='Home accessible for power-mobility device?',
help='ADP will not fund a scooter / power wheelchair if the home cannot '
'take it (device left outside or in the garage). If No, the home '
'needs an accessibility product (ramp / porch lift).')
x_fc_power_home_access_notes = fields.Text(string='Home Access Notes')
# ===== EXPRESS FORM: ROLLATOR MEASUREMENTS =====
rollator_handle_height = fields.Float(string='Handle Height (inches)', digits=(10, 2))
@@ -425,7 +451,15 @@ class FusionAssessment(models.Model):
readonly=True,
copy=False,
)
visit_id = fields.Many2one(
'fusion.assessment.visit',
string='Assessment Visit',
ondelete='set null',
index=True,
help='The home visit this ADP assessment belongs to (groups multiple '
'assessments / funding workflows from one visit).',
)
# ===== COMPUTED FIELDS =====
document_count = fields.Integer(
string='Document Count',
@@ -1452,20 +1486,18 @@ class FusionAssessment(models.Model):
})
def _send_completion_notifications(self):
"""Send email notifications when assessment is completed"""
"""Notify the CLIENT that the assessment is complete.
The authorizer, sales rep and office are already emailed (with the full
assessment report) by ``_send_assessment_completed_email`` inside
``_create_draft_sale_order``. This method used to ALSO send the
authorizer a second, template-only email — that duplicate is removed;
here we only notify the client.
"""
self.ensure_one()
# Send to authorizer
if self.authorizer_id and self.authorizer_id.email:
try:
template = self.env.ref('fusion_portal.mail_template_assessment_complete_authorizer', raise_if_not_found=False)
if template:
template.send_mail(self.id, force_send=True)
_logger.info(f"Sent assessment completion email to authorizer {self.authorizer_id.email}")
except Exception as e:
_logger.error(f"Failed to send authorizer notification: {e}")
# Send to client
# Send to client (authorizer/rep/office already emailed by
# _send_assessment_completed_email in _create_draft_sale_order)
if self.client_email:
try:
template = self.env.ref('fusion_portal.mail_template_assessment_complete_client', raise_if_not_found=False)

View File

@@ -53,6 +53,17 @@ class SaleOrder(models.Model):
'sale order — stair lift, VPL, ceiling lift, ramp, bathroom mod, '
'or tub cutout visits.',
)
# Link to the assessment visit (one visit -> one SO per funding workflow).
visit_id = fields.Many2one(
'fusion.assessment.visit',
string='Assessment Visit',
readonly=True,
index=True,
help='The home visit this sale order was generated from. A visit '
'produces one sale order per funding workflow (ADP / MOD / ODSP / '
'private / ...).',
)
# Authorizer helper field (consolidates multiple possible fields)
portal_authorizer_id = fields.Many2one(

View File

@@ -0,0 +1,299 @@
# -*- coding: utf-8 -*-
"""Assessment Visit — bundles the assessments done during one home visit.
A sales rep + occupational therapist visit a client for 30-45 min and may do
several assessments (an ADP wheelchair plus accessibility products like a stair
lift, ramp, or tub cutout). The Visit is the hub that holds the client/context
ONCE and, on completion, groups its assessments by FUNDING WORKFLOW
(x_fc_sale_type) and creates ONE draft sale order per workflow — never one
combined SO, and never a separate SO per item within the same funding.
See docs/superpowers/specs/2026-06-02-assessment-visit-funding-design.md.
"""
import logging
from markupsafe import Markup
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
# Accessibility funding source -> sale.order x_fc_sale_type. Mirrors
# fusion.accessibility.assessment._create_draft_sale_order so a grouped Visit
# routes exactly the way a single accessibility assessment would.
ACCESSIBILITY_SALE_TYPE_MAP = {
'march_of_dimes': 'march_of_dimes',
'odsp': 'odsp',
'wsib': 'wsib',
'hardship': 'hardship',
'insurance': 'insurance',
'direct_private': 'direct_private',
'other': 'other',
}
class FusionAssessmentVisit(models.Model):
_name = 'fusion.assessment.visit'
_description = 'Assessment Visit'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'visit_date desc, id desc'
name = fields.Char(
string='Visit Reference', required=True, readonly=True,
copy=False, default=lambda self: _('New'),
)
state = fields.Selection(
selection=[
('measuring', 'Measuring'),
('client_pending', 'Client Details Pending'),
('done', 'Completed'),
('cancelled', 'Cancelled'),
],
default='measuring', tracking=True, copy=False,
)
# --- Shared client + context (entered once for the whole visit) ---------
partner_id = fields.Many2one('res.partner', string='Client', tracking=True)
client_name = fields.Char(string='Client Name', tracking=True)
client_phone = fields.Char(string='Phone')
client_email = fields.Char(string='Email')
client_address = fields.Char(string='Address')
visit_date = fields.Date(
string='Visit Date', default=fields.Date.context_today, tracking=True,
)
sales_rep_id = fields.Many2one(
'res.users', string='Sales Rep',
default=lambda self: self.env.user, tracking=True,
)
authorizer_id = fields.Many2one(
'res.partner', string='Occupational Therapist', tracking=True,
)
# --- March of Dimes funding context (informational; spec §4.1) ----------
# MOD covers up to $15,000 per person (lifetime), income-gated.
x_fc_income_under_mod_threshold = fields.Selection(
selection=[
('yes', 'Yes — under threshold (full $15k available)'),
('no', 'No — over threshold (may be denied / partial)'),
('unknown', 'Unknown'),
],
string='Income under MOD threshold?', default='unknown',
help='March of Dimes funds up to $15,000 per person (lifetime) when the '
"client's income is under that year's threshold; over it, MOD may "
'deny or partially approve. Reminder only — no automatic cap '
'enforcement in this version.',
)
# --- Assessments performed during this visit ----------------------------
adp_assessment_ids = fields.One2many(
'fusion.assessment', 'visit_id', string='ADP Assessments',
)
accessibility_assessment_ids = fields.One2many(
'fusion.accessibility.assessment', 'visit_id',
string='Accessibility Assessments',
)
# --- Sale orders produced — one per funding workflow --------------------
sale_order_ids = fields.One2many(
'sale.order', 'visit_id', string='Sale Orders',
)
assessment_count = fields.Integer(compute='_compute_counts')
sale_order_count = fields.Integer(compute='_compute_counts')
has_mod_items = fields.Boolean(compute='_compute_has_mod_items')
@api.depends('adp_assessment_ids', 'accessibility_assessment_ids', 'sale_order_ids')
def _compute_counts(self):
for visit in self:
visit.assessment_count = (
len(visit.adp_assessment_ids)
+ len(visit.accessibility_assessment_ids)
)
visit.sale_order_count = len(visit.sale_order_ids)
@api.depends('accessibility_assessment_ids.x_fc_funding_source')
def _compute_has_mod_items(self):
for visit in self:
visit.has_mod_items = any(
a.x_fc_funding_source == 'march_of_dimes'
for a in visit.accessibility_assessment_ids
)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('name') or vals['name'] == _('New'):
seq = self.env['ir.sequence'].next_by_code('fusion.assessment.visit')
vals['name'] = seq or _('New')
return super().create(vals_list)
# ------------------------------------------------------------------
# Completion — group assessments by funding workflow → one SO each
# ------------------------------------------------------------------
def _ensure_partner(self):
"""Resolve the client partner for the visit, reusing an assessment's
partner/_ensure_partner when one is already set."""
self.ensure_one()
if self.partner_id:
return self.partner_id
# Borrow a child assessment's partner resolution if available.
for assessment in self.accessibility_assessment_ids:
if assessment.partner_id:
return assessment.partner_id
if hasattr(assessment, '_ensure_partner'):
return assessment._ensure_partner()
for assessment in self.adp_assessment_ids:
if assessment.partner_id:
return assessment.partner_id
if self.client_name:
return self.env['res.partner'].sudo().create({
'name': self.client_name,
'phone': self.client_phone or False,
'email': self.client_email or False,
})
raise UserError(_('Set a client (or client name) on the visit first.'))
def _create_grouped_sale_order(self, partner, sale_type, accessibility_assessments):
"""Create ONE draft sale order for a set of same-funding accessibility
assessments, link them all to it, and post each one's spec to chatter.
Mirrors fusion.accessibility.assessment._create_draft_sale_order but for
a group sharing one funding workflow."""
self.ensure_one()
SaleOrder = self.env['sale.order'].sudo()
so_vals = {
'partner_id': partner.id,
'user_id': self.sales_rep_id.id if self.sales_rep_id else self.env.user.id,
'state': 'draft',
'origin': _('Visit %s (%s)') % (self.name, sale_type),
'x_fc_sale_type': sale_type,
'visit_id': self.id,
}
if self.authorizer_id:
so_vals['x_fc_authorizer_id'] = self.authorizer_id.id
# MOD: pre-fill the accessibility specialist from the sales rep.
if sale_type == 'march_of_dimes' and self.sales_rep_id and self.sales_rep_id.partner_id:
so_vals['x_fc_mod_accessibility_specialist_id'] = self.sales_rep_id.partner_id.id
sale_order = SaleOrder.create(so_vals)
for assessment in accessibility_assessments:
assessment.sale_order_id = sale_order.id
assessment._add_assessment_tag(sale_order)
sale_order.message_post(
body=Markup(assessment._format_assessment_html_table()),
message_type='comment',
subtype_xmlid='mail.mt_note',
)
assessment.write({'state': 'completed'})
# One completion notification per SO (not per assessment) — mirrors the
# standalone accessibility completion's office email.
if accessibility_assessments:
try:
accessibility_assessments[0]._send_completion_email(sale_order)
except Exception as e:
_logger.warning(
"Visit %s: completion email failed for %s: %s",
self.name, sale_order.name, e,
)
_logger.info(
"Visit %s created %s sale order %s grouping %d accessibility assessment(s)",
self.name, sale_type, sale_order.name, len(accessibility_assessments),
)
return sale_order
def _validate_adp_combination(self, adp_assessments):
"""Enforce ADP device-combination rules: at most one seated-mobility
device (manual wheelchair / power wheelchair / scooter), optionally one
walker/rollator, no duplicates."""
seated_types = {'wheelchair', 'powerchair', 'scooter'}
seated = [a for a in adp_assessments if a.equipment_type in seated_types]
walkers = [a for a in adp_assessments if a.equipment_type == 'rollator']
labels = dict(self.env['fusion.assessment']._fields['equipment_type'].selection)
if len(seated) > 1:
raise UserError(_(
'An ADP order can include only one seated-mobility device '
'(manual wheelchair, power wheelchair, or scooter). This visit has: %s.'
) % ', '.join(labels.get(a.equipment_type, a.equipment_type) for a in seated))
if len(walkers) > 1:
raise UserError(_('An ADP order can include only one walker / rollator.'))
def _assessment_sale_type(self, adp_assessment):
"""Funding workflow key for an ADP equipment assessment, mirroring
fusion.assessment._create_draft_sale_order: ADP+ODSP when the client
type is an ODSP stream, plain ADP otherwise. ADP devices that share a
key are grouped onto one sale order."""
if adp_assessment.client_type in ('ods', 'acs', 'owp'):
return 'adp_odsp'
return 'adp'
def action_complete_visit(self):
"""Group the visit's accessibility assessments by funding workflow and
create one draft SO per workflow. ADP equipment assessments keep their
existing one-assessment-one-SO completion for now (ADP multi-device
grouping arrives in Phase 2)."""
self.ensure_one()
if self.state == 'done':
raise UserError(_('This visit is already completed.'))
if not (self.accessibility_assessment_ids or self.adp_assessment_ids):
raise UserError(_('Add at least one assessment before completing the visit.'))
partner = self._ensure_partner()
# Group accessibility assessments by their funding -> sale type.
by_sale_type = {}
for assessment in self.accessibility_assessment_ids:
if assessment.sale_order_id:
continue # already has an SO; don't duplicate
sale_type = ACCESSIBILITY_SALE_TYPE_MAP.get(
assessment.x_fc_funding_source, 'direct_private')
by_sale_type.setdefault(sale_type, []).append(assessment)
for sale_type, group in by_sale_type.items():
self._create_grouped_sale_order(partner, sale_type, group)
# ADP equipment assessments: one ADP order per funding type, with the
# device-combination guard, reusing the existing (prod-tested) express
# completion. The first device creates the SO; the rest attach to it.
adp_by_type = {}
for assessment in self.adp_assessment_ids:
if assessment.sale_order_id:
continue
adp_by_type.setdefault(self._assessment_sale_type(assessment), []).append(assessment)
labels = dict(self.env['fusion.assessment']._fields['equipment_type'].selection)
for sale_type, group in adp_by_type.items():
self._validate_adp_combination(group)
# Make sure each device carries the visit's client + OT so the
# existing completion logic has what it needs.
for assessment in group:
vals = {}
if not assessment.client_name:
vals['client_name'] = self.client_name or partner.name
if not assessment.authorizer_id and self.authorizer_id:
vals['authorizer_id'] = self.authorizer_id.id
if not assessment.partner_id:
vals['partner_id'] = partner.id
if vals:
assessment.write(vals)
primary = group[0]
sale_order = primary.action_complete_express()
sale_order.write({'visit_id': self.id, 'x_fc_sale_type': sale_type})
for extra in group[1:]:
extra.write({'state': 'completed', 'sale_order_id': sale_order.id})
sale_order.message_post(
body=Markup('<p><strong>Additional ADP device on this order:</strong> %s</p>')
% labels.get(extra.equipment_type, extra.equipment_type or 'device'),
subtype_xmlid='mail.mt_note',
)
self.write({'state': 'done', 'partner_id': partner.id})
return self._action_view_sale_orders()
def _action_view_sale_orders(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Visit Sale Orders'),
'res_model': 'sale.order',
'view_mode': 'list,form',
'domain': [('visit_id', '=', self.id)],
'context': {'create': False},
}

View File

@@ -7,6 +7,8 @@ access_fusion_assessment_user,fusion.assessment.user,model_fusion_assessment,bas
access_fusion_assessment_portal,fusion.assessment.portal,model_fusion_assessment,base.group_portal,1,1,1,0
access_fusion_accessibility_assessment_user,fusion.accessibility.assessment.user,model_fusion_accessibility_assessment,base.group_user,1,1,1,1
access_fusion_accessibility_assessment_portal,fusion.accessibility.assessment.portal,model_fusion_accessibility_assessment,base.group_portal,1,1,1,0
access_fusion_assessment_visit_user,fusion.assessment.visit.user,model_fusion_assessment_visit,base.group_user,1,1,1,1
access_fusion_assessment_visit_portal,fusion.assessment.visit.portal,model_fusion_assessment_visit,base.group_portal,1,1,1,0
access_fusion_pdf_template_user,fusion.pdf.template.user,model_fusion_pdf_template,base.group_user,1,1,1,1
access_fusion_pdf_template_preview_user,fusion.pdf.template.preview.user,model_fusion_pdf_template_preview,base.group_user,1,1,1,1
access_fusion_pdf_template_field_user,fusion.pdf.template.field.user,model_fusion_pdf_template_field,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
7 access_fusion_assessment_portal fusion.assessment.portal model_fusion_assessment base.group_portal 1 1 1 0
8 access_fusion_accessibility_assessment_user fusion.accessibility.assessment.user model_fusion_accessibility_assessment base.group_user 1 1 1 1
9 access_fusion_accessibility_assessment_portal fusion.accessibility.assessment.portal model_fusion_accessibility_assessment base.group_portal 1 1 1 0
10 access_fusion_assessment_visit_user fusion.assessment.visit.user model_fusion_assessment_visit base.group_user 1 1 1 1
11 access_fusion_assessment_visit_portal fusion.assessment.visit.portal model_fusion_assessment_visit base.group_portal 1 1 1 0
12 access_fusion_pdf_template_user fusion.pdf.template.user model_fusion_pdf_template base.group_user 1 1 1 1
13 access_fusion_pdf_template_preview_user fusion.pdf.template.preview.user model_fusion_pdf_template_preview base.group_user 1 1 1 1
14 access_fusion_pdf_template_field_user fusion.pdf.template.field.user model_fusion_pdf_template_field base.group_user 1 1 1 1

View File

@@ -448,6 +448,42 @@
font-size: 1.25rem;
}
/* Start a Visit Card on Portal Home (distinct blue->indigo so it differs from
the green New Assessment tile). Mirrors .portal-new-assessment-card. */
.portal-visit-card {
background: linear-gradient(135deg, #2e7aad 0%, #4338ca 100%) !important;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.portal-visit-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(67, 56, 202, 0.3) !important;
}
.portal-visit-card .card-body {
background: transparent !important;
}
.portal-visit-card h5,
.portal-visit-card small {
color: #fff !important;
}
.portal-visit-card .icon-circle {
width: 50px;
height: 50px;
background: rgba(255,255,255,0.25) !important;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.portal-visit-card .icon-circle i {
color: #fff !important;
font-size: 1.25rem;
}
/* Authorizer Portal Card on Portal Home */
.portal-authorizer-card {
background: var(--fc-portal-gradient, linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%)) !important;

View File

@@ -388,6 +388,7 @@
<small class="text-muted">Determines which sale order / funding workflow this case enters.</small>
</div>
</div>
<input type="hidden" name="visit_id" id="acc_visit_id"/>
</div>
</div>
</template>
@@ -647,6 +648,15 @@
// Fallback if Google Maps not loaded
window.initAddressAutocomplete = window.initAddressAutocomplete || function() {};
// Carry visit_id from the workspace launch (?visit_id=) into the form
(function() {
var _vid = new URLSearchParams(window.location.search).get('visit_id');
if (_vid) {
var f = document.getElementById('acc_visit_id');
if (f) { f.value = _vid; }
}
})();
// Form submission
function saveAssessment(createSaleOrder) {
var form = document.getElementById('accessibility_form');

View File

@@ -85,7 +85,19 @@
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="hidden" name="assessment_id" t-att-value="assessment.id if assessment else ''"/>
<input type="hidden" name="current_page" value="1"/>
<input type="hidden" name="visit_id" id="express_visit_id" t-att-value="visit_id or ''"/>
<!-- Part of an assessment visit: completing returns to the visit, which groups
this device with the rest into one funding-routed ADP order. -->
<div t-if="visit_id" class="alert alert-info d-flex align-items-start gap-2 mb-3">
<i class="fa fa-clipboard mt-1"/>
<div>
<strong>Part of an assessment visit.</strong>
Completing this device returns you to the visit — it is grouped with the
visit's other ADP devices into a single sale order when you complete the visit.
</div>
</div>
<!-- Equipment Selection Section -->
<div class="card shadow-sm">
<div class="card-body">
@@ -98,6 +110,7 @@
<option value="rollator" t-att-selected="assessment.equipment_type == 'rollator' if assessment else False">Rollator</option>
<option value="wheelchair" t-att-selected="assessment.equipment_type == 'wheelchair' if assessment else False">Wheelchair</option>
<option value="powerchair" t-att-selected="assessment.equipment_type == 'powerchair' if assessment else False">Powerchair</option>
<option value="scooter" t-att-selected="assessment.equipment_type == 'scooter' if assessment else False">Mobility Scooter</option>
</select>
</div>
@@ -688,8 +701,62 @@
<label class="form-label fw-bold">Additional Information/Customization</label>
<textarea name="additional_customization" class="form-control powerchair-field" rows="4" placeholder="Enter any additional requirements or customization notes..."><t t-esc="assessment.additional_customization if assessment else ''"/></textarea>
</div>
<!-- Power-mobility home-accessibility — ADP hard rule -->
<div class="mb-4 p-3 border rounded bg-light">
<label class="form-label fw-bold">Home accessible for the device — inside &amp; outside?</label>
<select name="x_fc_power_home_accessible" class="form-control">
<option value="">-- Select --</option>
<option value="yes" t-att-selected="assessment.x_fc_power_home_accessible == 'yes' if assessment else False">Yes — usable inside and outside independently</option>
<option value="no" t-att-selected="assessment.x_fc_power_home_accessible == 'no' if assessment else False">No — home needs accessibility work</option>
</select>
<div class="alert alert-warning mt-2 mb-0">
<i class="fa fa-exclamation-triangle"/> ADP funds power mobility only if the device can enter and be used at the residence <strong>independently, without lifting</strong> (not left outside / in the garage). If <strong>No</strong>, add an accessibility assessment (ramp / porch lift) for the home.
</div>
<textarea name="x_fc_power_home_access_notes" class="form-control mt-2" rows="2" placeholder="Access notes (entry steps, garage, thresholds, turning space...)"><t t-esc="assessment.x_fc_power_home_access_notes if assessment else ''"/></textarea>
</div>
</div>
<!-- ===== MOBILITY SCOOTER ===== -->
<div id="scooter_form" class="equipment-form" style="display: none;">
<h2 class="text-center fw-bold text-uppercase mb-4">Mobility Scooter Assessment</h2>
<div class="mb-4">
<label class="form-label fw-bold">Scooter Type</label>
<select name="scooter_type" class="form-select">
<option value="">-- Select Type --</option>
<option value="travel_3" t-att-selected="assessment.scooter_type == 'travel_3' if assessment else False">3-Wheel Travel/Portable</option>
<option value="travel_4" t-att-selected="assessment.scooter_type == 'travel_4' if assessment else False">4-Wheel Travel/Portable</option>
<option value="standard_3" t-att-selected="assessment.scooter_type == 'standard_3' if assessment else False">3-Wheel Standard</option>
<option value="standard_4" t-att-selected="assessment.scooter_type == 'standard_4' if assessment else False">4-Wheel Standard</option>
<option value="heavy_duty" t-att-selected="assessment.scooter_type == 'heavy_duty' if assessment else False">Heavy-Duty / Bariatric</option>
</select>
</div>
<div class="row mb-3">
<div class="col-12 col-md-6 mb-3">
<label class="form-label fw-bold">Maximum Range Needed (km)</label>
<div class="input-group">
<input type="number" step="1" name="scooter_max_range" class="form-control"
t-att-value="assessment.scooter_max_range if assessment else ''"/>
<span class="input-group-text">km</span>
</div>
</div>
</div>
<!-- Power-mobility home-accessibility — ADP hard rule -->
<div class="mb-4 p-3 border rounded bg-light">
<label class="form-label fw-bold">Home accessible for the device — inside &amp; outside?</label>
<select name="x_fc_power_home_accessible" class="form-control">
<option value="">-- Select --</option>
<option value="yes" t-att-selected="assessment.x_fc_power_home_accessible == 'yes' if assessment else False">Yes — usable inside and outside independently</option>
<option value="no" t-att-selected="assessment.x_fc_power_home_accessible == 'no' if assessment else False">No — home needs accessibility work</option>
</select>
<div class="alert alert-warning mt-2 mb-0">
<i class="fa fa-exclamation-triangle"/> ADP funds power mobility only if the device can enter and be used at the residence <strong>independently, without lifting</strong> (not left outside / in the garage). If <strong>No</strong>, add an accessibility assessment (ramp / porch lift) for the home.
</div>
<textarea name="x_fc_power_home_access_notes" class="form-control mt-2" rows="2" placeholder="Access notes (entry steps, garage, thresholds, turning space...)"><t t-esc="assessment.x_fc_power_home_access_notes if assessment else ''"/></textarea>
</div>
</div>
</div>
</div>
@@ -1191,9 +1258,10 @@
<div class="card shadow-sm">
<div class="card-body text-center">
<button type="submit" name="action" value="submit" class="btn btn-success btn-lg px-5">
<i class="fa fa-check me-2"/>Submit Assessment
<t t-if="visit_id"><i class="fa fa-clipboard me-2"/>Save to Visit</t>
<t t-else=""><i class="fa fa-check me-2"/>Submit Assessment</t>
</button>
<a href="/my/assessments" class="btn btn-outline-secondary btn-lg px-4 ms-3">
<a t-att-href="('/my/visit/%s' % visit_id) if visit_id else '/my/assessments'" class="btn btn-outline-secondary btn-lg px-4 ms-3">
Cancel
</a>
</div>
@@ -1278,6 +1346,7 @@
var rollatorForm = document.getElementById('rollator_form');
var wheelchairForm = document.getElementById('wheelchair_form');
var powerchairForm = document.getElementById('powerchair_form');
var scooterForm = document.getElementById('scooter_form');
var wheelchairTypeSelect = document.querySelector('select[name="wheelchair_type"]');
var reasonSelect = document.getElementById('reason_for_application');
var previousFundingContainer = document.getElementById('previous_funding_date_container');
@@ -1339,13 +1408,16 @@
disableFormInputs(rollatorForm);
disableFormInputs(wheelchairForm);
disableFormInputs(powerchairForm);
disableFormInputs(scooterForm);
if (value === 'rollator') {
enableFormInputs(rollatorForm);
} else if (value === 'wheelchair') {
enableFormInputs(wheelchairForm);
} else if (value === 'powerchair') {
enableFormInputs(powerchairForm);
} else if (value === 'scooter') {
enableFormInputs(scooterForm);
}
}

View File

@@ -50,6 +50,25 @@
<!-- Main Action Tiles - Professional Grid Layout -->
<div class="row g-3 mb-4">
<!-- 0. Start a Visit (Sales Rep) - bundle multiple assessments from one home visit -->
<t t-if="request.env.user.partner_id.is_sales_rep_portal">
<div class="col-md-6">
<a href="/my/visit/new" class="card h-100 border-0 shadow-sm text-decoration-none portal-visit-card" style="border-radius: 12px; min-height: 100px;">
<div class="card-body d-flex align-items-center p-4">
<div class="me-3">
<div class="icon-circle">
<i class="fa fa-clipboard"/>
</div>
</div>
<div>
<h5 class="mb-1">Start a Visit</h5>
<small>Bundle several assessments from one home visit into funding-routed orders</small>
</div>
</div>
</a>
</div>
</t>
<!-- 1. New Express Assessment (Sales Rep) - Featured tile -->
<t t-if="request.env.user.partner_id.is_sales_rep_portal">
<div class="col-md-6">

View File

@@ -0,0 +1,115 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<template id="portal_visit_workspace" name="Assessment Visit Workspace">
<t t-call="portal.portal_layout">
<t t-set="no_breadcrumbs" t-value="True"/>
<div class="container py-4">
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/my">Dashboard</a></li>
<li class="breadcrumb-item active">Assessment Visit</li>
</ol>
</nav>
<t t-if="error">
<div class="alert alert-danger"><i class="fa fa-exclamation-circle"/> <t t-esc="error"/></div>
</t>
<div class="d-flex justify-content-between align-items-center mb-3">
<h3 class="mb-0"><i class="fa fa-clipboard text-primary"/> Visit <t t-esc="visit.name"/></h3>
<span class="badge bg-secondary"><span t-field="visit.state"/></span>
</div>
<t t-if="visit.state == 'done'">
<div class="alert alert-success">
<strong>Visit completed.</strong> Sale orders created:
<ul class="mb-0">
<t t-foreach="visit.sale_order_ids" t-as="so">
<li><a t-attf-href="/my/sales/case/{{so.id}}"><t t-esc="so.name"/></a><span t-field="so.x_fc_sale_type"/></li>
</t>
</ul>
</div>
</t>
<!-- Assessments added this visit -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white"><strong>Assessments this visit</strong> (<t t-esc="visit.assessment_count"/>)</div>
<div class="card-body">
<p t-if="not visit.assessment_count" class="text-muted mb-0">
Nothing added yet — use the buttons below to add what you're assessing.
</p>
<ul class="list-group" t-if="visit.assessment_count">
<t t-foreach="visit.adp_assessment_ids" t-as="a">
<li class="list-group-item d-flex justify-content-between align-items-center">
<span><i class="fa fa-wheelchair text-primary"/> ADP — <span t-field="a.equipment_type"/></span>
<span class="badge bg-light text-dark"><span t-field="a.state"/></span>
</li>
</t>
<t t-foreach="visit.accessibility_assessment_ids" t-as="a">
<li class="list-group-item d-flex justify-content-between align-items-center">
<span><span t-field="a.assessment_type"/><span t-field="a.x_fc_funding_source"/></span>
<span class="badge bg-light text-dark"><span t-field="a.state"/></span>
</li>
</t>
</ul>
</div>
</div>
<t t-if="visit.state != 'done'">
<!-- Add assessment -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white"><strong>+ Add assessment</strong></div>
<div class="card-body d-flex flex-wrap gap-2">
<a class="btn btn-outline-primary" t-attf-href="/my/assessment/express?visit_id={{visit.id}}">Wheelchair / ADP</a>
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/stairlift/straight?visit_id={{visit.id}}">Straight Stair Lift</a>
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/stairlift/curved?visit_id={{visit.id}}">Curved Stair Lift</a>
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/vpl?visit_id={{visit.id}}">Platform / Porch Lift</a>
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/ceiling-lift?visit_id={{visit.id}}">Ceiling Lift</a>
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/ramp?visit_id={{visit.id}}">Custom Ramp</a>
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/bathroom?visit_id={{visit.id}}">Bathroom Mod</a>
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/tub-cutout?visit_id={{visit.id}}">Tub Cutout</a>
</div>
</div>
<!-- Client details (deferred) -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white"><strong>Client details</strong>
<span class="text-muted small">— fill in after the therapist leaves</span></div>
<div class="card-body">
<form t-attf-action="/my/visit/{{visit.id}}/save" method="post">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<div class="row">
<div class="col-md-6 mb-3"><label class="form-label">Client Name</label>
<input type="text" name="client_name" class="form-control" t-att-value="visit.client_name"/></div>
<div class="col-md-6 mb-3"><label class="form-label">Phone</label>
<input type="text" name="client_phone" class="form-control" t-att-value="visit.client_phone"/></div>
<div class="col-md-6 mb-3"><label class="form-label">Email</label>
<input type="email" name="client_email" class="form-control" t-att-value="visit.client_email"/></div>
<div class="col-md-6 mb-3"><label class="form-label">Address</label>
<input type="text" name="client_address" class="form-control" t-att-value="visit.client_address"/></div>
</div>
<div class="mb-3" t-if="visit.has_mod_items">
<label class="form-label">Income under March of Dimes threshold?
<span class="text-muted small">(MOD covers up to $15k/person, lifetime)</span></label>
<select name="x_fc_income_under_mod_threshold" class="form-select">
<option value="unknown" t-att-selected="visit.x_fc_income_under_mod_threshold == 'unknown'">Unknown</option>
<option value="yes" t-att-selected="visit.x_fc_income_under_mod_threshold == 'yes'">Yes — under threshold (full $15k)</option>
<option value="no" t-att-selected="visit.x_fc_income_under_mod_threshold == 'no'">No — over threshold (may be denied/partial)</option>
</select>
</div>
<button type="submit" class="btn btn-secondary">Save client details</button>
</form>
</div>
</div>
<!-- Complete -->
<form t-attf-action="/my/visit/{{visit.id}}/complete" method="post" t-if="visit.assessment_count">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<button type="submit" class="btn btn-primary btn-lg">Complete visit &amp; create sale orders &#8594;</button>
<p class="text-muted small mt-2">Creates one sale order per funding workflow (ADP / March of Dimes / private / ...).</p>
</form>
</t>
</div>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Form -->
<record id="view_fusion_assessment_visit_form" model="ir.ui.view">
<field name="name">fusion.assessment.visit.form</field>
<field name="model">fusion.assessment.visit</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_complete_visit" type="object"
string="Complete Visit &amp; Create Sale Orders"
class="btn-primary" invisible="state == 'done'"/>
<field name="state" widget="statusbar"
statusbar_visible="measuring,client_pending,done"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name" readonly="1"/></h1>
</div>
<group>
<group string="Client">
<field name="partner_id"/>
<field name="client_name"/>
<field name="client_phone"/>
<field name="client_email"/>
</group>
<group string="Visit">
<field name="visit_date"/>
<field name="sales_rep_id"/>
<field name="authorizer_id"/>
<field name="has_mod_items" invisible="1"/>
<field name="x_fc_income_under_mod_threshold"
invisible="not has_mod_items"/>
</group>
</group>
<div class="alert alert-info" role="alert" invisible="not has_mod_items">
<strong>March of Dimes:</strong> covers up to $15,000 per person
(lifetime), income-gated. Confirm the client's income status above.
</div>
<notebook>
<page string="Accessibility Assessments">
<field name="accessibility_assessment_ids" readonly="1">
<list>
<field name="reference"/>
<field name="assessment_type"/>
<field name="x_fc_funding_source"/>
<field name="state"/>
<field name="sale_order_id"/>
</list>
</field>
</page>
<page string="ADP Assessments">
<field name="adp_assessment_ids" readonly="1">
<list>
<field name="equipment_type"/>
<field name="state"/>
<field name="sale_order_id"/>
</list>
</field>
</page>
<page string="Sale Orders">
<field name="sale_order_ids" readonly="1">
<list>
<field name="name"/>
<field name="x_fc_sale_type"/>
<field name="state"/>
<field name="amount_total"/>
</list>
</field>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- List -->
<record id="view_fusion_assessment_visit_list" model="ir.ui.view">
<field name="name">fusion.assessment.visit.list</field>
<field name="model">fusion.assessment.visit</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="visit_date"/>
<field name="partner_id"/>
<field name="client_name"/>
<field name="sales_rep_id"/>
<field name="assessment_count"/>
<field name="sale_order_count"/>
<field name="state"/>
</list>
</field>
</record>
<!-- Action + menu -->
<record id="action_fusion_assessment_visit" model="ir.actions.act_window">
<field name="name">Assessment Visits</field>
<field name="res_model">fusion.assessment.visit</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_fusion_assessment_visit"
name="Assessment Visits"
parent="sale.sale_menu_root"
action="action_fusion_assessment_visit"
sequence="50"/>
</odoo>