Compare commits

...

19 Commits

Author SHA1 Message Date
gsinghpal
63694eccb1 fix(scripts): verify_service_booking — general orphan-FK sweep + test port fix + scoped tags
Hardened after the first real clone-verify on odoo-westin:
- Cleanup now generates an orphan-delete for EVERY single-column FK from PROD's
  pg_constraint and applies it to the clone (was tax-tables-only). westin-v19 also
  has deleted-company (payslip_tags_table, account_account_res_company_rel) and
  deleted-journal (account_payment_method_line) orphans that broke the clone -u.
- run_odoo passes --http-port=0 --gevent-port=0 so --test-enable (which forces
  http_spawn even with --no-http in Odoo 19) doesn't die on 'Address already in use'.
- TEST_TAGS scoped to this feature's classes (the broad tag also runs pre-existing
  dashboard/wizard tests that fail in this prod-config runner, unrelated to this work).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 06:10:25 -04:00
gsinghpal
252716156c test(fusion_tasks): tz test task needs description (NOT NULL) + is_in_store
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 06:07:36 -04:00
gsinghpal
dfa266d691 test(fusion_claims,fusion_tasks): fix clone-test failures (future dates + seed-aware asserts)
Real install verified on the Westin clone; these were test-only bugs:
- Task-create tests hardcoded scheduled_date 2026-06-03, now in the past, which
  the base _check_no_overlap rejects ('Cannot schedule tasks in the past'). Use
  future dates (tz test pins a future July date so Toronto stays EDT for the
  9:00->13:00 UTC assertion).
- Service-rate resolver tests created rows with seeded codes (callout_standard_normal,
  per_km) -> UNIQUE(code) violation post-install. Assert against the seed instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 06:04:11 -04:00
gsinghpal
7b8364eb58 fix(fusion_claims): seed service products as product.product (direct variant ref)
The <template>_product_variant auto external-ID is not reliably created in this
Odoo 19 (only 5 exist on westin-v19; none for these products or product_labor_hourly),
so the rate rows' product_id refs failed at install: 'External ID not found:
..._product_variant'. Seed each product as model=product.product (the xmlid IS the
variant; name/price/uom/etc. delegate via _inherits) and reference it directly.
In-shop labour now uses a dedicated product_labour_inshop ($75) rather than reusing
product_labor_hourly, whose variant xmlid likewise does not exist. Caught on the
Westin clone install.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 05:55:00 -04:00
gsinghpal
4e5e9f4c91 fix(fusion_claims): drop uom_po_id from seed labour products
product.template lost the separate purchase-UoM field uom_po_id in Odoo 19
(only uom_id remains). The plan's seed carried uom_po_id, which ParseErrors at
install: 'Invalid field uom_po_id in product.template'. Caught on first real
clone-install on the Westin Enterprise clone. The existing product_labor_hourly
uses uom_id only — match that.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 05:46:58 -04:00
gsinghpal
f84c22c743 feat(fusion_claims): Book a Service entry point + version bump
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:31:46 -04:00
gsinghpal
46d19fd581 fix(fusion_claims): OWL wizard review fixes (statement handler, scss borders, tech guard)
- Move the call-type select handler into onCallType() — OWL cannot compile a
  multi-statement inline t-on body (was a render-breaking crash on mount).
- Replace color-mix() inside border shorthands with var(--sb-border) (Odoo-19
  SCSS drops color-mix in a border shorthand).
- Technician placeholder option value '' (not 'false') so the required-tech
  guard isn't bypassed.
- Remove dead setTiming(); null-coalesce the refdata onWillStart load.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:29:55 -04:00
gsinghpal
56ca82c611 feat(fusion_claims): OWL service-booking wizard + dark/light SCSS
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:16:29 -04:00
gsinghpal
d457b86eaa fix(fusion_claims): default booking description + isolate order-less task test
Review follow-up: the base fusion.technician.task.description is required=True and
non-in-store tasks require an address (_check_address_required). So:
- action_book_from_wizard now defaults description to 'Service booking' when the
  payload carries neither description nor issue (avoids a required-field failure).
- test_task_without_order_is_allowed now sets description + is_in_store=True so it
  exercises only the relaxed _check_order_link, not those unrelated base constraints.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:09:14 -04:00
gsinghpal
92e8a18fcb feat(fusion_claims): action_book_from_wizard + jsonrpc booking routes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:00:53 -04:00
gsinghpal
245e551c68 feat(fusion_claims): service pricing resolver + draft-SO builder from rate table
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:54:39 -04:00
gsinghpal
a022eaaabe feat(fusion_claims): allow order-less tasks + service-repair SO flag
Relaxes _check_order_link to a no-op (service bookings auto-create their SO;
in-shop/walk-in tasks may have none) and adds x_fc_is_service_repair on
sale.order. The 'Service Repair' crm.tag from the plan is intentionally
omitted: fusion_claims does not depend on crm and sale.order has no tag_ids;
the boolean flag is the repair-SO identity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:51:10 -04:00
gsinghpal
0e6bb7b676 fix(fusion_tasks): make datetime inverses use the same tz resolver as compute
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:47:48 -04:00
gsinghpal
d5d410f6d0 chore(fusion_claims): bump version for service-rate foundation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:42:50 -04:00
gsinghpal
41141a75e8 feat(fusion_claims): Service Rates menu, list (inline-edit) + form + ACL
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:42:42 -04:00
gsinghpal
d512dfccf0 feat(fusion_claims): seed service-rate rows from the rate card
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:41:44 -04:00
gsinghpal
5e9576ed8f feat(fusion_claims): seed service-rate products
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:39:03 -04:00
gsinghpal
80d9a960e7 feat(fusion_claims): add fusion.service.rate model + resolvers
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:38:27 -04:00
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
29 changed files with 3792 additions and 15 deletions

View File

