Files
Odoo-Modules/docs/superpowers/plans/2026-06-03-service-rates-foundation-plan.md
gsinghpal f0400114f9 docs(service-booking): add spec, plans, mockup, and clone-verify script
Kickoff brief, design spec, both implementation plans (rates foundation +
booking wizard), the UI mockup, and the hands-off Westin clone-verify/deploy
script for the Technician Service Booking feature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:20:36 -04:00

35 KiB
Raw Blame History

Service Rates Foundation — Implementation Plan (Plan 1 of 2)

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: Add an editable fusion.service.rate table (the Westin rate card, admin-managed from a Service Rates menu) that the booking wizard (Plan 2) will price from.

Architecture: A new fusion.service.rate model in fusion_claims (owns SO + products). Each row holds an editable price and links to a product.product (for SO-line description/tax/account). Seeded once (noupdate=1) from the rate card; admins own it thereafter. Two resolver methods (get_callout, get_rate) are the read API for Plan 2.

Tech Stack: Odoo 19 (Python ORM, declarative models.Constraint, XML data/views, TransactionCase).

Spec: docs/superpowers/specs/2026-06-03-technician-service-booking-design.md (§3, §6.1).


⚠️ Testing reality (read before executing)

fusion_claims is Enterprise-only (depends ai) → it cannot install on local odoo-modsdev (Community). Tests here are real TransactionCase tests but they run on a Westin Enterprise clone (see the repo CLAUDE.md Westin Prod — Clone-Verify section). Canonical run on the clone host:

docker exec odoo-dev-app odoo -d westin-v19-ratetest --test-enable --test-tags /fusion_claims \
  -u fusion_claims --stop-after-init --no-http --workers 0 --log-level=test \
  --db_host db --db_user odoo --db_password 'DevSecure2025!' 2>&1 | tail -60

Where a step says "Run the test", it means on the clone. If the clone isn't available during a step, verify the logic via odoo shell -d <clone> instead and check the box once confirmed. Do not attempt -d modsdev (it can't install the module).


File structure

File Responsibility
fusion_claims/models/service_rate.py (create) fusion.service.rate model: fields, unique-code constraint, get_callout / get_rate resolvers
fusion_claims/models/__init__.py (modify) import service_rate
fusion_claims/data/service_rate_products.xml (create) seed product.product service products (one per rate) — noupdate=1
fusion_claims/data/service_rate_data.xml (create) seed fusion.service.rate rows linking the products — noupdate=1
fusion_claims/views/service_rate_views.xml (create) list + form + action + Service Rates menu
fusion_claims/security/ir.model.access.csv (modify) ACL: read for users, full for system/managers
fusion_claims/__manifest__.py (modify) register the 3 new data/view files; bump version
fusion_claims/tests/test_service_rate.py (create) model + resolver + seed tests
fusion_claims/tests/__init__.py (modify) import the test

Task 1: fusion.service.rate model + resolvers

Files:

  • Create: fusion_claims/models/service_rate.py

  • Modify: fusion_claims/models/__init__.py

  • Test: fusion_claims/tests/test_service_rate.py, fusion_claims/tests/__init__.py

  • Step 1: Write the failing test

Create fusion_claims/tests/test_service_rate.py:

# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase, tagged


@tagged('post_install', '-at_install')
class TestServiceRate(TransactionCase):

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.Rate = cls.env['fusion.service.rate']
        cls.product = cls.env['product.product'].create({
            'name': 'Test Service Product', 'type': 'service',
        })

    def _make(self, **kw):
        vals = dict(name='Rate', code='c1', rate_kind='callout', category='standard',
                    timing='normal', product_id=self.product.id, price=95.0, unit='fixed')
        vals.update(kw)
        return self.Rate.create(vals)

    def test_get_callout_matches_category_and_timing(self):
        r = self._make(code='callout_standard_normal', category='standard', timing='normal', price=95.0)
        self._make(code='callout_lift_normal', category='lift', timing='normal', price=160.0)
        self.assertEqual(self.Rate.get_callout('standard', 'normal'), r)

    def test_get_callout_in_shop_returns_empty(self):
        self._make(code='callout_standard_normal_b')
        self.assertFalse(self.Rate.get_callout('standard', 'normal', in_shop=True))

    def test_get_rate_by_code(self):
        r = self._make(code='per_km', rate_kind='travel', category='na', timing='na', unit='per_km', price=0.70)
        self.assertEqual(self.Rate.get_rate('per_km'), r)

    def test_code_must_be_unique(self):
        self._make(code='dup')
        with self.assertRaises(Exception):
            self._make(code='dup')
            self.env.flush_all()

