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>
738 lines
35 KiB
Markdown
738 lines
35 KiB
Markdown
# 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
|
||
<?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):
|
||
|
||
```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 `<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'|'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 `<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):
|
||
|
||
```scss
|
||
$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`:
|
||
|
||
```python
|
||
'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**
|
||
|
||
```bash
|
||
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
|
||
<?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**
|
||
|
||
```bash
|
||
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.
|