@@ -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 <file>` and
`python -m pyflakes <file>` (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".

View File

@@ -0,0 +1,325 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Book a Service — Mockup v2</title>
<style>
:root, [data-theme="light"] {
--page:#eef0f3; --panel:#e6e9ed; --card:#ffffff; --border:#d8dadd;
--text:#1f2430; --muted:#6b7280; --faint:#9ca3af;
--field:#ffffff; --field-border:#cfd3d8; --field-focus:#3a8fb7;
--chip:#f1f4f7; --shadow:0 1px 3px rgba(16,24,40,.08),0 1px 2px rgba(16,24,40,.06);
--accent:#2e7aad; --accent-soft:#e8f2f8; --ok:#16a34a; --star:#f5b301; --money:#0f7d4e; --money-soft:#e7f6ee;
}
[data-theme="dark"] {
--page:#14161b; --panel:#1b1e24; --card:#22262d; --border:#343a42;
--text:#e7eaef; --muted:#9aa3af; --faint:#6b7480;
--field:#1a1d23; --field-border:#3a4049; --field-focus:#4aa3cf;
--chip:#2a2f37; --shadow:0 1px 3px rgba(0,0,0,.4);
--accent:#3a8fb7; --accent-soft:#19303d; --ok:#22c55e; --star:#f5b301; --money:#34d27f; --money-soft:#15281f;
}
* { box-sizing:border-box; }
body { margin:0; background:var(--page); color:var(--text);
font-family:'Inter','Helvetica Neue',Helvetica,Arial,system-ui,sans-serif; font-size:14px; }
.wrap { max-width:1000px; margin:24px auto; padding:0 18px; }
.dialog { background:var(--panel); border:1px solid var(--border); border-radius:16px;
box-shadow:0 12px 40px rgba(16,24,40,.16); overflow:hidden; }
.topbar { background:linear-gradient(135deg,#5ba848 0%,#3a8fb7 60%,#2e7aad 100%);
padding:17px 24px; display:flex; align-items:center; justify-content:space-between; color:#fff; }
.topbar h1 { font-size:19px; font-weight:700; margin:0; }
.topbar .sub { font-size:12.5px; opacity:.9; margin-top:2px; }
.theme-btn { background:rgba(255,255,255,.18); border:1px solid rgba(255,255,255,.35); color:#fff;
border-radius:20px; padding:6px 14px; font-size:12.5px; cursor:pointer; font-weight:600; }
.stepper { display:flex; gap:6px; padding:11px 24px; background:var(--panel); border-bottom:1px solid var(--border); flex-wrap:wrap; }
.step { font-size:11.5px; font-weight:600; color:var(--faint); padding:5px 13px; border-radius:20px; background:var(--chip); }
.step.active { color:#fff; background:linear-gradient(135deg,#3a8fb7,#2e7aad); }
.step.draft { margin-left:auto; color:var(--money); background:var(--money-soft); }
.body { padding:20px 24px 6px; }
.grid { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
@media (max-width:780px){ .grid { grid-template-columns:1fr; } }
.card { background:var(--card); border:1px solid var(--border); border-radius:13px; padding:16px 17px; box-shadow:var(--shadow); }
.card.span2 { grid-column:1 / -1; }
.card h3 { margin:0 0 13px; font-size:11.5px; font-weight:700; letter-spacing:.7px; text-transform:uppercase;
color:var(--muted); display:flex; align-items:center; gap:7px; }
.card h3 .dot { width:7px; height:7px; border-radius:50%; background:linear-gradient(135deg,#5ba848,#2e7aad); }
.card h3 .tag { margin-left:auto; font-size:10px; font-weight:700; color:var(--money); background:var(--money-soft);
padding:2px 8px; border-radius:10px; letter-spacing:.3px; }
label.fl { display:block; font-size:12px; font-weight:600; color:var(--muted); margin:0 0 5px; }
.row { margin-bottom:12px; } .row:last-child { margin-bottom:0; }
.two { display:grid; grid-template-columns:1fr 1fr; gap:11px; }
.three { display:grid; grid-template-columns:1fr 1fr 1fr; gap:9px; }
input.f, select.f, textarea.f { width:100%; background:var(--field); color:var(--text); border:1px solid var(--field-border);
border-radius:9px; padding:9px 11px; font-size:13.5px; font-family:inherit; outline:none; transition:border .15s,box-shadow .15s; }
input.f:focus, select.f:focus, textarea.f:focus { border-color:var(--field-focus);
box-shadow:0 0 0 3px color-mix(in srgb, var(--field-focus) 22%, transparent); }
textarea.f { resize:vertical; min-height:56px; }
.hint { font-size:11px; color:var(--faint); margin-top:5px; }
.with-icon { position:relative; } .with-icon .pin { position:absolute; right:10px; top:50%; transform:translateY(-50%); color:#5ba848; font-size:16px; }
.seg { display:inline-flex; background:var(--chip); border:1px solid var(--border); border-radius:9px; padding:3px; gap:3px; }
.seg button { border:none; background:transparent; color:var(--muted); font-weight:600; font-size:12.5px; padding:6px 14px;
border-radius:7px; cursor:pointer; font-family:inherit; }
.seg button.on { background:var(--card); color:var(--accent); box-shadow:var(--shadow); }
.seg.full { display:flex; } .seg.full button { flex:1; }
.timepick { display:inline-flex; align-items:stretch; gap:7px; }
.timepick select.f { width:auto; padding-right:24px; }
.ampm { display:inline-flex; background:var(--chip); border:1px solid var(--border); border-radius:9px; padding:3px; }
.ampm button { border:none; background:transparent; color:var(--muted); font-weight:700; font-size:12px; padding:6px 12px; border-radius:7px; cursor:pointer; }
.ampm button.on { background:var(--accent); color:#fff; }
.endtime { font-size:13px; color:var(--muted); margin-top:7px; } .endtime b { color:var(--text); }
.avail { display:inline-flex; align-items:center; gap:6px; font-size:11.5px; font-weight:600; color:var(--ok);
background:color-mix(in srgb,var(--ok) 14%,transparent); padding:3px 9px; border-radius:20px; margin-top:6px; }
.opt { display:flex; align-items:center; justify-content:space-between; padding:9px 0; border-bottom:1px solid var(--border); }
.opt:last-child { border-bottom:none; }
.opt .lab { font-size:13.5px; font-weight:500; } .opt .lab small { display:block; color:var(--faint); font-weight:400; font-size:11.5px; }
.sw { width:42px; height:24px; border-radius:20px; background:var(--field-border); position:relative; cursor:pointer; transition:background .15s; flex-shrink:0; }
.sw::after { content:''; position:absolute; width:18px; height:18px; border-radius:50%; background:#fff; top:3px; left:3px; transition:left .15s; box-shadow:0 1px 2px rgba(0,0,0,.3); }
.sw.on { background:var(--ok); } .sw.on::after { left:21px; }
/* fee readout inside Service & Pricing */
.feeline { display:flex; align-items:center; justify-content:space-between; background:var(--money-soft);
border:1px solid color-mix(in srgb,var(--money) 35%,transparent); border-radius:10px; padding:11px 14px; margin-top:4px; }
.feeline .lbl { font-size:12.5px; font-weight:600; color:var(--text); }
.feeline .lbl small { display:block; color:var(--faint); font-weight:400; font-size:11px; }
.feeline .amt { font-size:20px; font-weight:800; color:var(--money); }
/* ESTIMATE strip */
.estimate { grid-column:1/-1; background:var(--money-soft); border:1px solid color-mix(in srgb,var(--money) 40%,transparent);
border-left:5px solid var(--money); border-radius:13px; padding:15px 18px; display:flex; align-items:center; gap:20px; flex-wrap:wrap; }
.estimate .breakdown { display:flex; gap:18px; flex-wrap:wrap; flex:1; }
.estimate .bk { } .estimate .bk .k { font-size:10.5px; text-transform:uppercase; letter-spacing:.5px; color:var(--faint); }
.estimate .bk .v { font-size:15px; font-weight:700; margin-top:1px; }
.estimate .total { text-align:right; }
.estimate .total .k { font-size:11px; text-transform:uppercase; letter-spacing:.5px; color:var(--money); font-weight:700; }
.estimate .total .v { font-size:27px; font-weight:800; color:var(--money); line-height:1; }
.estimate .total .note { font-size:11px; color:var(--faint); margin-top:3px; }
.foot { display:flex; align-items:center; justify-content:flex-end; gap:11px; padding:16px 24px; background:var(--panel); border-top:1px solid var(--border); }
.foot .spacer { margin-right:auto; font-size:12px; color:var(--faint); }
.btn { border:none; border-radius:10px; padding:11px 18px; font-size:13.5px; font-weight:600; cursor:pointer; font-family:inherit; }
.btn.ghost { background:transparent; color:var(--muted); border:1px solid var(--border); }
.btn.primary { color:#fff; background:linear-gradient(135deg,#5ba848,#2e7aad); box-shadow:0 3px 10px color-mix(in srgb,#2e7aad 40%,transparent); }
.hide { display:none !important; }
.note { max-width:1000px; margin:14px auto 40px; padding:0 18px; color:var(--muted); font-size:12.5px; }
.note code { background:var(--chip); padding:1px 6px; border-radius:5px; }
</style>
</head>
<body>
<div class="wrap">
<div class="dialog">
<div class="topbar">
<div><h1>Book a Service</h1><div class="sub">Repair · delivery · pickup — captures the job and creates the priced repair order</div></div>
<button class="theme-btn" onclick="toggleTheme()">◐ Light / Dark</button>
</div>
<div class="stepper">
<span class="step active">Scheduled</span><span class="step">En Route</span>
<span class="step">In Progress</span><span class="step">Completed</span>
<span class="step draft">● Draft repair SO will be created</span>
</div>
<div class="body">
<div class="grid">
<!-- CUSTOMER -->
<div class="card">
<h3><span class="dot"></span>Customer</h3>
<div class="row">
<div class="seg full">
<button class="on" id="segExisting" onclick="custMode('existing')">Existing customer</button>
<button id="segNew" onclick="custMode('new')">New client</button>
</div>
</div>
<div id="custExisting">
<div class="row">
<label class="fl">Search by phone, name or SO</label>
<input class="f" placeholder="e.g. (416) 555-0142 …" value="(416) 555-0142 — Margaret Chen">
<div class="hint">Inbound call? Type the phone number — we match the contact &amp; their history.</div>
</div>
</div>
<div id="custNew" class="hide">
<div class="row two">
<div><label class="fl">Client name *</label><input class="f" placeholder="Full name"></div>
<div><label class="fl">Phone *</label><input class="f" placeholder="(416) 555-…"></div>
</div>
<div class="row"><label class="fl">Email</label><input class="f" type="email" placeholder="client@email.com"></div>
<div class="row"><label class="fl">Address</label>
<div class="with-icon"><input class="f" placeholder="Start typing an address…"><span class="pin">📍</span></div>
</div>
<div class="row three">
<div><label class="fl">Unit</label><input class="f" placeholder="#"></div>
<div><label class="fl">Buzz</label><input class="f" placeholder="—"></div>
<div><label class="fl">City</label><input class="f" placeholder="City"></div>
</div>
<div class="hint">Contact is created &amp; linked on save — all from this page.</div>
</div>
</div>
<!-- SERVICE & PRICING -->
<div class="card">
<h3><span class="dot"></span>Service &amp; Pricing<span class="tag">$ REVENUE</span></h3>
<div class="row two">
<div>
<label class="fl">Device being serviced</label>
<select class="f" id="device" onchange="onDevice()">
<option value="standard">Mobility Scooter</option>
<option value="standard">Powerchair</option>
<option value="standard">Wheelchair</option>
<option value="lift">Stairlift</option>
<option value="lift">Patient / Ceiling Lift</option>
<option value="standard">Lift Chair</option>
<option value="standard">Hospital Bed</option>
<option value="standard">Other</option>
</select>
</div>
<div>
<label class="fl">Issue / symptom</label>
<input class="f" placeholder="e.g. won't power on">
</div>
</div>
<div class="row" id="callTypeRow">
<label class="fl">Service call type</label>
<select class="f" id="callType" onchange="recalc()">
<option data-fee="95" data-km="0">Standard Service Call — $95 (incl. 30 min labour)</option>
<option data-fee="160" data-km="0">Lift &amp; Elevating Service Call — $160 (incl. 30 min)</option>
<option data-fee="120" data-km="1">Rush Service Call — $120 + $0.70/km ×2-way</option>
<option data-fee="140" data-km="1">After-Hours Service Call — $140 + $0.70/km ×2-way</option>
</select>
<div class="hint">Auto-suggested from the device — change if needed.</div>
</div>
<div class="feeline" id="feeBox">
<div class="lbl">Call-out fee<small id="feeSub">Standard · includes 30 min labour</small></div>
<div class="amt" id="feeAmt">$95</div>
</div>
<div class="hint" id="inshopNote" style="display:none;">In-shop job — no call-out fee; labour billed at $75/hr.</div>
</div>
<!-- SCHEDULE -->
<div class="card">
<h3><span class="dot"></span>Schedule</h3>
<div class="row two">
<div><label class="fl">Date</label><input class="f" type="date" value="2026-06-03"></div>
<div><label class="fl">Duration</label>
<select class="f" id="dur" onchange="recalc();endTime()">
<option value="0.5">30 min</option><option value="1" selected>1 hour</option>
<option value="1.5">1.5 hours</option><option value="2">2 hours</option><option value="3">3 hours</option>
</select></div>
</div>
<div class="row">
<label class="fl">Start time</label>
<div class="timepick">
<select class="f" id="hh" onchange="endTime()"><option>9</option><option>10</option><option>11</option><option>12</option><option>1</option><option>2</option><option>3</option><option>4</option></select>
<select class="f" id="mm" onchange="endTime()"><option>:00</option><option>:15</option><option>:30</option><option>:45</option></select>
<div class="ampm"><button class="on" onclick="ampm(this)">AM</button><button onclick="ampm(this)">PM</button></div>
</div>
<div class="endtime">Ends at <b id="endlbl">10:00 AM</b> · your local time</div>
</div>
<div class="row">
<label class="fl">Technician</label>
<select class="f"><option>— Choose —</option><option selected>Dave Wilson</option><option>Priya Anand</option></select>
<span class="avail">● 3 open slots before 5:00 PM</span>
</div>
</div>
<!-- LOCATION -->
<div class="card">
<h3><span class="dot"></span>Location</h3>
<div class="opt" style="border:none; padding-top:0;">
<div class="lab">In-shop job<small>At the store — no call-out, labour @ $75/hr</small></div>
<div class="sw" id="inshopSw" onclick="toggleShop(this)"></div>
</div>
<div id="addrBlock">
<div class="row"><label class="fl">Job address</label>
<div class="with-icon"><input class="f" placeholder="Auto-fills from customer…" value="88 Bloor St E, Toronto"><span class="pin">📍</span></div>
</div>
<div class="row two">
<div><label class="fl">Unit / Suite</label><input class="f" placeholder="#"></div>
<div><label class="fl">Buzz code</label><input class="f" placeholder="—"></div>
</div>
</div>
</div>
<!-- JOB DETAILS -->
<div class="card span2">
<h3><span class="dot"></span>Job details</h3>
<div class="two">
<div class="row"><label class="fl">Work description</label><textarea class="f" placeholder="Symptom, what to check, history…"></textarea></div>
<div class="row"><label class="fl">Parts / materials to bring</label><textarea class="f" placeholder="Batteries, controller, casters…"></textarea></div>
</div>
<div class="opt"><div class="lab">Under manufacturer warranty<small>Parts not billed when covered</small></div><div class="sw" onclick="sw(this)"></div></div>
<div class="opt"><div class="lab">POD required<small>Capture proof of delivery on completion</small></div><div class="sw" onclick="sw(this)"></div></div>
<div class="opt"><div class="lab">Send client confirmation (email/SMS)<small>Booked · en-route · completed</small></div><div class="sw on" onclick="sw(this)"></div></div>
<div class="opt"><div class="lab">Request Google review after completion</div><div class="sw on" onclick="sw(this)"></div></div>
</div>
<!-- ESTIMATE -->
<div class="estimate">
<div class="breakdown">
<div class="bk"><div class="k">Call-out</div><div class="v" id="eCall">$95</div></div>
<div class="bk"><div class="k">Est. labour</div><div class="v" id="eLab">$85 · 1h</div></div>
<div class="bk" id="eKmBox" style="display:none;"><div class="k">Travel ($0.70/km ×2)</div><div class="v" id="eKm">$18</div></div>
</div>
<div class="total"><div class="k">Estimated total</div><div class="v" id="eTotal">$180</div>
<div class="note">+ parts as used · pre-tax · a draft SO is created</div></div>
</div>
</div>
</div>
<div class="foot">
<span class="spacer">Local time · America/Toronto · 13 km away</span>
<button class="btn ghost">Cancel</button>
<button class="btn primary">Book &amp; Create SO</button>
</div>
</div>
</div>
<div class="note">
Mockup v2 — demo-wired (theme, customer mode, device→call-type, in-shop, AM/PM, switches, live estimate).
Real build = an OWL client action; <b>Book &amp; Create SO</b> calls one server method that find-or-creates the
contact, creates the <code>fusion.technician.task</code> + a draft <code>sale.order</code> 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 <b></b> top-right for dark/light.
</div>
<script>
const DIST_2WAY = 26, KM_RATE = 0.70; // demo: 13km away, 2-way
let inshop=false, ap='AM';
function toggleTheme(){ const h=document.documentElement; h.dataset.theme=h.dataset.theme==='dark'?'light':'dark'; }
function custMode(m){ const ex=m==='existing';
segExisting.classList.toggle('on',ex); segNew.classList.toggle('on',!ex);
custExisting.classList.toggle('hide',!ex); custNew.classList.toggle('hide',ex); }
function onDevice(){ const cat=device.value; callType.selectedIndex = cat==='lift'?1:0; recalc(); }
function ampm(el){ [...el.parentNode.children].forEach(b=>b.classList.remove('on')); el.classList.add('on'); ap=el.textContent; endTime(); }
function sw(el){ el.classList.toggle('on'); }
function toggleShop(el){ el.classList.toggle('on'); inshop=el.classList.contains('on');
addrBlock.classList.toggle('hide',inshop); callTypeRow.classList.toggle('hide',inshop);
feeBox.classList.toggle('hide',inshop); inshopNote.style.display=inshop?'block':'none'; recalc(); }
function endTime(){ const h=+hh.value, m=+mm.value.replace(':',''), dur=+document.getElementById('dur').value;
let mins=((h%12)+(ap==='PM'?12:0))*60+m+dur*60;
let eh=Math.floor(mins/60)%24, em=mins%60; endlbl.textContent=(eh%12||12)+':'+String(em).padStart(2,'0')+' '+(eh>=12?'PM':'AM'); }
function money(n){ return '$'+n.toFixed(n%1?2:0); }
function recalc(){
const dur=+document.getElementById('dur').value;
const labRate = inshop?75:85;
let callout=0, km=0, sub='', kmFlag=false;
if(!inshop){ const o=callType.options[callType.selectedIndex];
callout=+o.dataset.fee; kmFlag=o.dataset.km==='1';
feeAmt.textContent=money(callout); feeSub.textContent=o.text.split('—')[0].trim()+(kmFlag?' · + travel':' · incl. 30 min labour');
if(kmFlag) km=DIST_2WAY*KM_RATE;
}
// labour: first 30 min included on standard/lift call (not rush/afterhours which are time-based but keep simple)
const incl = (!inshop && !kmFlag) ? 0.5 : 0;
const billLabHrs = Math.max(0, dur - incl);
const lab = billLabHrs*labRate;
eCall.textContent = inshop?'—':money(callout);
eLab.textContent = money(lab)+' · '+billLabHrs+'h @ $'+labRate;
eKmBox.style.display = kmFlag?'block':'none'; eKm.textContent=money(km);
eTotal.textContent = money(callout+lab+km);
}
endTime(); recalc();
</script>
</body>
</html>

View File

@@ -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 (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:
```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.

View File

