Merge fusion_maintenance brainstorm, design spec & Plan 1 into main

Docs only: the fusion_maintenance brief (+ Westin Step 0 / install-base sizing), the approved design spec (build into fusion_repairs; flat-fee per type; new-sale trigger + two-regime backfill; technician-aware booking on fusion_tasks), and Plan 1 (Foundation) + Plans 2-5 roadmap. Implementation pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-06-02 01:33:50 -04:00
3 changed files with 998 additions and 0 deletions

View File

@@ -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 25 (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 `<field name="body_html">` 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
<tr t-if="object.x_fc_maintenance_fee">
<td style="opacity:0.6;width:35%;">Maintenance fee</td>
<td><span t-field="object.x_fc_maintenance_fee"
t-options='{"widget": "monetary", "display_currency": object.currency_id}'/>
<span style="opacity:0.6;"> + applicable tax</span></td>
</tr>
```
- [ ] **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 `<form>` for the category.
- [ ] **Step 2: Add a Maintenance group to the form**
Inside the category form sheet, add:
```xml
<group string="Maintenance Policy">
<field name="x_fc_maintenance_enabled"/>
<field name="x_fc_maintenance_interval_months"
invisible="not x_fc_maintenance_enabled"/>
<field name="x_fc_maintenance_fee"
invisible="not x_fc_maintenance_enabled"/>
<field name="x_fc_maintenance_service_product_id"
invisible="not x_fc_maintenance_enabled"/>
<field name="currency_id" invisible="1"/>
</group>
```
- [ ] **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 25 (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 `<input type="date">` 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.

View File

@@ -0,0 +1,298 @@
# 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.