Register it in fusion_claims/tests/__init__.py (append):

from . import test_service_rate
  • Step 2: Run the test — verify it fails

Run (on the clone): the canonical command above with --test-tags /fusion_claims.TestServiceRate. Expected: FAIL — KeyError: 'fusion.service.rate' (model does not exist yet).

  • Step 3: Create the model

Create fusion_claims/models/service_rate.py:

# -*- coding: utf-8 -*-
from odoo import api, fields, models


class FusionServiceRate(models.Model):
    _name = 'fusion.service.rate'
    _description = 'Field Service Rate'
    _order = 'sequence, rate_kind, category, timing'

    name = fields.Char(string='Name', required=True)
    code = fields.Char(
        string='Code', required=True, index=True,
        help='Stable code used by the booking engine, e.g. callout_standard_normal, per_km.',
    )
    rate_kind = fields.Selection([
        ('callout', 'Service Call-out'),
        ('labour', 'Labour'),
        ('travel', 'Travel / per-km'),
        ('delivery', 'Delivery / Pickup'),
        ('other', 'Other'),
    ], string='Kind', required=True, default='callout')
    category = fields.Selection([
        ('standard', 'Standard'),
        ('lift', 'Lift & Elevating'),
        ('na', 'N/A'),
    ], string='Category', default='na')
    timing = fields.Selection([
        ('normal', 'Normal'),
        ('rush', 'Rush'),
        ('afterhours', 'After-Hours'),
        ('na', 'N/A'),
    ], string='Timing', default='na')
    in_shop = fields.Boolean(string='In-Shop')
    product_id = fields.Many2one(
        'product.product', string='Invoice Product', required=True, ondelete='restrict',
        help='Product used on the sale-order line (description, tax, income account).',
    )
    price = fields.Monetary(
        string='Rate', required=True, currency_field='currency_id',
        help='Editable price used on the SO line and the on-screen estimate.',
    )
    currency_id = fields.Many2one(
        'res.currency', string='Currency',
        default=lambda self: self.env.company.currency_id,
    )
    unit = fields.Selection([
        ('fixed', 'Flat'),
        ('per_hour', 'Per hour'),
        ('per_km', 'Per km'),
    ], string='Unit', required=True, default='fixed')
    adds_per_km = fields.Boolean(
        string='Adds per-km travel',
        help='Call-outs billed as $X + per-km × 2-way (rush / after-hours).',
    )
    included_labour_min = fields.Integer(
        string='Included labour (min)', default=0,
        help='Free labour minutes bundled into a service call (e.g. 30).',
    )
    active = fields.Boolean(string='Active', default=True)
    sequence = fields.Integer(string='Sequence', default=10)

    _unique_code = models.Constraint(
        'UNIQUE(code)',
        'A service-rate code must be unique.',
    )

    @api.model
    def get_callout(self, category, timing, in_shop=False):
        """Active call-out rate for category+timing. Empty recordset when in-shop."""
        if in_shop:
            return self.browse()
        return self.search([
            ('rate_kind', '=', 'callout'),
            ('category', '=', category),
            ('timing', '=', timing),
        ], limit=1)

    @api.model
    def get_rate(self, code):
        """Active rate row by code (e.g. 'per_km', 'labour_onsite')."""
        return self.search([('code', '=', code)], limit=1)