@@ -0,0 +1,718 @@
# Service Rates Foundation — Implementation Plan (Plan 1 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 for tracking.
**Goal:** Add an editable `fusion.service.rate` table (the Westin rate card, admin-managed from a **Service Rates** menu) that the booking wizard (Plan 2) will price from.
**Architecture:** A new `fusion.service.rate` model in `fusion_claims` (owns SO + products). Each row holds an editable `price` and links to a `product.product` (for SO-line description/tax/account). Seeded once (`noupdate=1`) from the rate card; admins own it thereafter. Two resolver methods (`get_callout`, `get_rate`) are the read API for Plan 2.
**Tech Stack:** Odoo 19 (Python ORM, declarative `models.Constraint`, XML data/views, `TransactionCase`).
**Spec:** `docs/superpowers/specs/2026-06-03-technician-service-booking-design.md` (§3, §6.1).
---
## ⚠️ Testing reality (read before executing)
`fusion_claims` is **Enterprise-only** (depends `ai`) → it **cannot install on local `odoo-modsdev` (Community)**. Tests here are real `TransactionCase` tests but they run on a **Westin Enterprise clone** (see the repo `CLAUDE.md` *Westin Prod — Clone-Verify* section). Canonical run on the clone host:
```bash
docker exec odoo-dev-app odoo -d westin-v19-ratetest --test-enable --test-tags /fusion_claims \
-u fusion_claims --stop-after-init --no-http --workers 0 --log-level=test \
--db_host db --db_user odoo --db_password 'DevSecure2025!' 2>&1 | tail -60
```
Where a step says "Run the test", it means *on the clone*. If the clone isn't available during a step, verify the logic via `odoo shell -d <clone>` instead and check the box once confirmed. **Do not** attempt `-d modsdev` (it can't install the module).
---
## File structure
| File | Responsibility |
|---|---|
| `fusion_claims/models/service_rate.py` *(create)* | `fusion.service.rate` model: fields, unique-code constraint, `get_callout` / `get_rate` resolvers |
| `fusion_claims/models/__init__.py` *(modify)* | import `service_rate` |
| `fusion_claims/data/service_rate_products.xml` *(create)* | seed `product.product` service products (one per rate) — `noupdate=1` |
| `fusion_claims/data/service_rate_data.xml` *(create)* | seed `fusion.service.rate` rows linking the products — `noupdate=1` |
| `fusion_claims/views/service_rate_views.xml` *(create)* | list + form + action + **Service Rates** menu |
| `fusion_claims/security/ir.model.access.csv` *(modify)* | ACL: read for users, full for system/managers |
| `fusion_claims/__manifest__.py` *(modify)* | register the 3 new data/view files; bump version |
| `fusion_claims/tests/test_service_rate.py` *(create)* | model + resolver + seed tests |
| `fusion_claims/tests/__init__.py` *(modify)* | import the test |
---
## Task 1: `fusion.service.rate` model + resolvers
**Files:**
- Create: `fusion_claims/models/service_rate.py`
- Modify: `fusion_claims/models/__init__.py`
- Test: `fusion_claims/tests/test_service_rate.py`, `fusion_claims/tests/__init__.py`
- [ ] **Step 1: Write the failing test**
Create `fusion_claims/tests/test_service_rate.py`:
```python
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestServiceRate(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.Rate = cls.env['fusion.service.rate']
cls.product = cls.env['product.product'].create({
'name': 'Test Service Product', 'type': 'service',
})
def _make(self, **kw):
vals = dict(name='Rate', code='c1', rate_kind='callout', category='standard',
timing='normal', product_id=self.product.id, price=95.0, unit='fixed')
vals.update(kw)
return self.Rate.create(vals)
def test_get_callout_matches_category_and_timing(self):
r = self._make(code='callout_standard_normal', category='standard', timing='normal', price=95.0)
self._make(code='callout_lift_normal', category='lift', timing='normal', price=160.0)
self.assertEqual(self.Rate.get_callout('standard', 'normal'), r)
def test_get_callout_in_shop_returns_empty(self):
self._make(code='callout_standard_normal_b')
self.assertFalse(self.Rate.get_callout('standard', 'normal', in_shop=True))
def test_get_rate_by_code(self):
r = self._make(code='per_km', rate_kind='travel', category='na', timing='na', unit='per_km', price=0.70)
self.assertEqual(self.Rate.get_rate('per_km'), r)
def test_code_must_be_unique(self):
self._make(code='dup')
with self.assertRaises(Exception):
self._make(code='dup')
self.env.flush_all()
```
Register it in `fusion_claims/tests/__init__.py` (append):
```python
from . import test_service_rate
```
- [ ] **Step 2: Run the test — verify it fails**
Run (on the clone): the canonical command above with `--test-tags /fusion_claims.TestServiceRate`.
Expected: FAIL — `KeyError: 'fusion.service.rate'` (model does not exist yet).
- [ ] **Step 3: Create the model**
Create `fusion_claims/models/service_rate.py`:
```python
# -*- coding: utf-8 -*-
from odoo import api, fields, models
class FusionServiceRate(models.Model):
_name = 'fusion.service.rate'
_description = 'Field Service Rate'
_order = 'sequence, rate_kind, category, timing'
name = fields.Char(string='Name', required=True)
code = fields.Char(
string='Code', required=True, index=True,
help='Stable code used by the booking engine, e.g. callout_standard_normal, per_km.',
)
rate_kind = fields.Selection([
('callout', 'Service Call-out'),
('labour', 'Labour'),
('travel', 'Travel / per-km'),
('delivery', 'Delivery / Pickup'),
('other', 'Other'),
], string='Kind', required=True, default='callout')
category = fields.Selection([
('standard', 'Standard'),
('lift', 'Lift & Elevating'),
('na', 'N/A'),
], string='Category', default='na')
timing = fields.Selection([
('normal', 'Normal'),
('rush', 'Rush'),
('afterhours', 'After-Hours'),
('na', 'N/A'),
], string='Timing', default='na')
in_shop = fields.Boolean(string='In-Shop')
product_id = fields.Many2one(
'product.product', string='Invoice Product', required=True, ondelete='restrict',
help='Product used on the sale-order line (description, tax, income account).',
)
price = fields.Monetary(
string='Rate', required=True, currency_field='currency_id',
help='Editable price used on the SO line and the on-screen estimate.',
)
currency_id = fields.Many2one(
'res.currency', string='Currency',
default=lambda self: self.env.company.currency_id,
)
unit = fields.Selection([
('fixed', 'Flat'),
('per_hour', 'Per hour'),
('per_km', 'Per km'),
], string='Unit', required=True, default='fixed')
adds_per_km = fields.Boolean(
string='Adds per-km travel',
help='Call-outs billed as $X + per-km × 2-way (rush / after-hours).',
)
included_labour_min = fields.Integer(
string='Included labour (min)', default=0,
help='Free labour minutes bundled into a service call (e.g. 30).',
)
active = fields.Boolean(string='Active', default=True)
sequence = fields.Integer(string='Sequence', default=10)
_unique_code = models.Constraint(
'UNIQUE(code)',
'A service-rate code must be unique.',
)
@api.model
def get_callout(self, category, timing, in_shop=False):
"""Active call-out rate for category+timing. Empty recordset when in-shop."""
if in_shop:
return self.browse()
return self.search([
('rate_kind', '=', 'callout'),
('category', '=', category),
('timing', '=', timing),
], limit=1)
@api.model
def get_rate(self, code):
"""Active rate row by code (e.g. 'per_km', 'labour_onsite')."""
return self.search([('code', '=', code)], limit=1)
```
Add to `fusion_claims/models/__init__.py` (append a line near the other imports):
```python
from . import service_rate
```
- [ ] **Step 4: Run the test — verify it passes**
Run (on the clone) with `--test-tags /fusion_claims.TestServiceRate`.
Expected: PASS (4 tests). If `test_code_must_be_unique` errors instead of failing cleanly, the unique constraint is firing — that is the pass condition (it raises).
- [ ] **Step 5: Commit**
```bash
git add fusion_claims/models/service_rate.py fusion_claims/models/__init__.py \
fusion_claims/tests/test_service_rate.py fusion_claims/tests/__init__.py
git commit -m "feat(fusion_claims): add fusion.service.rate model + resolvers"
```
---
## Task 2: Seed the service-rate products
**Files:**
- Create: `fusion_claims/data/service_rate_products.xml`
- Modify: `fusion_claims/__manifest__.py`
Products back each rate row (SO line description/tax/account). UoM: hour for labour, unit for everything else (per-km uses `unit` with qty = km×2 — avoids a custom km UoM). Taxes are **not** set here (matches the existing `LABOR` product convention — taxes applied per-DB by an admin).
- [ ] **Step 1: Create the product seed data**
Create `fusion_claims/data/service_rate_products.xml`:
```xml
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Call-outs (unit) -->
<record id="product_callout_standard_normal" model="product.template">
<field name="name">Service Call — Standard</field>
<field name="default_code">SVC-STD</field>
<field name="type">service</field>
<field name="list_price">95.00</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<record id="product_callout_standard_rush" model="product.template">
<field name="name">Service Call — Standard Rush</field>
<field name="default_code">SVC-STD-RUSH</field>
<field name="type">service</field>
<field name="list_price">120.00</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<record id="product_callout_standard_afterhours" model="product.template">
<field name="name">Service Call — Standard After-Hours</field>
<field name="default_code">SVC-STD-AH</field>
<field name="type">service</field>
<field name="list_price">140.00</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<record id="product_callout_lift_normal" model="product.template">
<field name="name">Service Call — Lift &amp; Elevating</field>
<field name="default_code">SVC-LIFT</field>
<field name="type">service</field>
<field name="list_price">160.00</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<record id="product_callout_lift_rush" model="product.template">
<field name="name">Service Call — Lift &amp; Elevating Rush</field>
<field name="default_code">SVC-LIFT-RUSH</field>
<field name="type">service</field>
<field name="list_price">185.00</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<record id="product_callout_lift_afterhours" model="product.template">
<field name="name">Service Call — Lift &amp; Elevating After-Hours</field>
<field name="default_code">SVC-LIFT-AH</field>
<field name="type">service</field>
<field name="list_price">205.00</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<!-- Labour (hour) -->
<record id="product_labour_onsite" model="product.template">
<field name="name">Labour — On-Site</field>
<field name="default_code">LAB-ONSITE</field>
<field name="type">service</field>
<field name="list_price">85.00</field>
<field name="uom_id" ref="uom.product_uom_hour"/>
<field name="uom_po_id" ref="uom.product_uom_hour"/>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<record id="product_labour_lift" model="product.template">
<field name="name">Labour — Lift &amp; Elevating</field>
<field name="default_code">LAB-LIFT</field>
<field name="type">service</field>
<field name="list_price">110.00</field>
<field name="uom_id" ref="uom.product_uom_hour"/>
<field name="uom_po_id" ref="uom.product_uom_hour"/>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<!-- Travel (unit; qty = km × 2) -->
<record id="product_per_km" model="product.template">
<field name="name">Travel — per km (2-way)</field>
<field name="default_code">SVC-KM</field>
<field name="type">service</field>
<field name="list_price">0.70</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<!-- Delivery / pickup (unit) -->
<record id="product_delivery_local" model="product.template">
<field name="name">Delivery / Pickup — Local</field>
<field name="default_code">DEL-LOCAL</field>
<field name="type">service</field><field name="list_price">35.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
<record id="product_delivery_outside" model="product.template">
<field name="name">Delivery / Pickup — Outside Local Area</field>
<field name="default_code">DEL-OUT</field>
<field name="type">service</field><field name="list_price">60.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
<record id="product_delivery_rush" model="product.template">
<field name="name">Rush Pickup / Delivery</field>
<field name="default_code">DEL-RUSH</field>
<field name="type">service</field><field name="list_price">60.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
<record id="product_setup_liftchair" model="product.template">
<field name="name">Lift Chair — Delivery &amp; Set-up</field>
<field name="default_code">SETUP-LIFTCHAIR</field>
<field name="type">service</field><field name="list_price">120.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
<record id="product_setup_hospitalbed" model="product.template">
<field name="name">Hospital Bed — Delivery &amp; Set-up</field>
<field name="default_code">SETUP-BED</field>
<field name="type">service</field><field name="list_price">120.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
<record id="product_setup_stairlift" model="product.template">
<field name="name">Stairlift — Delivery &amp; Set-up</field>
<field name="default_code">SETUP-STAIRLIFT</field>
<field name="type">service</field><field name="list_price">300.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
<record id="product_removal_stairlift" model="product.template">
<field name="name">Stairlift — Removal</field>
<field name="default_code">RMV-STAIRLIFT</field>
<field name="type">service</field><field name="list_price">300.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
</data>
</odoo>
```
- [ ] **Step 2: Register in the manifest**
In `fusion_claims/__manifest__.py`, add to the `data` list **immediately after** `'data/product_labor_data.xml'`:
```python
'data/service_rate_products.xml',
```
- [ ] **Step 3: Verify load (on the clone)**
Run: `docker exec odoo-dev-app odoo -d westin-v19-ratetest -u fusion_claims --stop-after-init --no-http --workers 0 --db_host db --db_user odoo --db_password 'DevSecure2025!' 2>&1 | tail -20`
Expected: no error; module upgraded. (No test yet — products are referenced by Task 3's data.)
- [ ] **Step 4: Commit**
```bash
git add fusion_claims/data/service_rate_products.xml fusion_claims/__manifest__.py
git commit -m "feat(fusion_claims): seed service-rate products"
```
---
## Task 3: Seed the rate rows
**Files:**
- Create: `fusion_claims/data/service_rate_data.xml`
- Modify: `fusion_claims/__manifest__.py`
- Test: `fusion_claims/tests/test_service_rate.py`
`product.template` external IDs from Task 2 resolve to a `product.product` via `.product_variant_id`. In data XML, reference the variant with `ref="product_callout_standard_normal_product_template"`? No — simplest is to point `product_id` at the template's variant using the template's xmlid is not valid for a `product.product` m2o. Use a tiny Python step instead: a `post_init`-style noupdate is awkward for m2o-to-variant. **Approach:** reference the product *variant* created automatically. Odoo creates `product.product` for each template; its xmlid is `<template_xmlid>_product_variant`? It is **not** auto-created. So we set `product_id` by searching on `default_code` in a noupdate `function`. Keep it simple and deterministic:
- [ ] **Step 1: Write the failing test (seed assertions)**
Append to `fusion_claims/tests/test_service_rate.py`:
```python
def test_seeded_callouts_exist(self):
# standard normal $95, lift after-hours $205 are the canonical seeds
std = self.env.ref('fusion_claims.rate_callout_standard_normal')
self.assertEqual(std.price, 95.0)
self.assertEqual(std.rate_kind, 'callout')
self.assertTrue(std.product_id)
lift_ah = self.env.ref('fusion_claims.rate_callout_lift_afterhours')
self.assertEqual(lift_ah.price, 205.0)
self.assertTrue(lift_ah.adds_per_km)
def test_seeded_per_km(self):
km = self.env['fusion.service.rate'].get_rate('per_km')
self.assertTrue(km)
self.assertEqual(km.unit, 'per_km')
self.assertEqual(km.price, 0.70)
```
- [ ] **Step 2: Run — verify it fails**
Run with `--test-tags /fusion_claims.TestServiceRate`.
Expected: FAIL — `ValueError: External ID not found: fusion_claims.rate_callout_standard_normal`.
- [ ] **Step 3: Create the rate seed data**
Create `fusion_claims/data/service_rate_data.xml`. Each rate's `product_id` is set with `eval` that resolves the template's variant at load time:
```xml
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- CALL-OUTS -->
<record id="rate_callout_standard_normal" model="fusion.service.rate">
<field name="name">Standard Service Call</field>
<field name="code">callout_standard_normal</field>
<field name="rate_kind">callout</field><field name="category">standard</field>
<field name="timing">normal</field><field name="unit">fixed</field>
<field name="included_labour_min">30</field><field name="price">95.0</field>
<field name="product_id" ref="product_callout_standard_normal_product_variant"/>
<field name="sequence">10</field>
</record>
<record id="rate_callout_standard_rush" model="fusion.service.rate">
<field name="name">Rush Service Call (Standard)</field>
<field name="code">callout_standard_rush</field>
<field name="rate_kind">callout</field><field name="category">standard</field>
<field name="timing">rush</field><field name="unit">fixed</field>
<field name="adds_per_km" eval="True"/><field name="price">120.0</field>
<field name="product_id" ref="product_callout_standard_rush_product_variant"/>
<field name="sequence">11</field>
</record>
<record id="rate_callout_standard_afterhours" model="fusion.service.rate">
<field name="name">After-Hours Service Call (Standard)</field>
<field name="code">callout_standard_afterhours</field>
<field name="rate_kind">callout</field><field name="category">standard</field>
<field name="timing">afterhours</field><field name="unit">fixed</field>
<field name="adds_per_km" eval="True"/><field name="price">140.0</field>
<field name="product_id" ref="product_callout_standard_afterhours_product_variant"/>
<field name="sequence">12</field>
</record>
<record id="rate_callout_lift_normal" model="fusion.service.rate">
<field name="name">Lift &amp; Elevating Service Call</field>
<field name="code">callout_lift_normal</field>
<field name="rate_kind">callout</field><field name="category">lift</field>
<field name="timing">normal</field><field name="unit">fixed</field>
<field name="included_labour_min">30</field><field name="price">160.0</field>
<field name="product_id" ref="product_callout_lift_normal_product_variant"/>
<field name="sequence">20</field>
</record>
<record id="rate_callout_lift_rush" model="fusion.service.rate">
<field name="name">Lift &amp; Elevating Rush Call</field>
<field name="code">callout_lift_rush</field>
<field name="rate_kind">callout</field><field name="category">lift</field>
<field name="timing">rush</field><field name="unit">fixed</field>
<field name="adds_per_km" eval="True"/><field name="price">185.0</field>
<field name="product_id" ref="product_callout_lift_rush_product_variant"/>
<field name="sequence">21</field>
</record>
<record id="rate_callout_lift_afterhours" model="fusion.service.rate">
<field name="name">Lift &amp; Elevating After-Hours Call</field>
<field name="code">callout_lift_afterhours</field>
<field name="rate_kind">callout</field><field name="category">lift</field>
<field name="timing">afterhours</field><field name="unit">fixed</field>
<field name="adds_per_km" eval="True"/><field name="price">205.0</field>
<field name="product_id" ref="product_callout_lift_afterhours_product_variant"/>
<field name="sequence">22</field>
</record>
<!-- LABOUR -->
<record id="rate_labour_onsite" model="fusion.service.rate">
<field name="name">Labour — On-Site</field><field name="code">labour_onsite</field>
<field name="rate_kind">labour</field><field name="category">standard</field>
<field name="timing">na</field><field name="unit">per_hour</field><field name="price">85.0</field>
<field name="product_id" ref="product_labour_onsite_product_variant"/><field name="sequence">30</field>
</record>
<record id="rate_labour_lift" model="fusion.service.rate">
<field name="name">Labour — Lift &amp; Elevating</field><field name="code">labour_lift</field>
<field name="rate_kind">labour</field><field name="category">lift</field>
<field name="timing">na</field><field name="unit">per_hour</field><field name="price">110.0</field>
<field name="product_id" ref="product_labour_lift_product_variant"/><field name="sequence">31</field>
</record>
<record id="rate_labour_inshop" model="fusion.service.rate">
<field name="name">Labour — In-Shop</field><field name="code">labour_inshop</field>
<field name="rate_kind">labour</field><field name="category">na</field><field name="in_shop" eval="True"/>
<field name="timing">na</field><field name="unit">per_hour</field><field name="price">75.0</field>
<field name="product_id" ref="product_labor_hourly_product_variant"/><field name="sequence">32</field>
</record>
<!-- TRAVEL -->
<record id="rate_per_km" model="fusion.service.rate">
<field name="name">Travel — per km (2-way)</field><field name="code">per_km</field>
<field name="rate_kind">travel</field><field name="category">na</field>
<field name="timing">na</field><field name="unit">per_km</field><field name="price">0.70</field>
<field name="product_id" ref="product_per_km_product_variant"/><field name="sequence">40</field>
</record>
<!-- DELIVERY / PICKUP -->
<record id="rate_delivery_local" model="fusion.service.rate">
<field name="name">Delivery / Pickup — Local</field><field name="code">delivery_local</field>
<field name="rate_kind">delivery</field><field name="category">na</field><field name="timing">na</field>
<field name="unit">fixed</field><field name="price">35.0</field>
<field name="product_id" ref="product_delivery_local_product_variant"/><field name="sequence">50</field>
</record>
<record id="rate_delivery_outside" model="fusion.service.rate">
<field name="name">Delivery / Pickup — Outside Local Area</field><field name="code">delivery_outside</field>
<field name="rate_kind">delivery</field><field name="category">na</field><field name="timing">na</field>
<field name="unit">fixed</field><field name="price">60.0</field>
<field name="product_id" ref="product_delivery_outside_product_variant"/><field name="sequence">51</field>
</record>
<record id="rate_setup_stairlift" model="fusion.service.rate">
<field name="name">Stairlift — Delivery &amp; Set-up</field><field name="code">setup_stairlift</field>
<field name="rate_kind">delivery</field><field name="category">lift</field><field name="timing">na</field>
<field name="unit">fixed</field><field name="price">300.0</field>
<field name="product_id" ref="product_setup_stairlift_product_variant"/><field name="sequence">52</field>
</record>
</data>
</odoo>
```
> **Note on `_product_variant` refs:** Odoo auto-creates the `product.product` for a single-variant `product.template` and assigns it the external ID `<template_xmlid>_product_variant`. This is the supported way to reference the variant from data XML. (The existing in-shop labour reuses `product_labor_hourly` from `product_labor_data.xml`, hence `product_labor_hourly_product_variant`.) If a `_product_variant` ref ever fails to resolve on your DB, the fallback is to set `product_id` via `eval="obj().env.ref('fusion_claims.product_xxx').product_variant_id.id"` — but try the `_product_variant` ref first.
Register in `fusion_claims/__manifest__.py`, **immediately after** `'data/service_rate_products.xml'`:
```python
'data/service_rate_data.xml',
```
- [ ] **Step 4: Run the test — verify it passes**
Run with `--test-tags /fusion_claims.TestServiceRate` (the `-u fusion_claims` reload loads the seed first).
Expected: PASS (all tests incl. `test_seeded_callouts_exist`, `test_seeded_per_km`).
- [ ] **Step 5: Commit**
```bash
git add fusion_claims/data/service_rate_data.xml fusion_claims/__manifest__.py fusion_claims/tests/test_service_rate.py
git commit -m "feat(fusion_claims): seed service-rate rows from the rate card"
```
---
## Task 4: Security ACL + Service Rates views & menu
**Files:**
- Modify: `fusion_claims/security/ir.model.access.csv`
- Create: `fusion_claims/views/service_rate_views.xml`
- Modify: `fusion_claims/__manifest__.py`
- [ ] **Step 1: Add the ACL rows**
Append to `fusion_claims/security/ir.model.access.csv`:
```csv
access_fusion_service_rate_user,fusion.service.rate.user,model_fusion_service_rate,base.group_user,1,0,0,0
access_fusion_service_rate_manager,fusion.service.rate.manager,model_fusion_service_rate,base.group_system,1,1,1,1
```
(Users read rates — the wizard needs that; system/managers edit. If `fusion_claims` defines a sales-manager group, swap the second row's group for it during review.)
- [ ] **Step 2: Find the parent menu**
Run: `grep -n "menuitem" fusion_claims/views/*.xml fusion_tasks/views/*.xml | grep -i "id=" | head -40`
Pick the appropriate Configuration/root menu for "Service Rates" (e.g. the fusion_claims app root or a Field-Service config menu). Record its full xmlid (e.g. `fusion_claims.menu_fusion_claims_config` or `sale.menu_sale_config`). Use it as `parent=` in Step 3.
- [ ] **Step 3: Create the views**
Create `fusion_claims/views/service_rate_views.xml` (replace `PARENT_MENU_XMLID` with the id found in Step 2):
```xml
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="fusion_service_rate_view_list" model="ir.ui.view">
<field name="name">fusion.service.rate.list</field>
<field name="model">fusion.service.rate</field>
<field name="arch" type="xml">
<list string="Service Rates" editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="code"/>
<field name="rate_kind"/>
<field name="category"/>
<field name="timing"/>
<field name="in_shop"/>
<field name="unit"/>
<field name="price"/>
<field name="currency_id" column_invisible="1"/>
<field name="adds_per_km"/>
<field name="product_id"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="fusion_service_rate_view_form" model="ir.ui.view">
<field name="name">fusion.service.rate.form</field>
<field name="model">fusion.service.rate</field>
<field name="arch" type="xml">
<form string="Service Rate">
<sheet>
<div class="oe_title">
<h1><field name="name" placeholder="e.g. Standard Service Call"/></h1>
</div>
<group>
<group>
<field name="code"/>
<field name="rate_kind"/>
<field name="category"/>
<field name="timing"/>
<field name="in_shop"/>
</group>
<group>
<field name="price"/>
<field name="currency_id" invisible="1"/>
<field name="unit"/>
<field name="adds_per_km"/>
<field name="included_labour_min"/>
<field name="product_id"/>
<field name="active"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_fusion_service_rate" model="ir.actions.act_window">
<field name="name">Service Rates</field>
<field name="res_model">fusion.service.rate</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">Define your field-service rate card</p>
<p>Call-out fees, labour, per-km and delivery charges used by the service booking wizard.</p>
</field>
</record>
<menuitem id="menu_fusion_service_rate"
name="Service Rates"
parent="PARENT_MENU_XMLID"
action="action_fusion_service_rate"
sequence="90"/>
</odoo>
```
Register in `fusion_claims/__manifest__.py` `data` list, **after** `'views/res_config_settings_views.xml'` (or near the other views):
```python
'views/service_rate_views.xml',
```
- [ ] **Step 4: Verify load + menu (on the clone)**
Run the `-u fusion_claims --stop-after-init` command; expected: no error.
Then in `odoo shell -d westin-v19-ratetest`: `env.ref('fusion_claims.action_fusion_service_rate')` resolves; `env['fusion.service.rate'].search_count([])` ≥ 14. `env.cr.rollback()`.
- [ ] **Step 5: Commit**
```bash
git add fusion_claims/security/ir.model.access.csv fusion_claims/views/service_rate_views.xml fusion_claims/__manifest__.py
git commit -m "feat(fusion_claims): Service Rates menu, list (inline-edit) + form + ACL"
```
---
## Task 5: Version bump + final verify
**Files:** Modify `fusion_claims/__manifest__.py`
- [ ] **Step 1: Bump version**
In `fusion_claims/__manifest__.py`, bump `'version'` (e.g. `19.0.9.2.0``19.0.9.3.0`).
- [ ] **Step 2: Full upgrade + test run (on the clone)**
Run the canonical test command (`--test-tags /fusion_claims.TestServiceRate`). Expected: all PASS, module upgraded, no warnings about the new data files.
- [ ] **Step 3: Manual smoke (browser, on the clone)**
Open *Service Rates* menu → confirm 14+ rows, prices editable inline, a new row can be added and saved. Toggle one `active` off and back.
- [ ] **Step 4: Commit**
```bash
git add fusion_claims/__manifest__.py
git commit -m "chore(fusion_claims): bump version for service-rate foundation"
```
---
## Self-Review (done while writing)
- **Spec coverage:** §6.1 model fields ✓ (Task 1), seed products ✓ (Task 2), seed rows incl. $185/$205 + per-km + labour + delivery ✓ (Task 3), Service Rates menu/views/ACL ✓ (Task 4), §3 values as seed ✓. Resolver API (`get_callout`/`get_rate`) ✓ (Task 1) — consumed by Plan 2.
- **Placeholders:** none — every step has full code. The one deliberate lookup is the menu parent (Task 4 Step 2), which is a real "find the xmlid" action, not a vague TODO.
- **Type/name consistency:** `get_callout(category, timing, in_shop)` and `get_rate(code)` signatures match the tests and the seed codes (`callout_standard_normal`, `per_km`, `labour_inshop` reusing `product_labor_hourly`). Rate `code`s match across data + tests.
- **Gap noted for Plan 2:** the `_product_variant` external-ID convention (Task 3 note) — Plan 2's SO builder uses `rate.product_id` directly, so it's unaffected.
---
## Execution Handoff
This is **Plan 1 of 2**. **Plan 2** (booking wizard: tz fix, constraint relax, pricing resolver consuming `get_callout`/`get_rate`, SO builder, `action_book_from_wizard`, OWL wizard + SCSS, entry point) will be written next and depends on this.
Before executing: move this work to a dedicated branch (e.g. `claude/technician-service-booking`) — it's currently alongside the unrelated fusion_schedule fixes.

View File

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

View File

@@ -7,6 +7,7 @@ import logging
from . import models from . import models
from . import wizard from . import wizard
from . import controllers
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Claims', 'name': 'Fusion Claims',
'version': '19.0.9.2.0', 'version': '19.0.9.4.0',
'category': 'Sales', 'category': 'Sales',
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.', 'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
'description': """ 'description': """
@@ -98,9 +98,13 @@
'data/ir_cron_data.xml', 'data/ir_cron_data.xml',
'data/ir_actions_server_data.xml', 'data/ir_actions_server_data.xml',
'data/product_labor_data.xml', 'data/product_labor_data.xml',
'data/service_rate_products.xml',
'data/service_rate_data.xml',
'wizard/status_change_reason_wizard_views.xml', 'wizard/status_change_reason_wizard_views.xml',
'views/res_company_views.xml', 'views/res_company_views.xml',
'views/res_config_settings_views.xml', 'views/res_config_settings_views.xml',
'views/service_rate_views.xml',
'views/service_booking_action.xml',
'views/sale_order_views.xml', 'views/sale_order_views.xml',
'views/account_move_views.xml', 'views/account_move_views.xml',
'views/account_journal_views.xml', 'views/account_journal_views.xml',
@@ -181,12 +185,20 @@
# Dashboard OWL countdown widget # Dashboard OWL countdown widget
'fusion_claims/static/src/js/fc_posting_countdown.js', 'fusion_claims/static/src/js/fc_posting_countdown.js',
'fusion_claims/static/src/xml/fc_posting_countdown.xml', 'fusion_claims/static/src/xml/fc_posting_countdown.xml',
# Service Booking wizard (client action): tokens MUST load before
# the component scss so the --sb-* vars resolve.
'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': [ 'web.assets_web_dark': [
# Dark bundle recompiles the same SCSS with the dark # Dark bundle recompiles the same SCSS with the dark
# $o-webclient-color-scheme default so tokens branch correctly. # $o-webclient-color-scheme default so tokens branch correctly.
'fusion_claims/static/src/scss/_fc_dashboard_tokens.scss', 'fusion_claims/static/src/scss/_fc_dashboard_tokens.scss',
'fusion_claims/static/src/scss/fc_dashboard.scss', 'fusion_claims/static/src/scss/fc_dashboard.scss',
'fusion_claims/static/src/scss/_service_booking_tokens.scss',
'fusion_claims/static/src/scss/service_booking.scss',
], ],
}, },
'images': ['static/description/icon.png'], 'images': ['static/description/icon.png'],

