diff --git a/docs/superpowers/EXECUTE-technician-service-booking.md b/docs/superpowers/EXECUTE-technician-service-booking.md new file mode 100644 index 00000000..1c06c26d --- /dev/null +++ b/docs/superpowers/EXECUTE-technician-service-booking.md @@ -0,0 +1,127 @@ +# KICKOFF BRIEF — Implement "Technician Service Booking & Auto-Quote" (hands-off) + +You are a fresh Claude Code session. **Implement this feature end-to-end, autonomously, from the +plans below.** The design is already locked through brainstorming — **do NOT re-design or +re-brainstorm.** Build it. + +--- + +## 1. Mission + +Replace the raw `fusion.technician.task` booking modal with a polished **OWL "Book a Service" +wizard** that: captures the client (incl. brand-new clients inline), books the technician task, +prices the call-out from an **editable rate table**, and **auto-creates a draft repair Sale Order** +— with correct, consistent timezone handling. Works in dark + light. + +## 2. Read these first, in order + +1. `K:\Github\Odoo-Modules\CLAUDE.md` (repo Odoo-19 rules) + the global `K:\Github\CLAUDE.md`. +2. Spec: `docs/superpowers/specs/2026-06-03-technician-service-booking-design.md` +3. **Plan 1** (do first): `docs/superpowers/plans/2026-06-03-service-rates-foundation-plan.md` +4. **Plan 2** (do second): `docs/superpowers/plans/2026-06-03-service-booking-wizard-plan.md` +5. UI source of truth (port its markup/CSS): `docs/superpowers/mockups/technician-booking-wizard.html` + +The plans are bite-sized (TDD, exact files, full code). They are the authority — follow them +task-by-task. The spec/mockup are context. + +## 3. Method + +- Use the **`superpowers:subagent-driven-development`** skill (the plan headers require it). One + task at a time; write test → implement → verify → **commit per task** with the messages in the plan. +- **Order: Plan 1 fully, then Plan 2** (Plan 2 consumes Plan 1's `fusion.service.rate`). +- Before writing any model/view/OWL code, obey repo rule #1: **read the real reference from Docker + first** (`docker exec odoo-modsdev-app cat …` or, for the Enterprise classes, read the on-disk + source) — never code Odoo APIs from memory. The plans flag the specific signatures to confirm + (`_get_local_tz`, `_compute_datetimes`, `_calculate_travel_time`, real task field names like + `in_store`/`client_name`/`address_lat`, the `crm.tag` vs `sale.order` tag model). + +## 4. Branch + +```bash +git -C K:\Github\Odoo-Modules checkout main +git -C K:\Github\Odoo-Modules checkout -b claude/technician-service-booking +``` +Create it **off `main`** — NOT off `claude/fusion-schedule-audit-fixes` (that branch has unrelated +calendar-sync fixes). The spec/plans/mockup are already on disk under `docs/superpowers/`; keep them. + +## 5. Hard constraints (do not violate) + +- **Odoo 19 idioms** (from CLAUDE.md): declarative `models.Constraint` / `models.Index` (never + `_sql_constraints`); `group_ids` not `groups_id`; HTTP routes `type="jsonrpc"`; backend OWL uses + **standalone `rpc()`** from `@web/core/network/rpc` (not `useService("rpc")`), client action + `static props = ["*"]`; **dark mode** = branch on `$o-webclient-color-scheme` at SCSS compile + time and register the SCSS in **both** `web.assets_backend` **and** `web.assets_web_dark`; new + fields use the **`x_fc_`** prefix; **Canadian English**; any `message_post(body=…)` HTML wrapped + in `Markup()`. +- **Enterprise-only:** `fusion_claims` pulls `ai` → it **cannot install on local Community + (`odoo-modsdev`)**. Do **not** attempt `-d modsdev -u fusion_claims`. (`fusion_tasks` alone may + install locally — the tz-fix test in Plan 2 Task 1 can be tried there; everything else is clone-only.) +- **The design is LOCKED** — implement exactly §6 below; don't add scope or re-open decisions. + +## 6. Locked design (build exactly this) + +- **Time:** 12-hour **AM/PM** entry on the wizard (custom control — Odoo's native widget is 24h). + Fix the `fusion_tasks` tz bug: the `_inverse_datetime_*` methods must use `self._get_local_tz()` + (same resolver as `_compute_datetimes`), not `self.env.user.tz`. +- **Client:** inline **new-client** (name / phone / email / address) on the page; **no forced SO** + (relax `fusion_claims` `_check_order_link` to a no-op); find-or-create the `res.partner` on save + (match by email then phone). +- **View:** a **full OWL client action** wizard (complete design freedom), ported from the mockup, + dark + light. +- **Pricing → SO:** pick service type → call-out fee → **auto draft repair `sale.order`** with the + call-out line **+ auto per-km line** for Rush/After-Hours (qty = `travel_distance_km × 2`, + $0.70/km). On-screen **estimate is UI-only** (labour/parts added later as actuals). Tag the SO + (`x_fc_is_service_repair` + a "Service Repair" tag). +- **Rates are an editable table** — `fusion.service.rate` with a **Service Rates** menu. The card + only **seeds** it (`noupdate=1`). Pricing is read from this table, never hardcoded. +- **Rate card seed:** Standard call $95 / Rush $120 / After-Hours $140; Lift & Elevating $160 / + **Rush $185** / **After-Hours $205** (the $185/$205 are *suggested* fills — seed them but they're + confirm-pending; leave a code comment). Labour: on-site $85, in-shop $75 (reuse existing `LABOR` + product), lift $110. Per-km $0.70 ×2-way. Delivery/setup: local $35 / outside $60 / rush $60+km / + lift-chair $120 / bed $120 / stairlift $300 / removal $300. **In-shop = no call-out, labour @ $75.** +- **Module split:** the tz fix goes in **`fusion_tasks`**; everything else (rate model, products, + menu, resolver, SO builder, `action_book_from_wizard`, controller, OWL wizard, SCSS, entry point) + goes in **`fusion_claims`**. + +## 7. Verification (you probably can't reach the Enterprise clone — handle both cases) + +- **Always do (no Odoo needed):** after each Python file, run `python -m py_compile ` and + `python -m pyflakes ` (or `docker exec odoo-modsdev-app python3 -m pyflakes …`). **Fix every + warning you introduce.** This is your local gate. +- **Full tests + smoke require a Westin Enterprise clone.** A one-command harness already exists: + `scripts/verify_service_booking.sh` (runs on the `odoo-westin` host: clones the DB, the + orphaned-tax-FK cleanup, stages the branch, `-u` + tests, PASS/FAIL; `--deploy` ships on green). + - If you have access to `odoo-westin`: push the branch, then run that script (verify-only first). + - If you do **not**: finish all code, ensure `py_compile`/`pyflakes` are clean, **commit the + branch task-by-task**, and clearly report **"clone-verification pending — run + `scripts/verify_service_booking.sh` on odoo-westin."** Do not fake a green test. +- **Never deploy to prod yourself.** Leave `--deploy` to the human. + +## 8. Definition of done + +- [ ] Branch `claude/technician-service-booking` off `main`. +- [ ] Plan 1 + Plan 2 implemented, **committed task-by-task** with the plans' commit messages. +- [ ] `py_compile` + `pyflakes` clean on every touched `.py`. +- [ ] OWL wizard renders the mockup layout in **both** light and dark bundles. +- [ ] Either **clone-verified GREEN** via the script, **or** branch committed + verification + explicitly flagged pending (with the exact command to run). +- [ ] A short final report: what was built, files changed, how to verify + deploy (`scripts/verify_service_booking.sh`), + and the one open business item (confirm Lift Rush/After-Hours $185/$205). + +## 9. Don't + +- Don't test on `odoo-modsdev` (Community — `fusion_claims` won't install). +- Don't re-brainstorm or change the design in §6. +- Don't hardcode prices (they live in `fusion.service.rate`). +- Don't deploy to prod or run `--deploy` — hand that to the human. +- Don't change the suggested $185/$205 silently — keep them, flag them confirm-pending. + +--- + +### Optional: launch it headless + +```bash +# from the repo root, on a machine with this checkout: +claude -p "$(cat docs/superpowers/EXECUTE-technician-service-booking.md)" --permission-mode acceptEdits +``` +…or just paste this file into a fresh Claude Code session and say "go". diff --git a/docs/superpowers/mockups/technician-booking-wizard.html b/docs/superpowers/mockups/technician-booking-wizard.html new file mode 100644 index 00000000..731e7a04 --- /dev/null +++ b/docs/superpowers/mockups/technician-booking-wizard.html @@ -0,0 +1,325 @@ + + + + + +Book a Service — Mockup v2 + + + +
+
+
+

