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

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

35 KiB
Raw Blame History

Service 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 (14) 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_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:

    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_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):

    @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.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

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_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

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_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:

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). Also pyflakes the 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.019.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 ✓ (component estimate getter, 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 OWL rpc() calls. action_book_from_wizard return shape {task_id, order_id} matches the component's res.task_id.
  • Flagged for execution: verify real task field names in T4 (in_store/client_name/address_lat…) and the crm.tag vs sale.order tag 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):

  1. Subagent-Driven (recommended) — a fresh subagent per task, reviewed between tasks. Best given the Enterprise-clone test loop.
  2. 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.