View File

@@ -0,0 +1 @@
from . import service_booking

View File

@@ -0,0 +1,38 @@
# -*- 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
Users = env['res.users']
techs = Users.search([('x_fc_is_field_staff', '=', True)]) \
if 'x_fc_is_field_staff' in Users._fields else Users.search([])
Rate = env['fusion.service.rate']
rates = Rate.search([('rate_kind', '=', 'callout'), ('active', '=', True)])
per_km = Rate.get_rate('per_km')
def labour(code):
r = 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)}

View File

@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- CALL-OUTS -->
<record id="rate_callout_standard_normal" model="fusion.service.rate">
<field name="name">Standard Service Call</field>
<field name="code">callout_standard_normal</field>
<field name="rate_kind">callout</field><field name="category">standard</field>
<field name="timing">normal</field><field name="unit">fixed</field>
<field name="included_labour_min">30</field><field name="price">95.0</field>
<field name="product_id" ref="product_callout_standard_normal"/>
<field name="sequence">10</field>
</record>
<record id="rate_callout_standard_rush" model="fusion.service.rate">
<field name="name">Rush Service Call (Standard)</field>
<field name="code">callout_standard_rush</field>
<field name="rate_kind">callout</field><field name="category">standard</field>
<field name="timing">rush</field><field name="unit">fixed</field>
<field name="adds_per_km" eval="True"/><field name="price">120.0</field>
<field name="product_id" ref="product_callout_standard_rush"/>
<field name="sequence">11</field>
</record>
<record id="rate_callout_standard_afterhours" model="fusion.service.rate">
<field name="name">After-Hours Service Call (Standard)</field>
<field name="code">callout_standard_afterhours</field>
<field name="rate_kind">callout</field><field name="category">standard</field>
<field name="timing">afterhours</field><field name="unit">fixed</field>
<field name="adds_per_km" eval="True"/><field name="price">140.0</field>
<field name="product_id" ref="product_callout_standard_afterhours"/>
<field name="sequence">12</field>
</record>
<record id="rate_callout_lift_normal" model="fusion.service.rate">
<field name="name">Lift &amp; Elevating Service Call</field>
<field name="code">callout_lift_normal</field>
<field name="rate_kind">callout</field><field name="category">lift</field>
<field name="timing">normal</field><field name="unit">fixed</field>
<field name="included_labour_min">30</field><field name="price">160.0</field>
<field name="product_id" ref="product_callout_lift_normal"/>
<field name="sequence">20</field>
</record>
<record id="rate_callout_lift_rush" model="fusion.service.rate">
<field name="name">Lift &amp; Elevating Rush Call</field>
<field name="code">callout_lift_rush</field>
<field name="rate_kind">callout</field><field name="category">lift</field>
<field name="timing">rush</field><field name="unit">fixed</field>
<field name="adds_per_km" eval="True"/><field name="price">185.0</field>
<field name="product_id" ref="product_callout_lift_rush"/>
<field name="sequence">21</field>
</record>
<record id="rate_callout_lift_afterhours" model="fusion.service.rate">
<field name="name">Lift &amp; Elevating After-Hours Call</field>
<field name="code">callout_lift_afterhours</field>
<field name="rate_kind">callout</field><field name="category">lift</field>
<field name="timing">afterhours</field><field name="unit">fixed</field>
<field name="adds_per_km" eval="True"/><field name="price">205.0</field>
<field name="product_id" ref="product_callout_lift_afterhours"/>
<field name="sequence">22</field>
</record>
<!-- LABOUR -->
<record id="rate_labour_onsite" model="fusion.service.rate">
<field name="name">Labour — On-Site</field><field name="code">labour_onsite</field>
<field name="rate_kind">labour</field><field name="category">standard</field>
<field name="timing">na</field><field name="unit">per_hour</field><field name="price">85.0</field>
<field name="product_id" ref="product_labour_onsite"/><field name="sequence">30</field>
</record>
<record id="rate_labour_lift" model="fusion.service.rate">
<field name="name">Labour — Lift &amp; Elevating</field><field name="code">labour_lift</field>
<field name="rate_kind">labour</field><field name="category">lift</field>
<field name="timing">na</field><field name="unit">per_hour</field><field name="price">110.0</field>
<field name="product_id" ref="product_labour_lift"/><field name="sequence">31</field>
</record>
<record id="rate_labour_inshop" model="fusion.service.rate">
<field name="name">Labour — In-Shop</field><field name="code">labour_inshop</field>
<field name="rate_kind">labour</field><field name="category">na</field><field name="in_shop" eval="True"/>
<field name="timing">na</field><field name="unit">per_hour</field><field name="price">75.0</field>
<field name="product_id" ref="product_labour_inshop"/><field name="sequence">32</field>
</record>
<!-- TRAVEL -->
<record id="rate_per_km" model="fusion.service.rate">
<field name="name">Travel — per km (2-way)</field><field name="code">per_km</field>
<field name="rate_kind">travel</field><field name="category">na</field>
<field name="timing">na</field><field name="unit">per_km</field><field name="price">0.70</field>
<field name="product_id" ref="product_per_km"/><field name="sequence">40</field>
</record>
<!-- DELIVERY / PICKUP -->
<record id="rate_delivery_local" model="fusion.service.rate">
<field name="name">Delivery / Pickup — Local</field><field name="code">delivery_local</field>
<field name="rate_kind">delivery</field><field name="category">na</field><field name="timing">na</field>
<field name="unit">fixed</field><field name="price">35.0</field>
<field name="product_id" ref="product_delivery_local"/><field name="sequence">50</field>
</record>
<record id="rate_delivery_outside" model="fusion.service.rate">
<field name="name">Delivery / Pickup — Outside Local Area</field><field name="code">delivery_outside</field>
<field name="rate_kind">delivery</field><field name="category">na</field><field name="timing">na</field>
<field name="unit">fixed</field><field name="price">60.0</field>
<field name="product_id" ref="product_delivery_outside"/><field name="sequence">51</field>
</record>
<record id="rate_setup_stairlift" model="fusion.service.rate">
<field name="name">Stairlift — Delivery &amp; Set-up</field><field name="code">setup_stairlift</field>
<field name="rate_kind">delivery</field><field name="category">lift</field><field name="timing">na</field>
<field name="unit">fixed</field><field name="price">300.0</field>
<field name="product_id" ref="product_setup_stairlift"/><field name="sequence">52</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,138 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Call-outs (unit) -->
<record id="product_callout_standard_normal" model="product.product">
<field name="name">Service Call — Standard</field>
<field name="default_code">SVC-STD</field>
<field name="type">service</field>
<field name="list_price">95.00</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<record id="product_callout_standard_rush" model="product.product">
<field name="name">Service Call — Standard Rush</field>
<field name="default_code">SVC-STD-RUSH</field>
<field name="type">service</field>
<field name="list_price">120.00</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<record id="product_callout_standard_afterhours" model="product.product">
<field name="name">Service Call — Standard After-Hours</field>
<field name="default_code">SVC-STD-AH</field>
<field name="type">service</field>
<field name="list_price">140.00</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<record id="product_callout_lift_normal" model="product.product">
<field name="name">Service Call — Lift &amp; Elevating</field>
<field name="default_code">SVC-LIFT</field>
<field name="type">service</field>
<field name="list_price">160.00</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<record id="product_callout_lift_rush" model="product.product">
<field name="name">Service Call — Lift &amp; Elevating Rush</field>
<field name="default_code">SVC-LIFT-RUSH</field>
<field name="type">service</field>
<field name="list_price">185.00</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<record id="product_callout_lift_afterhours" model="product.product">
<field name="name">Service Call — Lift &amp; Elevating After-Hours</field>
<field name="default_code">SVC-LIFT-AH</field>
<field name="type">service</field>
<field name="list_price">205.00</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<!-- Labour (hour) -->
<record id="product_labour_onsite" model="product.product">
<field name="name">Labour — On-Site</field>
<field name="default_code">LAB-ONSITE</field>
<field name="type">service</field>
<field name="list_price">85.00</field>
<field name="uom_id" ref="uom.product_uom_hour"/>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<record id="product_labour_lift" model="product.product">
<field name="name">Labour — Lift &amp; Elevating</field>
<field name="default_code">LAB-LIFT</field>
<field name="type">service</field>
<field name="list_price">110.00</field>
<field name="uom_id" ref="uom.product_uom_hour"/>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<record id="product_labour_inshop" model="product.product">
<field name="name">Labour — In-Shop</field>
<field name="default_code">LAB-INSHOP</field>
<field name="type">service</field>
<field name="list_price">75.00</field>
<field name="uom_id" ref="uom.product_uom_hour"/>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<!-- Travel (unit; qty = km x 2) -->
<record id="product_per_km" model="product.product">
<field name="name">Travel — per km (2-way)</field>
<field name="default_code">SVC-KM</field>
<field name="type">service</field>
<field name="list_price">0.70</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<!-- Delivery / pickup (unit) -->
<record id="product_delivery_local" model="product.product">
<field name="name">Delivery / Pickup — Local</field>
<field name="default_code">DEL-LOCAL</field>
<field name="type">service</field><field name="list_price">35.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
<record id="product_delivery_outside" model="product.product">
<field name="name">Delivery / Pickup — Outside Local Area</field>
<field name="default_code">DEL-OUT</field>
<field name="type">service</field><field name="list_price">60.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
<record id="product_delivery_rush" model="product.product">
<field name="name">Rush Pickup / Delivery</field>
<field name="default_code">DEL-RUSH</field>
<field name="type">service</field><field name="list_price">60.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
<record id="product_setup_liftchair" model="product.product">
<field name="name">Lift Chair — Delivery &amp; Set-up</field>
<field name="default_code">SETUP-LIFTCHAIR</field>
<field name="type">service</field><field name="list_price">120.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
<record id="product_setup_hospitalbed" model="product.product">
<field name="name">Hospital Bed — Delivery &amp; Set-up</field>
<field name="default_code">SETUP-BED</field>
<field name="type">service</field><field name="list_price">120.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
<record id="product_setup_stairlift" model="product.product">
<field name="name">Stairlift — Delivery &amp; Set-up</field>
<field name="default_code">SETUP-STAIRLIFT</field>
<field name="type">service</field><field name="list_price">300.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
<record id="product_removal_stairlift" model="product.product">
<field name="name">Stairlift — Removal</field>
<field name="default_code">RMV-STAIRLIFT</field>
<field name="type">service</field><field name="list_price">300.00</field>
<field name="sale_ok" eval="True"/><field name="purchase_ok" eval="False"/>
</record>
</data>
</odoo>

