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>
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
# Technician Service Booking & Auto-Quote — Design Spec
|
||||
|
||||
**Date:** 2026-06-03
|
||||
**Modules:** `fusion_tasks` (booking wizard, task, time/tz), `fusion_claims` (SO link, rate-card products, SO creation)
|
||||
**Status:** Draft for review
|
||||
**Mockup:** `docs/superpowers/mockups/technician-booking-wizard.html` (v2)
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem & Goal
|
||||
|
||||
Operators booking a technician service today use the raw `fusion.technician.task` form in a modal. Three problems:
|
||||
|
||||
1. **Forced SO:** a hard constraint (`fusion_claims/models/technician_task.py:105 _check_order_link`) requires a Sale Order **or** Purchase Order for every task except `ltc_visit`. A repair for a brand-new client (no SO yet) is blocked.
|
||||
2. **Time fields:** Start/End use a 24-hour `float_time` widget while every other view shows 12-hour AM/PM; and the local→UTC conversion is inconsistent (`_compute_datetimes` resolves *company-calendar-tz → user-tz → UTC*, but `_inverse_datetime_*` uses *user-tz → UTC* only — they disagree, and fall back to UTC when unset).
|
||||
3. **No revenue capture at booking:** the booking creates a task but no priced order, even though every service call has a defined call-out fee.
|
||||
|
||||
**Goal:** a fast, polished **"Book a Service"** wizard that, from one screen, (a) captures the client — including brand-new clients inline, (b) books the technician task, (c) prices the call-out from the rate card, and (d) auto-creates a **draft repair Sale Order**. Every service call becomes a revenue-tracked order. Works in dark + light.
|
||||
|
||||
---
|
||||
|
||||
## 2. Scope
|
||||
|
||||
**In:** OWL booking wizard (complete design freedom); inline new-client create (name/phone/email/address); rate-card product catalog; service-type → call-out pricing; auto draft repair SO (call-out line + auto per-km); live on-screen estimate; 12-hour AM/PM time entry; timezone-conversion fix; relaxation of the SO constraint.
|
||||
|
||||
**Out (phase 2):** deposit/payment capture; multi-technician labour auto-doubling; SMS gateway; maintenance/PM plans; full quote builder (estimated labour & parts written onto the SO at booking — for now the SO carries call-out + per-km only, labour/parts added as actuals).
|
||||
|
||||
---
|
||||
|
||||
## 3. Pricing model (Westin rate card)
|
||||
|
||||
> These values only **seed** the editable `fusion.service.rate` table (§6.1). After install, admins
|
||||
> change any price and add new rate types from the **Service Rates** menu — nothing here is hardcoded,
|
||||
> and the wizard reflects edits live.
|
||||
|
||||
### 3.1 Call-out fee matrix (the guaranteed charge; includes 30 min labour where noted)
|
||||
|
||||
| Category | Normal | Rush (+km) | After-Hours (+km) |
|
||||
|---|---|---|---|
|
||||
| **Standard** | $95 | $120 | $140 |
|
||||
| **Lift & Elevating** | $160 | **$185** ◆ | **$205** ◆ |
|
||||
|
||||
- ◆ **Suggested fills** (not on the printed card). Derived from the card's own surcharge deltas: Standard Rush = +$25, After-Hours = +$45 over base; same deltas applied to the Lift base ($160) → $185 / $205. *Owner to confirm.*
|
||||
- **Rush & After-Hours** add **$0.70/km × 2-way** (round trip), computed from the booking's travel distance.
|
||||
- **In-shop (any device):** no call-out fee; labour billed at $75/hr; no delivery.
|
||||
|
||||
### 3.2 Labour (hourly, pro-rated in 30-min increments; per technician)
|
||||
- On-site (Standard): **$85/hr**
|
||||
- In-shop: **$75/hr** (already exists as product `LABOR`, default_code `LABOR`)
|
||||
- Lift & Elevating on-site: **$110/hr**
|
||||
|
||||
### 3.3 Travel
|
||||
- Per-km surcharge: **$0.70/km × 2-way**
|
||||
|
||||
### 3.4 Delivery / Pickup
|
||||
| Item | Price |
|
||||
|---|---|
|
||||
| Local (within Brampton) | $35 |
|
||||
| Outside local area | $60 |
|
||||
| Rush pickup/delivery | $60 + $0.70/km ×2-way |
|
||||
| Lift-chair delivery & set-up | $120 |
|
||||
| Hospital-bed delivery & set-up | $120 |
|
||||
| Stairlift delivery & set-up | $300 |
|
||||
| Stairlift removal | $300 |
|
||||
|
||||
### 3.5 Footnote rules (from the card)
|
||||
- A Service Call is an appointment **outside** a Westin location, billed **once per request**, includes **30 min labour**; labour rates apply after.
|
||||
- Parts are **not** charged when covered under manufacturer warranty (→ "Under warranty" flag on the wizard).
|
||||
- Multiple technicians → labour applies **per technician** (phase-2 auto-double; for now informational).
|
||||
|
||||
---
|
||||
|
||||
## 4. UX — wizard layout
|
||||
|
||||
Single page (no multi-step), grouped cards, brand-gradient header, dark/light. Sections (see mockup v2):
|
||||
|
||||
- **Customer** — segmented `Existing customer | New client`. Existing = search by **phone / name / SO** → prefill. New = **name, phone, email, address (street/unit/buzz/city)** inline; contact find-or-created on save.
|
||||
- **Service & Pricing** — *device being serviced* (→ auto-suggests category: scooter/chair/bed → Standard; stairlift/lift → Lift & Elevating), *issue/symptom*, *service call type* (category × timing), and the resulting **call-out fee** readout.
|
||||
- **Schedule** — date, **12-hour AM/PM start picker**, duration → auto end ("Ends at 10:00 AM · local time"), technician + availability hint.
|
||||
- **Location** — **in-shop toggle** (drives pricing: no call-out, $75 labour, hides address), job address.
|
||||
- **Job details** — work description, parts to bring, **under-warranty** toggle, POD, send-confirmation, request-review.
|
||||
- **Estimate** (prominent strip) — *call-out + est. labour + per-km = total*; "a draft repair SO is created."
|
||||
- **Footer** — Cancel · **Book & Create SO**.
|
||||
|
||||
Behaviours: device→category auto-suggest (overridable); in-shop flips pricing & hides address + call-out; live estimate recomputes on every change; AM/PM picker stores local float hours.
|
||||
|
||||
---
|
||||
|
||||
## 5. Architecture
|
||||
|
||||
**Complete UI freedom without duplicating backend logic:**
|
||||
|
||||
- **OWL client action** `fusion_tasks.service_booking` — renders the layout; loads reference data (technicians, device types, rate products, customer search) via standalone `rpc()` (`@web/core/network/rpc`). Registered in `registry.category("actions")`. Opened from a "Book a Service" button/menu/dashboard tile (`ir.actions.client`).
|
||||
- **One server method** `fusion.technician.task.action_book_from_wizard(payload)`:
|
||||
1. Resolve customer — search `res.partner` by email then phone; create if new (name/phone/email/address). For "existing", use the chosen partner/SO's partner.
|
||||
2. Compute **travel distance now** (Google Distance Matrix via the existing `_calculate_travel_time`/`_get_google_maps_api_key`) from the shop / previous task to the job — needed for the per-km line.
|
||||
3. Create a **draft `sale.order`** tagged as a repair (see §6) with the **call-out product line** + an **auto per-km line** (qty = round(distance_km × 2), product = per-km $0.70) when the service type is Rush/After-Hours.
|
||||
4. Create the `fusion.technician.task` linked to that SO (reuses existing model `create` + address-fill + travel-chain logic).
|
||||
5. Return `{task_id, order_id}` so the client action can open the task or close.
|
||||
- **SCSS** `fusion_tasks/static/src/scss/_service_booking_tokens.scss` + `service_booking.scss`, branching on `$o-webclient-color-scheme` (per repo rule), registered in `web.assets_backend` **and** `web.assets_web_dark`. Three-layer contrast tokens (page → card → field), explicit hex.
|
||||
|
||||
All validation/workflow/pricing stays server-side; the OWL component is presentation + a single submit call.
|
||||
|
||||
---
|
||||
|
||||
## 6. Data model changes
|
||||
|
||||
### 6.1 New: editable rate table `fusion.service.rate` (the configurable pricing control)
|
||||
A dedicated model so admins manage **all** pricing from a **Service Rates** menu — no code to change a price or add a service type.
|
||||
|
||||
**Fields:** `name`; `code` (unique, e.g. `callout_standard_normal`, `callout_lift_rush`, `labour_onsite`, `labour_lift`, `per_km`, `delivery_local`); `rate_kind` (callout / labour / travel / delivery / other); `category` (standard / lift / na); `timing` (normal / rush / afterhours / na); `in_shop` (bool); `product_id` (the `product.product` used on the SO line — for description, tax, income account); `price` (Monetary — the **editable source of truth**); `unit` (fixed / per_hour / per_km); `adds_per_km` (bool); `included_labour_min` (int, e.g. 30); `active`; `sequence`; `currency_id`.
|
||||
|
||||
- **Seed** (`data/service_rate_data.xml`, `noupdate=1`): one row per §3 rate, each linked to a seeded `product.product` (type `service`, `sale_ok`, correct UoM — hour/km/unit, HST). `noupdate=1` means a later `-u` never overwrites admin price edits.
|
||||
- **Views/menu:** list + form under *Field Service → Configuration → Service Rates* (manager-only) — edit price, add/remove rows, toggle `active`.
|
||||
- **Products still exist** (SO lines + accounting need a product), but the **rate row's `price` is the source of truth** — the SO line takes `price_unit` from the rate, not the product's `list_price`. One place to edit.
|
||||
- The **wizard builds its service-type selector from the active `callout` rows**, so a new rate row appears in the wizard automatically.
|
||||
|
||||
### 6.2 `fusion_tasks` — `fusion.technician.task`
|
||||
- Make `_compute_datetimes` and `_inverse_datetime_start/_end` use **one** tz resolver (`_get_local_tz()` everywhere) so compute and inverse agree; document that local float hours ↔ UTC datetime is the single source of truth.
|
||||
- Time entry stays `time_start`/`time_end` floats (local); the **AM/PM presentation lives in the OWL wizard**; the existing `time_start_display` (12h) already covers list/kanban/calendar.
|
||||
|
||||
### 6.3 `fusion_claims` — `fusion.technician.task` + `sale.order`
|
||||
- **Relax** `_check_order_link`: no longer raise when there is no SO/PO — the wizard now auto-creates the SO, and in-shop/walk-in tasks may legitimately have none. (Keep the helper that auto-fills address from an SO when one *is* linked.)
|
||||
- Add `x_fc_service_call_type` (Selection: standard/lift × normal/rush/afterhours, + in_shop) on the task, set by the wizard, used to pick the call-out product and for reporting.
|
||||
- Add a **pricing resolver** that reads `fusion.service.rate`: `_get_callout_rate(category, timing, in_shop)` and `_get_rate(code)` (per-km, labour, delivery) + `_build_service_so(partner, rate, distance_km, ...)` that creates the SO + lines using each rate's `product_id` with `price_unit` taken from the rate row.
|
||||
- **Repair-SO identity:** boolean `x_fc_is_service_repair` on `sale.order` + an `crm.tag`/SO tag "Service Repair" so these orders are filterable; reuse the standard quotation flow.
|
||||
|
||||
---
|
||||
|
||||
## 7. Pricing engine
|
||||
|
||||
- Reads the **`fusion.service.rate`** table (§6.1) — never hardcoded.
|
||||
- `_get_callout_rate(category, timing, in_shop)` → the matching active `callout` row (none if in-shop). Its `price` → the SO call-out line `price_unit`; its `product_id` → the line product.
|
||||
- **Per-km:** when the call-out row's `adds_per_km` is set, add a line from the `per_km` rate row, qty = `round(distance_km × 2)`, `price_unit` = that row's price.
|
||||
- **On-screen estimate (UI only, not written to SO):** `callout.price + max(0, duration − included_labour_min/60) × labour_rate + per-km`, where `labour_rate` is read from the `labour_*` rate rows (in-shop / on-site / lift).
|
||||
|
||||
---
|
||||
|
||||
## 8. Timezone fix (folds in the audit finding)
|
||||
|
||||
Single resolver `_get_local_tz()` (company resource-calendar tz → user tz → UTC) used by **both** `_compute_datetimes` and the inverses, eliminating the compute/inverse mismatch and the silent UTC fallback. Booking writes local float hours; datetime_start/end (UTC) recompute consistently for the calendar/sync.
|
||||
|
||||
---
|
||||
|
||||
## 9. Open decisions (defaults chosen — confirm at review)
|
||||
|
||||
| # | Decision | Default |
|
||||
|---|---|---|
|
||||
| 1 | Lift Rush / After-Hours call-out | **$185 / $205** (parallel surcharge) |
|
||||
| 2 | In-shop pricing | no call-out, labour @ $75/hr, no delivery |
|
||||
| 3 | Repair-SO identity | boolean `x_fc_is_service_repair` + SO tag "Service Repair" |
|
||||
| 4 | Estimate labour | on-screen guide only; SO = call-out + per-km; labour/parts as actuals |
|
||||
| 5 | Per-km distance basis | Distance Matrix, shop/previous-task → job, ×2-way |
|
||||
| 6 | Rate configurability | editable `fusion.service.rate` table + **Service Rates** menu; the card only seeds it, admin-owned thereafter |
|
||||
|
||||
---
|
||||
|
||||
## 10. Testing & rollout
|
||||
|
||||
- Enterprise-only stack (these modules need `fusion_claims`/`fusion_portal` deps) → **verify on a Westin clone**, not local Community.
|
||||
- Seed products + taxes; smoke-test: new-client booking → contact + task + draft SO created with the right call-out (+ per-km on rush/after-hours); existing-customer booking; in-shop (no call-out); tz correctness on the task + calendar; dark + light bundles.
|
||||
|
||||
---
|
||||
|
||||
## 11. Build sequence (for the implementation plan)
|
||||
|
||||
1. **`fusion.service.rate` model** + seeded rows + products + taxes + *Service Rates* menu/views.
|
||||
2. **TZ fix** + confirm AM/PM round-trips (time floats).
|
||||
3. **Constraint relax** + `x_fc_service_call_type` + pricing resolver + `_build_service_so` + `action_book_from_wizard` (server).
|
||||
4. **OWL wizard** client action + SCSS (dark/light).
|
||||
5. **Entry point** (button/menu/tile) + `ir.actions.client`.
|
||||
6. **Clone-verify** end-to-end.
|
||||
Reference in New Issue
Block a user