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>
719 lines
35 KiB
Markdown
719 lines
35 KiB
Markdown
# 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 <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`:
|
||
|
||
```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
|
||
<?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 & 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 & 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 & 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 & 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 & 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 & 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 & 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'`:
|
||
|
||
```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 `<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`:
|
||
|
||
```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
|
||
<?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 & 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 & 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 & 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 & 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 & 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'`:
|
||
|
||
```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
|
||
<?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):
|
||
|
||
```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.
|