Book a Service

Repair · delivery · pickup — captures the job and creates the priced repair order
+ +
+
+ ScheduledEn Route + In ProgressCompleted + ● Draft repair SO will be created +
+ +
+
+ +
+

Customer

+
+
+ + +
+
+
+
+ + +
Inbound call? Type the phone number — we match the contact & their history.
+
+
+
+
+
+
+
+
+
+
📍
+
+
+
+
+
+
+
Contact is created & linked on save — all from this page.
+
+
+ + +
+

Service & Pricing$ REVENUE

+
+
+ + +
+
+ + +
+
+
+ + +
Auto-suggested from the device — change if needed.
+
+
+
Call-out feeStandard · includes 30 min labour
+
$95
+
+ +
+ + +
+

Schedule

+
+
+
+
+
+
+ +
+ + +
+
+
Ends at 10:00 AM · your local time
+
+
+ + + ● 3 open slots before 5:00 PM +
+
+ + +
+

Location

+
+
In-shop jobAt the store — no call-out, labour @ $75/hr
+
+
+
+
+
📍
+
+
+
+
+
+
+
+ + +
+

Job details

+
+
+
+
+
Under manufacturer warrantyParts not billed when covered
+
POD requiredCapture proof of delivery on completion
+
Send client confirmation (email/SMS)Booked · en-route · completed
+
Request Google review after completion
+
+ + +
+
+
Call-out
$95
+
Est. labour
$85 · 1h
+ +
+
Estimated total
$180
+
+ parts as used · pre-tax · a draft SO is created
+
+
+
+ +
+ Local time · America/Toronto · 13 km away + + +
+
+
+ +
+ Mockup v2 — demo-wired (theme, customer mode, device→call-type, in-shop, AM/PM, switches, live estimate). + Real build = an OWL client action; Book & Create SO calls one server method that find-or-creates the + contact, creates the fusion.technician.task + a draft sale.order with the call-out line + (+ auto per-km for rush/after-hours, from the computed distance). Rate-card items are seeded as service products. + Toggle top-right for dark/light. +
+ + + + diff --git a/docs/superpowers/plans/2026-06-03-service-booking-wizard-plan.md b/docs/superpowers/plans/2026-06-03-service-booking-wizard-plan.md new file mode 100644 index 00000000..8b1cbf51 --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-service-booking-wizard-plan.md @@ -0,0 +1,737 @@ +# 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 `