View File

@@ -26,4 +26,5 @@ from . import ai_agent_ext
from . import dashboard from . import dashboard
from . import res_partner from . import res_partner
from . import technician_task from . import technician_task
from . import page11_sign_request from . import page11_sign_request
from . import service_rate

View File

@@ -338,6 +338,11 @@ class SaleOrder(models.Model):
help='Type of sale for billing purposes. This field determines the workflow and billing rules.', help='Type of sale for billing purposes. This field determines the workflow and billing rules.',
) )
x_fc_is_service_repair = fields.Boolean(
string='Service Repair', copy=False,
help='Auto-created from the technician service booking wizard.',
)
x_fc_sale_type_locked = fields.Boolean( x_fc_sale_type_locked = fields.Boolean(
string='Sale Type Locked', string='Sale Type Locked',
compute='_compute_sale_type_locked', compute='_compute_sale_type_locked',

View File

@@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models
class FusionServiceRate(models.Model):
_name = 'fusion.service.rate'
_description = 'Field Service Rate'
_order = 'sequence, rate_kind, category, timing'
name = fields.Char(string='Name', required=True)
code = fields.Char(
string='Code', required=True, index=True,
help='Stable code used by the booking engine, e.g. callout_standard_normal, per_km.',
)
rate_kind = fields.Selection([
('callout', 'Service Call-out'),
('labour', 'Labour'),
('travel', 'Travel / per-km'),
('delivery', 'Delivery / Pickup'),
('other', 'Other'),
], string='Kind', required=True, default='callout')
category = fields.Selection([
('standard', 'Standard'),
('lift', 'Lift & Elevating'),
('na', 'N/A'),
], string='Category', default='na')
timing = fields.Selection([
('normal', 'Normal'),
('rush', 'Rush'),
('afterhours', 'After-Hours'),
('na', 'N/A'),
], string='Timing', default='na')
in_shop = fields.Boolean(string='In-Shop')
product_id = fields.Many2one(
'product.product', string='Invoice Product', required=True, ondelete='restrict',
help='Product used on the sale-order line (description, tax, income account).',
)
price = fields.Monetary(
string='Rate', required=True, currency_field='currency_id',
help='Editable price used on the SO line and the on-screen estimate.',
)
currency_id = fields.Many2one(
'res.currency', string='Currency',
default=lambda self: self.env.company.currency_id,
)
unit = fields.Selection([
('fixed', 'Flat'),
('per_hour', 'Per hour'),
('per_km', 'Per km'),
], string='Unit', required=True, default='fixed')
adds_per_km = fields.Boolean(
string='Adds per-km travel',
help='Call-outs billed as $X + per-km \xd7 2-way (rush / after-hours).',
)
included_labour_min = fields.Integer(
string='Included labour (min)', default=0,
help='Free labour minutes bundled into a service call (e.g. 30).',
)
active = fields.Boolean(string='Active', default=True)
sequence = fields.Integer(string='Sequence', default=10)
_unique_code = models.Constraint(
'UNIQUE(code)',
'A service-rate code must be unique.',
)
@api.model
def get_callout(self, category, timing, in_shop=False):
"""Active call-out rate for category+timing. Empty recordset when in-shop."""
if in_shop:
return self.browse()
return self.search([
('rate_kind', '=', 'callout'),
('category', '=', category),
('timing', '=', timing),
], limit=1)
@api.model
def get_rate(self, code):
"""Active rate row by code (e.g. 'per_km', 'labour_onsite')."""
return self.search([('code', '=', code)], limit=1)

View File

@@ -9,7 +9,7 @@ features to the base fusion.technician.task model.
""" """
from odoo import models, fields, api, _ from odoo import models, fields, api, _
from odoo.exceptions import UserError, ValidationError from odoo.exceptions import UserError
from markupsafe import Markup from markupsafe import Markup
import logging import logging
@@ -72,6 +72,15 @@ class FusionTechnicianTaskClaims(models.Model):
default=False, default=False,
) )
# ------------------------------------------------------------------
# SERVICE BOOKING FIELDS
# ------------------------------------------------------------------
x_fc_service_call_type = fields.Char(
string='Service Call Type',
help='Rate code resolved by the booking wizard (e.g. callout_standard_rush).',
)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# ONCHANGES # ONCHANGES
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -104,15 +113,9 @@ class FusionTechnicianTaskClaims(models.Model):
@api.constrains('sale_order_id', 'purchase_order_id') @api.constrains('sale_order_id', 'purchase_order_id')
def _check_order_link(self): def _check_order_link(self):
for task in self: # Relaxed 2026-06: service bookings auto-create their SO, and in-shop /
if task.x_fc_sync_source: # walk-in tasks may legitimately have none. No order link is required anymore.
continue return
if task.task_type == 'ltc_visit':
continue
if not task.sale_order_id and not task.purchase_order_id:
raise ValidationError(_(
"A task must be linked to either a Sale Order (Case) or a Purchase Order."
))
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# HOOK OVERRIDES # HOOK OVERRIDES
@@ -395,6 +398,166 @@ class FusionTechnicianTaskClaims(models.Model):
order.name, e, order.name, e,
) )
# ------------------------------------------------------------------
# SERVICE BOOKING HELPERS
# ------------------------------------------------------------------
@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 \xd7 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.
Repair-SO identity is the x_fc_is_service_repair boolean (no crm.tag: fusion_claims
has no crm dependency). x_fc_sale_type is intentionally left blank — a service repair
is not one of the ADP/ODSP funder workflows, and the draft is editable afterwards.
"""
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,
}
return self.env['sale.order'].create(so_vals)
def _service_travel_origin(self):
"""Return (lat, lng) of the technician's day-start location, to be used
as the ORIGIN for the per-km travel calculation. NEVER returns the job's
own address (that would give origin == destination == 0 km).
Fallback chain (all read-only — no geocoding API calls here):
1. The technician's personal start address cached coords
(res.users.partner_id.x_fc_start_address_lat/_lng — populated when
the start address is saved, see fusion_tasks/models/res_partner.py).
2. The company HQ start address cached coords, keyed off the
``fusion_claims.technician_start_address`` ICP and cached by
fusion_tasks under ``fusion_tasks.hq_coords:<address>``.
3. (0.0, 0.0) — the correct graceful fallback. _calculate_travel_time
guards on a falsy origin and simply returns False (→ no per-km line).
Geocoding is deliberately NOT performed here: a freshly typed new-client
job address usually has no geocoded destination anyway, so distance is
expected to be 0 in v1. We only avoid passing a WRONG origin.
"""
self.ensure_one()
tech = self.technician_id
if tech:
partner = tech.partner_id
if partner and partner.x_fc_start_address_lat and partner.x_fc_start_address_lng:
return partner.x_fc_start_address_lat, partner.x_fc_start_address_lng
ICP = self.env['ir.config_parameter'].sudo()
hq_addr = (ICP.get_param('fusion_claims.technician_start_address', '') or '').strip()
if hq_addr:
cached = ICP.get_param('fusion_tasks.hq_coords:%s' % hq_addr, '')
if cached and ',' in cached:
try:
lat_s, lng_s = cached.split(',', 1)
return float(lat_s), float(lng_s)
except (ValueError, TypeError):
pass
return 0.0, 0.0
@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.
Returns {'task_id', 'order_id'}."""
Partner = self.env['res.partner']
cust = payload.get('customer') or {}
# 1. contact: new -> find-or-create (match email then phone); existing -> chosen partner
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['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'))
# technician_id is REQUIRED on a task
technician_id = payload.get('technician_id')
if not technician_id:
raise UserError(_("Please choose a technician for this service booking."))
technician_id = int(technician_id)
# 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',
'technician_id': technician_id,
'scheduled_date': payload.get('date'),
'time_start': t_start,
'time_end': t_start + dur,
'duration_hours': dur,
'is_in_store': in_shop,
'x_fc_service_call_type': '%s_%s' % (category, timing),
'description': payload.get('description') or payload.get('issue') or _('Service booking'),
}
if partner:
task_vals['partner_id'] = partner.id
task = self.create(task_vals)
# 3. per-km distance: only when the rate adds it AND we have a real origin + a
# geocoded job destination. Origin is the technician's start, never the job.
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:
origin_lat, origin_lng = task._service_travel_origin()
if origin_lat and origin_lng:
try:
task._calculate_travel_time(origin_lat, origin_lng) # sets travel_distance_km
distance_km = task.travel_distance_km or 0.0
except Exception:
distance_km = 0.0
# 4. draft repair SO + link back to the task
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}
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# VIEW ACTIONS # VIEW ACTIONS
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View File

