# 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: ```bash 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 ` 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`: ```python # -*- 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): ```python 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`: ```python # -*- 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): ```python 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** ```bash 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 Service Call — Standard SVC-STD service 95.00 Service Call — Standard Rush SVC-STD-RUSH service 120.00 Service Call — Standard After-Hours SVC-STD-AH service 140.00 Service Call — Lift & Elevating SVC-LIFT service 160.00 Service Call — Lift & Elevating Rush SVC-LIFT-RUSH service 185.00 Service Call — Lift & Elevating After-Hours SVC-LIFT-AH service 205.00 Labour — On-Site LAB-ONSITE service 85.00 Labour — Lift & Elevating LAB-LIFT service 110.00 Travel — per km (2-way) SVC-KM service 0.70 Delivery / Pickup — Local DEL-LOCAL service35.00 Delivery / Pickup — Outside Local Area DEL-OUT service60.00 Rush Pickup / Delivery DEL-RUSH service60.00 Lift Chair — Delivery & Set-up SETUP-LIFTCHAIR service120.00 Hospital Bed — Delivery & Set-up SETUP-BED service120.00 Stairlift — Delivery & Set-up SETUP-STAIRLIFT service300.00 Stairlift — Removal RMV-STAIRLIFT service300.00 ``` - [ ] **Step 2: Register in the manifest** In `fusion_claims/__manifest__.py`, add to the `data` list **immediately after** `'data/product_labor_data.xml'`: ```python '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** ```bash 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 `_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`: ```python 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 Standard Service Call callout_standard_normal calloutstandard normalfixed 3095.0 10 Rush Service Call (Standard) callout_standard_rush calloutstandard rushfixed 120.0 11 After-Hours Service Call (Standard) callout_standard_afterhours calloutstandard afterhoursfixed 140.0 12 Lift & Elevating Service Call callout_lift_normal calloutlift normalfixed 30160.0 20 Lift & Elevating Rush Call callout_lift_rush calloutlift rushfixed 185.0 21 Lift & Elevating After-Hours Call callout_lift_afterhours calloutlift afterhoursfixed 205.0 22 Labour — On-Sitelabour_onsite labourstandard naper_hour85.0 30 Labour — Lift & Elevatinglabour_lift labourlift naper_hour110.0 31 Labour — In-Shoplabour_inshop labourna naper_hour75.0 32 Travel — per km (2-way)per_km travelna naper_km0.70 40 Delivery / Pickup — Localdelivery_local deliverynana fixed35.0 50 Delivery / Pickup — Outside Local Areadelivery_outside deliverynana fixed60.0 51 Stairlift — Delivery & Set-upsetup_stairlift deliveryliftna fixed300.0 52 ``` > **Note on `_product_variant` refs:** Odoo auto-creates the `product.product` for a single-variant `product.template` and assigns it the external ID `_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'`: ```python '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** ```bash 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`: ```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 fusion.service.rate.list fusion.service.rate fusion.service.rate.form fusion.service.rate

Service Rates fusion.service.rate list,form

Define your field-service rate card

Call-out fees, labour, per-km and delivery charges used by the service booking wizard.

``` Register in `fusion_claims/__manifest__.py` `data` list, **after** `'views/res_config_settings_views.xml'` (or near the other views): ```python '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** ```bash 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.0` → `19.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** ```bash 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 `code`s 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.