Files
Odoo-Modules/docs/superpowers/specs/2026-06-02-fusion-maintenance-design.md
gsinghpal 17d21bffb5 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) <noreply@anthropic.com>
2026-06-02 01:21:08 -04:00

299 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# fusion_maintenance — Design Spec
> Automated preventivemaintenance followups + selfserve realtime 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 20260602). 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 **16 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** (realtime, selfserve, 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 autorescheduled** → 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 pervisit revenue. Configured per equipment **category** with perproduct 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 | **Technicianaware picker on `fusion_tasks`** (NOT Enterprise `appointment`) | Clients see only slots a qualified tech is genuinely free for (route/skillaware); booking creates the technician task directly — one scheduling world, no appointment↔task bridge. Bonus: **no Enterprise dependency → Communitytestable 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/1day** bands, perband dedup, queued
branded email `email_template_maintenance_due_reminder` with the tokenized link.
- Public booking controller `/repairs/maintenance/book/<token>``auth='public'`, tokenvalidated,
alreadybooked guard, thanks page.
- `create_repair_from_booking()` — spawns a `repair.order` (`x_fc_intake_source='client_portal'`),
links `x_fc_maintenance_contract_id`, dedups.
- **Rollforward** 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.**
- Prepaid **serviceplan 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.
- Visitreport 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) — **routeaware** (tech start address + geocoding + travel). Tech skills on `res.users.x_fc_repair_skills`.
### 3.2 The 4 gaps this spec closes
1. **Contract autocreation 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 `<input type="date">` ("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 techtask creation, no structured maintenance log, no officefollowup crons**
(`ir.config_parameter` toggles exist; no cron/Python).
### 3.3 Installbase sizing (Westin live, 20260602)
- Serial numbers are captured **~only on real equipment** (parts have 0 serials) → `x_fc_serial_number`
is a defacto "trackable unit" marker and the natural **idempotency key**.
- ADPside base ≈ **138 serialtracked units / ~136 customers** (walkers 68, wheelchairs 45, power
bases 7, scooters 4, +14 nodevicetype). Funders: adp 109, direct_private 13, adp_odsp 10,
march_of_dimes 7. Deliveries 202210 → 202605.
- **Lifts (sized 20260602; namebased, 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 liftchair). So the serialasunitkey
approach that works for ADP wheelchairs **does NOT work for lifts** — lifts must be keyed by
(partner + baseunit 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); nonADP units
lack `x_fc_adp_delivery_date` (need an invoice/orderdate fallback anchor).
## 4. Architecture
Extend `fusion_repairs`. No new module, no new toplevel 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/testruns without `fusion_claims` on local dev.
Reuse map: contract engine (extend), `fusion.technician.task` (booking target + availability +
rollforward), `repair.order` (visit container/pricing/Poynt), inspection certificate (lift
compliance), visitreport 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 (16+).
- `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 categorybased** (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.
**Perproduct 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 pervisit 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
nonADP 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 (onetime wizard, idempotent)
`fusion.repair.maintenance.backfill.wizard`:
- **Scan** historical `sale.order.line` for products whose category/product is maintenanceenabled and
were delivered. **Two unitidentity regimes**, because lifts carry no serials (§3.3):
- **Serialtracked** (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**.
- **Nonserial** (lifts — stair/porch/VPL/liftchair): do **NOT** require a serial. One contract per
**baseunit line**, **dedup by (partner + maintainable product + source sale line)**. The perproduct
`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 addons.
- **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.
- **Dryrun first**: produce a report (counts by category, # new vs alreadyenrolled, # 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 selfserve (no login)
1. Reminder email (existing branded template, **+ fee line added**) → tokenized link.
2. Public slotpicker page (extend the existing `/repairs/maintenance/book/<token>` 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 ~23 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:
- **Revalidate** the slot is still free (gap check) — if taken/expired, rerender slots with a
gentle notice (prevents doublebooking).
- Create a `fusion.technician.task` (`task_type='maintenance'`) on that slot, **assigned to the
qualified tech** (autoassignment by availability+skill), linked to the contract.
- Spawn/link the maintenancetype `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. **Noslot 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
slotpicker 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
slotpicker 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: **payatdoor via `fusion_poynt`**
(existing `action_collect_payment` on the repair) or invoice after the visit.
- Recurring revenue = one priced visit per cycle; the rollforward arms the next cycle automatically.
(Prepaid 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 visitreport 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 **rollforward**: `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` reenters the 30day band.
## 10. Office followup crons (togglegated, 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`.
- Perrow **savepoint** isolation inside the cron loop (no `cr.commit()` in tests — CLAUDE.md #14).
## 11. Out of scope (v1 — YAGNI)
- SMS reminders / twoway SMS booking (needs `fusion_ringcentral`).
- Loggedin `/my/equipment` client portal (X5).
- Prepaid annual maintenanceplan autoupsell at booking.
- Full multistop route optimization / batching (we use pertech availability + proximity ranking,
not a global optimizer).
- ADP funder rebilling of maintenance (maintenance is privatepay flat fee in v1).
## 12. Error handling & edge cases
- **Doublebooking:** revalidate the gap at confirm; lose the race → reshow slots.
- **Token:** percycle regeneration; invalid/expired/alreadybooked → friendly pages (exist, extend).
- **No qualified tech / no slots:** callback fallback, not an error page.
- **Backfill:** dryrun + report; strict serial dedup; stagger; fallback anchor chain; never email on
dryrun.
- **Missing data:** units with no device_type/category → excluded from autobackfill, 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 Communitytestable** on
`odoo-modsdev`. `TransactionCase` coverage:
- Contract spawn on `sale.order` confirm (enabled vs disabled category; quantity; idempotency).
- Backfill wizard: **tworegime dedup** (serial for wheelchairs; partner+product+line for lifts), accessoryline exclusion, stagger, dryrun produces no records, anchor fallback.
- Booking: slot list comes from real gaps; confirm creates task+repair+visit; **doublebook guard**;
noslot fallback.
- Rollforward on completion: dates advance, band reset, **token regenerated**, visit → done.
- Crons: reminder bands; unbooked/overdue followups (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 ratecard 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 dryrun → 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 visitreport wizard's current fields/flow before extending it with the checklist.
- The inspectioncertificate issue API (how M1 creates a certificate) for the lift link.
- **Lift base sized** (§3.3): ~254 stairlift + ~30 porch/VPL + ~41 liftchair 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 → maintenancecategory 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. **Technicianaware booking** (slotpicker page + controller on `fusion_tasks` availability; task/repair/visit creation; doublebook guard; office action; token regen) + tests — the largest unit.
5. **Maintenance visit log + checklist** (model, percategory seed, visitreportwizard extension, inspectioncert link) + tests.
6. **Backfill wizard** (scan/dedup/stagger/dryrun; fusion_claims soft bridge) + tests.
7. **Office followup crons** (unbooked/overdue) + tests.
8. **Deploy + configure + backfill** on Westin.