@@ -63,4 +63,6 @@ access_fusion_page11_sign_request_manager,fusion.page11.sign.request.manager,mod
access_fusion_page11_sign_request_public,fusion.page11.sign.request.public,model_fusion_page11_sign_request,base.group_public,1,0,0,0 access_fusion_page11_sign_request_public,fusion.page11.sign.request.public,model_fusion_page11_sign_request,base.group_public,1,0,0,0
access_fusion_send_page11_wizard_user,fusion_claims.send.page11.wizard.user,model_fusion_claims_send_page11_wizard,sales_team.group_sale_salesman,1,1,1,1 access_fusion_send_page11_wizard_user,fusion_claims.send.page11.wizard.user,model_fusion_claims_send_page11_wizard,sales_team.group_sale_salesman,1,1,1,1
access_fusion_send_page11_wizard_manager,fusion_claims.send.page11.wizard.manager,model_fusion_claims_send_page11_wizard,sales_team.group_sale_manager,1,1,1,1 access_fusion_send_page11_wizard_manager,fusion_claims.send.page11.wizard.manager,model_fusion_claims_send_page11_wizard,sales_team.group_sale_manager,1,1,1,1
access_fusion_adp_import_wizard_user,fusion_claims.adp.import.wizard.user,model_fusion_claims_adp_import_wizard,account.group_account_invoice,1,1,1,1 access_fusion_adp_import_wizard_user,fusion_claims.adp.import.wizard.user,model_fusion_claims_adp_import_wizard,account.group_account_invoice,1,1,1,1
access_fusion_service_rate_user,fusion.service.rate.user,model_fusion_service_rate,base.group_user,1,0,0,0
access_fusion_service_rate_admin,fusion.service.rate.admin,model_fusion_service_rate,base.group_system,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
63 access_fusion_page11_sign_request_public fusion.page11.sign.request.public model_fusion_page11_sign_request base.group_public 1 0 0 0
64 access_fusion_send_page11_wizard_user fusion_claims.send.page11.wizard.user model_fusion_claims_send_page11_wizard sales_team.group_sale_salesman 1 1 1 1
65 access_fusion_send_page11_wizard_manager fusion_claims.send.page11.wizard.manager model_fusion_claims_send_page11_wizard sales_team.group_sale_manager 1 1 1 1
66 access_fusion_adp_import_wizard_user fusion_claims.adp.import.wizard.user model_fusion_claims_adp_import_wizard account.group_account_invoice 1 1 1 1
67 access_fusion_service_rate_user fusion.service.rate.user model_fusion_service_rate base.group_user 1 0 0 0
68 access_fusion_service_rate_admin fusion.service.rate.admin model_fusion_service_rate base.group_system 1 1 1 1

View File

