Files
Odoo-Modules/docs/superpowers/plans/2026-06-02-fusion-maintenance-foundation.md
gsinghpal cc568b0ec8 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) <noreply@anthropic.com>
2026-06-02 01:27:38 -04:00

24 KiB
Raw Blame History

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. 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:

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 — 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 — category model; safety_critical, equipment_class; _code_unique constraint line 56.
  • product_template.pyx_fc_repair_category_id (line 11), x_fc_maintenance_interval_months (line 23, default 0).
  • repair_service_plan.pyexisting 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:

from . import test_maintenance_foundation

Create fusion_repairs/tests/test_maintenance_foundation.py:

# -*- 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:

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:

    # ── 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
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)

    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):

    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 passestest_product_fee_override_field_exists PASS.

  • Step 5: Commit

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

    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 failsInvalid 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:

    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 passestest_contract_extension_fields_exist PASS.

  • Step 5: Commit

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

    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:

    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:

        self._fc_spawn_labor_warranties()
        return res

to:

        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

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:

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):

<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:

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:

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
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:

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:

<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:

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
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.