From cc568b0ec8142558a263a3d615f07968ba728311 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 2 Jun 2026 01:27:38 -0400 Subject: [PATCH] docs(fusion_maintenance): Plan 1 (Foundation) implementation plan + Plans 2-5 roadmap TDD plan for the enrollment+pricing foundation: maintenance policy fields on the equipment category (+ product fee override), maintenance-contract extensions, fix+wire the dead _spawn_maintenance_contracts into the existing action_confirm (delivery-date anchor, two-regime serial dedup, fee snapshot), fee line in the reminder email, category UI, version 19.0.2.3.0. Grounded in real source. Plans 2-5 (booking on fusion_tasks, visit log + checklist, two-regime backfill, office crons) roadmapped. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...026-06-02-fusion-maintenance-foundation.md | 506 ++++++++++++++++++ 1 file changed, 506 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-02-fusion-maintenance-foundation.md diff --git a/docs/superpowers/plans/2026-06-02-fusion-maintenance-foundation.md b/docs/superpowers/plans/2026-06-02-fusion-maintenance-foundation.md new file mode 100644 index 00000000..9e35b45c --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-fusion-maintenance-foundation.md @@ -0,0 +1,506 @@ +# fusion_maintenance Foundation — Implementation Plan (Plan 1 of 5) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Confirming a sale of a maintainable product auto-creates a *priced* maintenance contract, and the due-reminder email shows the maintenance cost. + +**Architecture:** Extend `fusion_repairs`. A maintenance **policy** (enabled / interval / flat fee) lives on `fusion.repair.product.category`, with a per-product fee/interval override on `product.template`. We fix the dead `_spawn_maintenance_contracts()` (anchor on delivery date, capture serial + fee + provenance, dedup) and call it from the **existing** `action_confirm()` override. The branded reminder email gains a fee line. + +**Tech Stack:** Odoo 19 **Community**, Python, `TransactionCase`. Local dev: `docker odoo-modsdev-app`, DB `fusion-dev`. + +**Spec:** [`2026-06-02-fusion-maintenance-design.md`](../specs/2026-06-02-fusion-maintenance-design.md). This is **Plan 1 of 5**; see the Roadmap at the bottom for Plans 2–5 (booking, visit log, backfill, office crons) — each is written when reached because it needs its own live-source reads (spec §15). + +**Conventions (from CLAUDE.md):** new fields `x_fc_` prefix; Canadian English; Monetary = `$` + `currency_id`; declarative `models.Constraint` / `models.Index` (no `_sql_constraints`); `message_post` HTML wrapped in `Markup()`; `res.users` group field is `group_ids`. + +**Run tests:** +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_repairs \ + -u fusion_repairs --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60 +``` + +**Grounding (verified source, 2026-06-02):** +- [`maintenance_contract.py`](../../../fusion_repairs/models/maintenance_contract.py) — contract model (fields end at `company_id`, line 81; `_booking_token_unique` constraint line 83); dead `_spawn_maintenance_contracts()` (line 198, anchors on `today`, dedups by partner/product/SO, no fee/serial/source). +- [`repair_product_category.py`](../../../fusion_repairs/models/repair_product_category.py) — category model; `safety_critical`, `equipment_class`; `_code_unique` constraint line 56. +- [`product_template.py`](../../../fusion_repairs/models/product_template.py) — `x_fc_repair_category_id` (line 11), `x_fc_maintenance_interval_months` (line 23, default 0). +- [`repair_service_plan.py`](../../../fusion_repairs/models/repair_service_plan.py) — **existing** `action_confirm()` override (line 229) ending `return res` (line 250); wire the maintenance spawn here. + +--- + +## File Structure + +- **Modify** `fusion_repairs/models/repair_product_category.py` — add maintenance-policy fields + `currency_id`. +- **Modify** `fusion_repairs/models/product_template.py` — add `x_fc_maintenance_fee` override. +- **Modify** `fusion_repairs/models/maintenance_contract.py` — add contract fields + indexes; add `_fc_maintenance_anchor_date`; rewrite `_spawn_maintenance_contracts`. +- **Modify** `fusion_repairs/models/repair_service_plan.py` — call `self._spawn_maintenance_contracts()` inside `action_confirm`. +- **Modify** `fusion_repairs/data/mail_template_data.xml` — add a fee row to the reminder template. +- **Modify** `fusion_repairs/views/repair_product_category_views.xml` — expose the policy fields. +- **Create** `fusion_repairs/tests/__init__.py`, `fusion_repairs/tests/test_maintenance_foundation.py`. +- **Modify** `fusion_repairs/__manifest__.py` — bump `version` to `19.0.2.3.0`. + +> **Scope note:** the technician-skill field (`x_fc_maintenance_skill_id`) is deferred to **Plan 2 (booking)** because skill matching is a booking concern and the exact skills representation is an open item (spec §15). Plan 1 is enrollment + pricing only. + +--- + +## Task 1: Maintenance policy fields on the equipment category + +**Files:** +- Modify: `fusion_repairs/models/repair_product_category.py` (insert after `intake_template_id`, before `_code_unique` at line 56) +- Test: `fusion_repairs/tests/test_maintenance_foundation.py` + +- [ ] **Step 1: Create the tests package + write the failing test** + +Create `fusion_repairs/tests/__init__.py`: +```python +from . import test_maintenance_foundation +``` + +Create `fusion_repairs/tests/test_maintenance_foundation.py`: +```python +# -*- coding: utf-8 -*- +from odoo.tests import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestMaintenanceFoundation(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env['res.partner'].create({'name': 'Mrs. Test Client'}) + cls.category = cls.env['fusion.repair.product.category'].create({ + 'name': 'Stair Lift', 'code': 'stairlift', + 'equipment_class': 'lift_elevating', 'safety_critical': True, + 'x_fc_maintenance_enabled': True, + 'x_fc_maintenance_interval_months': 6, + 'x_fc_maintenance_fee': 149.0, + }) + + def test_category_policy_fields_exist(self): + self.assertTrue(self.category.x_fc_maintenance_enabled) + self.assertEqual(self.category.x_fc_maintenance_interval_months, 6) + self.assertEqual(self.category.x_fc_maintenance_fee, 149.0) + self.assertTrue(self.category.currency_id) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_repairs -u fusion_repairs --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -40 +``` +Expected: FAIL — `Invalid field 'x_fc_maintenance_enabled' on model 'fusion.repair.product.category'`. + +- [ ] **Step 3: Add the policy fields** + +In `repair_product_category.py`, insert before the `_code_unique = models.Constraint(...)` line: +```python + # ── Maintenance policy (per equipment type) ────────────────────────── + x_fc_maintenance_enabled = fields.Boolean( + string='Offer Maintenance', + help='If set, units in this category are enrolled in recurring preventive ' + 'maintenance on sale (and via the backfill wizard).', + ) + x_fc_maintenance_interval_months = fields.Integer( + string='Maintenance Interval (Months)', default=6, + help='Default months between preventive maintenance visits for this category. ' + 'Overridden by the product field of the same name when that is > 0.', + ) + currency_id = fields.Many2one( + 'res.currency', string='Currency', + default=lambda self: self.env.company.currency_id, + ) + x_fc_maintenance_fee = fields.Monetary( + string='Maintenance Fee', currency_field='currency_id', + help='Flat fee shown to the client for a maintenance visit of this equipment type.', + ) + x_fc_maintenance_service_product_id = fields.Many2one( + 'product.product', string='Maintenance Service Product', + help='Optional product used when drafting the priced visit line (Plan 2). ' + 'Falls back to a generic visit product.', + ) +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run the same command as Step 2. Expected: `test_category_policy_fields_exist` PASS. + +- [ ] **Step 5: Commit** +```bash +git add fusion_repairs/models/repair_product_category.py fusion_repairs/tests/ +git commit -m "feat(fusion_repairs): maintenance policy fields on equipment category" +``` + +--- + +## Task 2: Per-product fee override + +**Files:** +- Modify: `fusion_repairs/models/product_template.py` (after `x_fc_maintenance_interval_months`, line 28) +- Test: `fusion_repairs/tests/test_maintenance_foundation.py` + +- [ ] **Step 1: Write the failing test** (append to the test class) +```python + def test_product_fee_override_field_exists(self): + tmpl = self.env['product.template'].create({ + 'name': 'Handicare Freecurve Stairlift', + 'x_fc_repair_category_id': self.category.id, + 'x_fc_maintenance_fee': 199.0, + }) + self.assertEqual(tmpl.x_fc_maintenance_fee, 199.0) +``` + +- [ ] **Step 2: Run to verify it fails** + +Run the test command. Expected: FAIL — `Invalid field 'x_fc_maintenance_fee' on model 'product.template'`. + +- [ ] **Step 3: Add the field** + +In `product_template.py`, after the `x_fc_maintenance_interval_months` field (line 28): +```python + x_fc_maintenance_fee = fields.Monetary( + string='Maintenance Fee (override)', currency_field='currency_id', + help='Per-product override of the category maintenance fee. 0 = use the category fee.', + ) +``` +(`product.template` already provides `currency_id`.) + +- [ ] **Step 4: Run to verify it passes** — `test_product_fee_override_field_exists` PASS. + +- [ ] **Step 5: Commit** +```bash +git add fusion_repairs/models/product_template.py fusion_repairs/tests/test_maintenance_foundation.py +git commit -m "feat(fusion_repairs): per-product maintenance fee override" +``` + +--- + +## Task 3: Contract model extensions (fee, source, serial, policy) + +**Files:** +- Modify: `fusion_repairs/models/maintenance_contract.py` (add fields after `company_id`, line 81; add indexes near `_booking_token_unique`, line 83) +- Test: `fusion_repairs/tests/test_maintenance_foundation.py` + +- [ ] **Step 1: Write the failing test** +```python + def test_contract_extension_fields_exist(self): + c = self.env['fusion.repair.maintenance.contract'].create({ + 'partner_id': self.partner.id, + 'product_id': self.env['product.product'].create({'name': 'Unit'}).id, + 'next_due_date': '2026-12-01', + 'x_fc_source': 'sale', + 'x_fc_device_serial': 'SN-123', + 'x_fc_maintenance_fee': 149.0, + }) + self.assertEqual(c.x_fc_source, 'sale') + self.assertEqual(c.x_fc_device_serial, 'SN-123') + self.assertEqual(c.x_fc_maintenance_fee, 149.0) +``` + +- [ ] **Step 2: Run to verify it fails** — `Invalid field 'x_fc_source' ...`. + +- [ ] **Step 3: Add the fields + indexes** + +In `maintenance_contract.py`, after the `company_id` field (line 81), before `_booking_token_unique`: +```python + currency_id = fields.Many2one( + 'res.currency', default=lambda self: self.env.company.currency_id, + ) + x_fc_maintenance_fee = fields.Monetary( + string='Maintenance Fee', currency_field='currency_id', + help='Flat fee shown to the client for this maintenance visit.', + ) + x_fc_source = fields.Selection( + [('sale', 'New Sale'), ('backfill', 'Backfill'), + ('claims', 'Claims Bridge'), ('manual', 'Manual')], + string='Source', default='manual', index=True, + ) + x_fc_source_sale_line_id = fields.Many2one( + 'sale.order.line', string='Source Sale Line', index=True, copy=False, + ) + x_fc_device_serial = fields.Char(string='Serial (text)', index=True, copy=False) + x_fc_policy_category_id = fields.Many2one( + 'fusion.repair.product.category', string='Maintenance Policy', + ) +``` +(Idempotency is enforced in Python — Task 4 — to support the two-regime dedup in spec §6.2; the `index=True` above covers lookups.) + +- [ ] **Step 4: Run to verify it passes** — `test_contract_extension_fields_exist` PASS. + +- [ ] **Step 5: Commit** +```bash +git add fusion_repairs/models/maintenance_contract.py fusion_repairs/tests/test_maintenance_foundation.py +git commit -m "feat(fusion_repairs): maintenance contract fee/source/serial/policy fields" +``` + +--- + +## Task 4: Spawn priced contracts on sale confirm (fix the dead trigger + wire it) + +**Files:** +- Modify: `fusion_repairs/models/maintenance_contract.py` (rewrite `_spawn_maintenance_contracts`, lines 198-227; add `_fc_maintenance_anchor_date` helper) +- Modify: `fusion_repairs/models/repair_service_plan.py` (call it in `action_confirm`, before `return res` at line 250) +- Test: `fusion_repairs/tests/test_maintenance_foundation.py` + +- [ ] **Step 1: Write the failing tests** +```python + def _make_product(self, **kw): + vals = {'name': 'Stairlift Unit', 'type': 'consu', + 'x_fc_repair_category_id': self.category.id} + vals.update(kw) + return self.env['product.product'].create(vals) + + def _confirm_so(self, product, commitment='2026-01-10'): + so = self.env['sale.order'].create({ + 'partner_id': self.partner.id, + 'commitment_date': commitment, + 'order_line': [(0, 0, {'product_id': product.id, 'product_uom_qty': 1})], + }) + so.action_confirm() + return so + + def _contracts_for(self, so): + return self.env['fusion.repair.maintenance.contract'].search( + [('original_sale_order_id', '=', so.id)]) + + def test_no_contract_when_category_not_maintainable(self): + cat = self.env['fusion.repair.product.category'].create( + {'name': 'Cane', 'code': 'cane', 'x_fc_maintenance_enabled': False}) + so = self._confirm_so(self._make_product(x_fc_repair_category_id=cat.id)) + self.assertFalse(self._contracts_for(so)) + + def test_contract_created_via_category_policy(self): + so = self._confirm_so(self._make_product()) + contracts = self._contracts_for(so) + self.assertEqual(len(contracts), 1) + c = contracts + self.assertEqual(c.interval_months, 6) + self.assertEqual(c.x_fc_maintenance_fee, 149.0) + self.assertEqual(c.x_fc_source, 'sale') + self.assertEqual(c.x_fc_policy_category_id, self.category) + # anchor = commitment_date + 6 months + self.assertEqual(str(c.next_due_date), '2026-07-10') + + def test_product_override_beats_category(self): + p = self._make_product() + p.product_tmpl_id.x_fc_maintenance_interval_months = 3 + p.product_tmpl_id.x_fc_maintenance_fee = 199.0 + so = self._confirm_so(p) + c = self._contracts_for(so) + self.assertEqual(c.interval_months, 3) + self.assertEqual(c.x_fc_maintenance_fee, 199.0) + + def test_idempotent_on_reconfirm(self): + p = self._make_product() + so = self._confirm_so(p) + so._spawn_maintenance_contracts() # call again + self.assertEqual(len(self._contracts_for(so)), 1) +``` + +- [ ] **Step 2: Run to verify they fail** — contracts not created (trigger not wired) → assertions fail. + +- [ ] **Step 3: Rewrite `_spawn_maintenance_contracts` + add the anchor helper** + +Replace the body of `_spawn_maintenance_contracts` (lines 198-227) and add the helper, in the `SaleOrder` class of `maintenance_contract.py`: +```python + def _fc_maintenance_anchor_date(self, line): + """Best-available delivery anchor: commitment_date -> date_order -> today. + (Non-ADP/lift units lack a delivery date; this fallback chain handles them.)""" + so = line.order_id + anchor = so.commitment_date or so.date_order + return fields.Date.to_date(anchor) if anchor else fields.Date.context_today(self) + + def _spawn_maintenance_contracts(self): + """Create a priced maintenance contract per maintainable unit on a confirmed SO. + Policy = product interval override, else the product's category policy. + Idempotent: by serial when captured, else by source sale line.""" + Contract = self.env['fusion.repair.maintenance.contract'].sudo() + for so in self: + if so.state not in ('sale', 'done'): + continue + for line in so.order_line: + product = line.product_id + if not product: + continue + tmpl = product.product_tmpl_id + category = tmpl.x_fc_repair_category_id + product_interval = tmpl.x_fc_maintenance_interval_months or 0 + cat_enabled = bool(category) and category.x_fc_maintenance_enabled + interval = product_interval or ( + category.x_fc_maintenance_interval_months if cat_enabled else 0) + if interval <= 0 or not (product_interval > 0 or cat_enabled): + continue + fee = tmpl.x_fc_maintenance_fee or ( + category.x_fc_maintenance_fee if category else 0.0) + # Capture serial only if fusion_claims' line field is present. + serial = '' + if 'x_fc_serial_number' in line._fields: + serial = (line.x_fc_serial_number or '').strip() + # Idempotency: serial regime vs source-line regime (spec §6.2). + if serial: + dedup = [('state', '=', 'active'), ('x_fc_device_serial', '=', serial)] + else: + dedup = [('state', '=', 'active'), + ('x_fc_source_sale_line_id', '=', line.id)] + if Contract.search_count(dedup): + continue + anchor = so._fc_maintenance_anchor_date(line) + # One contract per serialized unit; without a serial, per quantity. + count = 1 if serial else max(int(line.product_uom_qty or 1), 1) + for _i in range(count): + Contract.create({ + 'partner_id': so.partner_id.id, + 'product_id': product.id, + 'original_sale_order_id': so.id, + 'x_fc_source_sale_line_id': line.id, + 'x_fc_source': 'sale', + 'x_fc_device_serial': serial, + 'x_fc_policy_category_id': category.id if category else False, + 'interval_months': interval, + 'x_fc_maintenance_fee': fee, + 'next_due_date': anchor + relativedelta(months=interval), + 'state': 'active', + }) +``` + +- [ ] **Step 4: Wire it into the existing `action_confirm`** + +In `repair_service_plan.py`, in `action_confirm`, change line 249-250 from: +```python + self._fc_spawn_labor_warranties() + return res +``` +to: +```python + self._fc_spawn_labor_warranties() + self._spawn_maintenance_contracts() + return res +``` + +- [ ] **Step 5: Run to verify the Task-4 tests pass** — all four PASS. + +- [ ] **Step 6: Commit** +```bash +git add fusion_repairs/models/maintenance_contract.py fusion_repairs/models/repair_service_plan.py fusion_repairs/tests/test_maintenance_foundation.py +git commit -m "feat(fusion_repairs): spawn priced maintenance contracts on sale confirm" +``` + +--- + +## Task 5: Show the fee in the reminder email + +**Files:** +- Modify: `fusion_repairs/data/mail_template_data.xml` (the `email_template_maintenance_due_reminder` record) + +- [ ] **Step 1: Read the current template** + +Run: +```bash +docker exec odoo-modsdev-app sh -c "grep -n 'email_template_maintenance_due_reminder' /mnt/odoo-modules/fusion_repairs/data/mail_template_data.xml" +``` +Then open that record's `` and find the equipment-name / due-date details table (the green-accent reminder). + +- [ ] **Step 2: Add a fee row to the details table** + +Inside the details table of the reminder body, after the "Next due" row, add (Canadian English, `$` + currency): +```xml + + Maintenance fee + + + applicable tax + +``` + +- [ ] **Step 3: Upgrade + manually verify the rendered email** + +Run: +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev -u fusion_repairs --stop-after-init +``` +Then in odoo-shell render the template for a contract with a fee and confirm the fee line appears: +```bash +docker exec odoo-modsdev-app odoo shell -d fusion-dev --no-http <<'PY' +c = env['fusion.repair.maintenance.contract'].search([('x_fc_maintenance_fee','>',0)], limit=1) +tpl = env.ref('fusion_repairs.email_template_maintenance_due_reminder') +print('FEE' if 'applicable tax' in tpl._render_field('body_html', c.ids)[c.id] else 'MISSING') +PY +``` +Expected: `FEE`. + +- [ ] **Step 4: Commit** +```bash +git add fusion_repairs/data/mail_template_data.xml +git commit -m "feat(fusion_repairs): show maintenance fee in due-reminder email" +``` + +--- + +## Task 6: Expose policy fields in the category form + bump version + +**Files:** +- Modify: `fusion_repairs/views/repair_product_category_views.xml` +- Modify: `fusion_repairs/__manifest__.py` + +- [ ] **Step 1: Read the category form view** + +Run: +```bash +docker exec odoo-modsdev-app sh -c "grep -n 'fusion.repair.product.category' /mnt/odoo-modules/fusion_repairs/views/repair_product_category_views.xml | head" +``` +Locate the `
` for the category. + +- [ ] **Step 2: Add a Maintenance group to the form** + +Inside the category form sheet, add: +```xml + + + + + + + +``` + +- [ ] **Step 3: Bump the version** + +In `fusion_repairs/__manifest__.py`, change `'version': '19.0.2.2.6',` to `'version': '19.0.2.3.0',`. + +- [ ] **Step 4: Upgrade + run the full test module green** + +Run: +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_repairs -u fusion_repairs --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -40 +``` +Expected: all `TestMaintenanceFoundation` tests PASS, 0 failures, module loads. + +- [ ] **Step 5: Commit** +```bash +git add fusion_repairs/views/repair_product_category_views.xml fusion_repairs/__manifest__.py +git commit -m "feat(fusion_repairs): category maintenance-policy UI + version 19.0.2.3.0" +``` + +--- + +## Self-Review (against the spec) + +- **Spec §2 D2 (flat fee per type):** Tasks 1-2 (policy on category + product override), Task 4 (fee snapshot on contract), Task 5 (fee in email). ✓ +- **Spec §3.2 gap #1 (dead trigger):** Task 4 fixes + wires `_spawn_maintenance_contracts`. ✓ +- **Spec §3.2 gap #3 (no cost shown):** Task 5. ✓ +- **Spec §5.1 / §5.2 (policy + contract fields):** Tasks 1-3. ✓ +- **Spec §6.1 (new-sale path, delivery anchor, idempotent, serial when present):** Task 4 (`_fc_maintenance_anchor_date`, two-regime dedup, guarded serial capture). ✓ +- **Deferred to Plan 2:** `x_fc_maintenance_skill_id` (skills representation is §15 open item) — noted in File Structure. +- **No placeholders:** every code step shows complete code; the two "read first" steps (Tasks 5-6) target XML whose exact surrounding markup must be read live before editing, and give the exact snippet to insert. +- **Type consistency:** `x_fc_maintenance_fee` Monetary + `currency_id` used identically on category, product, contract; `_spawn_maintenance_contracts` / `_fc_maintenance_anchor_date` names consistent between maintenance_contract.py and the call site in repair_service_plan.py. + +--- + +## Roadmap — Plans 2–5 (write each when reached; each needs its own live-source reads per spec §15) + +- **Plan 2 — Technician-aware booking** (the largest build): read `fusion_tasks/models/technician_task.py` `_find_next_available_slot` (line 544) / `_get_available_gaps` (line 664) signatures + working-hours source; add `x_fc_maintenance_skill_id` to the category and confirm the `res.users.x_fc_repair_skills` representation; replace the `` booking page with a real slot-picker controller; on confirm create a `fusion.technician.task` (`task_type='maintenance'`) + the maintenance `repair.order`; double-book guard; office "Book maintenance" action; per-cycle `booking_token` regen in `roll_next_due_date`. Delivers: real self-serve booking. +- **Plan 3 — Maintenance visit log + checklist**: read the visit-report wizard + the inspection-certificate (M1) API; add `fusion.repair.maintenance.visit` + `fusion.repair.maintenance.checklist.line`; seed checklists per category; issue an inspection certificate for `safety_critical` categories. Delivers: queryable per-unit history + compliance proof. +- **Plan 4 — Backfill wizard** (two-regime, spec §6.2): `fusion.repair.maintenance.backfill.wizard`; serial dedup for ADP wheelchairs (guarded `fusion_claims` read), partner+base-product+sale-line dedup for lifts with accessory-line exclusion; stagger; dry-run report → execute. Delivers: the existing install base enrolled. +- **Plan 5 — Office follow-up crons**: `unbooked` + `overdue` crons gated on the existing `ir.config_parameter` toggles; per-row savepoint isolation. Delivers: staff nudges when clients don't self-serve.