@@ -0,0 +1,108 @@
/** @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 ?? 0.70,
labour: r.labour || this.state.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}`;
}
fmt(n) { return (n || 0).toFixed(2); }
onDevice(ev) {
this.state.device = ev.target.value;
this.state.category = ev.target.value === "lift" ? "lift" : "standard";
}
onCallType(ev) {
const r = this.state.calloutRates.find(x => x.code === ev.target.value);
if (r) { this.state.category = r.category; this.state.timing = r.timing; }
}
setCust(m) { this.state.custMode = m; }
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;
}
if (!s.technicianId) {
this.notification.add("Please choose a technician.", { 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);

View File

@@ -0,0 +1,73 @@
// Fusion Claims — Service Booking wizard design tokens.
//
// Per the repo dark-mode rule (CLAUDE.md "Dark Mode — Branch on
// $o-webclient-color-scheme at SCSS Compile Time"): this file is compiled into
// BOTH web.assets_backend (bright) and web.assets_web_dark (dark). We branch at
// COMPILE TIME on $o-webclient-color-scheme and emit one --sb-* CSS custom
// property per token, scoped to .o_service_booking. Do NOT use .o_dark_mode /
// [data-bs-theme] / prefers-color-scheme — none fire reliably in Odoo 19.
//
// Values are copied verbatim from the mockup's :root (light) and
// [data-theme="dark"] (dark) blocks — technician-booking-wizard.html.
$o-webclient-color-scheme: bright !default;
// --- light values (mockup :root / [data-theme="light"]) ---
$_page: #eef0f3;
$_panel: #e6e9ed;
$_card: #ffffff;
$_border: #d8dadd;
$_text: #1f2430;
$_muted: #6b7280;
$_faint: #9ca3af;
$_field: #ffffff;
$_field-border: #cfd3d8;
$_field-focus: #3a8fb7;
$_chip: #f1f4f7;
$_accent: #2e7aad;
$_accent-soft: #e8f2f8;
$_ok: #16a34a;
$_star: #f5b301;
$_money: #0f7d4e;
$_money-soft: #e7f6ee;
@if $o-webclient-color-scheme == dark {
// --- dark values (mockup [data-theme="dark"]) ---
$_page: #14161b !global;
$_panel: #1b1e24 !global;
$_card: #22262d !global;
$_border: #343a42 !global;
$_text: #e7eaef !global;
$_muted: #9aa3af !global;
$_faint: #6b7480 !global;
$_field: #1a1d23 !global;
$_field-border: #3a4049 !global;
$_field-focus: #4aa3cf !global;
$_chip: #2a2f37 !global;
$_accent: #3a8fb7 !global;
$_accent-soft: #19303d !global;
$_ok: #22c55e !global;
$_star: #f5b301 !global;
$_money: #34d27f !global;
$_money-soft: #15281f !global;
}
.o_service_booking {
--sb-page: #{$_page};
--sb-panel: #{$_panel};
--sb-card: #{$_card};
--sb-border: #{$_border};
--sb-text: #{$_text};
--sb-muted: #{$_muted};
--sb-faint: #{$_faint};
--sb-field: #{$_field};
--sb-field-border: #{$_field-border};
--sb-field-focus: #{$_field-focus};
--sb-chip: #{$_chip};
--sb-accent: #{$_accent};
--sb-accent-soft: #{$_accent-soft};
--sb-ok: #{$_ok};
--sb-star: #{$_star};
--sb-money: #{$_money};
--sb-money-soft: #{$_money-soft};
}

View File

@@ -0,0 +1,283 @@
// Fusion Claims — Service Booking wizard component styles.
//
// Ported from the mockup (technician-booking-wizard.html) scoped under
// .o_service_booking. The mockup's CSS custom properties (--page, --card, …)
// are renamed mechanically to the --sb-* tokens emitted by
// _service_booking_tokens.scss (which MUST load first in the bundle). The
// manual .theme-btn dark toggle is dropped — Odoo serves the dark bundle.
//
// Surfaces use the explicit-hex tokens (three-layer contrast: page -> card ->
// field), never var(--bs-*). color-mix() is used only in standalone
// background / box-shadow properties — never inside a border shorthand (the
// Odoo 19 SCSS compiler silently drops color-mix in border shorthands).
.o_service_booking {
background: var(--sb-page);
color: var(--sb-text);
font-family: 'Inter', 'Helvetica Neue', Helvetica, Arial, system-ui, sans-serif;
font-size: 14px;
min-height: 100%;
overflow: auto;
* { box-sizing: border-box; }
.wrap { max-width: 1000px; margin: 24px auto; padding: 0 18px; }
.dialog {
background: var(--sb-panel);
border: 1px solid var(--sb-border);
border-radius: 16px;
box-shadow: 0 12px 40px rgba(16, 24, 40, .16);
overflow: hidden;
}
.topbar {
background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%);
padding: 17px 24px;
display: flex;
align-items: center;
justify-content: space-between;
color: #fff;
h1 { font-size: 19px; font-weight: 700; margin: 0; }
.sub { font-size: 12.5px; opacity: .9; margin-top: 2px; }
}
.stepper {
display: flex;
gap: 6px;
padding: 11px 24px;
background: var(--sb-panel);
border-bottom: 1px solid var(--sb-border);
flex-wrap: wrap;
}
.step {
font-size: 11.5px;
font-weight: 600;
color: var(--sb-faint);
padding: 5px 13px;
border-radius: 20px;
background: var(--sb-chip);
}
.step.active { color: #fff; background: linear-gradient(135deg, #3a8fb7, #2e7aad); }
.step.draft { margin-left: auto; color: var(--sb-money); background: var(--sb-money-soft); }
.body { padding: 20px 24px 6px; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 780px) { .grid { grid-template-columns: 1fr; } }
.card {
background: var(--sb-card);
border: 1px solid var(--sb-border);
border-radius: 13px;
padding: 16px 17px;
box-shadow: 0 1px 3px rgba(16, 24, 40, .08), 0 1px 2px rgba(16, 24, 40, .06);
}
.card.span2 { grid-column: 1 / -1; }
.card h3 {
margin: 0 0 13px;
font-size: 11.5px;
font-weight: 700;
letter-spacing: .7px;
text-transform: uppercase;
color: var(--sb-muted);
display: flex;
align-items: center;
gap: 7px;
}
.card h3 .dot { width: 7px; height: 7px; border-radius: 50%; background: linear-gradient(135deg, #5ba848, #2e7aad); }
.card h3 .tag {
margin-left: auto;
font-size: 10px;
font-weight: 700;
color: var(--sb-money);
background: var(--sb-money-soft);
padding: 2px 8px;
border-radius: 10px;
letter-spacing: .3px;
}
label.fl { display: block; font-size: 12px; font-weight: 600; color: var(--sb-muted); margin: 0 0 5px; }
.row { margin-bottom: 12px; }
.row:last-child { margin-bottom: 0; }
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 11px; }
.three { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 9px; }
input.f, select.f, textarea.f {
width: 100%;
background: var(--sb-field);
color: var(--sb-text);
border: 1px solid var(--sb-field-border);
border-radius: 9px;
padding: 9px 11px;
font-size: 13.5px;
font-family: inherit;
outline: none;
transition: border .15s, box-shadow .15s;
}
input.f:focus, select.f:focus, textarea.f:focus {
border-color: var(--sb-field-focus);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--sb-field-focus) 22%, transparent);
}
textarea.f { resize: vertical; min-height: 56px; }
.hint { font-size: 11px; color: var(--sb-faint); margin-top: 5px; }
.with-icon { position: relative; }
.with-icon .pin { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); color: #5ba848; font-size: 16px; }
.seg {
display: inline-flex;
background: var(--sb-chip);
border: 1px solid var(--sb-border);
border-radius: 9px;
padding: 3px;
gap: 3px;
}
.seg button {
border: none;
background: transparent;
color: var(--sb-muted);
font-weight: 600;
font-size: 12.5px;
padding: 6px 14px;
border-radius: 7px;
cursor: pointer;
font-family: inherit;
}
.seg button.on { background: var(--sb-card); color: var(--sb-accent); box-shadow: 0 1px 3px rgba(16, 24, 40, .08), 0 1px 2px rgba(16, 24, 40, .06); }
.seg.full { display: flex; }
.seg.full button { flex: 1; }
.timepick { display: inline-flex; align-items: stretch; gap: 7px; }
.timepick select.f { width: auto; padding-right: 24px; }
.ampm { display: inline-flex; background: var(--sb-chip); border: 1px solid var(--sb-border); border-radius: 9px; padding: 3px; }
.ampm button {
border: none;
background: transparent;
color: var(--sb-muted);
font-weight: 700;
font-size: 12px;
padding: 6px 12px;
border-radius: 7px;
cursor: pointer;
font-family: inherit;
}
.ampm button.on { background: var(--sb-accent); color: #fff; }
.endtime { font-size: 13px; color: var(--sb-muted); margin-top: 7px; }
.endtime b { color: var(--sb-text); }
.avail {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11.5px;
font-weight: 600;
color: var(--sb-ok);
background: color-mix(in srgb, var(--sb-ok) 14%, transparent);
padding: 3px 9px;
border-radius: 20px;
margin-top: 6px;
}
.opt {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 0;
border-bottom: 1px solid var(--sb-border);
}
.opt:last-child { border-bottom: none; }
.opt .lab { font-size: 13.5px; font-weight: 500; }
.opt .lab small { display: block; color: var(--sb-faint); font-weight: 400; font-size: 11.5px; }
.sw {
width: 42px;
height: 24px;
border-radius: 20px;
background: var(--sb-field-border);
position: relative;
cursor: pointer;
transition: background .15s;
flex-shrink: 0;
}
.sw::after {
content: '';
position: absolute;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
top: 3px;
left: 3px;
transition: left .15s;
box-shadow: 0 1px 2px rgba(0, 0, 0, .3);
}
.sw.on { background: var(--sb-ok); }
.sw.on::after { left: 21px; }
// fee readout inside Service & Pricing
.feeline {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--sb-money-soft);
border: 1px solid var(--sb-border);
border-radius: 10px;
padding: 11px 14px;
margin-top: 4px;
}
.feeline .lbl { font-size: 12.5px; font-weight: 600; color: var(--sb-text); }
.feeline .lbl small { display: block; color: var(--sb-faint); font-weight: 400; font-size: 11px; }
.feeline .amt { font-size: 20px; font-weight: 800; color: var(--sb-money); }
// ESTIMATE strip
.estimate {
grid-column: 1 / -1;
background: var(--sb-money-soft);
border: 1px solid var(--sb-border);
border-left: 5px solid var(--sb-money);
border-radius: 13px;
padding: 15px 18px;
display: flex;
align-items: center;
gap: 20px;
flex-wrap: wrap;
}
.estimate .breakdown { display: flex; gap: 18px; flex-wrap: wrap; flex: 1; }
.estimate .bk .k { font-size: 10.5px; text-transform: uppercase; letter-spacing: .5px; color: var(--sb-faint); }
.estimate .bk .v { font-size: 15px; font-weight: 700; margin-top: 1px; }
.estimate .total { text-align: right; }
.estimate .total .k { font-size: 11px; text-transform: uppercase; letter-spacing: .5px; color: var(--sb-money); font-weight: 700; }
.estimate .total .v { font-size: 27px; font-weight: 800; color: var(--sb-money); line-height: 1; }
.estimate .total .note { font-size: 11px; color: var(--sb-faint); margin-top: 3px; }
.foot {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 11px;
padding: 16px 24px;
background: var(--sb-panel);
border-top: 1px solid var(--sb-border);
}
.foot .spacer { margin-right: auto; font-size: 12px; color: var(--sb-faint); }
.btn {
border: none;
border-radius: 10px;
padding: 11px 18px;
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
}
.btn.ghost { background: transparent; color: var(--sb-muted); border: 1px solid var(--sb-border); }
.btn.primary {
color: #fff;
background: linear-gradient(135deg, #5ba848, #2e7aad);
box-shadow: 0 3px 10px color-mix(in srgb, #2e7aad 40%, transparent);
}
.btn[disabled] { opacity: .6; cursor: not-allowed; }
.hide { display: none !important; }
}

View File

@@ -0,0 +1,208 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_claims.ServiceBookingWizard" owl="1">
<div class="o_service_booking">
<div class="wrap">
<div class="dialog">
<div class="topbar">
<div>
<h1>Book a Service</h1>
<div class="sub">Repair · delivery · pickup — captures the job and creates the priced repair order</div>
</div>
</div>
<div class="stepper">
<span class="step active">Scheduled</span>
<span class="step">En Route</span>
<span class="step">In Progress</span>
<span class="step">Completed</span>
<span class="step draft">● Draft repair SO will be created</span>
</div>
<div class="body">
<div class="grid">
<!-- CUSTOMER -->
<div class="card">
<h3><span class="dot"></span>Customer</h3>
<div class="row">
<div class="seg full">
<button t-att-class="{ on: state.custMode === 'existing' }"
t-on-click="() => this.setCust('existing')">Existing customer</button>
<button t-att-class="{ on: state.custMode === 'new' }"
t-on-click="() => this.setCust('new')">New client</button>
</div>
</div>
<div t-if="state.custMode === 'existing'">
<div class="row">
<label class="fl">Search by phone, name or SO</label>
<input class="f" t-model="state.soSearch" placeholder="e.g. (416) 555-0142 …"/>
<div class="hint">Inbound call? Type the phone number — we match the contact &amp; their history.</div>
</div>
</div>
<div t-if="state.custMode === 'new'">
<div class="row two">
<div><label class="fl">Client name *</label><input class="f" t-model="state.customer.name" placeholder="Full name"/></div>
<div><label class="fl">Phone *</label><input class="f" t-model="state.customer.phone" placeholder="(416) 555-…"/></div>
</div>
<div class="row"><label class="fl">Email</label><input class="f" type="email" t-model="state.customer.email" placeholder="client@email.com"/></div>
<div class="row"><label class="fl">Address</label>
<div class="with-icon"><input class="f" t-model="state.customer.street" placeholder="Start typing an address…"/><span class="pin">📍</span></div>
</div>
<div class="row three">
<div><label class="fl">Unit</label><input class="f" t-model="state.customer.unit" placeholder="#"/></div>
<div><label class="fl">Buzz</label><input class="f" t-model="state.customer.buzz" placeholder="—"/></div>
<div><label class="fl">City</label><input class="f" t-model="state.customer.city" placeholder="City"/></div>
</div>
<div class="hint">Contact is created &amp; linked on save — all from this page.</div>
</div>
</div>
<!-- SERVICE & PRICING -->
<div class="card">
<h3><span class="dot"></span>Service &amp; Pricing<span class="tag">$ REVENUE</span></h3>
<div class="row two">
<div>
<label class="fl">Device being serviced</label>
<select class="f" t-on-change="onDevice">
<option value="standard">Mobility Scooter</option>
<option value="standard">Powerchair</option>
<option value="standard">Wheelchair</option>
<option value="lift">Stairlift</option>
<option value="lift">Patient / Ceiling Lift</option>
<option value="standard">Lift Chair</option>
<option value="standard">Hospital Bed</option>
<option value="standard">Other</option>
</select>
</div>
<div>
<label class="fl">Issue / symptom</label>
<input class="f" t-model="state.issue" placeholder="e.g. won't power on"/>
</div>
</div>
<div class="row" t-if="!state.inShop">
<label class="fl">Service call type</label>
<select class="f"
t-on-change="onCallType">
<t t-foreach="state.calloutRates" t-as="r" t-key="r.code">
<option t-att-value="r.code"
t-att-selected="state.category === r.category and state.timing === r.timing">
<t t-esc="r.name"/> — $<t t-esc="fmt(r.price)"/><t t-if="r.adds_per_km"> + $<t t-esc="fmt(state.perKm)"/>/km ×2-way</t>
</option>
</t>
</select>
<div class="hint">Auto-suggested from the device — change if needed.</div>
</div>
<div class="feeline" t-if="!state.inShop and callout">
<div class="lbl">Call-out fee<small><t t-esc="callout.name"/><t t-if="callout.adds_per_km"> · + travel</t><t t-else=""> · includes 30 min labour</t></small></div>
<div class="amt">$<t t-esc="fmt(callout.price)"/></div>
</div>
<div class="hint" t-if="state.inShop">In-shop job — no call-out fee; labour billed at $<t t-esc="fmt(state.labour.inshop)"/>/hr.</div>
</div>
<!-- SCHEDULE -->
<div class="card">
<h3><span class="dot"></span>Schedule</h3>
<div class="row two">
<div><label class="fl">Date</label><input class="f" type="date" t-model="state.date"/></div>
<div><label class="fl">Duration</label>
<select class="f" t-model.number="state.durationHr">
<option value="0.5">30 min</option>
<option value="1">1 hour</option>
<option value="1.5">1.5 hours</option>
<option value="2">2 hours</option>
<option value="3">3 hours</option>
</select>
</div>
</div>
<div class="row">
<label class="fl">Start time</label>
<div class="timepick">
<select class="f" t-model.number="state.hour">
<option value="9">9</option>
<option value="10">10</option>
<option value="11">11</option>
<option value="12">12</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
</select>
<select class="f" t-model.number="state.minute">
<option value="0">:00</option>
<option value="15">:15</option>
<option value="30">:30</option>
<option value="45">:45</option>
</select>
<div class="ampm">
<button t-att-class="{ on: state.ampm === 'AM' }" t-on-click="() => this.setAmpm('AM')">AM</button>
<button t-att-class="{ on: state.ampm === 'PM' }" t-on-click="() => this.setAmpm('PM')">PM</button>
</div>
</div>
<div class="endtime">Ends at <b><t t-esc="endLabel"/></b> · your local time</div>
</div>
<div class="row">
<label class="fl">Technician</label>
<select class="f" t-model.number="state.technicianId">
<option value="">— Choose —</option>
<t t-foreach="state.technicians" t-as="t" t-key="t.id">
<option t-att-value="t.id"><t t-esc="t.name"/></option>
</t>
</select>
</div>
</div>
<!-- LOCATION -->
<div class="card">
<h3><span class="dot"></span>Location</h3>
<div class="opt" style="border:none; padding-top:0;">
<div class="lab">In-shop job<small>At the store — no call-out, labour @ $<t t-esc="fmt(state.labour.inshop)"/>/hr</small></div>
<div class="sw" t-att-class="{ on: state.inShop }" t-on-click="toggleInShop"></div>
</div>
<div t-if="!state.inShop">
<div class="row"><label class="fl">Job address</label>
<div class="with-icon"><input class="f" t-model="state.customer.street" placeholder="Auto-fills from customer…"/><span class="pin">📍</span></div>
</div>
<div class="row two">
<div><label class="fl">Unit / Suite</label><input class="f" t-model="state.customer.unit" placeholder="#"/></div>
<div><label class="fl">Buzz code</label><input class="f" t-model="state.customer.buzz" placeholder="—"/></div>
</div>
</div>
</div>
<!-- JOB DETAILS -->
<div class="card span2">
<h3><span class="dot"></span>Job details</h3>
<div class="two">
<div class="row"><label class="fl">Work description</label><textarea class="f" t-model="state.description" placeholder="Symptom, what to check, history…"></textarea></div>
<div class="row"><label class="fl">Parts / materials to bring</label><textarea class="f" t-model="state.materials" placeholder="Batteries, controller, casters…"></textarea></div>
</div>
<div class="opt"><div class="lab">Under manufacturer warranty<small>Parts not billed when covered</small></div><div class="sw" t-att-class="{ on: state.warranty }" t-on-click="() => state.warranty = !state.warranty"></div></div>
<div class="opt"><div class="lab">POD required<small>Capture proof of delivery on completion</small></div><div class="sw" t-att-class="{ on: state.pod }" t-on-click="() => state.pod = !state.pod"></div></div>
<div class="opt"><div class="lab">Send client confirmation (email/SMS)<small>Booked · en-route · completed</small></div><div class="sw" t-att-class="{ on: state.emailConfirm }" t-on-click="() => state.emailConfirm = !state.emailConfirm"></div></div>
<div class="opt"><div class="lab">Request Google review after completion</div><div class="sw" t-att-class="{ on: state.googleReview }" t-on-click="() => state.googleReview = !state.googleReview"></div></div>
</div>
<!-- ESTIMATE -->
<div class="estimate">
<div class="breakdown">
<div class="bk"><div class="k">Call-out</div><div class="v"><t t-if="state.inShop"></t><t t-else="">$<t t-esc="fmt(estimate.callout)"/></t></div></div>
<div class="bk"><div class="k">Est. labour</div><div class="v">$<t t-esc="fmt(estimate.labour)"/> · <t t-esc="estimate.billHr"/>h @ $<t t-esc="fmt(labourRate)"/></div></div>
<div class="bk" t-if="estimate.addsKm"><div class="k">Travel ($<t t-esc="fmt(state.perKm)"/>/km ×2)</div><div class="v">$<t t-esc="fmt(estimate.km)"/></div></div>
</div>
<div class="total"><div class="k">Estimated total</div><div class="v">$<t t-esc="fmt(estimate.total)"/></div>
<div class="note">+ parts as used · pre-tax · a draft SO is created</div></div>
</div>
</div>
</div>
<div class="foot">
<span class="spacer">Local time · America/Toronto · <t t-esc="state.distanceKm"/> km away</span>
<button class="btn ghost" t-on-click="() => this.action.doAction({ type: 'ir.actions.act_window_close' })">Cancel</button>
<button class="btn primary" t-on-click="submit" t-att-disabled="state.saving">Book &amp; Create SO</button>
</div>
</div>
</div>
</div>
</t>
</templates>

View File

@@ -3,3 +3,5 @@
from . import test_signed_pages_gate from . import test_signed_pages_gate
from . import test_application_received_wizard from . import test_application_received_wizard
from . import test_dashboard from . import test_dashboard
from . import test_service_rate
from . import test_service_booking

View File

@@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
from datetime import date, timedelta
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']
# technician_id is required on a task (domain x_fc_is_field_staff=True).
cls.tech = cls.env['res.users'].create({
'name': 'Service Booking Tech',
'login': 'svcbook_tech',
'x_fc_is_field_staff': True,
})
def test_task_without_order_is_allowed(self):
# No SO/PO must NOT raise after the relax. description is required and a
# non-in-store task needs an address, so set both here to isolate the test
# to the order-link relaxation (not those unrelated base constraints).
t = self.Task.create({
'task_type': 'repair',
'technician_id': self.tech.id,
'scheduled_date': date.today() + timedelta(days=7),
'description': 'Test repair',
'is_in_store': True,
})
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)
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)
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': (date.today() + timedelta(days=7)).strftime('%Y-%m-%d'), 'time_start': 9.0, 'duration_hr': 1.0,
'technician_id': self.tech.id, '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)

View File

@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestServiceRate(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.Rate = cls.env['fusion.service.rate']
cls.product = cls.env['product.product'].create({
'name': 'Test Service Product', 'type': 'service',
})
def _make(self, **kw):
vals = dict(name='Rate', code='c1', rate_kind='callout', category='standard',
timing='normal', product_id=self.product.id, price=95.0, unit='fixed')
vals.update(kw)
return self.Rate.create(vals)
def test_get_callout_matches_category_and_timing(self):
# Assert against the real seed (codes are unique, so creating colliding
# standard/normal rows would violate the UNIQUE(code) constraint).
r = self.Rate.get_callout('standard', 'normal')
self.assertTrue(r)
self.assertEqual(r.code, 'callout_standard_normal')
self.assertEqual(r.rate_kind, 'callout')
def test_get_callout_in_shop_returns_empty(self):
self._make(code='callout_standard_normal_b')
self.assertFalse(self.Rate.get_callout('standard', 'normal', in_shop=True))
def test_get_rate_by_code(self):
# 'per_km' is a seeded code; the resolver returns that row.
r = self.Rate.get_rate('per_km')
self.assertTrue(r)
self.assertEqual(r.unit, 'per_km')
def test_code_must_be_unique(self):
self._make(code='dup')
with self.assertRaises(Exception):
self._make(code='dup')
self.env.flush_all()
def test_seeded_callouts_exist(self):
# standard normal $95, lift after-hours $205 are the canonical seeds
std = self.env.ref('fusion_claims.rate_callout_standard_normal')
self.assertEqual(std.price, 95.0)
self.assertEqual(std.rate_kind, 'callout')
self.assertTrue(std.product_id)
lift_ah = self.env.ref('fusion_claims.rate_callout_lift_afterhours')
self.assertEqual(lift_ah.price, 205.0)
self.assertTrue(lift_ah.adds_per_km)
def test_seeded_per_km(self):
km = self.env['fusion.service.rate'].get_rate('per_km')
self.assertTrue(km)
self.assertEqual(km.unit, 'per_km')
self.assertEqual(km.price, 0.70)

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2024-2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Claim Assistant product family.
-->
<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="fusion_tasks.menu_field_service_root"
action="action_service_booking_wizard"
sequence="1"/>
</odoo>

View File

@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2024-2025 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Claim Assistant product family.
-->
<odoo>
<!-- ===================================================================== -->
<!-- SERVICE RATE: List View (inline-edit enabled) -->
<!-- ===================================================================== -->
<record id="view_fusion_service_rate_list" model="ir.ui.view">
<field name="name">fusion.service.rate.list</field>
<field name="model">fusion.service.rate</field>
<field name="arch" type="xml">
<list string="Service Rates" editable="top"
default_order="sequence, rate_kind, category, timing">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="code"/>
<field name="rate_kind" string="Kind"/>
<field name="category"/>
<field name="timing"/>
<field name="unit"/>
<field name="price" string="Rate"/>
<field name="currency_id" column_invisible="True"/>
<field name="adds_per_km" string="+ km"/>
<field name="included_labour_min" string="Incl. Labour (min)"/>
<field name="in_shop" string="In-Shop"/>
<field name="product_id" string="Invoice Product"/>
<field name="active" column_invisible="True"/>
</list>
</field>
</record>
<!-- ===================================================================== -->
<!-- SERVICE RATE: Form View -->
<!-- ===================================================================== -->
<record id="view_fusion_service_rate_form" model="ir.ui.view">
<field name="name">fusion.service.rate.form</field>
<field name="model">fusion.service.rate</field>
<field name="arch" type="xml">
<form string="Service Rate">
<sheet>
<div class="oe_title">
<h1><field name="name" placeholder="Rate name…"/></h1>
</div>
<group>
<group string="Identification">
<field name="code"/>
<field name="rate_kind" string="Kind"/>
<field name="category"/>
<field name="timing"/>
<field name="in_shop"/>
<field name="active"/>
<field name="sequence"/>
</group>
<group string="Pricing">
<field name="price" string="Rate"/>
<field name="currency_id"/>
<field name="unit"/>
<field name="adds_per_km"/>
<field name="included_labour_min"/>
</group>
</group>
<group string="Invoice Product">
<field name="product_id" string="Product" colspan="2"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- ===================================================================== -->
<!-- SERVICE RATE: Action -->
<!-- ===================================================================== -->
<record id="action_fusion_service_rate" model="ir.actions.act_window">
<field name="name">Service Rates</field>
<field name="res_model">fusion.service.rate</field>
<field name="view_mode">list,form</field>
<field name="context">{'active_test': False}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No service rates found.
</p>
<p>
Add rates used for booking service calls, labour, travel, and delivery.
</p>
</field>
</record>
<!-- ===================================================================== -->
<!-- SERVICE RATE: Menu item under Technician Configuration -->
<!-- ===================================================================== -->
<menuitem id="menu_fusion_service_rate"
name="Service Rates"
parent="fusion_tasks.menu_technician_config"
action="action_fusion_service_rate"
sequence="50"
groups="base.group_system"/>
</odoo>

View File

@@ -781,7 +781,7 @@ class FusionTechnicianTask(models.Model):
def _inverse_datetime_start(self): def _inverse_datetime_start(self):
"""When datetime_start is changed (e.g. from calendar drag), update date + time.""" """When datetime_start is changed (e.g. from calendar drag), update date + time."""
import pytz import pytz
user_tz = pytz.timezone(self.env.user.tz or 'UTC') user_tz = self._get_local_tz()
for task in self: for task in self:
if task.datetime_start: if task.datetime_start:
local_dt = pytz.utc.localize(task.datetime_start).astimezone(user_tz) local_dt = pytz.utc.localize(task.datetime_start).astimezone(user_tz)
@@ -791,7 +791,7 @@ class FusionTechnicianTask(models.Model):
def _inverse_datetime_end(self): def _inverse_datetime_end(self):
"""When datetime_end is changed (e.g. from calendar resize), update time_end.""" """When datetime_end is changed (e.g. from calendar resize), update time_end."""
import pytz import pytz
user_tz = pytz.timezone(self.env.user.tz or 'UTC') user_tz = self._get_local_tz()
for task in self: for task in self:
if task.datetime_end: if task.datetime_end:
local_dt = pytz.utc.localize(task.datetime_end).astimezone(user_tz) local_dt = pytz.utc.localize(task.datetime_end).astimezone(user_tz)

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import test_task_tz

View File

@@ -0,0 +1,44 @@
# -*- 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()
# _compute_datetimes resolves company resource-calendar tz FIRST, then user tz.
# Set BOTH to Toronto so the UTC assertion and the round-trip are deterministic.
cls.env.user.tz = 'America/Toronto'
cal = cls.env.company.resource_calendar_id
if cal:
cal.tz = 'America/Toronto'
# technician_id is required (domain x_fc_is_field_staff=True) -> make a field tech.
cls.tech = cls.env['res.users'].create({
'name': 'TZ Test Tech',
'login': 'tz_test_tech_svcbook',
'x_fc_is_field_staff': True,
})
# A FUTURE date in July so the task is not "in the past" (the base
# _check_no_overlap constraint rejects past dates) and Toronto is firmly
# in EDT (-4), keeping the 9:00 -> 13:00 UTC assertion deterministic.
cls.task = cls.env['fusion.technician.task'].create({
'technician_id': cls.tech.id,
'scheduled_date': date(date.today().year + 1, 7, 1),
'time_start': 9.0,
'time_end': 10.0,
'description': 'TZ round-trip test', # description is required (NOT NULL)
'is_in_store': True, # avoids the address-required constraint
})
def test_local_to_utc_compute(self):
# 9:00 local Toronto (EDT, -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 recovers 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)

174
scripts/verify_service_booking.sh Executable file
View File

@@ -0,0 +1,174 @@
#!/usr/bin/env bash
# =============================================================================
# verify_service_booking.sh
#
# HANDS-OFF clone-verify (and, behind a flag, deploy) for the Technician
# Service Booking feature (fusion_tasks + fusion_claims) on the Westin host.
#
# It automates the documented "Westin Prod — Clone-Verify / Deploy" procedure
# (see Odoo-Modules/CLAUDE.md) end-to-end:
# 1. refresh the branch checkout on the host
# 2. clone the live DB to a throwaway test DB (+ the orphaned-tax-FK cleanup)
# 3. stage the branch modules into the _test shadow prefix (prod untouched)
# 4. install/upgrade + run the module tests on the clone (PASS/FAIL gate)
# 5. (only with --deploy AND green tests) back up, swap, -u prod, restart
# 6. always clean up the clone + staging
#
# Verify-only by default. Deploy is OFF unless you pass --deploy.
#
# RUN IT ON THE WESTIN HOST:
# ssh odoo-westin # (via your usual jump)
# # one-time: put the branch on the host, e.g.
# # git clone <remote> /opt/odoo/staging/Odoo-Modules (or scp the tree there)
# bash verify_service_booking.sh # verify only
# DEPLOY=1 bash verify_service_booking.sh --deploy # verify, then deploy on green
#
# Prereq: the feature code must already be implemented on $BRANCH. This script
# does NOT write code — it verifies/deploys what's on the branch.
# =============================================================================
set -Eeuo pipefail
# ----------------------------- CONFIG (env-overridable) ----------------------
APP="${APP:-odoo-dev-app}" # Odoo app container
DBC="${DBC:-odoo-dev-db}" # Postgres container
PROD_DB="${PROD_DB:-westin-v19}" # live DB (cloned, never -u'd unless --deploy)
CLONE_DB="${CLONE_DB:-westin-v19-svcbook}" # throwaway verify DB
PGPW="${PGPW:-DevSecure2025!}"
PGUSER="${PGUSER:-odoo}"
MODULES="${MODULES:-fusion_tasks,fusion_claims}" # comma list for -u
# Scope to THIS feature's test classes — the broad /fusion_claims tag also runs
# pre-existing dashboard/wizard tests that fail in this prod-config runner
# (CLAUDE.md fusion_repairs note: post_install trips on a pre-existing module),
# which is unrelated to this feature. Override TEST_TAGS to widen if desired.
TEST_TAGS="${TEST_TAGS:-/fusion_tasks:TestTaskTz,/fusion_claims:TestServiceRate,/fusion_claims:TestServiceBooking}"
MOD_DIRS=(fusion_tasks fusion_claims) # dirs to stage/deploy
BRANCH="${BRANCH:-claude/technician-service-booking}"
SRC="${SRC:-/opt/odoo/staging/Odoo-Modules}" # host checkout of the branch
STAGE="${STAGE:-/opt/odoo/custom-addons/_test}" # shadow prefix (CLAUDE.md)
LIVE_ADDONS="${LIVE_ADDONS:-/opt/odoo/custom-addons}"
BACKUPS="${BACKUPS:-/opt/odoo/backups}" # OUTSIDE the addons path
CONF="${CONF:-/etc/odoo/odoo.conf}"
# _test prefix SHADOWS prod (first match wins); deps load from the real path.
ADDONS_PATH="/usr/lib/python3/dist-packages/odoo/addons,/usr/lib/python3/dist-packages/addons,${STAGE},/mnt/enterprise-addons,/mnt/extra-addons"
LIVE_ADDONS_PATH="/usr/lib/python3/dist-packages/odoo/addons,/usr/lib/python3/dist-packages/addons,/mnt/enterprise-addons,/mnt/extra-addons"
DEPLOY=0
[[ "${1:-}" == "--deploy" || "${DEPLOY:-0}" == "1" ]] && DEPLOY=1
STAMP="$(date +%Y%m%d-%H%M%S 2>/dev/null || echo manual)"
LOG="/tmp/svcbook_verify_${STAMP}.log"
c() { printf '\n\033[1;36m== %s ==\033[0m\n' "$*"; } # section
ok() { printf '\033[1;32m%s\033[0m\n' "$*"; }
err() { printf '\033[1;31m%s\033[0m\n' "$*" >&2; }
dexec() { docker exec "$@"; }
psql_clone() { dexec -e PGPASSWORD="$PGPW" "$DBC" psql -U "$PGUSER" -d "$CLONE_DB" -v ON_ERROR_STOP=1 "$@"; }
# ----------------------------- CLEANUP TRAP ----------------------------------
cleanup() {
c "Cleanup"
rm -rf "${STAGE:?}/"* 2>/dev/null || true
dexec -e PGPASSWORD="$PGPW" "$DBC" dropdb -U "$PGUSER" --if-exists "$CLONE_DB" 2>/dev/null || true
ok "Dropped clone $CLONE_DB, cleared $STAGE"
}
trap 'err "FAILED (line $LINENO). See $LOG"; cleanup' ERR
trap 'cleanup' EXIT
# ----------------------------- 0. SANITY -------------------------------------
c "Pre-flight"
docker ps --format '{{.Names}}' | grep -qx "$APP" || { err "container $APP not running"; exit 1; }
docker ps --format '{{.Names}}' | grep -qx "$DBC" || { err "container $DBC not running"; exit 1; }
if [[ -d "$SRC/.git" ]]; then
git -C "$SRC" fetch --quiet origin "$BRANCH" && git -C "$SRC" checkout --quiet "$BRANCH" && git -C "$SRC" pull --quiet --ff-only origin "$BRANCH"
ok "Branch $BRANCH @ $(git -C "$SRC" rev-parse --short HEAD)"
else
err "WARNING: $SRC is not a git checkout — staging whatever is on disk there."
fi
for m in "${MOD_DIRS[@]}"; do [[ -d "$SRC/$m" ]] || { err "missing module dir: $SRC/$m"; exit 1; }; done
# ----------------------------- 1. CLONE THE DB -------------------------------
c "Clone $PROD_DB -> $CLONE_DB (read-only on prod)"
dexec -e PGPASSWORD="$PGPW" "$DBC" sh -c \
"dropdb -U $PGUSER --if-exists $CLONE_DB; createdb -U $PGUSER -O $PGUSER $CLONE_DB && pg_dump -U $PGUSER $PROD_DB | psql -U $PGUSER -q -d $CLONE_DB" \
>>"$LOG" 2>&1
ok "Cloned."
# ----------------------------- 2. ORPHANED-FK CLEANUP (clone only) -----------
# westin-v19 has orphaned rows under VALIDATED FKs (deleted taxes, companies,
# journals, ...). A plain pg_dump|psql clone cannot rebuild a validating FK over
# orphans, so the clone is MISSING those FKs; Odoo's check_foreign_keys then
# re-adds them and fails (e.g. payslip_tags_table.res_company_id=3,
# account_payment_method_line.journal_id=35). Generate an orphan-delete for EVERY
# single-column FK that exists on PROD (read-only SELECT on prod) and apply it to
# the clone. The clone is a throwaway; prod is never modified.
# (CLAUDE.md orphan-FK gotcha, generalised beyond the tax tables.)
c "Orphaned-FK cleanup (clone only) — general sweep from prod's FK definitions"
FKSQL="/tmp/svcbook_fkclean_${STAMP}.sql"
printf '%s\n' '\set ON_ERROR_STOP off' > "$FKSQL"
dexec -e PGPASSWORD="$PGPW" "$DBC" psql -U "$PGUSER" -d "$PROD_DB" -t -A -c "SELECT format('DELETE FROM %I a WHERE a.%I IS NOT NULL AND NOT EXISTS (SELECT 1 FROM %I b WHERE b.%I = a.%I);', src.relname, srcatt.attname, tgt.relname, tgtatt.attname, srcatt.attname) FROM pg_constraint con JOIN pg_class src ON src.oid=con.conrelid JOIN pg_namespace ns ON ns.oid=src.relnamespace AND ns.nspname='public' JOIN pg_class tgt ON tgt.oid=con.confrelid JOIN pg_attribute srcatt ON srcatt.attrelid=con.conrelid AND srcatt.attnum=con.conkey[1] JOIN pg_attribute tgtatt ON tgtatt.attrelid=con.confrelid AND tgtatt.attnum=con.confkey[1] WHERE con.contype='f' AND array_length(con.conkey,1)=1;" >> "$FKSQL" 2>>"$LOG" || true
dexec -i -e PGPASSWORD="$PGPW" "$DBC" psql -U "$PGUSER" -d "$CLONE_DB" < "$FKSQL" >>"$LOG" 2>&1 || true
ok "Orphan FKs cleared on clone (general sweep, $(grep -c '^DELETE' "$FKSQL" 2>/dev/null || echo 0) FK relations)."
# ----------------------------- 3. STAGE MODULES (shadow) ---------------------
c "Stage modules into $STAGE (shadows prod, prod files untouched)"
mkdir -p "$STAGE"
for m in "${MOD_DIRS[@]}"; do rm -rf "${STAGE:?}/$m"; cp -r "$SRC/$m" "$STAGE/$m"; done
ok "Staged: ${MOD_DIRS[*]}"
# ----------------------------- 4. INSTALL/UPGRADE + TESTS (clone) -----------
# Test-runner gotchas on the prod-config container (CLAUDE.md / fusion_repairs):
# --test-enable SILENTLY SKIPS without --workers 0; log_level=warn hides test
# output -> add --log-level=test. The EXIT CODE is authoritative.
run_odoo() { # $1 = extra args
# --test-enable forces http_spawn() even with --no-http (Odoo 19), so the test
# run binds 8069 (held by the live app) and dies with "Address already in use".
# --http-port=0 --gevent-port=0 makes it pick ephemeral ports. (CLAUDE.md gotcha.)
dexec "$APP" odoo -d "$CLONE_DB" \
--db_host db --db_port 5432 --db_user "$PGUSER" --db_password "$PGPW" \
--addons-path="$ADDONS_PATH" --stop-after-init --no-http --http-port=0 --gevent-port=0 $1
}
c "Install/upgrade on clone (catches install/render errors)"
if run_odoo "-u $MODULES" >>"$LOG" 2>&1; then ok "Upgrade OK"; else err "UPGRADE FAILED — see $LOG"; tail -40 "$LOG"; exit 2; fi
c "Run module tests on clone"
if run_odoo "-u $MODULES --test-enable --test-tags $TEST_TAGS --workers 0 --log-level=test" >>"$LOG" 2>&1; then
TESTS_OK=1; ok "TESTS PASSED"
else
TESTS_OK=0; err "TESTS FAILED (exit $?)"; grep -E 'FAIL|ERROR|Traceback' "$LOG" | tail -40 || true
fi
echo
c "VERIFY RESULT"
if [[ "${TESTS_OK:-0}" == "1" ]]; then ok "✅ Clone-verify GREEN (full log: $LOG)"; else err "❌ Clone-verify RED (full log: $LOG)"; fi
# ----------------------------- 5. DEPLOY (gated) -----------------------------
if [[ "$DEPLOY" == "1" ]]; then
if [[ "${TESTS_OK:-0}" != "1" ]]; then err "Not deploying — tests are red."; exit 3; fi
c "DEPLOY to $PROD_DB (tests green)"
mkdir -p "$BACKUPS"
# DB backup (-Fc) + module dir backups OUTSIDE the addons path
dexec -e PGPASSWORD="$PGPW" "$DBC" pg_dump -Fc -U "$PGUSER" "$PROD_DB" > "$BACKUPS/${PROD_DB}_${STAMP}.dump"
for m in "${MOD_DIRS[@]}"; do [[ -d "$LIVE_ADDONS/$m" ]] && cp -r "$LIVE_ADDONS/$m" "$BACKUPS/${m}_${STAMP}"; done
ok "Backed up DB + module dirs to $BACKUPS"
# swap branch modules into the real addons
for m in "${MOD_DIRS[@]}"; do rm -rf "${LIVE_ADDONS:?}/$m"; cp -r "$SRC/$m" "$LIVE_ADDONS/$m"; done
# -u prod, gated on exit 0
if dexec "$APP" odoo -d "$PROD_DB" --db_host db --db_port 5432 --db_user "$PGUSER" --db_password "$PGPW" \
--addons-path="$LIVE_ADDONS_PATH" -u "$MODULES" --stop-after-init --no-http >>"$LOG" 2>&1; then
dexec -e PGPASSWORD="$PGPW" "$DBC" psql -U "$PGUSER" -d "$PROD_DB" -c \
"DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';" >>"$LOG" 2>&1 || true
docker restart "$APP" >>"$LOG" 2>&1
ok "🚀 Deployed + assets cleared + $APP restarted."
else
err "PROD -u FAILED — restoring module dirs, NOT restarting."
for m in "${MOD_DIRS[@]}"; do rm -rf "${LIVE_ADDONS:?}/$m"; [[ -d "$BACKUPS/${m}_${STAMP}" ]] && cp -r "$BACKUPS/${m}_${STAMP}" "$LIVE_ADDONS/$m"; done
err "Restore the DB if needed: pg_restore from $BACKUPS/${PROD_DB}_${STAMP}.dump"
exit 4
fi
else
echo
ok "Verify-only run (no deploy). Re-run with --deploy to ship on green."
fi