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>
35 KiB
Service Booking Wizard + Auto-Quote — Implementation Plan (Plan 2 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.
Goal: A polished OWL "Book a Service" wizard that captures the client (incl. new clients inline), books the technician task, prices the call-out from the Plan-1 rate table, and auto-creates a draft repair Sale Order — with a correct, consistent timezone conversion.
Architecture: TZ fix in fusion_tasks; everything else in fusion_claims (it owns the SO + the technician.task SO-link + Plan 1's rates). A server method action_book_from_wizard does the work (contact + task + SO); an OWL client action is the UI and calls it through two jsonrpc controller routes. Pricing is read from fusion.service.rate (Plan 1) — never hardcoded.
Tech Stack: Odoo 19 (ORM, TransactionCase), OWL (@odoo/owl, standalone rpc from @web/core/network/rpc, registry.category("actions")), SCSS branching on $o-webclient-color-scheme.
Depends on: Plan 1 (fusion.service.rate + get_callout/get_rate). Spec: …/specs/2026-06-03-technician-service-booking-design.md. Mockup (UI source of truth): …/mockups/technician-booking-wizard.html.
⚠️ Testing reality
fusion_claims is Enterprise-only → not installable on local Community. TransactionCase tests run on a Westin Enterprise clone (see Plan 1's testing note + repo CLAUDE.md). OWL UI has no unit test — verify by manual smoke on the clone browser. Pure-Python tasks (1–4) are TDD; the OWL task (5) is build-then-smoke.
Pre-flight (rule #1 — never code from memory): before Tasks 1, 3, 4, read the real signatures:
docker exec odoo-dev-app sed -n '760,800p;975,1010p;2725,2775p' \
/mnt/extra-addons/fusion_tasks/models/technician_task.py
Confirm _get_local_tz, _compute_datetimes/inverses, _calculate_travel_time(origin_lat, origin_lng) (sets travel_distance_km), and _quick_travel_time.
File structure
| File | Responsibility |
|---|---|
fusion_tasks/models/technician_task.py (modify ~781-798) |
tz-consistent inverses |
fusion_tasks/tests/test_task_tz.py + __init__.py (create) |
tz round-trip test |
fusion_claims/models/technician_task.py (modify) |
relax _check_order_link; add x_fc_service_call_type; pricing resolver; SO builder; action_book_from_wizard |
fusion_claims/models/sale_order.py (modify) |
x_fc_is_service_repair flag |
fusion_claims/data/service_repair_data.xml (create) |
"Service Repair" CRM tag |
fusion_claims/controllers/__init__.py + controllers/service_booking.py (create) |
jsonrpc refdata + submit routes |
fusion_claims/__init__.py (modify) |
import controllers |
fusion_claims/static/src/js/service_booking/service_booking.js (create) |
OWL client action |
fusion_claims/static/src/xml/service_booking.xml (create) |
OWL template (ported from mockup) |
fusion_claims/static/src/scss/_service_booking_tokens.scss + service_booking.scss (create) |
styles, dark/light |
fusion_claims/views/service_booking_action.xml (create) |
ir.actions.client + menu |
fusion_claims/__manifest__.py (modify) |
assets + data + version |
fusion_claims/tests/test_service_booking.py (create) |
resolver, SO builder, booking method |
Task 1: Timezone-consistent inverses (fusion_tasks)
Files: Modify fusion_tasks/models/technician_task.py; create fusion_tasks/tests/test_task_tz.py (+ tests/__init__.py if absent).
- Step 1: Write the failing test
Create fusion_tasks/tests/test_task_tz.py:
# -*- coding: utf-8 -*-
from datetime import date
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestTaskTz(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env.user.tz = 'America/Toronto' # UTC-4 in summer
cls.task = cls.env['fusion.technician.task'].create({
'scheduled_date': date(2026, 6, 3),
'time_start': 9.0, 'time_end': 10.0,
})
def test_local_to_utc_compute(self):
# 9:00 local Toronto (DST, -4) -> 13:00 UTC stored
self.assertEqual(self.task.datetime_start.hour, 13)
def test_inverse_round_trips_with_same_tz(self):
# writing datetime_start back must recover the same local time_start
self.task.datetime_start = self.task.datetime_start # force inverse
self.task.flush_recordset(['datetime_start'])
self.assertAlmostEqual(self.task.time_start, 9.0, places=2)
Register in fusion_tasks/tests/__init__.py (create if missing):
from . import test_task_tz
If fusion_tasks/tests/ doesn't exist, also add 'fusion_tasks/tests' is auto-discovered — just ensure the __init__.py exists.
-
Step 2: Run — verify it fails (on the clone,
--test-tags /fusion_tasks.TestTaskTz). Expected:test_inverse_round_tripsFAILS if user.tz ≠ company-calendar tz, or passes spuriously if they're equal — set the companyresource_calendar_id.tztoAmerica/TorontoinsetUpClasstoo if needed to expose the mismatch. -
Step 3: Fix the inverses
In fusion_tasks/models/technician_task.py, the two inverse methods currently use pytz.timezone(self.env.user.tz or 'UTC'). Change both to use the same resolver as _compute_datetimes:
def _inverse_datetime_start(self):
"""When datetime_start changes (calendar drag), update date + time. Uses the
SAME tz resolver as _compute_datetimes so the round-trip is consistent."""
import pytz
user_tz = self._get_local_tz()
for task in self:
if task.datetime_start:
local_dt = pytz.utc.localize(task.datetime_start).astimezone(user_tz)
task.scheduled_date = local_dt.date()
task.time_start = local_dt.hour + local_dt.minute / 60.0
def _inverse_datetime_end(self):
import pytz
user_tz = self._get_local_tz()
for task in self:
if task.datetime_end:
local_dt = pytz.utc.localize(task.datetime_end).astimezone(user_tz)
task.time_end = local_dt.hour + local_dt.minute / 60.0
(Only the user_tz = … line changes in each — from pytz.timezone(self.env.user.tz or 'UTC') to self._get_local_tz().)
-
Step 4: Run — verify it passes (
--test-tags /fusion_tasks.TestTaskTz). Expected: PASS. -
Step 5: Commit
git add fusion_tasks/models/technician_task.py fusion_tasks/tests/test_task_tz.py fusion_tasks/tests/__init__.py
git commit -m "fix(fusion_tasks): make datetime inverses use the same tz resolver as compute"
Task 2: Relax SO constraint + repair-SO identity (fusion_claims)
Files: Modify fusion_claims/models/technician_task.py, fusion_claims/models/sale_order.py; create fusion_claims/data/service_repair_data.xml; modify __manifest__.py; test in fusion_claims/tests/test_service_booking.py.
- Step 1: Write the failing test
Create fusion_claims/tests/test_service_booking.py:
# -*- coding: utf-8 -*-
from datetime import date
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestServiceBooking(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.Task = cls.env['fusion.technician.task']
def test_task_without_order_is_allowed(self):
# repair for a brand-new client: no SO/PO must NOT raise
t = self.Task.create({'task_type': 'repair', 'scheduled_date': date(2026, 6, 3)})
self.assertTrue(t.id)
def test_sale_order_has_service_repair_flag(self):
so = self.env['sale.order'].new({})
self.assertIn('x_fc_is_service_repair', so._fields)
Register in fusion_claims/tests/__init__.py (append): from . import test_service_booking.
-
Step 2: Run — verify it fails (
--test-tags /fusion_claims.TestServiceBooking). Expected:test_task_without_order_is_allowedFAILS with the ValidationError from_check_order_link;test_sale_order_has_service_repair_flagFAILS (field missing). -
Step 3: Relax the constraint
In fusion_claims/models/technician_task.py, replace the body of _check_order_link so it no longer requires an order (the wizard auto-creates one; in-shop/walk-in legitimately have none):
@api.constrains('sale_order_id', 'purchase_order_id')
def _check_order_link(self):
# Relaxed 2026-06: service bookings auto-create their SO, and in-shop /
# walk-in tasks may have none. No order link is required anymore.
return
(Keep the method as a no-op rather than deleting it, so any external super()/override chains stay intact.)
- Step 4: Add the repair flag + tag
In fusion_claims/models/sale_order.py, add to the sale.order class:
x_fc_is_service_repair = fields.Boolean(
string='Service Repair', copy=False,
help='Auto-created from the technician service booking wizard.',
)
Create fusion_claims/data/service_repair_data.xml:
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="tag_service_repair" model="crm.tag">
<field name="name">Service Repair</field>
</record>
</data>
</odoo>
Register it in __manifest__.py data (after the service-rate data from Plan 1):
'data/service_repair_data.xml',
crm.tagrequires thesale_crm/crmdependency. Iffusion_claimsdoesn't pullcrm, usesale.order.tag— verify which tag model exists:docker exec odoo-dev-app odoo shell -d westin-v19-ratetest -c "print('crm.tag' in env, 'sale.order' in env)". Default tocrm.tag(Westin has CRM); fall back to skipping the tag and relying on the boolean flag if neither is clean.
-
Step 5: Run — verify it passes. Expected: both tests PASS.
-
Step 6: Commit
git add fusion_claims/models/technician_task.py fusion_claims/models/sale_order.py \
fusion_claims/data/service_repair_data.xml fusion_claims/__manifest__.py \
fusion_claims/tests/test_service_booking.py fusion_claims/tests/__init__.py
git commit -m "feat(fusion_claims): allow order-less tasks + service-repair SO flag/tag"
Task 3: x_fc_service_call_type + pricing resolver + SO builder (fusion_claims)
Files: Modify fusion_claims/models/technician_task.py; test in test_service_booking.py.
- Step 1: Write the failing test (append to
TestServiceBooking):
def test_resolve_service_lines_standard_rush(self):
Task = self.Task
lines = Task._resolve_service_lines('standard', 'rush', in_shop=False, distance_km=10.0)
# call-out $120 + per-km line qty 20 @ $0.70
callout = [l for l in lines if l['price_unit'] == 120.0]
per_km = [l for l in lines if l['name_is_km']]
self.assertTrue(callout)
self.assertEqual(per_km[0]['product_uom_qty'], 20.0)
self.assertEqual(per_km[0]['price_unit'], 0.70)
def test_resolve_service_lines_in_shop_empty_callout(self):
lines = self.Task._resolve_service_lines('standard', 'normal', in_shop=True, distance_km=5.0)
self.assertEqual(lines, [])
def test_build_service_so(self):
partner = self.env['res.partner'].create({'name': 'Walk-in Wanda'})
so = self.Task._build_service_so(partner, 'standard', 'normal', False, 0.0)
self.assertEqual(so.state, 'draft')
self.assertTrue(so.x_fc_is_service_repair)
self.assertEqual(so.partner_id, partner)
self.assertEqual(so.order_line[0].price_unit, 95.0)
-
Step 2: Run — verify it fails (methods undefined).
-
Step 3: Add the field + resolver + builder
In fusion_claims/models/technician_task.py, add the field to the class:
x_fc_service_call_type = fields.Char(
string='Service Call Type',
help='Rate code resolved by the booking wizard (e.g. callout_standard_rush).',
)
Add these methods (model methods; rely on Plan 1's fusion.service.rate):
@api.model
def _resolve_service_lines(self, category, timing, in_shop, distance_km):
"""Return a list of sale.order.line vals dicts for a service booking,
priced from fusion.service.rate. Empty when in-shop (labour-only, added later)."""
Rate = self.env['fusion.service.rate']
lines = []
callout = Rate.get_callout(category, timing, in_shop=in_shop)
if not callout:
return lines
lines.append({
'product_id': callout.product_id.id,
'name': callout.name,
'product_uom_qty': 1.0,
'price_unit': callout.price,
'name_is_km': False,
})
if callout.adds_per_km and distance_km:
per_km = Rate.get_rate('per_km')
if per_km:
lines.append({
'product_id': per_km.product_id.id,
'name': '%s — %.1f km × 2-way' % (per_km.name, distance_km),
'product_uom_qty': round(distance_km * 2.0, 1),
'price_unit': per_km.price,
'name_is_km': True,
})
return lines
@api.model
def _build_service_so(self, partner, category, timing, in_shop, distance_km):
"""Create a draft repair sale.order with the resolved call-out (+per-km) lines."""
line_vals = self._resolve_service_lines(category, timing, in_shop, distance_km)
order_lines = [(0, 0, {k: v for k, v in l.items() if k != 'name_is_km'}) for l in line_vals]
so_vals = {
'partner_id': partner.id,
'x_fc_is_service_repair': True,
'order_line': order_lines,
}
tag = self.env.ref('fusion_claims.tag_service_repair', raise_if_not_found=False)
if tag and 'tag_ids' in self.env['sale.order']._fields:
so_vals['tag_ids'] = [(4, tag.id)]
return self.env['sale.order'].create(so_vals)
The
name_is_kmkey is a test-only marker stripped before create. Ifsale.orderhas notag_ids(no CRM), the guard skips the tag.
-
Step 4: Run — verify it passes.
-
Step 5: Commit
git add fusion_claims/models/technician_task.py fusion_claims/tests/test_service_booking.py
git commit -m "feat(fusion_claims): service pricing resolver + draft-SO builder from rate table"
Task 4: action_book_from_wizard + controller routes (fusion_claims)
Files: Modify fusion_claims/models/technician_task.py; create fusion_claims/controllers/__init__.py, controllers/service_booking.py; modify fusion_claims/__init__.py; test in test_service_booking.py.
- Step 1: Write the failing test (append):
def test_action_book_creates_contact_task_and_so(self):
payload = {
'cust_mode': 'new',
'customer': {'name': 'Nina New', 'phone': '4165550199', 'email': 'nina@x.com',
'street': '88 Bloor St E', 'city': 'Toronto'},
'category': 'standard', 'timing': 'normal', 'in_shop': False,
'device': 'scooter', 'issue': "won't power on",
'date': '2026-06-03', 'time_start': 9.0, 'duration_hr': 1.0,
'technician_id': False, 'description': 'check battery',
}
res = self.Task.action_book_from_wizard(payload)
self.assertTrue(res['task_id'] and res['order_id'])
task = self.Task.browse(res['task_id'])
self.assertEqual(task.sale_order_id.id, res['order_id'])
self.assertEqual(task.sale_order_id.order_line[0].price_unit, 95.0)
partner = self.env['res.partner'].search([('email', '=ilike', 'nina@x.com')], limit=1)
self.assertTrue(partner)
-
Step 2: Run — verify it fails.
-
Step 3: Implement
action_book_from_wizard
Add to fusion_claims/models/technician_task.py (read the travel method first — pre-flight). Distance: create the task, run its travel calc to populate travel_distance_km, read it for the per-km line, then attach the SO:
@api.model
def action_book_from_wizard(self, payload):
"""Single entry point for the OWL booking wizard:
resolve/create contact -> create task -> compute distance -> build SO -> link."""
Partner = self.env['res.partner']
# 1. contact
cust = payload.get('customer') or {}
if payload.get('cust_mode') == 'new':
partner = False
email = (cust.get('email') or '').strip()
phone = (cust.get('phone') or '').strip()
if email:
partner = Partner.search([('email', '=ilike', email)], limit=1)
if not partner and phone:
partner = Partner.search([('phone', '=', phone)], limit=1)
if not partner:
partner = Partner.create({
'name': cust.get('name') or 'Walk-in',
'phone': phone or False, 'email': email or False,
'street': cust.get('street') or False, 'city': cust.get('city') or False,
})
else:
partner = Partner.browse(int(payload.get('partner_id'))) if payload.get('partner_id') else Partner
category = payload.get('category', 'standard')
timing = payload.get('timing', 'normal')
in_shop = bool(payload.get('in_shop'))
# 2. task
dur = float(payload.get('duration_hr') or 1.0)
t_start = float(payload.get('time_start') or 9.0)
task_vals = {
'task_type': 'repair',
'scheduled_date': payload.get('date'),
'time_start': t_start, 'time_end': t_start + dur, 'duration_hours': dur,
'in_store': in_shop,
'x_fc_service_call_type': '%s_%s' % (category, timing),
'description': payload.get('description') or payload.get('issue') or '',
}
if payload.get('technician_id'):
task_vals['technician_id'] = int(payload['technician_id'])
if partner:
task_vals['client_name'] = partner.name
task_vals['client_phone'] = partner.phone or False
task = self.create(task_vals)
# 3. distance (km) for per-km, if the rate adds it and the job has a location
distance_km = 0.0
callout = self.env['fusion.service.rate'].get_callout(category, timing, in_shop=in_shop)
if callout and callout.adds_per_km and not in_shop and task.address_lat and task.address_lng:
try:
task._calculate_travel_time(task.address_lat, task.address_lng) # sets travel_distance_km
distance_km = task.travel_distance_km or 0.0
except Exception:
distance_km = 0.0
# 4. SO + link
order = self._build_service_so(partner, category, timing, in_shop, distance_km) if partner else False
if order:
task.sale_order_id = order.id
return {'task_id': task.id, 'order_id': order.id if order else False}
Verify field names against the model during the pre-flight read:
in_storevsin_shop,client_name/client_phone,address_lat/address_lng,technician_id. Adjust the vals keys to the real field names (the screenshot shows In-Store, Client Name/Phone, Task Address). If_calculate_travel_timeneeds a different origin, pass the shop/technician start coords instead.
- Step 4: Create the controller
Create fusion_claims/controllers/__init__.py:
from . import service_booking
Create fusion_claims/controllers/service_booking.py:
# -*- coding: utf-8 -*-
from odoo import http
from odoo.http import request
class ServiceBookingController(http.Controller):
@http.route('/fusion_claims/service_booking/refdata', type='jsonrpc', auth='user')
def refdata(self, **kw):
env = request.env
techs = env['res.users'].search([('x_fc_is_field_staff', '=', True)]) \
if 'x_fc_is_field_staff' in env['res.users']._fields else env['res.users'].search([])
rates = env['fusion.service.rate'].search([('rate_kind', '=', 'callout'), ('active', '=', True)])
per_km = env['fusion.service.rate'].get_rate('per_km')
def labour(code):
r = env['fusion.service.rate'].get_rate(code)
return r.price if r else 0.0
return {
'technicians': [{'id': t.id, 'name': t.name} for t in techs],
'callout_rates': [{
'code': r.code, 'category': r.category, 'timing': r.timing,
'name': r.name, 'price': r.price, 'adds_per_km': r.adds_per_km,
} for r in rates],
'per_km': per_km.price if per_km else 0.70,
'labour': {'onsite': labour('labour_onsite'), 'inshop': labour('labour_inshop'),
'lift': labour('labour_lift')},
}
@http.route('/fusion_claims/service_booking/submit', type='jsonrpc', auth='user')
def submit(self, payload=None, **kw):
try:
return request.env['fusion.technician.task'].action_book_from_wizard(payload or {})
except Exception as e:
return {'error': str(e)}
Modify fusion_claims/__init__.py (append):
from . import controllers
-
Step 5: Run — verify it passes (
--test-tags /fusion_claims.TestServiceBooking). Alsopyflakesthe controller:docker exec odoo-dev-app python3 -m pyflakes /mnt/extra-addons/fusion_claims/controllers/service_booking.py. -
Step 6: Commit
git add fusion_claims/models/technician_task.py fusion_claims/controllers/ fusion_claims/__init__.py fusion_claims/tests/test_service_booking.py
git commit -m "feat(fusion_claims): action_book_from_wizard + jsonrpc booking routes"
Task 5: OWL booking wizard + SCSS (fusion_claims)
Files: create static/src/js/service_booking/service_booking.js, static/src/xml/service_booking.xml, static/src/scss/_service_booking_tokens.scss, static/src/scss/service_booking.scss; modify __manifest__.py (assets). No unit test — manual smoke.
- Step 1: Write the OWL component
Create fusion_claims/static/src/js/service_booking/service_booking.js:
/** @odoo-module **/
import { Component, useState, onWillStart } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
export class ServiceBookingWizard extends Component {
static template = "fusion_claims.ServiceBookingWizard";
static props = ["*"];
setup() {
this.action = useService("action");
this.notification = useService("notification");
this.state = useState({
custMode: "existing", customer: {name:"",phone:"",email:"",street:"",unit:"",buzz:"",city:""},
partnerId: false, soSearch: "",
device: "standard", category: "standard", timing: "normal", inShop: false, issue: "",
date: "", hour: 9, minute: 0, ampm: "AM", durationHr: 1.0, technicianId: false,
warranty: false, pod: false, emailConfirm: true, googleReview: true,
description: "", materials: "",
technicians: [], calloutRates: [], perKm: 0.70,
labour: {onsite:85, inshop:75, lift:110}, distanceKm: 13, saving: false,
});
onWillStart(async () => {
const r = await rpc("/fusion_claims/service_booking/refdata", {});
Object.assign(this.state, {
technicians: r.technicians, calloutRates: r.callout_rates,
perKm: r.per_km, labour: r.labour,
});
});
}
get callout() {
if (this.state.inShop) return null;
return this.state.calloutRates.find(
r => r.category === this.state.category && r.timing === this.state.timing) || null;
}
get labourRate() {
if (this.state.inShop) return this.state.labour.inshop;
return this.state.category === "lift" ? this.state.labour.lift : this.state.labour.onsite;
}
get estimate() {
const c = this.callout;
const callout = c ? c.price : 0;
const incl = (c && !c.adds_per_km) ? 0.5 : 0;
const billHr = Math.max(0, this.state.durationHr - incl);
const labour = billHr * this.labourRate;
const km = (c && c.adds_per_km) ? this.state.distanceKm * 2 * this.state.perKm : 0;
return { callout, labour, billHr, km, total: callout + labour + km, addsKm: !!(c && c.adds_per_km) };
}
get endLabel() {
let h = (this.state.hour % 12) + (this.state.ampm === "PM" ? 12 : 0);
let m = h * 60 + this.state.minute + this.state.durationHr * 60;
let eh = Math.floor(m / 60) % 24, em = m % 60, ap = eh >= 12 ? "PM" : "AM";
return `${eh % 12 || 12}:${String(em).padStart(2, "0")} ${ap}`;
}
onDevice(ev) { this.state.device = ev.target.value; this.state.category = ev.target.value === "lift" ? "lift" : "standard"; }
setCust(m) { this.state.custMode = m; }
setTiming(t) { this.state.timing = t; }
setAmpm(v) { this.state.ampm = v; }
toggleInShop() { this.state.inShop = !this.state.inShop; }
_timeStartFloat() { return (this.state.hour % 12) + (this.state.ampm === "PM" ? 12 : 0) + this.state.minute / 60; }
async submit() {
if (this.state.saving) return;
const s = this.state;
if (s.custMode === "new" && (!s.customer.name || !s.customer.phone)) {
this.notification.add("Client name and phone are required.", { type: "danger" }); return;
}
s.saving = true;
const payload = {
cust_mode: s.custMode, customer: s.customer, partner_id: s.partnerId, so_search: s.soSearch,
category: s.category, timing: s.timing, in_shop: s.inShop, device: s.device, issue: s.issue,
date: s.date, time_start: this._timeStartFloat(), duration_hr: s.durationHr,
technician_id: s.technicianId, warranty: s.warranty, pod: s.pod,
email_confirm: s.emailConfirm, google_review: s.googleReview,
description: s.description, materials: s.materials,
};
try {
const res = await rpc("/fusion_claims/service_booking/submit", { payload });
if (res.error) { this.notification.add(res.error, { type: "danger" }); s.saving = false; return; }
this.notification.add("Service booked — draft repair SO created.", { type: "success" });
this.action.doAction({
type: "ir.actions.act_window", res_model: "fusion.technician.task",
res_id: res.task_id, views: [[false, "form"]], target: "current",
});
} catch (e) {
this.notification.add("Booking failed: " + (e.message || e), { type: "danger" });
s.saving = false;
}
}
}
registry.category("actions").add("fusion_claims.service_booking", ServiceBookingWizard);
- Step 2: Write the OWL template — port the mockup
Create fusion_claims/static/src/xml/service_booking.xml with <t t-name="fusion_claims.ServiceBookingWizard">. Port each section from the mockup (docs/superpowers/mockups/technician-booking-wizard.html) converting static HTML → OWL bindings, per this exact mapping:
| Mockup element | OWL binding |
|---|---|
class="theme-btn" |
remove — Odoo handles dark/light via the bundle (Step 4) |
Customer Existing/New seg buttons |
t-att-class="{on: state.custMode==='existing'}" + t-on-click="() => setCust('existing')" |
| New-client inputs | t-model="state.customer.name" etc. (name, phone, email, street, unit, buzz, city) |
<select id="device"> |
t-on-change="onDevice" (options: scooter/powerchair/wheelchair→standard, stairlift/lift→lift, …) |
<select id="callType"> |
render from state.calloutRates with t-foreach; bind selection to category+timing |
| timing seg | t-on-click → `setTiming('normal' |
feeAmt / eCall/eLab/eKm/eTotal |
t-esc="estimate.callout" etc. (format with a fmt(n) helper or t-out) |
| in-shop switch | t-att-class="{on: state.inShop}" + t-on-click="toggleInShop" |
| AM/PM buttons | t-on-click → `setAmpm('AM' |
endlbl |
t-esc="endLabel" |
technician <select> |
t-foreach="state.technicians" + t-model.number="state.technicianId" |
| switches (warranty/pod/email/review) | t-att-class="{on: state.warranty}" + t-on-click="() => state.warranty = !state.warranty" |
footer Book & Create SO |
t-on-click="submit" t-att-disabled="state.saving" |
Keep the mockup's class names so the SCSS (Step 3) applies unchanged. Wrap the root in <div class="o_service_booking">…</div>.
- Step 3: Port the SCSS (dark/light)
Create fusion_claims/static/src/scss/_service_booking_tokens.scss — the mockup's :root/[data-theme] token values, converted to the repo's compile-time branch (per CLAUDE.md dark-mode rule):
$o-webclient-color-scheme: bright !default;
$_page:#eef0f3; $_panel:#e6e9ed; $_card:#ffffff; $_border:#d8dadd; $_text:#1f2430;
$_muted:#6b7280; $_field:#ffffff; $_money:#0f7d4e; $_money-soft:#e7f6ee; // …copy the rest from the mockup :root
@if $o-webclient-color-scheme == dark {
$_page:#14161b !global; $_panel:#1b1e24 !global; $_card:#22262d !global; $_border:#343a42 !global;
$_text:#e7eaef !global; $_muted:#9aa3af !global; $_field:#1a1d23 !global;
$_money:#34d27f !global; $_money-soft:#15281f !global; // …copy the dark values from the mockup [data-theme="dark"]
}
.o_service_booking {
--sb-page:#{$_page}; --sb-panel:#{$_panel}; --sb-card:#{$_card}; --sb-border:#{$_border};
--sb-text:#{$_text}; --sb-muted:#{$_muted}; --sb-field:#{$_field};
--sb-money:#{$_money}; --sb-money-soft:#{$_money-soft}; /* …rest */
}
Create fusion_claims/static/src/scss/service_booking.scss — the mockup's component CSS, scoped under .o_service_booking and using the --sb-* vars instead of the mockup's --page etc. (mechanical rename). Drop the .theme-btn rule.
- Step 4: Register assets in
__manifest__.py:
'assets': {
'web.assets_backend': [
# … existing entries …
'fusion_claims/static/src/scss/_service_booking_tokens.scss',
'fusion_claims/static/src/scss/service_booking.scss',
'fusion_claims/static/src/js/service_booking/service_booking.js',
'fusion_claims/static/src/xml/service_booking.xml',
],
'web.assets_web_dark': [
# dark bundle recompiles the same tokens with the dark scheme
'fusion_claims/static/src/scss/_service_booking_tokens.scss',
'fusion_claims/static/src/scss/service_booking.scss',
],
},
- Step 5: Smoke (manual, on the clone)
-u fusion_claims, hard-refresh. Trigger the action (Task 6) → the wizard renders; toggle a user dark-mode profile to confirm the dark bundle; book a new client → task form opens, draft SO exists with the right call-out line.
- Step 6: Commit
git add fusion_claims/static/ fusion_claims/__manifest__.py
git commit -m "feat(fusion_claims): OWL service-booking wizard + dark/light SCSS"
Task 6: Entry point + version bump
Files: create fusion_claims/views/service_booking_action.xml; modify __manifest__.py.
- Step 1: Create the client action + menu
Create fusion_claims/views/service_booking_action.xml:
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_service_booking_wizard" model="ir.actions.client">
<field name="name">Book a Service</field>
<field name="tag">fusion_claims.service_booking</field>
</record>
<menuitem id="menu_service_booking"
name="Book a Service"
parent="PARENT_MENU_XMLID"
action="action_service_booking_wizard"
sequence="1"/>
</odoo>
Use the same Field-Service menu parent identified in Plan 1 Task 4 Step 2 (e.g. the technician-task app menu). Register in __manifest__.py data after the views.
-
Step 2: Bump version in
__manifest__.py(e.g.19.0.9.3.0→19.0.9.4.0). -
Step 3: Full upgrade + all tests (clone):
--test-tags /fusion_claims,/fusion_tasks. Expected: all PASS. -
Step 4: End-to-end smoke (clone browser) — Book a Service menu → existing customer path (SO search prefill) and new-client path; confirm task + draft repair SO + correct call-out; rush/after-hours adds the per-km line; reschedule lands at the right local time (Task 1).
-
Step 5: Commit
git add fusion_claims/views/service_booking_action.xml fusion_claims/__manifest__.py
git commit -m "feat(fusion_claims): Book a Service entry point + version bump"
Self-Review (done while writing)
- Spec coverage: tz fix §8 ✓ (T1); constraint relax §6.3 ✓ (T2); repair-SO flag/tag §6.3 ✓ (T2); resolver reads rate table §7 ✓ (T3); SO builder + per-km §7 ✓ (T3);
action_book_from_wizard(contact→task→distance→SO) §5 ✓ (T4); OWL wizard + dark/light SCSS §5 ✓ (T5); entry point §11 ✓ (T6). Estimate-as-UI-only §9 ✓ (componentestimategetter, not written to SO). - Placeholders: none for logic. Two deliberate lookups — the menu parent xmlid (T6/Plan-1) and the field-name verification in T4 (real "read the model first" per rule #1), both concrete actions, not vague TODOs. The template/SCSS port references the mockup (a complete existing artifact) with an explicit element→binding mapping — concrete, not "similar to".
- Type/name consistency:
_resolve_service_lines(category, timing, in_shop, distance_km)and_build_service_so(partner, category, timing, in_shop, distance_km)match across T3 tests, T4 caller, and the controller. Rate codes (callout_standard_rush,per_km,labour_onsite/inshop/lift) match Plan 1's seed. Controller routes/fusion_claims/service_booking/{refdata,submit}match the OWLrpc()calls.action_book_from_wizardreturn shape{task_id, order_id}matches the component'sres.task_id. - Flagged for execution: verify real task field names in T4 (
in_store/client_name/address_lat…) and thecrm.tagvssale.ordertag model in T2 — both have explicit verify steps.
Execution Handoff
Both plans are written:
- Plan 1 —
…/plans/2026-06-03-service-rates-foundation-plan.md - Plan 2 — this file.
Order: Plan 1 → Plan 2 (Plan 2 consumes Plan 1's rate table). First move the work to a dedicated branch: git checkout -b claude/technician-service-booking (off main, not the fusion_schedule-fix branch).
Two execution options (per the writing-plans skill):
- Subagent-Driven (recommended) — a fresh subagent per task, reviewed between tasks. Best given the Enterprise-clone test loop.
- Inline Execution — execute tasks in this session with checkpoints.
Caveat: verification requires the Westin Enterprise clone (no local Community install). Plan to run the test/smoke steps there.