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>
22 KiB
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 (brief + Step 0 + sizing), 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:
- The system automatically emails the client when a unit is due for maintenance.
- The client can book the visit themselves (real‑time, self‑serve, no login) or call the office and staff book it for them.
- The booking lands in our scheduling/calendar as a real technician job.
- The technician accesses and updates the maintenance log on the visit; the system keeps the full history per unit.
- The next maintenance is auto‑rescheduled → recurring loop.
- The client is told the cost up front.
- Outcome: clients stay on track and Westin gains recurring revenue.
- 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, technician_task.py, 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 viarelativedelta.- Reminder cron
cron_send_due_reminders— daily, 30/7/1‑day bands, per‑band dedup, queued branded emailemail_template_maintenance_due_reminderwith the tokenized link. - Public booking controller
/repairs/maintenance/book/<token>—auth='public', token‑validated, already‑booked guard, thanks page. create_repair_from_booking()— spawns arepair.order(x_fc_intake_source='client_portal'), linksx_fc_maintenance_contract_id, dedups.- Roll‑forward on technician task completion (
technician_task.py:88): when atask_type='maintenance'task →status='completed', setslast_service_date, callsroll_next_due_date(), posts chatter. This is the recurring loop. - Pre‑paid service‑plan subscriptions (
fusion.repair.service.plan.subscription) wired tosale.order.action_confirm()+ visit burn engine (revenue primitive; optional here). - Rate card (
fusion.repair.callout.rate, standard vslift_elevating),repair.order.x_fc_quote_total. - Equipment category taxonomy (
fusion.repair.product.category): stairlift / porch_lift / lift_chair flaggedequipment_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_tasksavailability engine:_find_next_available_slot(tech_id, date, ...)and_get_available_gaps(tech_id, date, ...)— route‑aware (tech start address + geocoding + travel). Tech skills onres.users.x_fc_repair_skills.
3.2 The 4 gaps this spec closes
- Contract auto‑creation trigger is dead code —
_spawn_maintenance_contracts()is defined onsale.order(maintenance_contract.py:198) but never called. Noaction_confirmoverride invokes it → no contracts exist today. - 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. - No cost shown to the client anywhere (email or booking page).
- No auto tech‑task creation, no structured maintenance log, no office‑follow‑up crons
(
ir.config_parametertoggles 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_numberis 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 (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).
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 tores.users.x_fc_repair_skills). If skills are already category‑based (a tech'sx_fc_repair_skillsare 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(M2Oproduct.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(M2Osale.order.line) — provenance + idempotency.x_fc_device_serial(Char, indexed) — idempotency key (esp. for claims/backfill where no lot).x_fc_policy_category_id(M2Ofusion.repair.product.category).- Constraint: at most one active contract per
(x_fc_device_serial)(or per source sale line when serial absent) — declarativemodels.Constraint/ partialmodels.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(M2Ofusion.technician.task),repair_order_id(M2Orepair.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 tofusion.repair.maintenance.checklist.line: label, resultpass/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 forsafety_criticalcategories).- "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
activecontract per unit (respect quantity),x_fc_source='sale',x_fc_source_sale_line_idset, 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.linefor 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_claimsserial/device_typedata — soft dep, guarded; map ADPdevice_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_enabledflag 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.
- Serial‑tracked (ADP wheelchairs/power chairs, via the
- Stagger the first
next_due_dateacross 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)
- Reminder email (existing branded template, + fee line added) → tokenized link.
- Public slot‑picker 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_skillsinclude the policy'sx_fc_maintenance_skill_id. - Calls
fusion_tasks_get_available_gaps/_find_next_available_slotper 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).
- 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) + thefusion.repair.maintenance.visit(statescheduled, checklist seeded from the category). - Send the branded confirmation email (date/window/tech, fee, what to expect).
- Set
booking_repair_id(dedup).
- 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.contractform 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 viafusion_poynt(existingaction_collect_paymenton 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.visitrecord. - For
safety_criticalcategories (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, resetlast_reminder_band, regenerate token, visit →done. - Next cycle's reminder fires automatically when
next_due_datere‑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_datepassed with no completed visit in the cycle → escalation activity. - Driven by the existing
ir.config_parametertoggles indata/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/equipmentclient 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 separateself.env.registry.cursor()so it survives rollback (CLAUDE.md audit rule). message_postHTML bodies wrapped inMarkup()(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.orderconfirm (enabled vs disabled category; quantity; idempotency). - 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.
- 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
- Land on local dev, full E2E + tests green.
- Deploy
fusion_repairsto Westin (odoo-westin/westin-v19) — the accepted bigger lift (first production deploy of fusion_repairs; verify rate‑card numbers, ACLs, asset bundles). - Configure maintainable categories:
x_fc_maintenance_enabled, interval, fee, skill, service product — for lifts (stairlift/porch/lift chair) + power & manual wheelchairs. - Ensure technicians have
x_fc_repair_skills+ start addresses (for availability/routing). - Run the backfill wizard dry‑run → review report → execute (staggered).
- 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 modellingx_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.
- 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_enabledlands on base units only), plus the lift interval/fee per category. Lift products aren't yet tagged withfusion_repairscategories on Westin (module not deployed there) — categorization is a deploy step. fusion_claimsdevice_type → maintenance‑category mapping table for the wheelchair backfill.
16. Build sequence (for the implementation plan)
- Policy + fee data model (category fields, product override, contract extensions, constraints).
- Path A trigger (wire
_spawn_maintenance_contractsintoaction_confirm, fee resolution, anchor fallback) + tests. - Cost in email (add fee to the reminder template).
- Technician‑aware booking (slot‑picker page + controller on
fusion_tasksavailability; task/repair/visit creation; double‑book guard; office action; token regen) + tests — the largest unit. - Maintenance visit log + checklist (model, per‑category seed, visit‑report‑wizard extension, inspection‑cert link) + tests.
- Backfill wizard (scan/dedup/stagger/dry‑run; fusion_claims soft bridge) + tests.
- Office follow‑up crons (unbooked/overdue) + tests.
- Deploy + configure + backfill on Westin.