# 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: ```bash 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`: ```python # -*- 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): ```python 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_trips` FAILS if user.tz ≠ company-calendar tz, or passes spuriously if they're equal — set the company `resource_calendar_id.tz` to `America/Toronto` in `setUpClass` too 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`: ```python 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** ```bash 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`: ```python # -*- 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_allowed` FAILS with the ValidationError from `_check_order_link`; `test_sale_order_has_service_repair_flag` FAILS (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): ```python @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: ```python 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 Service Repair ``` Register it in `__manifest__.py` `data` (after the service-rate data from Plan 1): ```python 'data/service_repair_data.xml', ``` > `crm.tag` requires the `sale_crm`/`crm` dependency. If `fusion_claims` doesn't pull `crm`, use `sale.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 to `crm.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** ```bash 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`): ```python 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: ```python 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`): ```python @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_km` key is a test-only marker stripped before create. If `sale.order` has no `tag_ids` (no CRM), the guard skips the tag. - [ ] **Step 4: Run — verify it passes.** - [ ] **Step 5: Commit** ```bash 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): ```python 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: ```python @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_store` vs `in_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_time` needs a different origin, pass the shop/technician start coords instead. - [ ] **Step 4: Create the controller** Create `fusion_claims/controllers/__init__.py`: ```python from . import service_booking ``` Create `fusion_claims/controllers/service_booking.py`: ```python # -*- 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): ```python from . import controllers ``` - [ ] **Step 5: Run — verify it passes** (`--test-tags /fusion_claims.TestServiceBooking`). Also `pyflakes` the controller: `docker exec odoo-dev-app python3 -m pyflakes /mnt/extra-addons/fusion_claims/controllers/service_booking.py`. - [ ] **Step 6: Commit** ```bash 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`: ```javascript /** @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 ``. **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) | | `` | render from `state.calloutRates` with `t-foreach`; bind selection to category+timing | | timing seg | `t-on-click` → `setTiming('normal'|'rush'|'afterhours')` | | `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'|'PM')`; hour/minute `t-model.number` | | `endlbl` | `t-esc="endLabel"` | | technician `