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

22 KiB
Raw Blame History

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 (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 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, 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 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): 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_tasks availability engine: _find_next_available_slot(tech_id, date, ...) and _get_available_gaps(tech_id, date, ...)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) 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.