Add to fusion_claims/models/__init__.py (append a line near the other imports):

from . import service_rate
  • Step 4: Run the test — verify it passes

Run (on the clone) with --test-tags /fusion_claims.TestServiceRate. Expected: PASS (4 tests). If test_code_must_be_unique errors instead of failing cleanly, the unique constraint is firing — that is the pass condition (it raises).

  • Step 5: Commit
git add fusion_claims/models/service_rate.py fusion_claims/models/__init__.py \
        fusion_claims/tests/test_service_rate.py fusion_claims/tests/__init__.py
git commit -m "feat(fusion_claims): add fusion.service.rate model + resolvers"

Task 2: Seed the service-rate products

Files:

  • Create: fusion_claims/data/service_rate_products.xml
  • Modify: fusion_claims/__manifest__.py

Products back each rate row (SO line description/tax/account). UoM: hour for labour, unit for everything else (per-km uses unit with qty = km×2 — avoids a custom km UoM). Taxes are not set here (matches the existing LABOR product convention — taxes applied per-DB by an admin).

  • Step 1: Create the product seed data

Create fusion_claims/data/service_rate_products.xml:

<?xml version="1.0" encoding="utf-8"?>
<odoo>
    <data noupdate="1">
        <!-- Call-outs (unit) -->
        <record id="product_callout_standard_normal" model="product.template">
            <field name="name">Service Call — Standard</field>
            <field name="default_code">SVC-STD</field>
            <field name="type">service</field>
            <field name="list_price">95.00</field>
            <field name="sale_ok" eval="True"/>
            <field name="purchase_ok" eval="False"/>
        </record>
        <record id="product_callout_standard_rush" model="product.template">
            <field name="name">Service Call — Standard Rush</field>
            <field name="default_code">SVC-STD-RUSH</field>
            <field name="type">service</field>
            <field name="list_price">120.00</field>
            <field name="sale_ok" eval="True"/>
            <field name="purchase_ok" eval="False"/>
        </record>
        <record id="product_callout_standard_afterhours" model="product.template">
            <field name="name">Service Call — Standard After-Hours</field>
            <field name="default_code">SVC-STD-AH</field>
            <field name="type">service</field>
            <field name="list_price">140.00</field>
            <field name="sale_ok" eval="True"/>
            <field name="purchase_ok" eval="False"/>
        </record>
        <record id="product_callout_lift_normal" model="product.template">
            <field name="name">Service Call — Lift &amp; Elevating</field>
            <field name="default_code">SVC-LIFT</field>
            <field name="type">service</field>
            <field name="list_price">160.00</field>
            <field name="sale_ok" eval="True"/>
            <field name="purchase_ok" eval="False"/>
        </record>
        <record id="product_callout_lift_rush" model="product.template">
            <field name="name">Service Call — Lift &amp; Elevating Rush</field>
            <field name="default_code">SVC-LIFT-RUSH</field>
            <field name="type">service</field>
            <field name="list_price">185.00</field>
            <field name="sale_ok" eval="True"/>
            <field name="purchase_ok" eval="False"/>
        </record>
        <record id="product_callout_lift_afterhours" model="product.template">
            <field name="name">Service Call — Lift &amp; Elevating After-Hours</field>
            <field name="default_code">SVC-LIFT-AH</field>
            <field name="type">service</field>
            <field name="list_price">205.00</field>
            <field name="sale_ok" eval="True"/>
            <field name="purchase_ok" eval="False"/>
        </record>

        <!-- Labour (hour) -->
        <record id="product_labour_onsite" model="product.template">
            <field name="name">Labour — On-Site</field>
            <field name="default_code">LAB-ONSITE</field>
            <field name="type">service</field>
            <field name="list_price">85.00</field>
            <field name="uom_id" ref="uom.product_uom_hour"/>
            <field name="uom_po_id" ref="uom.product_uom_hour"/>
            <field name="sale_ok" eval="True"/>
            <field name="purchase_ok" eval="False"/>
        </record>
        <record id="product_labour_lift" model="product.template">
            <field name="name">Labour — Lift &amp; Elevating</field>
            <field name="default_code">LAB-LIFT</field>
            <field name="type">service</field>
            <field name="list_price">110.00</field>
            <field name="uom_id" ref="uom.product_uom_hour"/>
            <field name="uom_po_id" ref="uom.product_uom_hour"/>
            <field name="sale_ok" eval="True"/>
            <field name="purchase_ok" eval="False"/>
        </record>

        <!-- Travel (unit; qty = km × 2) -->
        <record id="product_per_km" model="product.template">
            <field name="name">Travel — per km (2-way)</field>
            <field name="default_code">SVC-KM</field>
            <field name="type">service</field>
            <field name="list_price">0.70</field>
            <field name="sale_ok" eval="True"/>
            <field name="purchase_ok" eval="False"/>
        </record>

        <!-- Delivery / pickup (unit) -->
        <record id="product_delivery_local" model="product.template">
            <field name="name">Delivery / Pickup — Local</field>
            <field name="default_code">DEL-LOCAL</field>
            <field name="type">service</field><field name="list_price">35.00</field>
            <field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
        </record>
        <record id="product_delivery_outside" model="product.template">
            <field name="name">Delivery / Pickup — Outside Local Area</field>
            <field name="default_code">DEL-OUT</field>
            <field name="type">service</field><field name="list_price">60.00</field>
            <field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
        </record>
        <record id="product_delivery_rush" model="product.template">
            <field name="name">Rush Pickup / Delivery</field>
            <field name="default_code">DEL-RUSH</field>
            <field name="type">service</field><field name="list_price">60.00</field>
            <field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
        </record>
        <record id="product_setup_liftchair" model="product.template">
            <field name="name">Lift Chair — Delivery &amp; Set-up</field>
            <field name="default_code">SETUP-LIFTCHAIR</field>
            <field name="type">service</field><field name="list_price">120.00</field>
            <field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
        </record>
        <record id="product_setup_hospitalbed" model="product.template">
            <field name="name">Hospital Bed — Delivery &amp; Set-up</field>
            <field name="default_code">SETUP-BED</field>
            <field name="type">service</field><field name="list_price">120.00</field>
            <field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
        </record>
        <record id="product_setup_stairlift" model="product.template">
            <field name="name">Stairlift — Delivery &amp; Set-up</field>
            <field name="default_code">SETUP-STAIRLIFT</field>
            <field name="type">service</field><field name="list_price">300.00</field>
            <field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
        </record>
        <record id="product_removal_stairlift" model="product.template">
            <field name="name">Stairlift — Removal</field>
            <field name="default_code">RMV-STAIRLIFT</field>
            <field name="type">service</field><field name="list_price">300.00</field>
            <field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
        </record>
    </data>
