From de3e0df5fc41322c865b02726f8326ce7411853b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 04:01:54 +0000 Subject: [PATCH 1/6] docs(fusion_maintenance): brainstorm + handoff brief for connected-env session Captures the maintenance-followup design exploration so it can resume from a Tailscale-connected environment with access to Westin production: - fusion_repairs already has a maintenance contract/reminder/booking engine to reuse - fusion_claims (sale.order.line + adp.device.code.device_type) is the trigger source - locked decisions: same DB, Enterprise appointment, public self-serve token booking - Step 0 live-inspection command pack to run on Westin prod before any code - open questions (MVP cut, revenue mechanic, tech assignment, booking route) https://claude.ai/code/session_011wfSKQfSWhKZcm1yzSGznW --- docs/plans/fusion_maintenance_brainstorm.md | 116 ++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 docs/plans/fusion_maintenance_brainstorm.md diff --git a/docs/plans/fusion_maintenance_brainstorm.md b/docs/plans/fusion_maintenance_brainstorm.md new file mode 100644 index 00000000..f3ec900d --- /dev/null +++ b/docs/plans/fusion_maintenance_brainstorm.md @@ -0,0 +1,116 @@ +# 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/` 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/`. +- 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 +APP= ; 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 /models/appointment_type.py | head -160 +docker exec "$APP" ls /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;" +``` + +## Open questions to resolve with the user (in the connected session) +- **MVP cut**: reminders+booking for which device categories first (e.g. stair/porch lifts — the + compliance-heavy, highest recurring-revenue ones)? +- **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**: enhance the existing `/repairs/maintenance/book/` in place + (fusion_repairs isn't production-deployed yet) vs. add a new `/maintenance/book/` route. + +## 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`). From 139e917e09398374e34c1cd9df97d8928506b04c Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 2 Jun 2026 00:22:51 -0400 Subject: [PATCH 2/6] docs(fusion_maintenance): record Step 0 live-grounding results from Westin prod Ran Step 0 against Westin prod (westin-v19 on odoo-westin). Resolved the APP/DB placeholders (DO boxes dead; migrated on-prem to odoo-dev-app), added a dated STEP 0 RESULTS section, and corrected the open questions the live inspection disproved: no stair/porch lifts in Westin ADP data; Enterprise appointment already ships native token booking; fusion_repairs contract engine not deployed; device_type is the ADP billing-code catalog taxonomy, not the install base. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/plans/fusion_maintenance_brainstorm.md | 64 +++++++++++++++++++-- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/docs/plans/fusion_maintenance_brainstorm.md b/docs/plans/fusion_maintenance_brainstorm.md index f3ec900d..266c3d4f 100644 --- a/docs/plans/fusion_maintenance_brainstorm.md +++ b/docs/plans/fusion_maintenance_brainstorm.md @@ -70,7 +70,15 @@ door payment via existing `fusion_poynt`. > from memory — read the real Enterprise `appointment` source before building the booking layer. ```bash -APP= ; DB= +# 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 \ @@ -95,15 +103,61 @@ 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 must be sized on `sale.order.line`** (`x_fc_adp_device_type` +[computed 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`) — **NOT yet +run; this is the next grounding step.** + +**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/?…invite_token`), live availability +(`/appointment//update_available_slots`, jsonrpc/public), slot submit → real `calendar.event` +(`/appointment//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. + ## Open questions to resolve with the user (in the connected session) -- **MVP cut**: reminders+booking for which device categories first (e.g. stair/porch lifts — the - compliance-heavy, highest recurring-revenue ones)? +- **MVP cut**: reminders+booking for which device categories first? (Step 0 update: stair/porch lifts + do NOT exist in Westin's ADP data. Candidates are the powered units most likely to need recurring + service — power wheelchairs, power bases, power scooters — then manual wheelchairs / walkers.) - **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**: enhance the existing `/repairs/maintenance/book/` in place - (fusion_repairs isn't production-deployed yet) vs. add a new `/maintenance/book/` route. +- **Booking-portal strategy**: Step 0 shows Enterprise `appointment` already ships public, + token-based real-time booking (`appointment.invite` + `/appointment//...`, `auth="public"`). + Ride on that (generate an invite per reminder, partner pre-bound, no login) vs. a custom + `/maintenance/book/` 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). From 12d383a8c2daaf006e69c37816fa82c2ffb381bc Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 2 Jun 2026 00:33:12 -0400 Subject: [PATCH 3/6] docs(fusion_maintenance): add Westin install-base sizing to Step 0 results 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) --- docs/plans/fusion_maintenance_brainstorm.md | 38 +++++++++++++++++---- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/docs/plans/fusion_maintenance_brainstorm.md b/docs/plans/fusion_maintenance_brainstorm.md index 266c3d4f..7166e0f0 100644 --- a/docs/plans/fusion_maintenance_brainstorm.md +++ b/docs/plans/fusion_maintenance_brainstorm.md @@ -124,10 +124,9 @@ installed**. Top entries are seating COMPONENTS (Seat Cushion 564, Back Support 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 must be sized on `sale.order.line`** (`x_fc_adp_device_type` -[computed 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`) — **NOT yet -run; this is the next grounding step.** +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 @@ -146,10 +145,35 @@ email, and hook `calendar.event` create → spawn the service task + advance the 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**: reminders+booking for which device categories first? (Step 0 update: stair/porch lifts - do NOT exist in Westin's ADP data. Candidates are the powered units most likely to need recurring - service — power wheelchairs, power bases, power scooters — then manual wheelchairs / walkers.) +- **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 From 6c3830fd4cfcf7fa33f24d9384169699a60f42d5 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 2 Jun 2026 01:14:49 -0400 Subject: [PATCH 4/6] =?UTF-8?q?docs(fusion=5Fmaintenance):=20approved=20de?= =?UTF-8?q?sign=20spec=20=E2=80=94=20extend=20fusion=5Frepairs=20(booking,?= =?UTF-8?q?=20backfill,=20flat-fee)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build maintenance INTO fusion_repairs (engine ~90% already there): per-category policy (interval + flat fee, product override); fix the dead contract-spawn trigger for new sales + a one-time idempotent backfill of the existing install base (lifts + fusion_claims wheelchairs); technician-aware self-serve booking on fusion_tasks availability (NO Enterprise appointment) creating a technician task; structured maintenance visit log + inspection cert for lifts; office follow-up crons; cost shown to client. Out of v1: SMS, /my/equipment, route optimization. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-02-fusion-maintenance-design.md | 289 ++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-02-fusion-maintenance-design.md diff --git a/docs/superpowers/specs/2026-06-02-fusion-maintenance-design.md b/docs/superpowers/specs/2026-06-02-fusion-maintenance-design.md new file mode 100644 index 00000000..53f5e09a --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-fusion-maintenance-design.md @@ -0,0 +1,289 @@ +# fusion_maintenance — Design Spec + +> Automated preventive‑maintenance follow‑ups + self‑serve real‑time booking for Westin +> medical mobility equipment (stair lifts, porch lifts, lift chairs, wheelchairs, power +> wheelchairs/scooters), to keep clients on schedule and turn service into recurring revenue. + +| | | +|---|---| +| **Status** | Design **approved** (brainstorm dialogue 2026‑06‑02). Ready for implementation plan. | +| **Implemented by** | **Extending `fusion_repairs`** (no new module). Version bump. | +| **Target instance** | Westin production — host `odoo-westin` (192.168.1.40), container `odoo-dev-app`, DB `westin-v19`. One company / one DB running `fusion_claims` (live) + `fusion_repairs` (to be deployed). | +| **Relates to** | [`docs/plans/fusion_maintenance_brainstorm.md`](../../plans/fusion_maintenance_brainstorm.md) (brief + Step 0 + sizing), [`2026-05-20-fusion-repairs-design.md`](2026-05-20-fusion-repairs-design.md) (base module). | +| **Next step** | `writing-plans` → implementation plan. **No code until the plan is written and this spec is reviewed.** | + +--- + +## 1. Goal + +Westin sells/services mobility equipment that needs preventive maintenance every **1–6 months +depending on the product**. Today there is no system keeping clients on schedule. We want: + +1. The system **automatically emails the client** when a unit is due for maintenance. +2. The client can **book the visit themselves** (real‑time, self‑serve, no login) **or** call the + office and staff book it for them. +3. The booking **lands in our scheduling/calendar** as a real technician job. +4. The **technician accesses and updates the maintenance log** on the visit; the system keeps the + full history per unit. +5. The **next maintenance is auto‑rescheduled** → recurring loop. +6. The client is **told the cost** up front. +7. Outcome: clients stay on track **and** Westin gains **recurring revenue**. +8. Design/UX stays **consistent with `fusion_claims`** (branded emails, `x_fc_` naming, Canadian + English, `$`+`currency_id`). + +## 2. Locked decisions (from the brainstorm) + +| # | Decision | Choice | Why | +|---|----------|--------|-----| +| D1 | Separate module vs. part of `fusion_repairs` | **Build into `fusion_repairs`** | The maintenance engine already lives there (~90% built); a separate module would duplicate it. fusion_repairs already owns the equipment categories, `repair.order`, technician tasks, service plans, and the Westin rate card. | +| D2 | Pricing / revenue model | **Flat fee per equipment type** | Transparent cost to show the client; recurring per‑visit revenue. Configured per equipment **category** with per‑product override. | +| D3 | Enrollment scope | **New sales + backfill existing install base** | The recurring revenue and "keep clients on track" value is in the *existing* base, not just future sales. | +| D4 | Booking engine | **Technician‑aware picker on `fusion_tasks`** (NOT Enterprise `appointment`) | Clients see only slots a qualified tech is genuinely free for (route/skill‑aware); booking creates the technician task directly — one scheduling world, no appointment↔task bridge. Bonus: **no Enterprise dependency → Community‑testable locally.** | + +## 3. Grounding (verified, not assumed) + +### 3.1 What `fusion_repairs` ALREADY has (reuse — do not rebuild) +Source: [`fusion_repairs/models/maintenance_contract.py`](../../../fusion_repairs/models/maintenance_contract.py), [`technician_task.py`](../../../fusion_repairs/models/technician_task.py), [`repair_service_plan.py`](../../../fusion_repairs/models/repair_service_plan.py), `cloud.md`. + +- `fusion.repair.maintenance.contract` — partner/product/lot/original_SO, `interval_months`, + `last_service_date`, `next_due_date`, state machine (`draft/active/paused/cancelled`), + `booking_token` (unique), `last_reminder_band`, `booking_repair_id`. `roll_next_due_date()` + advances the cycle correctly via `relativedelta`. +- Reminder cron `cron_send_due_reminders` — daily, **30/7/1‑day** bands, per‑band dedup, queued + branded email `email_template_maintenance_due_reminder` with the tokenized link. +- Public booking controller `/repairs/maintenance/book/` — `auth='public'`, token‑validated, + already‑booked guard, thanks page. +- `create_repair_from_booking()` — spawns a `repair.order` (`x_fc_intake_source='client_portal'`), + links `x_fc_maintenance_contract_id`, dedups. +- **Roll‑forward** on technician task completion ([`technician_task.py:88`](../../../fusion_repairs/models/technician_task.py:88)): when a `task_type='maintenance'` task → `status='completed'`, sets `last_service_date`, calls `roll_next_due_date()`, posts chatter. **This is the recurring loop.** +- Pre‑paid **service‑plan subscriptions** (`fusion.repair.service.plan.subscription`) wired to + `sale.order.action_confirm()` + visit burn engine (revenue primitive; optional here). +- **Rate card** (`fusion.repair.callout.rate`, standard vs `lift_elevating`), `repair.order.x_fc_quote_total`. +- **Equipment category taxonomy** (`fusion.repair.product.category`): stairlift / porch_lift / + lift_chair flagged `equipment_class=lift_elevating`, `safety_critical=True`. +- **Inspection certificate** (`fusion.repair.inspection.certificate`, M1 — Done): PDF + expiry cron. +- Visit‑report wizard (signature, parts, labour timer). +- `product.template.x_fc_maintenance_interval_months` (exists, [product_template.py:23](../../../fusion_repairs/models/product_template.py:23)). +- `fusion_tasks` availability engine: [`_find_next_available_slot(tech_id, date, ...)`](../../../fusion_tasks/models/technician_task.py:544) and [`_get_available_gaps(tech_id, date, ...)`](../../../fusion_tasks/models/technician_task.py:664) — **route‑aware** (tech start address + geocoding + travel). Tech skills on `res.users.x_fc_repair_skills`. + +### 3.2 The 4 gaps this spec closes +1. **Contract auto‑creation trigger is dead code** — `_spawn_maintenance_contracts()` is defined on + `sale.order` ([maintenance_contract.py:198](../../../fusion_repairs/models/maintenance_contract.py:198)) but **never called**. No `action_confirm` override invokes it → no contracts exist today. +2. **No real booking** — the booking page is a bare `` ("a team member will call + to confirm"); no availability, no slots, no calendar/task. **This is the main new build.** +3. **No cost shown to the client** anywhere (email or booking page). +4. **No auto tech‑task creation, no structured maintenance log, no office‑follow‑up crons** + (`ir.config_parameter` toggles exist; no cron/Python). + +### 3.3 Install‑base sizing (Westin live, 2026‑06‑02) +- Serial numbers are captured **~only on real equipment** (parts have 0 serials) → `x_fc_serial_number` + is a de‑facto "trackable unit" marker and the natural **idempotency key**. +- ADP‑side base ≈ **138 serial‑tracked units / ~136 customers** (walkers 68, wheelchairs 45, power + bases 7, scooters 4, +14 no‑device‑type). Funders: adp 109, direct_private 13, adp_odsp 10, + march_of_dimes 7. Deliveries 2022‑10 → 2026‑05. +- **Lifts are NOT in the ADP data** (private‑pay) — their count must be sized from lift‑category + sale lines at implementation time (see §15). +- Two backfill data gaps: 14 units have no device_type (need product/manual category); non‑ADP units + lack `x_fc_adp_delivery_date` (need an invoice/order‑date fallback anchor). + +## 4. Architecture + +Extend `fusion_repairs`. No new module, no new top‑level dependency for the core flow (booking uses +`fusion_tasks`, already a hard dep; pricing/Poynt already deps). The optional `fusion_claims` read +for the wheelchair backfill is a **soft** dependency (guarded `if 'fusion.claims' model present`), +so `fusion_repairs` still installs/test‑runs without `fusion_claims` on local dev. + +Reuse map: contract engine (extend), `fusion.technician.task` (booking target + availability + +roll‑forward), `repair.order` (visit container/pricing/Poynt), inspection certificate (lift +compliance), visit‑report wizard (extend with checklist), branded email pattern, rate card. + +## 5. Data model + +All new fields `x_fc_`, Canadian English labels, Monetary = `$` + `currency_id`. + +### 5.1 Maintenance policy — on `fusion.repair.product.category` ("per equipment type") +- `x_fc_maintenance_enabled` (Boolean) — is this category maintainable? +- `x_fc_maintenance_interval_months` (Integer) — default cadence (1–6+). +- `x_fc_maintenance_fee` (Monetary, `currency_id`) — the **flat fee** shown to the client. +- `x_fc_maintenance_skill_id` — the technician skill the booking matches on (maps to + `res.users.x_fc_repair_skills`). **If skills are already category‑based** (a tech's + `x_fc_repair_skills` are equipment categories), drop this field and simply match technicians whose + skills include *this* category — confirm the skills representation before modelling (§15). +- `x_fc_maintenance_service_product_id` (M2O `product.product`, optional) — the service product used + when drafting the priced invoice/SO line; falls back to a generic "Maintenance visit" product. + +**Per‑product override:** `product.template.x_fc_maintenance_interval_months` (exists) + +new `product.template.x_fc_maintenance_fee` (Monetary, optional). Resolution order at contract +creation: product override → category policy. + +### 5.2 Extend `fusion.repair.maintenance.contract` +- `x_fc_maintenance_fee` (Monetary) — resolved price snapshot, shown to client. +- `x_fc_source` (Selection: `sale` / `backfill` / `claims` / `manual`). +- `x_fc_source_sale_line_id` (M2O `sale.order.line`) — provenance + idempotency. +- `x_fc_device_serial` (Char, indexed) — idempotency key (esp. for claims/backfill where no lot). +- `x_fc_policy_category_id` (M2O `fusion.repair.product.category`). +- Constraint: at most one **active** contract per `(x_fc_device_serial)` (or per source sale line + when serial absent) — declarative `models.Constraint` / partial `models.Index`. + +### 5.3 New `fusion.repair.maintenance.visit` (the log) +A structured, queryable per‑visit record — *not* buried in chatter. +- `contract_id` (M2O, required), `technician_task_id` (M2O `fusion.technician.task`), + `repair_order_id` (M2O `repair.order`, the container), `partner_id`, `product_id`, `lot_id`. +- `visit_date`, `technician_id` (res.users), `state` (`scheduled/in_progress/done/no_show/cancelled`). +- `checklist_line_ids` (O2M to `fusion.repair.maintenance.checklist.line`: label, result + `pass/fail/na`, note) — items seeded **per equipment category** (lift checklist ≠ wheelchair + checklist). +- `findings` (Html, `Markup()`), `parts_note`, `x_fc_fee` (Monetary), `signature` (Binary), + `inspection_certificate_id` (M2O — set for `safety_critical` categories). +- "log/history" view = the list of visits per contract/unit (smart button on contract + partner). + +## 6. Enrollment — two paths + +### 6.1 Path A — new sales (fix the dead trigger) +Override `sale.order.action_confirm()` to call `_spawn_maintenance_contracts()` (reuse the existing +method; fix + wire it). For each confirmed line whose product/category has +`x_fc_maintenance_enabled` and a serial/lot: +- Create one `active` contract per unit (respect quantity), `x_fc_source='sale'`, + `x_fc_source_sale_line_id` set, serial captured. +- `next_due_date = (delivery/commitment date or date_order) + interval` (fallback chain handles + non‑ADP units lacking a delivery date). +- Resolve + snapshot `x_fc_maintenance_fee`. +- **Idempotent**: skip if an active contract already exists for the serial / sale line. + +### 6.2 Path B — backfill existing install base (one‑time wizard, idempotent) +`fusion.repair.maintenance.backfill.wizard`: +- **Scan** historical `sale.order.line` for products whose category is maintenance‑enabled **and** + that have a serial **and** were delivered. Two sources: + - **Lifts / repairs‑side** equipment — lift categories (`lift_elevating` etc.). + - **Wheelchairs / power chairs** — via the `fusion_claims` serial/`device_type` data (soft dep, + guarded). Map ADP `device_type` → maintenance category. +- **Dedup** strictly by serial (one active contract per unit). +- **Stagger** the first `next_due_date` across a configurable window (e.g. spread overdue units over + N weeks) so years of equipment don't all email on day one. +- **Dry‑run first**: produce a report (counts by category, # new vs already‑enrolled, # skipped for + missing serial/date, the stagger schedule). Nothing is created or emailed until the operator + approves and runs "Execute". +- Anchor fallback for units with no delivery date: invoice date → order date → today. + +## 7. Booking flow (the main build) + +### 7.1 Client self‑serve (no login) +1. Reminder email (existing branded template, **+ fee line added**) → tokenized link. +2. Public slot‑picker page (extend the existing `/repairs/maintenance/book/` route; replace + the date input). The page: + - Resolves the contract from the token; shows unit + **flat fee** ("$X + applicable tax"). + - Computes candidate technicians = users whose `x_fc_repair_skills` include the policy's + `x_fc_maintenance_skill_id`. + - Calls `fusion_tasks` `_get_available_gaps` / `_find_next_available_slot` per candidate tech over + the next ~2–3 weeks, ranked by **proximity** to the client address → presents a short list of + real open slots (date + window + implied tech). +3. Client picks a slot → POST confirm: + - **Re‑validate** the slot is still free (gap check) — if taken/expired, re‑render slots with a + gentle notice (prevents double‑booking). + - Create a `fusion.technician.task` (`task_type='maintenance'`) on that slot, **assigned to the + qualified tech** (auto‑assignment by availability+skill), linked to the contract. + - Spawn/link the maintenance‑type `repair.order` (container) + the `fusion.repair.maintenance.visit` + (state `scheduled`, checklist seeded from the category). + - Send the branded confirmation email (date/window/tech, fee, what to expect). + - Set `booking_repair_id` (dedup). +4. **No‑slot fallback:** if no qualified tech/slot in range → show "request a callback" → create an + office activity. Never a dead end. + +### 7.2 Office books on the client's behalf +- A **"Book maintenance"** action on the `fusion.repair.maintenance.contract` form opens the same + slot‑picker logic in the backend (office books while on the phone). +- The existing dispatch board remains available for manual scheduling/override. + +### 7.3 Token security fix +On `roll_next_due_date()`, **regenerate `booking_token`** (currently it is not regenerated, so an +old link stays valid across cycles). Old token → friendly "link expired" page. + +## 8. Cost & revenue + +- The **flat fee** (`x_fc_maintenance_fee`) is shown in **both** the reminder email and the + slot‑picker page, Canadian English, `$` + tax note. +- On booking, draft a priced line (SO/invoice) using `x_fc_maintenance_service_product_id` (or the + generic visit product) at the contract's fee. Payment options: **pay‑at‑door via `fusion_poynt`** + (existing `action_collect_payment` on the repair) or invoice after the visit. +- Recurring revenue = one priced visit per cycle; the roll‑forward arms the next cycle automatically. + (Pre‑paid annual plan upsell via the existing subscription engine is out of v1 — §11.) + +## 9. Maintenance log & the recurring loop + +- The technician fills the visit via the **extended visit‑report wizard** (existing tool) — checklist + results, findings, parts, signature — which writes the `fusion.repair.maintenance.visit` record. +- For `safety_critical` categories (lifts), completing the visit **issues an inspection certificate** + (reuse M1) and links it on the visit — the log doubles as compliance proof. +- On task `status='completed'` → existing **roll‑forward**: `last_service_date=today`, + `next_due_date += interval`, reset `last_reminder_band`, **regenerate token**, visit → `done`. +- Next cycle's reminder fires automatically when `next_due_date` re‑enters the 30‑day band. + +## 10. Office follow‑up crons (toggle‑gated, exist as config only today) +- **Unbooked**: reminder sent, no booking after N days → office call activity on the contract. +- **Overdue**: `next_due_date` passed with no completed visit in the cycle → escalation activity. +- Driven by the existing `ir.config_parameter` toggles in `data/ir_config_parameter_data.xml`. +- Per‑row **savepoint** isolation inside the cron loop (no `cr.commit()` in tests — CLAUDE.md #14). + +## 11. Out of scope (v1 — YAGNI) +- SMS reminders / two‑way SMS booking (needs `fusion_ringcentral`). +- Logged‑in `/my/equipment` client portal (X5). +- Pre‑paid annual maintenance‑plan auto‑upsell at booking. +- Full multi‑stop route optimization / batching (we use per‑tech availability + proximity ranking, + not a global optimizer). +- ADP funder re‑billing of maintenance (maintenance is private‑pay flat fee in v1). + +## 12. Error handling & edge cases +- **Double‑booking:** re‑validate the gap at confirm; lose the race → re‑show slots. +- **Token:** per‑cycle regeneration; invalid/expired/already‑booked → friendly pages (exist, extend). +- **No qualified tech / no slots:** callback fallback, not an error page. +- **Backfill:** dry‑run + report; strict serial dedup; stagger; fallback anchor chain; never email on + dry‑run. +- **Missing data:** units with no device_type/category → excluded from auto‑backfill, listed in the + report for manual enrollment. +- **Audit on failure paths** (if any "booking failed" row is written in an `except`): use a separate + `self.env.registry.cursor()` so it survives rollback (CLAUDE.md audit rule). +- **`message_post` HTML** bodies wrapped in `Markup()` (CLAUDE.md). + +## 13. Testing +`fusion_repairs/tests/` (none exist today). Local dev is **Community** and — because we chose +`fusion_tasks` over Enterprise `appointment` — the **entire feature is Community‑testable** on +`odoo-modsdev`. `TransactionCase` coverage: +- Contract spawn on `sale.order` confirm (enabled vs disabled category; quantity; idempotency). +- Backfill wizard: dedup by serial, stagger, dry‑run produces no records, anchor fallback. +- Booking: slot list comes from real gaps; confirm creates task+repair+visit; **double‑book guard**; + no‑slot fallback. +- Roll‑forward on completion: dates advance, band reset, **token regenerated**, visit → done. +- Crons: reminder bands; unbooked/overdue follow‑ups (savepoint isolation). +- Run: `docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_repairs -u fusion_repairs --stop-after-init --http-port=0 --gevent-port=0`. + +## 14. Deployment & configuration +1. Land on local dev, full E2E + tests green. +2. **Deploy `fusion_repairs` to Westin** (`odoo-westin` / `westin-v19`) — the accepted bigger lift + (first production deploy of fusion_repairs; verify rate‑card numbers, ACLs, asset bundles). +3. **Configure** maintainable categories: `x_fc_maintenance_enabled`, interval, fee, skill, service + product — for lifts (stairlift/porch/lift chair) + power & manual wheelchairs. +4. Ensure technicians have `x_fc_repair_skills` + start addresses (for availability/routing). +5. Run the **backfill wizard dry‑run → review report → execute** (staggered). +6. Watch the first reminder/booking cycle; confirm emails, slots, task creation, completion → roll. + +## 15. Open items to verify at implementation (rule #1 — read live source) +- Exact representation of tech skills (`res.users.x_fc_repair_skills`) and how a category's required + skill maps to it (Selection vs M2O vs tag) — read fusion_repairs/fusion_tasks before modelling + `x_fc_maintenance_skill_id`. +- Signatures of `_find_next_available_slot` / `_get_available_gaps` (params, return shape, working + hours source) and whether they already account for travel windows. +- The visit‑report wizard's current fields/flow before extending it with the checklist. +- The inspection‑certificate issue API (how M1 creates a certificate) for the lift link. +- **Size the lift install base** on Westin (lift‑category sale lines with serials) — not yet counted; + needed to set expectations and the backfill stagger window. +- `fusion_claims` device_type → maintenance‑category mapping table for the wheelchair backfill. + +## 16. Build sequence (for the implementation plan) +1. **Policy + fee data model** (category fields, product override, contract extensions, constraints). +2. **Path A trigger** (wire `_spawn_maintenance_contracts` into `action_confirm`, fee resolution, anchor fallback) + tests. +3. **Cost in email** (add fee to the reminder template). +4. **Technician‑aware booking** (slot‑picker page + controller on `fusion_tasks` availability; task/repair/visit creation; double‑book guard; office action; token regen) + tests — the largest unit. +5. **Maintenance visit log + checklist** (model, per‑category seed, visit‑report‑wizard extension, inspection‑cert link) + tests. +6. **Backfill wizard** (scan/dedup/stagger/dry‑run; fusion_claims soft bridge) + tests. +7. **Office follow‑up crons** (unbooked/overdue) + tests. +8. **Deploy + configure + backfill** on Westin. From 17d21bffb57902685bb84748db06964f6e65d4c5 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 2 Jun 2026 01:21:08 -0400 Subject: [PATCH 5/6] docs(fusion_maintenance): correct backfill for lifts (no serials) after live sizing Live sizing on Westin: stair lifts ~254 customers / porch-VPL ~30 / lift chairs ~41, but lift serial coverage ~0 (12/416 stairlift lines). The serial-as-unit-key approach (valid for ADP wheelchairs) fails for lifts. Backfill now splits into two regimes: serial dedup for wheelchairs; partner+base-product+sale-line dedup for lifts with accessory-line exclusion via the per-product maintainable flag. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-02-fusion-maintenance-design.md | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/docs/superpowers/specs/2026-06-02-fusion-maintenance-design.md b/docs/superpowers/specs/2026-06-02-fusion-maintenance-design.md index 53f5e09a..f9f9da67 100644 --- a/docs/superpowers/specs/2026-06-02-fusion-maintenance-design.md +++ b/docs/superpowers/specs/2026-06-02-fusion-maintenance-design.md @@ -81,8 +81,13 @@ Source: [`fusion_repairs/models/maintenance_contract.py`](../../../fusion_repair - ADP‑side base ≈ **138 serial‑tracked units / ~136 customers** (walkers 68, wheelchairs 45, power bases 7, scooters 4, +14 no‑device‑type). Funders: adp 109, direct_private 13, adp_odsp 10, march_of_dimes 7. Deliveries 2022‑10 → 2026‑05. -- **Lifts are NOT in the ADP data** (private‑pay) — their count must be sized from lift‑category - sale lines at implementation time (see §15). +- **Lifts (sized 2026‑06‑02; name‑based, approximate)** — a LARGE base in Westin's Odoo: stair lifts + ~254 customers (416 lines incl. accessories), porch/VPL ~30 customers (75 lines), lift chairs ~41 + customers (47 lines) — real products (Access BDD, Handicare, Serenity VPL, Pride VivaLift). **But lift + serial coverage is ~0** (12/416 stairlift lines, 0 VPL, 2 lift‑chair). So the serial‑as‑unit‑key + approach that works for ADP wheelchairs **does NOT work for lifts** — lifts must be keyed by + (partner + base‑unit product + sale line), excluding accessory lines (curves, rails, remotes, charging + stations, rentals). This splits the backfill into two regimes (§6.2). - Two backfill data gaps: 14 units have no device_type (need product/manual category); non‑ADP units lack `x_fc_adp_delivery_date` (need an invoice/order‑date fallback anchor). @@ -152,12 +157,14 @@ method; fix + wire it). For each confirmed line whose product/category has ### 6.2 Path B — backfill existing install base (one‑time wizard, idempotent) `fusion.repair.maintenance.backfill.wizard`: -- **Scan** historical `sale.order.line` for products whose category is maintenance‑enabled **and** - that have a serial **and** were delivered. Two sources: - - **Lifts / repairs‑side** equipment — lift categories (`lift_elevating` etc.). - - **Wheelchairs / power chairs** — via the `fusion_claims` serial/`device_type` data (soft dep, - guarded). Map ADP `device_type` → maintenance category. -- **Dedup** strictly by serial (one active contract per unit). +- **Scan** historical `sale.order.line` for products whose category/product is maintenance‑enabled and + were delivered. **Two unit‑identity regimes**, because lifts carry no serials (§3.3): + - **Serial‑tracked** (ADP wheelchairs/power chairs, via the `fusion_claims` serial/`device_type` data + — soft dep, guarded; map ADP `device_type` → maintenance category): require a serial, **dedup by serial**. + - **Non‑serial** (lifts — stair/porch/VPL/lift‑chair): do **NOT** require a serial. One contract per + **base‑unit line**, **dedup by (partner + maintainable product + source sale line)**. The per‑product + `x_fc_maintenance_enabled` flag is what includes base units and **excludes accessory lines** (curves, + rails, remotes, charging stations, rentals) — only the lift itself gets a contract, not its add‑ons. - **Stagger** the first `next_due_date` across a configurable window (e.g. spread overdue units over N weeks) so years of equipment don't all email on day one. - **Dry‑run first**: produce a report (counts by category, # new vs already‑enrolled, # skipped for @@ -249,7 +256,7 @@ old link stays valid across cycles). Old token → friendly "link expired" page. `fusion_tasks` over Enterprise `appointment` — the **entire feature is Community‑testable** on `odoo-modsdev`. `TransactionCase` coverage: - Contract spawn on `sale.order` confirm (enabled vs disabled category; quantity; idempotency). -- Backfill wizard: dedup by serial, stagger, dry‑run produces no records, anchor fallback. +- Backfill wizard: **two‑regime dedup** (serial for wheelchairs; partner+product+line for lifts), accessory‑line exclusion, stagger, dry‑run produces no records, anchor fallback. - Booking: slot list comes from real gaps; confirm creates task+repair+visit; **double‑book guard**; no‑slot fallback. - Roll‑forward on completion: dates advance, band reset, **token regenerated**, visit → done. @@ -274,8 +281,10 @@ old link stays valid across cycles). Old token → friendly "link expired" page. hours source) and whether they already account for travel windows. - The visit‑report wizard's current fields/flow before extending it with the checklist. - The inspection‑certificate issue API (how M1 creates a certificate) for the lift link. -- **Size the lift install base** on Westin (lift‑category sale lines with serials) — not yet counted; - needed to set expectations and the backfill stagger window. +- **Lift base sized** (§3.3): ~254 stairlift + ~30 porch/VPL + ~41 lift‑chair customers, but ~0 serials. + Still to verify: which exact products are **base units vs accessories** (so `x_fc_maintenance_enabled` + lands on base units only), plus the lift interval/fee per category. Lift products aren't yet tagged + with `fusion_repairs` categories on Westin (module not deployed there) — categorization is a deploy step. - `fusion_claims` device_type → maintenance‑category mapping table for the wheelchair backfill. ## 16. Build sequence (for the implementation plan) From cc568b0ec8142558a263a3d615f07968ba728311 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 2 Jun 2026 01:27:38 -0400 Subject: [PATCH 6/6] docs(fusion_maintenance): Plan 1 (Foundation) implementation plan + Plans 2-5 roadmap TDD plan for the enrollment+pricing foundation: maintenance policy fields on the equipment category (+ product fee override), maintenance-contract extensions, fix+wire the dead _spawn_maintenance_contracts into the existing action_confirm (delivery-date anchor, two-regime serial dedup, fee snapshot), fee line in the reminder email, category UI, version 19.0.2.3.0. Grounded in real source. Plans 2-5 (booking on fusion_tasks, visit log + checklist, two-regime backfill, office crons) roadmapped. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...026-06-02-fusion-maintenance-foundation.md | 506 ++++++++++++++++++ 1 file changed, 506 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-02-fusion-maintenance-foundation.md diff --git a/docs/superpowers/plans/2026-06-02-fusion-maintenance-foundation.md b/docs/superpowers/plans/2026-06-02-fusion-maintenance-foundation.md new file mode 100644 index 00000000..9e35b45c --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-fusion-maintenance-foundation.md @@ -0,0 +1,506 @@ +# fusion_maintenance Foundation — Implementation Plan (Plan 1 of 5) + +> **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:** Confirming a sale of a maintainable product auto-creates a *priced* maintenance contract, and the due-reminder email shows the maintenance cost. + +**Architecture:** Extend `fusion_repairs`. A maintenance **policy** (enabled / interval / flat fee) lives on `fusion.repair.product.category`, with a per-product fee/interval override on `product.template`. We fix the dead `_spawn_maintenance_contracts()` (anchor on delivery date, capture serial + fee + provenance, dedup) and call it from the **existing** `action_confirm()` override. The branded reminder email gains a fee line. + +**Tech Stack:** Odoo 19 **Community**, Python, `TransactionCase`. Local dev: `docker odoo-modsdev-app`, DB `fusion-dev`. + +**Spec:** [`2026-06-02-fusion-maintenance-design.md`](../specs/2026-06-02-fusion-maintenance-design.md). This is **Plan 1 of 5**; see the Roadmap at the bottom for Plans 2–5 (booking, visit log, backfill, office crons) — each is written when reached because it needs its own live-source reads (spec §15). + +**Conventions (from CLAUDE.md):** new fields `x_fc_` prefix; Canadian English; Monetary = `$` + `currency_id`; declarative `models.Constraint` / `models.Index` (no `_sql_constraints`); `message_post` HTML wrapped in `Markup()`; `res.users` group field is `group_ids`. + +**Run tests:** +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_repairs \ + -u fusion_repairs --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60 +``` + +**Grounding (verified source, 2026-06-02):** +- [`maintenance_contract.py`](../../../fusion_repairs/models/maintenance_contract.py) — contract model (fields end at `company_id`, line 81; `_booking_token_unique` constraint line 83); dead `_spawn_maintenance_contracts()` (line 198, anchors on `today`, dedups by partner/product/SO, no fee/serial/source). +- [`repair_product_category.py`](../../../fusion_repairs/models/repair_product_category.py) — category model; `safety_critical`, `equipment_class`; `_code_unique` constraint line 56. +- [`product_template.py`](../../../fusion_repairs/models/product_template.py) — `x_fc_repair_category_id` (line 11), `x_fc_maintenance_interval_months` (line 23, default 0). +- [`repair_service_plan.py`](../../../fusion_repairs/models/repair_service_plan.py) — **existing** `action_confirm()` override (line 229) ending `return res` (line 250); wire the maintenance spawn here. + +--- + +## File Structure + +- **Modify** `fusion_repairs/models/repair_product_category.py` — add maintenance-policy fields + `currency_id`. +- **Modify** `fusion_repairs/models/product_template.py` — add `x_fc_maintenance_fee` override. +- **Modify** `fusion_repairs/models/maintenance_contract.py` — add contract fields + indexes; add `_fc_maintenance_anchor_date`; rewrite `_spawn_maintenance_contracts`. +- **Modify** `fusion_repairs/models/repair_service_plan.py` — call `self._spawn_maintenance_contracts()` inside `action_confirm`. +- **Modify** `fusion_repairs/data/mail_template_data.xml` — add a fee row to the reminder template. +- **Modify** `fusion_repairs/views/repair_product_category_views.xml` — expose the policy fields. +- **Create** `fusion_repairs/tests/__init__.py`, `fusion_repairs/tests/test_maintenance_foundation.py`. +- **Modify** `fusion_repairs/__manifest__.py` — bump `version` to `19.0.2.3.0`. + +> **Scope note:** the technician-skill field (`x_fc_maintenance_skill_id`) is deferred to **Plan 2 (booking)** because skill matching is a booking concern and the exact skills representation is an open item (spec §15). Plan 1 is enrollment + pricing only. + +--- + +## Task 1: Maintenance policy fields on the equipment category + +**Files:** +- Modify: `fusion_repairs/models/repair_product_category.py` (insert after `intake_template_id`, before `_code_unique` at line 56) +- Test: `fusion_repairs/tests/test_maintenance_foundation.py` + +- [ ] **Step 1: Create the tests package + write the failing test** + +Create `fusion_repairs/tests/__init__.py`: +```python +from . import test_maintenance_foundation +``` + +Create `fusion_repairs/tests/test_maintenance_foundation.py`: +```python +# -*- coding: utf-8 -*- +from odoo.tests import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestMaintenanceFoundation(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env['res.partner'].create({'name': 'Mrs. Test Client'}) + cls.category = cls.env['fusion.repair.product.category'].create({ + 'name': 'Stair Lift', 'code': 'stairlift', + 'equipment_class': 'lift_elevating', 'safety_critical': True, + 'x_fc_maintenance_enabled': True, + 'x_fc_maintenance_interval_months': 6, + 'x_fc_maintenance_fee': 149.0, + }) + + def test_category_policy_fields_exist(self): + self.assertTrue(self.category.x_fc_maintenance_enabled) + self.assertEqual(self.category.x_fc_maintenance_interval_months, 6) + self.assertEqual(self.category.x_fc_maintenance_fee, 149.0) + self.assertTrue(self.category.currency_id) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_repairs -u fusion_repairs --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -40 +``` +Expected: FAIL — `Invalid field 'x_fc_maintenance_enabled' on model 'fusion.repair.product.category'`. + +- [ ] **Step 3: Add the policy fields** + +In `repair_product_category.py`, insert before the `_code_unique = models.Constraint(...)` line: +```python + # ── Maintenance policy (per equipment type) ────────────────────────── + x_fc_maintenance_enabled = fields.Boolean( + string='Offer Maintenance', + help='If set, units in this category are enrolled in recurring preventive ' + 'maintenance on sale (and via the backfill wizard).', + ) + x_fc_maintenance_interval_months = fields.Integer( + string='Maintenance Interval (Months)', default=6, + help='Default months between preventive maintenance visits for this category. ' + 'Overridden by the product field of the same name when that is > 0.', + ) + currency_id = fields.Many2one( + 'res.currency', string='Currency', + default=lambda self: self.env.company.currency_id, + ) + x_fc_maintenance_fee = fields.Monetary( + string='Maintenance Fee', currency_field='currency_id', + help='Flat fee shown to the client for a maintenance visit of this equipment type.', + ) + x_fc_maintenance_service_product_id = fields.Many2one( + 'product.product', string='Maintenance Service Product', + help='Optional product used when drafting the priced visit line (Plan 2). ' + 'Falls back to a generic visit product.', + ) +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run the same command as Step 2. Expected: `test_category_policy_fields_exist` PASS. + +- [ ] **Step 5: Commit** +```bash +git add fusion_repairs/models/repair_product_category.py fusion_repairs/tests/ +git commit -m "feat(fusion_repairs): maintenance policy fields on equipment category" +``` + +--- + +## Task 2: Per-product fee override + +**Files:** +- Modify: `fusion_repairs/models/product_template.py` (after `x_fc_maintenance_interval_months`, line 28) +- Test: `fusion_repairs/tests/test_maintenance_foundation.py` + +- [ ] **Step 1: Write the failing test** (append to the test class) +```python + def test_product_fee_override_field_exists(self): + tmpl = self.env['product.template'].create({ + 'name': 'Handicare Freecurve Stairlift', + 'x_fc_repair_category_id': self.category.id, + 'x_fc_maintenance_fee': 199.0, + }) + self.assertEqual(tmpl.x_fc_maintenance_fee, 199.0) +``` + +- [ ] **Step 2: Run to verify it fails** + +Run the test command. Expected: FAIL — `Invalid field 'x_fc_maintenance_fee' on model 'product.template'`. + +- [ ] **Step 3: Add the field** + +In `product_template.py`, after the `x_fc_maintenance_interval_months` field (line 28): +```python + x_fc_maintenance_fee = fields.Monetary( + string='Maintenance Fee (override)', currency_field='currency_id', + help='Per-product override of the category maintenance fee. 0 = use the category fee.', + ) +``` +(`product.template` already provides `currency_id`.) + +- [ ] **Step 4: Run to verify it passes** — `test_product_fee_override_field_exists` PASS. + +- [ ] **Step 5: Commit** +```bash +git add fusion_repairs/models/product_template.py fusion_repairs/tests/test_maintenance_foundation.py +git commit -m "feat(fusion_repairs): per-product maintenance fee override" +``` + +--- + +## Task 3: Contract model extensions (fee, source, serial, policy) + +**Files:** +- Modify: `fusion_repairs/models/maintenance_contract.py` (add fields after `company_id`, line 81; add indexes near `_booking_token_unique`, line 83) +- Test: `fusion_repairs/tests/test_maintenance_foundation.py` + +- [ ] **Step 1: Write the failing test** +```python + def test_contract_extension_fields_exist(self): + c = self.env['fusion.repair.maintenance.contract'].create({ + 'partner_id': self.partner.id, + 'product_id': self.env['product.product'].create({'name': 'Unit'}).id, + 'next_due_date': '2026-12-01', + 'x_fc_source': 'sale', + 'x_fc_device_serial': 'SN-123', + 'x_fc_maintenance_fee': 149.0, + }) + self.assertEqual(c.x_fc_source, 'sale') + self.assertEqual(c.x_fc_device_serial, 'SN-123') + self.assertEqual(c.x_fc_maintenance_fee, 149.0) +``` + +- [ ] **Step 2: Run to verify it fails** — `Invalid field 'x_fc_source' ...`. + +- [ ] **Step 3: Add the fields + indexes** + +In `maintenance_contract.py`, after the `company_id` field (line 81), before `_booking_token_unique`: +```python + currency_id = fields.Many2one( + 'res.currency', default=lambda self: self.env.company.currency_id, + ) + x_fc_maintenance_fee = fields.Monetary( + string='Maintenance Fee', currency_field='currency_id', + help='Flat fee shown to the client for this maintenance visit.', + ) + x_fc_source = fields.Selection( + [('sale', 'New Sale'), ('backfill', 'Backfill'), + ('claims', 'Claims Bridge'), ('manual', 'Manual')], + string='Source', default='manual', index=True, + ) + x_fc_source_sale_line_id = fields.Many2one( + 'sale.order.line', string='Source Sale Line', index=True, copy=False, + ) + x_fc_device_serial = fields.Char(string='Serial (text)', index=True, copy=False) + x_fc_policy_category_id = fields.Many2one( + 'fusion.repair.product.category', string='Maintenance Policy', + ) +``` +(Idempotency is enforced in Python — Task 4 — to support the two-regime dedup in spec §6.2; the `index=True` above covers lookups.) + +- [ ] **Step 4: Run to verify it passes** — `test_contract_extension_fields_exist` PASS. + +- [ ] **Step 5: Commit** +```bash +git add fusion_repairs/models/maintenance_contract.py fusion_repairs/tests/test_maintenance_foundation.py +git commit -m "feat(fusion_repairs): maintenance contract fee/source/serial/policy fields" +``` + +--- + +## Task 4: Spawn priced contracts on sale confirm (fix the dead trigger + wire it) + +**Files:** +- Modify: `fusion_repairs/models/maintenance_contract.py` (rewrite `_spawn_maintenance_contracts`, lines 198-227; add `_fc_maintenance_anchor_date` helper) +- Modify: `fusion_repairs/models/repair_service_plan.py` (call it in `action_confirm`, before `return res` at line 250) +- Test: `fusion_repairs/tests/test_maintenance_foundation.py` + +- [ ] **Step 1: Write the failing tests** +```python + def _make_product(self, **kw): + vals = {'name': 'Stairlift Unit', 'type': 'consu', + 'x_fc_repair_category_id': self.category.id} + vals.update(kw) + return self.env['product.product'].create(vals) + + def _confirm_so(self, product, commitment='2026-01-10'): + so = self.env['sale.order'].create({ + 'partner_id': self.partner.id, + 'commitment_date': commitment, + 'order_line': [(0, 0, {'product_id': product.id, 'product_uom_qty': 1})], + }) + so.action_confirm() + return so + + def _contracts_for(self, so): + return self.env['fusion.repair.maintenance.contract'].search( + [('original_sale_order_id', '=', so.id)]) + + def test_no_contract_when_category_not_maintainable(self): + cat = self.env['fusion.repair.product.category'].create( + {'name': 'Cane', 'code': 'cane', 'x_fc_maintenance_enabled': False}) + so = self._confirm_so(self._make_product(x_fc_repair_category_id=cat.id)) + self.assertFalse(self._contracts_for(so)) + + def test_contract_created_via_category_policy(self): + so = self._confirm_so(self._make_product()) + contracts = self._contracts_for(so) + self.assertEqual(len(contracts), 1) + c = contracts + self.assertEqual(c.interval_months, 6) + self.assertEqual(c.x_fc_maintenance_fee, 149.0) + self.assertEqual(c.x_fc_source, 'sale') + self.assertEqual(c.x_fc_policy_category_id, self.category) + # anchor = commitment_date + 6 months + self.assertEqual(str(c.next_due_date), '2026-07-10') + + def test_product_override_beats_category(self): + p = self._make_product() + p.product_tmpl_id.x_fc_maintenance_interval_months = 3 + p.product_tmpl_id.x_fc_maintenance_fee = 199.0 + so = self._confirm_so(p) + c = self._contracts_for(so) + self.assertEqual(c.interval_months, 3) + self.assertEqual(c.x_fc_maintenance_fee, 199.0) + + def test_idempotent_on_reconfirm(self): + p = self._make_product() + so = self._confirm_so(p) + so._spawn_maintenance_contracts() # call again + self.assertEqual(len(self._contracts_for(so)), 1) +``` + +- [ ] **Step 2: Run to verify they fail** — contracts not created (trigger not wired) → assertions fail. + +- [ ] **Step 3: Rewrite `_spawn_maintenance_contracts` + add the anchor helper** + +Replace the body of `_spawn_maintenance_contracts` (lines 198-227) and add the helper, in the `SaleOrder` class of `maintenance_contract.py`: +```python + def _fc_maintenance_anchor_date(self, line): + """Best-available delivery anchor: commitment_date -> date_order -> today. + (Non-ADP/lift units lack a delivery date; this fallback chain handles them.)""" + so = line.order_id + anchor = so.commitment_date or so.date_order + return fields.Date.to_date(anchor) if anchor else fields.Date.context_today(self) + + def _spawn_maintenance_contracts(self): + """Create a priced maintenance contract per maintainable unit on a confirmed SO. + Policy = product interval override, else the product's category policy. + Idempotent: by serial when captured, else by source sale line.""" + Contract = self.env['fusion.repair.maintenance.contract'].sudo() + for so in self: + if so.state not in ('sale', 'done'): + continue + for line in so.order_line: + product = line.product_id + if not product: + continue + tmpl = product.product_tmpl_id + category = tmpl.x_fc_repair_category_id + product_interval = tmpl.x_fc_maintenance_interval_months or 0 + cat_enabled = bool(category) and category.x_fc_maintenance_enabled + interval = product_interval or ( + category.x_fc_maintenance_interval_months if cat_enabled else 0) + if interval <= 0 or not (product_interval > 0 or cat_enabled): + continue + fee = tmpl.x_fc_maintenance_fee or ( + category.x_fc_maintenance_fee if category else 0.0) + # Capture serial only if fusion_claims' line field is present. + serial = '' + if 'x_fc_serial_number' in line._fields: + serial = (line.x_fc_serial_number or '').strip() + # Idempotency: serial regime vs source-line regime (spec §6.2). + if serial: + dedup = [('state', '=', 'active'), ('x_fc_device_serial', '=', serial)] + else: + dedup = [('state', '=', 'active'), + ('x_fc_source_sale_line_id', '=', line.id)] + if Contract.search_count(dedup): + continue + anchor = so._fc_maintenance_anchor_date(line) + # One contract per serialized unit; without a serial, per quantity. + count = 1 if serial else max(int(line.product_uom_qty or 1), 1) + for _i in range(count): + Contract.create({ + 'partner_id': so.partner_id.id, + 'product_id': product.id, + 'original_sale_order_id': so.id, + 'x_fc_source_sale_line_id': line.id, + 'x_fc_source': 'sale', + 'x_fc_device_serial': serial, + 'x_fc_policy_category_id': category.id if category else False, + 'interval_months': interval, + 'x_fc_maintenance_fee': fee, + 'next_due_date': anchor + relativedelta(months=interval), + 'state': 'active', + }) +``` + +- [ ] **Step 4: Wire it into the existing `action_confirm`** + +In `repair_service_plan.py`, in `action_confirm`, change line 249-250 from: +```python + self._fc_spawn_labor_warranties() + return res +``` +to: +```python + self._fc_spawn_labor_warranties() + self._spawn_maintenance_contracts() + return res +``` + +- [ ] **Step 5: Run to verify the Task-4 tests pass** — all four PASS. + +- [ ] **Step 6: Commit** +```bash +git add fusion_repairs/models/maintenance_contract.py fusion_repairs/models/repair_service_plan.py fusion_repairs/tests/test_maintenance_foundation.py +git commit -m "feat(fusion_repairs): spawn priced maintenance contracts on sale confirm" +``` + +--- + +## Task 5: Show the fee in the reminder email + +**Files:** +- Modify: `fusion_repairs/data/mail_template_data.xml` (the `email_template_maintenance_due_reminder` record) + +- [ ] **Step 1: Read the current template** + +Run: +```bash +docker exec odoo-modsdev-app sh -c "grep -n 'email_template_maintenance_due_reminder' /mnt/odoo-modules/fusion_repairs/data/mail_template_data.xml" +``` +Then open that record's `` and find the equipment-name / due-date details table (the green-accent reminder). + +- [ ] **Step 2: Add a fee row to the details table** + +Inside the details table of the reminder body, after the "Next due" row, add (Canadian English, `$` + currency): +```xml + + Maintenance fee + + + applicable tax + +``` + +- [ ] **Step 3: Upgrade + manually verify the rendered email** + +Run: +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev -u fusion_repairs --stop-after-init +``` +Then in odoo-shell render the template for a contract with a fee and confirm the fee line appears: +```bash +docker exec odoo-modsdev-app odoo shell -d fusion-dev --no-http <<'PY' +c = env['fusion.repair.maintenance.contract'].search([('x_fc_maintenance_fee','>',0)], limit=1) +tpl = env.ref('fusion_repairs.email_template_maintenance_due_reminder') +print('FEE' if 'applicable tax' in tpl._render_field('body_html', c.ids)[c.id] else 'MISSING') +PY +``` +Expected: `FEE`. + +- [ ] **Step 4: Commit** +```bash +git add fusion_repairs/data/mail_template_data.xml +git commit -m "feat(fusion_repairs): show maintenance fee in due-reminder email" +``` + +--- + +## Task 6: Expose policy fields in the category form + bump version + +**Files:** +- Modify: `fusion_repairs/views/repair_product_category_views.xml` +- Modify: `fusion_repairs/__manifest__.py` + +- [ ] **Step 1: Read the category form view** + +Run: +```bash +docker exec odoo-modsdev-app sh -c "grep -n 'fusion.repair.product.category' /mnt/odoo-modules/fusion_repairs/views/repair_product_category_views.xml | head" +``` +Locate the `
` for the category. + +- [ ] **Step 2: Add a Maintenance group to the form** + +Inside the category form sheet, add: +```xml + + + + + + + +``` + +- [ ] **Step 3: Bump the version** + +In `fusion_repairs/__manifest__.py`, change `'version': '19.0.2.2.6',` to `'version': '19.0.2.3.0',`. + +- [ ] **Step 4: Upgrade + run the full test module green** + +Run: +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_repairs -u fusion_repairs --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -40 +``` +Expected: all `TestMaintenanceFoundation` tests PASS, 0 failures, module loads. + +- [ ] **Step 5: Commit** +```bash +git add fusion_repairs/views/repair_product_category_views.xml fusion_repairs/__manifest__.py +git commit -m "feat(fusion_repairs): category maintenance-policy UI + version 19.0.2.3.0" +``` + +--- + +## Self-Review (against the spec) + +- **Spec §2 D2 (flat fee per type):** Tasks 1-2 (policy on category + product override), Task 4 (fee snapshot on contract), Task 5 (fee in email). ✓ +- **Spec §3.2 gap #1 (dead trigger):** Task 4 fixes + wires `_spawn_maintenance_contracts`. ✓ +- **Spec §3.2 gap #3 (no cost shown):** Task 5. ✓ +- **Spec §5.1 / §5.2 (policy + contract fields):** Tasks 1-3. ✓ +- **Spec §6.1 (new-sale path, delivery anchor, idempotent, serial when present):** Task 4 (`_fc_maintenance_anchor_date`, two-regime dedup, guarded serial capture). ✓ +- **Deferred to Plan 2:** `x_fc_maintenance_skill_id` (skills representation is §15 open item) — noted in File Structure. +- **No placeholders:** every code step shows complete code; the two "read first" steps (Tasks 5-6) target XML whose exact surrounding markup must be read live before editing, and give the exact snippet to insert. +- **Type consistency:** `x_fc_maintenance_fee` Monetary + `currency_id` used identically on category, product, contract; `_spawn_maintenance_contracts` / `_fc_maintenance_anchor_date` names consistent between maintenance_contract.py and the call site in repair_service_plan.py. + +--- + +## Roadmap — Plans 2–5 (write each when reached; each needs its own live-source reads per spec §15) + +- **Plan 2 — Technician-aware booking** (the largest build): read `fusion_tasks/models/technician_task.py` `_find_next_available_slot` (line 544) / `_get_available_gaps` (line 664) signatures + working-hours source; add `x_fc_maintenance_skill_id` to the category and confirm the `res.users.x_fc_repair_skills` representation; replace the `` booking page with a real slot-picker controller; on confirm create a `fusion.technician.task` (`task_type='maintenance'`) + the maintenance `repair.order`; double-book guard; office "Book maintenance" action; per-cycle `booking_token` regen in `roll_next_due_date`. Delivers: real self-serve booking. +- **Plan 3 — Maintenance visit log + checklist**: read the visit-report wizard + the inspection-certificate (M1) API; add `fusion.repair.maintenance.visit` + `fusion.repair.maintenance.checklist.line`; seed checklists per category; issue an inspection certificate for `safety_critical` categories. Delivers: queryable per-unit history + compliance proof. +- **Plan 4 — Backfill wizard** (two-regime, spec §6.2): `fusion.repair.maintenance.backfill.wizard`; serial dedup for ADP wheelchairs (guarded `fusion_claims` read), partner+base-product+sale-line dedup for lifts with accessory-line exclusion; stagger; dry-run report → execute. Delivers: the existing install base enrolled. +- **Plan 5 — Office follow-up crons**: `unbooked` + `overdue` crons gated on the existing `ir.config_parameter` toggles; per-row savepoint isolation. Delivers: staff nudges when clients don't self-serve.