Sized the real serial-tracked install base on sale.order.line: ~138 units / ~136 customers across all funders (walkers 68, wheelchairs 45, power bases 7, scooters 4, +14 with no ADP device_type). Serial# is captured ~only on equipment, so it doubles as a trackable-unit marker. ADP-only gating misses ~28 units (direct_private/adp_odsp/march_of_dimes) -> bridge should key on serial, funder-agnostic. Flags two data gaps (no-device_type units; non-ADP units lacking delivery_date) and reframes the MVP open question as volume (walkers/chairs) vs margin (powered units). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
195 lines
14 KiB
Markdown
195 lines
14 KiB
Markdown
# fusion_maintenance — Brainstorm & Handoff Brief
|
||
|
||
> Status: **research/brainstorm only — no code, no final decisions.** Written from a
|
||
> Claude Code *web* session that could **not** reach the private network (no Tailscale,
|
||
> no docker daemon, Supabase KB unreachable). Resume from a **Tailscale-connected env**
|
||
> (dev box or a host that can reach Westin production) and do the live inspection in
|
||
> Step 0 **before** committing to the design.
|
||
|
||
## Goal (user's words, paraphrased)
|
||
Automated maintenance follow-ups for mobility/accessibility equipment we've sold, to turn
|
||
service into **recurring revenue**. Reminder emails → client books maintenance → booking
|
||
happens in **real time** and **lands in our calendar**. Leverage Odoo Enterprise's
|
||
appointment system. Decide whether this lives in `fusion_repairs` or a new module — the
|
||
result must be **seamless and production-ready**.
|
||
|
||
## Decisions locked with the user (this session)
|
||
- **Same DB**: `fusion_claims` + `fusion_repairs` run on one database → new module may depend on both.
|
||
- **Enterprise `appointment` is available** → build real-time booking ON it (`appointment.type` /
|
||
`appointment.slot` / `calendar.event`), do **not** hand-roll a calendar.
|
||
- **Public self-serve booking** → reminder email carries a token link to a no-login slot picker
|
||
(extend the existing `/repairs/maintenance/book/<token>` pattern). Elderly clients shouldn't log in.
|
||
- **Target box for grounding = Westin production** (where `fusion_claims` runs day-to-day).
|
||
|
||
## Key findings from repo exploration
|
||
|
||
### `fusion_repairs` (v19.0.2.2.6) ALREADY has a maintenance engine — reuse it, don't fork
|
||
- `fusion.repair.maintenance.contract`: interval, due/last-service dates, state machine.
|
||
Auto-spawned on SO confirm when `product.template.x_fc_maintenance_interval_months > 0`.
|
||
- Daily reminder cron `cron_maintenance_due_reminders` → 30/7/1-day bands → branded email
|
||
`email_template_maintenance_due_reminder` with tokenized link `/repairs/maintenance/book/<token>`.
|
||
- Booking controller: `controllers/portal_maintenance_booking.py` — **single date-confirm form,
|
||
NO slot availability, NO conflict check, NO calendar event.** ← this is the real gap.
|
||
- Contract **roll-forward** on technician-task completion (`next_due_date += interval`).
|
||
- `fusion.repair.service.plan.subscription`: pre-paid visit plans (recurring-revenue primitive).
|
||
- Deps: `repair, maintenance, sale_management, stock, purchase, website, portal, fusion_tasks,
|
||
fusion_poynt, fusion_authorizer_portal`. ~8.3k LOC, 25+ models.
|
||
|
||
### `fusion_claims` (v19.0.9.2.0) is the ideal trigger source
|
||
- Claim container = `sale.order` (`x_fc_sale_type`: adp, odsp, wsib, insurance, march_of_dimes, …).
|
||
- **Equipment unit** = `sale.order.line.x_fc_serial_number` + `product_id`.
|
||
- **Equipment category** = `fusion.adp.device.code.device_type` (wheelchair, walker, hospital bed,
|
||
stair lift, porch lift, custom ramp, …) — matches the user's "sale groups".
|
||
- **Schedule anchors**: `x_fc_adp_delivery_date`, `x_fc_service_start_date`; gate on `x_fc_adp_approved`.
|
||
- Customer = `sale.order.partner_id`; prescriber = `x_fc_authorizer_id`.
|
||
- Already depends on `calendar, fusion_tasks, ai, fusion_ringcentral`.
|
||
|
||
## Proposed architecture (PENDING live verification)
|
||
**New module `fusion_maintenance`** depending on `fusion_repairs`, `fusion_claims`, `appointment`.
|
||
Reuses the existing contract/reminder/roll-forward engine; adds the 3 genuinely-missing pieces:
|
||
|
||
1. **`fusion.maintenance.policy`** (ops-configurable, no code per category):
|
||
`device_type` → `interval_months`, reminder bands, `service_product_id` (priced visit),
|
||
`appointment_type_id`, required technician skill. Turns "stair lift = 6 mo, $X" into data.
|
||
2. **Claims bridge** (daily cron): scan `fusion_claims` `sale.order.line` for delivered+approved
|
||
devices whose `device_type` matches an active policy → ensure a maintenance contract exists,
|
||
anchored at `delivery_date + interval`. Idempotent (key on serial / sale-line). Extend the
|
||
reused contract with `x_fc_source_claim_line_id`, `x_fc_device_type`, `x_fc_policy_id` so the
|
||
repairs path and claims path both feed **one** contract model.
|
||
3. **Real-time booking on `appointment`**: token link → slot picker backed by `appointment.type`
|
||
(partner pre-resolved from token, no login). Slot pick → real `calendar.event` → hook spawns
|
||
`repair.order` + technician task, assigns by skill/zone, advances reminder band, rolls contract
|
||
forward.
|
||
|
||
**Recurring revenue**: each policy carries `service_product_id` → booked visit drafts a priced
|
||
SO/invoice; optional pre-paid annual plan via existing `service.plan.subscription`; optional
|
||
door payment via existing `fusion_poynt`.
|
||
|
||
## STEP 0 — run on Westin production FIRST (grounding before any decision)
|
||
> Replace `APP`/`DB` with the real Westin container + database. CLAUDE.md rule #1: never code
|
||
> from memory — read the real Enterprise `appointment` source before building the booking layer.
|
||
|
||
```bash
|
||
# RESOLVED 2026-06-02 — Westin Odoo prod migrated OFF Digital Ocean onto the on-prem Proxmox
|
||
# cluster. Old DO IPs (152.42.146.204 / 178.128.229.92) are DEAD (:22 timeout). Live box:
|
||
# host `odoo-westin` = 192.168.1.40 via the `supabase-prod` Tailscale jump (Windows OpenSSH
|
||
# ProxyCommand → run `ssh odoo-westin ...` from PowerShell). App container `odoo-dev-app`
|
||
# (odoo:19, Enterprise); DB container `odoo-dev-db`; DB `westin-v19`; user `odoo` (local-socket
|
||
# trust inside odoo-dev-db). Enterprise addons → /mnt/enterprise-addons, custom → /mnt/extra-addons.
|
||
# SQL: ssh odoo-westin 'docker exec odoo-dev-db psql -U odoo -d westin-v19 -c "..."'
|
||
# FS read: ssh odoo-westin 'docker exec odoo-dev-app sed -n 1,160p /mnt/enterprise-addons/...'
|
||
APP=odoo-dev-app ; DB=westin-v19 ; DBC=odoo-dev-db
|
||
|
||
# 1) Install matrix — confirm same-DB + Enterprise appointment present + versions
|
||
docker exec "$APP" psql -U odoo -d "$DB" -c \
|
||
"SELECT name,state,latest_version FROM ir_module_module \
|
||
WHERE name IN ('fusion_claims','fusion_repairs','fusion_maintenance','calendar','maintenance','repair') \
|
||
OR name LIKE 'appointment%' ORDER BY name;"
|
||
|
||
# 2) Real device_type distribution (drives per-category policies)
|
||
docker exec "$APP" psql -U odoo -d "$DB" -c \
|
||
"SELECT device_type, count(*) FROM fusion_adp_device_code GROUP BY device_type ORDER BY 2 DESC;"
|
||
|
||
# 3) Locate the Enterprise appointment source (read, don't guess the API)
|
||
docker exec "$APP" bash -lc 'ls -d /mnt/enterprise-addons/appointment 2>/dev/null || \
|
||
find / -maxdepth 6 -type d -name appointment 2>/dev/null | grep -i addons | head'
|
||
|
||
# 4) Appointment model surface to build booking on (adjust path from #3)
|
||
docker exec "$APP" cat <appointment_path>/models/appointment_type.py | head -160
|
||
docker exec "$APP" ls <appointment_path>/controllers/ # find the public booking controller
|
||
|
||
# 5) How fusion_repairs maintenance contracts already look in live data
|
||
docker exec "$APP" psql -U odoo -d "$DB" -c \
|
||
"SELECT state, count(*) FROM fusion_repair_maintenance_contract GROUP BY state;"
|
||
```
|
||
|
||
## STEP 0 — RESULTS (ran 2026-06-02 against Westin prod `westin-v19`)
|
||
> Grounding facts only — **no design decisions made**. These correct several assumptions above.
|
||
|
||
**Connection (resolved):** host `odoo-westin` (192.168.1.40) via the `supabase-prod` Tailscale jump.
|
||
App container `odoo-dev-app` (odoo:19, Enterprise), DB container `odoo-dev-db`, DB `westin-v19`,
|
||
user `odoo`. Old Digital Ocean boxes are DEAD — Westin migrated on-prem.
|
||
|
||
**1) Install matrix** — `appointment` **19.0.1.3 installed** (+ `appointment_account_payment`,
|
||
`_crm`, `_hr`, `_microsoft_calendar`, `_sms`). All deps present: `calendar`, `maintenance`, `repair`,
|
||
`sale_management`, `portal`, `website`, `resource`, `phone_validation`, `web_gantt`. `fusion_claims`
|
||
**19.0.9.2.0 installed**. `fusion_repairs` and `fusion_maintenance` are **absent entirely** (no
|
||
records). → a module depending on `appointment` installs cleanly; "reuse the fusion_repairs engine"
|
||
means *deploy fusion_repairs to Westin first* (heavy) **or** own a lean contract model here. Note
|
||
Odoo's native `maintenance` (CMMS) is installed — an under-considered third reuse option.
|
||
|
||
**2) device_type** — 119 distinct values, but `fusion.adp.device.code` is the ADP billing-code
|
||
**CATALOG** (`_order='device_type, device_code'`), so counts are catalog codes per type, **NOT units
|
||
installed**. Top entries are seating COMPONENTS (Seat Cushion 564, Back Support 375, Headrest 193).
|
||
The maintainable **equipment classes** ≈ wheelchairs (manual + power tilt), power bases, power
|
||
scooters, wheeled walkers / walking frames, paediatric standing frames, specialty strollers (~6-8
|
||
clean categories). → `device_type` can't be a 1:1 policy key (119 values, mostly parts); needs a
|
||
grouping/whitelist. **Real install base sized on `sale.order.line`** (`x_fc_adp_device_type` [stored compute from
|
||
product's `x_fc_adp_device_code_id.device_type`], `x_fc_serial_number`, `x_fc_adp_approved`; delivery
|
||
dates `x_fc_adp_delivery_date` / `x_fc_service_start_date`) — **see the Install-base sizing block below.**
|
||
|
||
**3) + 4) Enterprise appointment source** — `/mnt/enterprise-addons/appointment`. The no-login token
|
||
slot-picker is **mostly NATIVE — don't hand-roll it**: public booking (`auth="public"`), invite
|
||
tokens (`appointment.invite`, `/appointment/<id>?…invite_token`), live availability
|
||
(`/appointment/<id>/update_available_slots`, jsonrpc/public), slot submit → real `calendar.event`
|
||
(`/appointment/<id>/submit`), auto/manual staff+resource assignment, capacity, booked/cancelled mail
|
||
templates. Model `appointment.type`; controller `controllers/appointment.py`. → the module mainly
|
||
needs to: seed an `appointment.type` per category, drop a partner-bound invite link into the reminder
|
||
email, and hook `calendar.event` create → spawn the service task + advance the contract.
|
||
`appointment_account_payment` is installed → native pay-to-book is on the table for the revenue mechanic.
|
||
|
||
**5) Maintenance-contract state** — `relation "fusion_repair_maintenance_contract" does not exist`
|
||
→ confirms the fusion_repairs maintenance engine is **not** on Westin.
|
||
|
||
**Headline correction:** Westin's ADP data has **zero** stair lifts / porch lifts / ramps / hospital
|
||
beds — those belong to the fusion_repairs / EN-Tech (mobility) domain. Westin's recurring-revenue
|
||
play is **wheelchairs / power bases / scooters / walkers / seating**. Open questions updated below.
|
||
|
||
**Install-base sizing (ran 2026-06-02 — the REAL units, complementing #2's catalog counts).** Big tell:
|
||
serial numbers are captured **~only on actual equipment** (every part/option/mod device_type shows 0
|
||
serials), so `x_fc_serial_number` is already a de-facto "trackable unit" marker — convenient, because the
|
||
bridge's idempotency key is the serial.
|
||
|
||
- **Addressable base ≈ 138 serial-tracked units across ~136 customers** (all funders). By equipment
|
||
family (serial-tracked / of which delivered): **Walkers & walking frames 68 (55)**, **Wheelchairs 45
|
||
(40)**, **Power bases 7 (6)**, **Scooters 4 (3)**, plus **14 units with no ADP device_type** (likely
|
||
private-pay) and 1 misc.
|
||
- **Funder split** (serial-tracked): adp 109, direct_private 13, adp_odsp 10, march_of_dimes 7;
|
||
wsib / insurance / standalone-odsp / rental / regular = **0 serials**. → an ADP-only gate
|
||
(`x_fc_adp_approved`) captures ~110 and **misses ~28** real units. The bridge should likely key on
|
||
**serial (funder-agnostic)**, not approval.
|
||
- **Two data gaps the design must absorb:** (a) the 14 serial units with no ADP device_type can't be
|
||
classified by a device_type→policy map → need a product-level or manual category override; (b) non-ADP
|
||
units have no `x_fc_adp_delivery_date` → the contract anchor (`delivery_date + interval`) needs a
|
||
fallback (invoice/order date).
|
||
- Deliveries span **2022-10 → 2026-05** (active program) — history to anchor intervals + a live pipeline.
|
||
- Top serial-tracked device_types: Adult Wheeled Walker Type 3 (47), Adult Manual Dynamic Tilt Type 5
|
||
Wheelchair (23), Adult Lightweight Performance Type 3 (11), Adult Lightweight Standard Type 1 (10),
|
||
Adult Wheeled Walker Type 2 (9), Adult Power Base Type 3 (5), Power Scooter (3). (1 line ≈ 1 unit;
|
||
equipment device_types are 1 base line each.)
|
||
|
||
## Open questions to resolve with the user (in the connected session)
|
||
- **MVP cut**: which categories first? Sizing surfaces a real tension: **by volume** it's walkers (68) +
|
||
wheelchairs (45) ≈ 82% of the base, but rollators/walkers are mechanically low-service; **by
|
||
service-revenue-per-unit** the targets are the powered units (power bases 7 + scooters 4 + power
|
||
wheelchairs) — high maintenance value but only ~11–15 units today. Volume vs. margin — or phase it
|
||
(powered units first to prove the booking loop, then walkers/manual chairs for reach)?
|
||
- **Revenue mechanic**: auto-draft a priced SO/invoice per booking, vs. pre-paid annual plan, vs.
|
||
pay-at-door via Poynt — which is the default?
|
||
- **Technician assignment**: auto-assign by skill+zone at booking time, or leave dispatch manual
|
||
(fusion_tasks) and only reserve the calendar slot?
|
||
- **Booking-portal strategy**: Step 0 shows Enterprise `appointment` already ships public,
|
||
token-based real-time booking (`appointment.invite` + `/appointment/<id>/...`, `auth="public"`).
|
||
Ride on that (generate an invite per reminder, partner pre-bound, no login) vs. a custom
|
||
`/maintenance/book/<token>` route? (The `/repairs/...` route is moot — fusion_repairs isn't on Westin.)
|
||
|
||
## Applicable CLAUDE.md rules (don't relearn the hard way)
|
||
- Rule #1: read reference files from the running instance before coding (esp. the appointment source).
|
||
- Odoo 19: `res.users.group_ids` (not `groups_id`); `ir.cron` has no `numbercall`; declarative
|
||
`models.Constraint`/`models.Index`; HTTP routes `type="jsonrpc"`; OWL uses standalone `rpc()`.
|
||
- No `sale.subscription` model exists — a subscription is a `sale.order` with `is_subscription=True`.
|
||
- New fields use `x_fc_` prefix; Canadian English; `$` Monetary + `currency_id`.
|
||
- Route attachment opens through `fusion_pdf_preview` (`att.action_fusion_preview(...)`).
|
||
- Tests need `--http-port=0 --gevent-port=0`. Westin prod is Enterprise; local dev is Community
|
||
(so the appointment-dependent module can't be installed/tested on `odoo-modsdev-app`).
|