</odoo>
  • Step 2: Register in the manifest

In fusion_claims/__manifest__.py, add to the data list immediately after 'data/product_labor_data.xml':

        'data/service_rate_products.xml',
  • Step 3: Verify load (on the clone)

Run: docker exec odoo-dev-app odoo -d westin-v19-ratetest -u fusion_claims --stop-after-init --no-http --workers 0 --db_host db --db_user odoo --db_password 'DevSecure2025!' 2>&1 | tail -20 Expected: no error; module upgraded. (No test yet — products are referenced by Task 3's data.)

  • Step 4: Commit
git add fusion_claims/data/service_rate_products.xml fusion_claims/__manifest__.py
git commit -m "feat(fusion_claims): seed service-rate products"

Task 3: Seed the rate rows

Files:

  • Create: fusion_claims/data/service_rate_data.xml
  • Modify: fusion_claims/__manifest__.py
  • Test: fusion_claims/tests/test_service_rate.py

product.template external IDs from Task 2 resolve to a product.product via .product_variant_id. In data XML, reference the variant with ref="product_callout_standard_normal_product_template"? No — simplest is to point product_id at the template's variant using the template's xmlid is not valid for a product.product m2o. Use a tiny Python step instead: a post_init-style noupdate is awkward for m2o-to-variant. Approach: reference the product variant created automatically. Odoo creates product.product for each template; its xmlid is <template_xmlid>_product_variant? It is not auto-created. So we set product_id by searching on default_code in a noupdate function. Keep it simple and deterministic:

  • Step 1: Write the failing test (seed assertions)

Append to fusion_claims/tests/test_service_rate.py:

    def test_seeded_callouts_exist(self):
        # standard normal $95, lift after-hours $205 are the canonical seeds
        std = self.env.ref('fusion_claims.rate_callout_standard_normal')
        self.assertEqual(std.price, 95.0)
        self.assertEqual(std.rate_kind, 'callout')
        self.assertTrue(std.product_id)
        lift_ah = self.env.ref('fusion_claims.rate_callout_lift_afterhours')
        self.assertEqual(lift_ah.price, 205.0)
        self.assertTrue(lift_ah.adds_per_km)

    def test_seeded_per_km(self):
        km = self.env['fusion.service.rate'].get_rate('per_km')
        self.assertTrue(km)
        self.assertEqual(km.unit, 'per_km')
        self.assertEqual(km.price, 0.70)
  • Step 2: Run — verify it fails

Run with --test-tags /fusion_claims.TestServiceRate. Expected: FAIL — ValueError: External ID not found: fusion_claims.rate_callout_standard_normal.

  • Step 3: Create the rate seed data

Create fusion_claims/data/service_rate_data.xml. Each rate's product_id is set with eval that resolves the template's variant at load time:

<?xml version="1.0" encoding="utf-8"?>
<odoo>
    <data noupdate="1">
        <!-- CALL-OUTS -->
        <record id="rate_callout_standard_normal" model="fusion.service.rate">
            <field name="name">Standard Service Call</field>
            <field name="code">callout_standard_normal</field>
            <field name="rate_kind">callout</field><field name="category">standard</field>
            <field name="timing">normal</field><field name="unit">fixed</field>
            <field name="included_labour_min">30</field><field name="price">95.0</field>
            <field name="product_id" ref="product_callout_standard_normal_product_variant"/>
            <field name="sequence">10</field>
        </record>
        <record id="rate_callout_standard_rush" model="fusion.service.rate">
            <field name="name">Rush Service Call (Standard)</field>
            <field name="code">callout_standard_rush</field>
            <field name="rate_kind">callout</field><field name="category">standard</field>
            <field name="timing">rush</field><field name="unit">fixed</field>
            <field name="adds_per_km" eval="True"/><field name="price">120.0</field>
            <field name="product_id" ref="product_callout_standard_rush_product_variant"/>
            <field name="sequence">11</field>
        </record>
        <record id="rate_callout_standard_afterhours" model="fusion.service.rate">
            <field name="name">After-Hours Service Call (Standard)</field>
            <field name="code">callout_standard_afterhours</field>
            <field name="rate_kind">callout</field><field name="category">standard</field>
            <field name="timing">afterhours</field><field name="unit">fixed</field>
            <field name="adds_per_km" eval="True"/><field name="price">140.0</field>
            <field name="product_id" ref="product_callout_standard_afterhours_product_variant"/>
            <field name="sequence">12</field>
        </record>
        <record id="rate_callout_lift_normal" model="fusion.service.rate">
            <field name="name">Lift &amp; Elevating Service Call</field>
            <field name="code">callout_lift_normal</field>
            <field name="rate_kind">callout</field><field name="category">lift</field>
            <field name="timing">normal</field><field name="unit">fixed</field>
            <field name="included_labour_min">30</field><field name="price">160.0</field>
            <field name="product_id" ref="product_callout_lift_normal_product_variant"/>
            <field name="sequence">20</field>
        </record>
        <record id="rate_callout_lift_rush" model="fusion.service.rate">
            <field name="name">Lift &amp; Elevating Rush Call</field>
            <field name="code">callout_lift_rush</field>
            <field name="rate_kind">callout</field><field name="category">lift</field>
            <field name="timing">rush</field><field name="unit">fixed</field>
            <field name="adds_per_km" eval="True"/><field name="price">185.0</field>
            <field name="product_id" ref="product_callout_lift_rush_product_variant"/>
            <field name="sequence">21</field>
        </record>
        <record id="rate_callout_lift_afterhours" model="fusion.service.rate">
            <field name="name">Lift &amp; Elevating After-Hours Call</field>
            <field name="code">callout_lift_afterhours</field>
            <field name="rate_kind">callout</field><field name="category">lift</field>
            <field name="timing">afterhours</field><field name="unit">fixed</field>
            <field name="adds_per_km" eval="True"/><field name="price">205.0</field>
            <field name="product_id" ref="product_callout_lift_afterhours_product_variant"/>
            <field name="sequence">22</field>
        </record>

        <!-- LABOUR -->
        <record id="rate_labour_onsite" model="fusion.service.rate">
            <field name="name">Labour — On-Site</field><field name="code">labour_onsite</field>
            <field name="rate_kind">labour</field><field name="category">standard</field>
            <field name="timing">na</field><field name="unit">per_hour</field><field name="price">85.0</field>
            <field name="product_id" ref="product_labour_onsite_product_variant"/><field name="sequence">30</field>
        </record>
        <record id="rate_labour_lift" model="fusion.service.rate">
            <field name="name">Labour — Lift &amp; Elevating</field><field name="code">labour_lift</field>
            <field name="rate_kind">labour</field><field name="category">lift</field>
            <field name="timing">na</field><field name="unit">per_hour</field><field name="price">110.0</field>
            <field name="product_id" ref="product_labour_lift_product_variant"/><field name="sequence">31</field>
        </record>
        <record id="rate_labour_inshop" model="fusion.service.rate">
            <field name="name">Labour — In-Shop</field><field name="code">labour_inshop</field>
            <field name="rate_kind">labour</field><field name="category">na</field><field name="in_shop" eval="True"/>
            <field name="timing">na</field><field name="unit">per_hour</field><field name="price">75.0</field>
            <field name="product_id" ref="product_labor_hourly_product_variant"/><field name="sequence">32</field>
        </record>

        <!-- TRAVEL -->
        <record id="rate_per_km" model="fusion.service.rate">
            <field name="name">Travel — per km (2-way)</field><field name="code">per_km</field>
            <field name="rate_kind">travel</field><field name="category">na</field>
            <field name="timing">na</field><field name="unit">per_km</field><field name="price">0.70</field>
            <field name="product_id" ref="product_per_km_product_variant"/><field name="sequence">40</field>
        </record>

        <!-- DELIVERY / PICKUP -->
        <record id="rate_delivery_local" model="fusion.service.rate">
            <field name="name">Delivery / Pickup — Local</field><field name="code">delivery_local</field>
            <field name="rate_kind">delivery</field><field name="category">na</field><field name="timing">na</field>
            <field name="unit">fixed</field><field name="price">35.0</field>
            <field name="product_id" ref="product_delivery_local_product_variant"/><field name="sequence">50</field>
        </record>
        <record id="rate_delivery_outside" model="fusion.service.rate">
            <field name="name">Delivery / Pickup — Outside Local Area</field><field name="code">delivery_outside</field>
            <field name="rate_kind">delivery</field><field name="category">na</field><field name="timing">na</field>
            <field name="unit">fixed</field><field name="price">60.0</field>
            <field name="product_id" ref="product_delivery_outside_product_variant"/><field name="sequence">51</field>
        </record>
        <record id="rate_setup_stairlift" model="fusion.service.rate">
            <field name="name">Stairlift — Delivery &amp; Set-up</field><field name="code">setup_stairlift</field>
            <field name="rate_kind">delivery</field><field name="category">lift</field><field name="timing">na</field>
            <field name="unit">fixed</field><field name="price">300.0</field>
            <field name="product_id" ref="product_setup_stairlift_product_variant"/><field name="sequence">52</field>
        </record>
    </data>
</odoo>

Note on _product_variant refs: Odoo auto-creates the product.product for a single-variant product.template and assigns it the external ID <template_xmlid>_product_variant. This is the supported way to reference the variant from data XML. (The existing in-shop labour reuses product_labor_hourly from product_labor_data.xml, hence product_labor_hourly_product_variant.) If a _product_variant ref ever fails to resolve on your DB, the fallback is to set product_id via eval="obj().env.ref('fusion_claims.product_xxx').product_variant_id.id" — but try the _product_variant ref first.

Register in fusion_claims/__manifest__.py, immediately after 'data/service_rate_products.xml':

        'data/service_rate_data.xml',
  • Step 4: Run the test — verify it passes

Run with --test-tags /fusion_claims.TestServiceRate (the -u fusion_claims reload loads the seed first). Expected: PASS (all tests incl. test_seeded_callouts_exist, test_seeded_per_km).

  • Step 5: Commit
git add fusion_claims/data/service_rate_data.xml fusion_claims/__manifest__.py fusion_claims/tests/test_service_rate.py
git commit -m "feat(fusion_claims): seed service-rate rows from the rate card"

Task 4: Security ACL + Service Rates views & menu

Files:

  • Modify: fusion_claims/security/ir.model.access.csv

  • Create: fusion_claims/views/service_rate_views.xml

  • Modify: fusion_claims/__manifest__.py

  • Step 1: Add the ACL rows

Append to fusion_claims/security/ir.model.access.csv:

access_fusion_service_rate_user,fusion.service.rate.user,model_fusion_service_rate,base.group_user,1,0,0,0
access_fusion_service_rate_manager,fusion.service.rate.manager,model_fusion_service_rate,base.group_system,1,1,1,1

(Users read rates — the wizard needs that; system/managers edit. If fusion_claims defines a sales-manager group, swap the second row's group for it during review.)

  • Step 2: Find the parent menu

Run: grep -n "menuitem" fusion_claims/views/*.xml fusion_tasks/views/*.xml | grep -i "id=" | head -40 Pick the appropriate Configuration/root menu for "Service Rates" (e.g. the fusion_claims app root or a Field-Service config menu). Record its full xmlid (e.g. fusion_claims.menu_fusion_claims_config or sale.menu_sale_config). Use it as parent= in Step 3.

  • Step 3: Create the views

Create fusion_claims/views/service_rate_views.xml (replace PARENT_MENU_XMLID with the id found in Step 2):

<?xml version="1.0" encoding="utf-8"?>
<odoo>
    <record id="fusion_service_rate_view_list" model="ir.ui.view">
        <field name="name">fusion.service.rate.list</field>
        <field name="model">fusion.service.rate</field>
        <field name="arch" type="xml">
            <list string="Service Rates" editable="bottom">
                <field name="sequence" widget="handle"/>
                <field name="name"/>
                <field name="code"/>
                <field name="rate_kind"/>
                <field name="category"/>
                <field name="timing"/>
                <field name="in_shop"/>
                <field name="unit"/>
                <field name="price"/>
                <field name="currency_id" column_invisible="1"/>
                <field name="adds_per_km"/>
                <field name="product_id"/>
                <field name="active" widget="boolean_toggle"/>
            </list>
        </field>
    </record>

    <record id="fusion_service_rate_view_form" model="ir.ui.view">
        <field name="name">fusion.service.rate.form</field>
        <field name="model">fusion.service.rate</field>
        <field name="arch" type="xml">
            <form string="Service Rate">
                <sheet>
                    <div class="oe_title">
                        <h1><field name="name" placeholder="e.g. Standard Service Call"/></h1>
                    </div>
                    <group>
                        <group>
                            <field name="code"/>
                            <field name="rate_kind"/>
                            <field name="category"/>
                            <field name="timing"/>
                            <field name="in_shop"/>
                        </group>
                        <group>
                            <field name="price"/>
                            <field name="currency_id" invisible="1"/>
                            <field name="unit"/>
                            <field name="adds_per_km"/>
                            <field name="included_labour_min"/>
                            <field name="product_id"/>
                            <field name="active"/>
                        </group>
                    </group>
                </sheet>
            </form>
        </field>
    </record>

    <record id="action_fusion_service_rate" model="ir.actions.act_window">
        <field name="name">Service Rates</field>
        <field name="res_model">fusion.service.rate</field>
        <field name="view_mode">list,form</field>
        <field name="help" type="html">
            <p class="o_view_nocontent_smiling_face">Define your field-service rate card</p>
            <p>Call-out fees, labour, per-km and delivery charges used by the service booking wizard.</p>
        </field>
    </record>

    <menuitem id="menu_fusion_service_rate"
              name="Service Rates"
              parent="PARENT_MENU_XMLID"
              action="action_fusion_service_rate"
              sequence="90"/>
</odoo>

Register in fusion_claims/__manifest__.py data list, after 'views/res_config_settings_views.xml' (or near the other views):

        'views/service_rate_views.xml',
  • Step 4: Verify load + menu (on the clone)

Run the -u fusion_claims --stop-after-init command; expected: no error. Then in odoo shell -d westin-v19-ratetest: env.ref('fusion_claims.action_fusion_service_rate') resolves; env['fusion.service.rate'].search_count([]) ≥ 14. env.cr.rollback().

  • Step 5: Commit
git add fusion_claims/security/ir.model.access.csv fusion_claims/views/service_rate_views.xml fusion_claims/__manifest__.py
git commit -m "feat(fusion_claims): Service Rates menu, list (inline-edit) + form + ACL"

Task 5: Version bump + final verify

Files: Modify fusion_claims/__manifest__.py

  • Step 1: Bump version

In fusion_claims/__manifest__.py, bump 'version' (e.g. 19.0.9.2.019.0.9.3.0).

  • Step 2: Full upgrade + test run (on the clone)

Run the canonical test command (--test-tags /fusion_claims.TestServiceRate). Expected: all PASS, module upgraded, no warnings about the new data files.

  • Step 3: Manual smoke (browser, on the clone)

Open Service Rates menu → confirm 14+ rows, prices editable inline, a new row can be added and saved. Toggle one active off and back.

  • Step 4: Commit
git add fusion_claims/__manifest__.py
git commit -m "chore(fusion_claims): bump version for service-rate foundation"

Self-Review (done while writing)

  • Spec coverage: §6.1 model fields ✓ (Task 1), seed products ✓ (Task 2), seed rows incl. $185/$205 + per-km + labour + delivery ✓ (Task 3), Service Rates menu/views/ACL ✓ (Task 4), §3 values as seed ✓. Resolver API (get_callout/get_rate) ✓ (Task 1) — consumed by Plan 2.
  • Placeholders: none — every step has full code. The one deliberate lookup is the menu parent (Task 4 Step 2), which is a real "find the xmlid" action, not a vague TODO.
  • Type/name consistency: get_callout(category, timing, in_shop) and get_rate(code) signatures match the tests and the seed codes (callout_standard_normal, per_km, labour_inshop reusing product_labor_hourly). Rate codes match across data + tests.
  • Gap noted for Plan 2: the _product_variant external-ID convention (Task 3 note) — Plan 2's SO builder uses rate.product_id directly, so it's unaffected.

Execution Handoff

This is Plan 1 of 2. Plan 2 (booking wizard: tz fix, constraint relax, pricing resolver consuming get_callout/get_rate, SO builder, action_book_from_wizard, OWL wizard + SCSS, entry point) will be written next and depends on this.

Before executing: move this work to a dedicated branch (e.g. claude/technician-service-booking) — it's currently alongside the unrelated fusion_schedule fixes.