This commit is contained in:
gsinghpal
2026-06-04 09:49:51 -04:00
parent 41ce3784d7
commit ba7c028c30
11 changed files with 3504 additions and 61 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.

799
fusion_schedule/CLAUDE.md Normal file
View File

@@ -0,0 +1,799 @@
# fusion_schedule — Claude Code Instructions
> Module-level guide. The repo-wide Odoo 19 rules in `K:\Github\Odoo-Modules\CLAUDE.md`
> (and the global `K:\Github\CLAUDE.md`) **still apply** — this file only adds what is
> specific to `fusion_schedule`. Read both.
>
> **Companion docs:** [`CODE_MAP.md`](CODE_MAP.md) is the precise symbol-level
> "where-is-what" index (every field/method/route/JS fn/template with line numbers) — use it
> to locate code; use this file for guidance. Open audit findings are tracked in Supabase
> `fusionapps.issues` under project **Fusion Schedule**
> (`576de219-57e6-4596-8c8c-0c093e4cb54a`) and summarised in §16 below.
>
> **Provenance:** this module was originally designed & coded with **Cursor using Claude 4.5
> Opus** (AI-generated), then audited by Claude Code. That shows in the failure profile: the
> Odoo-19 *syntax/idioms* are clean (no deprecated APIs), but the bugs cluster in semantic areas
> that need domain reasoning or a running install to catch — unscoped ORM queries (cross-user
> event merging), timezone handling, copy-paste-drifted duplicates (authenticated vs public
> booking), swallowed exceptions, and untested public/render paths. When extending it, **assume
> plausible-but-unverified until tested on Enterprise.**
---
## 1. What this module is
**Fusion Schedule** (`fusion_schedule`, `__manifest__.py` version **19.0.2.1.0**, author
"Fusion Claims", LGPL-3) is a **multi-account calendar synchronisation hub + portal
booking system** for staff (authorizers / sales reps / technicians) in the Fusion Claims
product family.
Three product surfaces, one engine:
1. **Multi-calendar sync** — a staff user connects any number of **Google** and **Microsoft
Outlook** calendars. A 5-minute cron pulls external events into Odoo `calendar.event`
and pushes Odoo-native events out, so the user has one merged calendar and is "busy on
one → blocked on all".
2. **Portal "My Schedule"** (`/my/schedule`) — a portal dashboard: today's + upcoming
appointments, connected-account management, schedule preferences (work hours / break /
travel buffer / base address), a booking form with a week-calendar preview, **AI slot
suggestions** and **AI day-route optimization**, and travel-time blocking.
3. **Public booking links** (`/schedule/<slug>`) — each user gets a shareable slug; external
visitors (no login) can self-book into the user's free slots and later
cancel/reschedule via a per-event **manage token** (`/schedule/manage/<token>`).
> ⚠️ This is the active **Outlook ↔ Odoo sync** for this deployment — **not** Odoo's native
> `microsoft_calendar`/`google_calendar` sync. The backend calendar UI patch (see §11)
> deliberately **hides** the native sync buttons and substitutes Fusion Schedule's own.
It was originally built in **Cursor** (note the leftover `graphify-out/` artifact — a Cursor
code-graph dump; safe to ignore/delete, not loaded by Odoo). Development now happens in
Claude Code.
---
## 2. Enterprise-only — you cannot install this on local Community
The manifest depends on **`appointment`** (Odoo **Enterprise**), plus `google_account` and
`microsoft_account`. Therefore — like `fusion_portal` and `fusion_repairs` — **it cannot be
installed or tested on local `odoo-modsdev` (Community).** The old
`-d fusion-dev -u <module>` recipe does **not** work here.
Test on an Enterprise environment (a Westin clone is the natural choice since
`fusion_portal` already runs there — see the *Westin Prod* section of the repo `CLAUDE.md`).
There are currently **no automated tests** in this module (`tests/` does not exist).
---
## 3. Dependency map
### 3.1 Hard dependencies (`__manifest__.py` → `depends`)
```
base · portal · website · calendar · appointment · google_account · microsoft_account · fusion_portal
```
- `appointment` — Enterprise. Uses `appointment.type`, `appointment.invite`, and
`appointment_type._prepare_calendar_event_values(...)` to build booking events.
- `calendar` — the core model everything revolves around (`calendar.event` is inherited).
- `google_account` / `microsoft_account` — base OAuth plumbing. **Note:** the module rolls
its *own* OAuth flow (it does not reuse `google_calendar`/`microsoft_calendar` sync). It
only borrows their stored client-id ICP params as a *fallback* (see §10).
- `fusion_portal` — the **only `fusion_*` hard dependency**. This is what transitively pulls
in the whole claims stack: `fusion_portal → fusion_claims` (+ `fusion_tasks`,
`fusion_loaners_management`, `knowledge`). So **`fusion_claims` is a transitive
dependency**, always present at runtime.
### 3.2 Soft dependencies (used via `try/except`, NOT in `depends`)
- **`fusion_api`** (`fusion.api.service`) — preferred broker for the Google Maps key and
OpenAI calls. Not declared in `depends`; every call is wrapped in `try/except` and falls
back to `fusion_claims.*` ICP params, then degrades gracefully. The module still runs if
`fusion_api` is absent.
### 3.3 Reverse dependencies
- **Nothing depends on `fusion_schedule`.** It is a leaf/top module. The only mention
elsewhere is `fusion_repairs/__manifest__.py` which lists "fusion_schedule slots" as a
*deferred / future* integration — not a real dependency today.
```
┌─────────────────┐
│ fusion_schedule │ (leaf — nothing depends on it)
└────────┬────────┘
depends │ soft (try/except, NOT in manifest)
┌─────────────────┼──────────────────────────┐
▼ ▼ ▼
fusion_portal appointment (EE) fusion_api ── fusion.api.service
│ google_account / microsoft_account (Maps key + OpenAI broker)
fusion_claims ── owns the `fusion_claims.*` ICP params reused as fallbacks
│ (+ fusion_tasks, fusion_loaners_management, knowledge)
```
---
## 4. ⭐ Relationship with `fusion_claims` (read this — it's the whole point of the coupling)
`fusion_schedule` **does not modify any `fusion_claims` model or view.** The coupling is
indirect and entirely through shared infrastructure. Five concrete links:
### 4.1 Transitive dependency (stack position)
`fusion_schedule` sits **on top of** the claims stack via `fusion_portal → fusion_claims`.
It assumes the claims/portal data model and the authorizer/sales-rep portal already exist.
### 4.2 Config-parameter namespace reuse (the main runtime link)
The portal pages **borrow `fusion_claims`-owned `ir.config_parameter` values** so the
schedule UI matches the claims portal branding and shares the same API keys. These params
are **defined in `fusion_claims/models/res_config_settings.py`**, *not* here:
| ICP key (owned by fusion_claims) | Used in fusion_schedule for | Where |
|---|---|---|
| `fusion_claims.portal_gradient_start` / `_mid` / `_end` | portal header gradient (brand colour) | `PortalSchedule._get_schedule_values()` |
| `fusion_claims.google_maps_api_key` | Maps/Places/Distance-Matrix key **fallback** | `_get_maps_api_key()` |
| `fusion_claims.ai_api_key` | OpenAI key **fallback** (direct HTTP) | `_call_ai()` |
> If you rename/remove these in `fusion_claims`, the schedule portal silently loses its
> gradient / maps / AI. They are read with defaults, so it won't crash — it just degrades.
### 4.3 The `fusion.api.service` broker (preferred path, fusion_claims-family convention)
`_get_maps_api_key()` and `_call_ai()` first try `request.env['fusion.api.service']`
(from **`fusion_api`**) — the same metered, budget-/rate-limited broker the rest of the
Fusion family uses — with `consumer='fusion_schedule'`. Only if that raises do they fall
back to the `fusion_claims.*` ICP params above. So the order is:
**`fusion_api` broker → `fusion_claims` ICP param → graceful no-op.**
> Two non-obvious facts (detail in [`CODE_MAP.md`](CODE_MAP.md) §9): (1) `get_api_key` returns
> the `group_admin`-gated `key.api_key` on a **non-sudo** recordset, so from a portal/public
> request it likely raises `AccessError` and the **ICP fallback fires every time** — for portal
> callers `fusion_claims.google_maps_api_key` is effectively the real source, not the broker.
> (2) That maps-key param is actually **owned by `fusion_tasks`** (`res_config_settings.py:12`),
> not `fusion_claims`, despite the `fusion_claims.*` prefix — grepping `fusion_claims/` for it
> finds nothing.
### 4.4 Portal tile injection (into fusion_portal, which is built on fusion_claims)
`views/portal_schedule_tile.xml` (`portal_my_home_schedule`, priority 45) inherits
**`fusion_portal.portal_my_home_authorizer`** (which itself inherits `portal.portal_my_home`,
priority 40) and `xpath`s a "My Schedule" card into the authorizer/sales-rep portal home
grid. It reuses fusion_portal's `fc_gradient` template var.
- **`fc_gradient` origin:** set in `fusion_portal/views/portal_templates.xml` as
`portal_gradient or <default green/blue>`, where `portal_gradient` is computed by
fusion_portal's home controller from the same `fusion_claims.portal_gradient_*` params
(§4.2). The tile falls back to the literal default if `fc_gradient` is unset.
- **⚠ Fragile xpath:** the tile anchors on
`//a[@href='/my/funding-claims']/ancestor::div[hasclass('row') and hasclass('g-3') and hasclass('mb-4')]`.
If fusion_portal renames the funding-claims route, removes that card, or restructures the
home grid's classes, the tile **silently disappears** (or the view fails to load on `-u`).
Re-check this xpath whenever fusion_portal's home template changes.
### 4.5 The `tz` cookie is populated by fusion_portal
Fusion Schedule's timezone resolution (`_resolve_timezone`) reads a browser **`tz`** cookie
(IANA name). That cookie is set by **`fusion_portal/static/src/js/timezone_detect.js`**
(`tz=<IANA>;path=/;max-age=1yr;SameSite=Lax`) — and, redundantly, by Fusion Schedule's own
booking JS (`setTzCookie` IIFE). So on portal pages the correct timezone flows in **from
fusion_portal**; without that cookie (or a `user.tz`), times fall back to the company
calendar tz, then UTC.
### 4.5 Parallel/overlapping scheduling — they share the `calendar.event` table
`fusion_claims` already has its **own, simpler** scheduling:
- `fusion_claims.schedule.assessment.wizard` (`wizard/schedule_assessment_wizard.py`) — a
*backend* wizard that creates a plain `calendar.event` for an ADP assessment from a
`sale.order` (optional 1-day email alarm). No sync, no portal, no travel logic.
- `technician_task` routing — push notifications + travel time using the **same**
`fusion_claims.google_maps_api_key`.
`fusion_schedule` is the **newer, richer, portal-facing + multi-calendar layer**. Both
write to `calendar.event`, so they **interplay**: an assessment event created by the
fusion_claims wizard for a user who has connected calendars will be picked up by Fusion
Schedule's **cross-calendar push** (it's an unlinked `calendar.event` on the user's partner)
and mirrored to that user's external calendar, and it appears in `/my/schedule`. They are
**complementary, not isolated** — keep that shared table in mind when changing either side.
---
## 5. Data model
All custom fields use the `x_fc_*` prefix (repo convention). Models load in this order
(`models/__init__.py`): `fusion_calendar_account → fusion_calendar_event_link →
calendar_event → res_users → res_config_settings`.
### 5.1 `fusion.calendar.account` — the OAuth account + sync engine *(god object, ~35 edges)*
`models/fusion_calendar_account.py`. One row per connected external calendar.
| Field | Notes |
|---|---|
| `x_fc_user_id` (m2o res.users, required, cascade) | owner |
| `x_fc_provider` (sel: google/microsoft, required) | |
| `x_fc_email` / `x_fc_name` (compute, stored) | label = "Google — a@b.com" |
| `x_fc_active` (bool) | |
| `x_fc_rtoken` / `x_fc_token` / `x_fc_token_validity` | **`groups='base.group_system'`** — OAuth secrets, admin-only |
| `x_fc_sync_token` | provider delta/sync token (`group_system`). Clear it to force a fresh full sync |
| `x_fc_calendar_id` (default `'primary'`) | |
| `x_fc_last_sync`, `x_fc_sync_status` (active/error/paused), `x_fc_error_message` | |
| `x_fc_link_ids` (o2m → event link) | |
This file is the engine. Key method groups (all on the account record):
- **Credential resolution** `_get_google_client_id/_secret`, `_get_microsoft_*` — dedicated
`fusion_schedule_*` ICP param → native `google_calendar_client_id` / `microsoft_calendar_client_id`.
- **Token mgmt** `_get_valid_token` (1-min skew buffer), `_refresh_token`
`_refresh_google_token` / `_refresh_microsoft_token` (MS may rotate the refresh token —
it's re-saved). On HTTP 400/401 the account is marked `error` and tokens cleared.
- **Code exchange** `_exchange_google_code` / `_exchange_microsoft_code` (called from the
controller callback). `_fetch_google_email` / `_fetch_microsoft_email`.
- **Pull (external → Odoo)** `_sync_pull``_sync_pull_google` / `_sync_pull_microsoft`,
with `_google_request_with_retry` / `_microsoft_request_with_retry` (429/503 + connection
retry, capped). Google initial window **now-14d … now+30d**; subsequent syncs use the
sync token (HTTP 410 → drop token, full resync). MS uses Graph `calendarView/delta`;
delta token expiry (`fullSyncRequired`/`SyncStateNotFound`) → full resync. MS page cap:
2000 events initial / 5000 incremental.
- **Event mapping** `_google_event_to_odoo_vals` / `_microsoft_event_to_odoo_vals` and the
reverse `_odoo_event_to_google` / `_odoo_event_to_microsoft`.
- **Upsert/dedup** `_process_google_event` / `_process_microsoft_event`,
`_find_existing_event` (matches name+start+stop, **includes archived** to reuse), and
`_upsert_event_link`.
- **Push (Odoo → external)** `_sync_push_event` (+ insert/patch/delete per provider).
- **Cross-calendar busy block** `_cross_calendar_push` (see §6.3).
- **Backend RPC** `get_user_accounts_status()`, `sync_current_user()` (called from the
calendar UI patch).
- **Cron** `_cron_sync_all_accounts()`.
- **Teardown** `action_disconnect()` — deletes pushed external events, unlinks rows, pauses.
### 5.2 `fusion.calendar.event.link` — Odoo-event ↔ external-event join
`models/fusion_calendar_event_link.py`. One row per (Odoo event, account).
- `x_fc_event_id` (m2o calendar.event, cascade), `x_fc_account_id` (m2o account, cascade),
`x_fc_external_id` (required), `x_fc_universal_id` (iCalUID — used for cross-provider
dedup), `x_fc_last_synced`, `x_fc_sync_direction` (pull/push/both).
- **Constraint:** `models.Constraint('UNIQUE(x_fc_account_id, x_fc_external_id)')` — an
external event links once per account. (Odoo-19 declarative constraint, per repo rule #9.)
### 5.3 `calendar.event` (inherited)
`models/calendar_event.py`. Adds:
- `x_fc_source_account_id` (m2o account) — set when an event was *pulled* from external;
used for colour-coding the source in the portal.
- `x_fc_is_external` (compute, **stored** from source account).
- `x_fc_link_ids` (o2m → link).
- `x_fc_manage_token` (indexed, `copy=False`) — 32-hex public manage token.
- `x_fc_client_email` / `x_fc_client_phone`.
- `x_fc_address_lat` / `x_fc_address_lng` (Float, digits 10,7) — for travel-time calc.
- `x_fc_travel_minutes_before` (int) and `x_fc_is_travel_block` (bool) — travel placeholder
events generated after booking.
- **`write()` / `unlink()` overrides** push updates/deletions to all linked external
calendars — **unless** `_skip_fc_sync()` is true (context has `no_calendar_sync` or
`dont_notify`). `write()` only pushes when a sync-relevant field changed.
### 5.4 `res.users` (inherited)
`models/res_users.py`. Adds per-staff scheduling config:
- `x_fc_calendar_account_ids` (o2m), `x_fc_schedule_slug` (**`UNIQUE` constraint**),
`x_fc_booking_enabled` (default False).
- Work prefs: `x_fc_work_start` (9.0), `x_fc_work_end` (17.0), `x_fc_break_start` (12.0),
`x_fc_break_duration` (0.5h), `x_fc_travel_buffer` (30 min), `x_fc_home_address` +
`x_fc_home_lat`/`x_fc_home_lng`.
- **`create()` override** auto-generates a slug from the name + 4-hex suffix
(`_generate_schedule_slug`). Every user (including pre-existing ones created elsewhere)
gets a unique public slug.
### 5.5 `res.config.settings` (inherited)
`models/res_config_settings.py`. See §12.
---
## 6. The sync engine — how events flow
### 6.1 Pull (external → Odoo), per account
1. `_get_valid_token()` (refresh if needed).
2. Fetch pages (sync-token delta when available, else the ±window).
3. For each event: cancelled/removed → archive local + unlink the link row; otherwise
**upsert** with a 3-tier dedup ladder:
- existing link for `(account, external_id)` → update in place;
- else existing link by **iCalUID** (cross-provider/same-event) → relink;
- else `_find_existing_event` by name+start+stop (incl. archived) → reuse + relink;
- else **create** a new `calendar.event` (owner partner attached) + new link.
4. Persist `x_fc_sync_token`, `x_fc_last_sync`, status.
### 6.2 Push (Odoo → external), per event
`calendar.event.write()` triggers `_sync_push_event` on each linked active account
(insert if no link, patch if linked). New links are tagged `direction='push'`.
### 6.3 Cross-calendar busy-blocking (`_cross_calendar_push`)
Runs in the cron **only for users with ≥2 active accounts**. It finds the user's
**Odoo-native** events (those with **no** existing link) in the window now-1d … now+90d and
pushes them to the **first active account only** (lowest id). Pushing to a single calendar +
only un-linked events together prevent the **pull → push → pull feedback loop** and
cross-calendar duplicates. *This is the "busy on one, blocked on all" mechanism.*
### 6.4 Cron
`data/ir_cron_data.xml``ir_cron_fusion_calendar_sync`, every **5 minutes**, runs as
`base.user_root`, code `model._cron_sync_all_accounts()`. Never-synced accounts are
processed first. Per-account isolation uses `self.env.cr.commit()` / `rollback()` so one bad
account doesn't poison the batch (see §13 footgun about tests).
---
## 7. OAuth connect/callback flow
`/my/schedule/connect/google` and `/connect/microsoft` build the auth URL (scopes:
Google `calendar` + `userinfo.email`, offline + consent; Microsoft `offline_access openid
Calendars.ReadWrite User.Read`), stash a CSRF token in `request.session['fc_oauth_csrf']`,
and encode `{provider, csrf}` into `state`. Redirect URI is always
`<web.base.url>/my/schedule/oauth/callback`.
`/my/schedule/oauth/callback` validates `state` + CSRF, exchanges the code, fetches the
account email, then **find-or-creates** a `fusion.calendar.account` (re-activating a matching
existing one). Requires a **refresh token** — if the provider didn't return one, it errors
asking the user to grant offline access. There's a resilience fallback:
`_find_recently_connected_account` (created in the last 10 min) so a refreshed/timed-out
callback still reports success instead of erroring.
---
## 8. Travel time + AI scheduling
- **Travel time** `_get_travel_time(lat,lng→lat,lng)` — Google **Distance Matrix** (driving,
avoid tolls, depart now), returns minutes or 0 on any failure. `_geocode_address` uses the
Geocoding API (region `ca`).
- **Travel blocks** `_create_travel_blocks(event, staff_user)` — after a booking, looks at
the prev/next located appointments that day and inserts `Travel to …` placeholder events
(`x_fc_is_travel_block=True`, `show_as=busy`) sized to `max(distance-matrix, travel_buffer)`.
- **AI slot suggest** `/my/schedule/ai/suggest` — builds a schedule context, asks OpenAI
(`gpt-4o-mini`) to pick **exactly 3** times **from the provided free-slot list only**
(strict prompt + post-filter against the real slots; never invents times). Used by the
booking form.
- **AI day optimize** `/my/schedule/ai/optimize` — needs ≥2 located appointments; builds a
travel matrix and asks OpenAI for an optimal visiting order + suggested times + savings.
- Both AI calls route through `_call_ai()` (`fusion.api.service.call_openai`
`fusion_claims.ai_api_key` direct-HTTP fallback). Failures degrade to "AI unavailable".
---
## 9. Routes (controllers/portal_schedule.py — `PortalSchedule(CustomerPortal)`)
| Method | Route | Auth | Renders / returns |
|---|---|---|---|
| http | `/my/schedule` | user | `portal_schedule_page` |
| jsonrpc | `/my/schedule/preferences` | user | save work/break/travel/home prefs (geocodes address) |
| http | `/my/schedule/book` | user | `portal_schedule_book` |
| jsonrpc | `/my/schedule/available-slots` | user | free slots for a date |
| jsonrpc | `/my/schedule/week-events` | user | MonSun events for the week strip |
| http POST | `/my/schedule/book/submit` | user | create booking (+ confirmation email + travel blocks) |
| jsonrpc | `/my/schedule/event/cancel` | user | delete own event |
| jsonrpc | `/my/schedule/event/reschedule` | user | move own event |
| jsonrpc | `/my/schedule/ai/suggest` | user | 3 AI slot picks |
| jsonrpc | `/my/schedule/ai/optimize` | user | AI day route |
| http | `/my/schedule/connect/google` · `/connect/microsoft` | user | start OAuth |
| http | `/my/schedule/oauth/callback` | user | finish OAuth |
| jsonrpc | `/my/schedule/disconnect` | user | `action_disconnect` |
| jsonrpc | `/my/schedule/sync-now` | user | `_sync_pull` one account |
| jsonrpc | `/my/schedule/toggle-booking` | user | enable/disable public page |
| http | `/schedule/<slug>` | **public** | `public_booking_page` |
| jsonrpc | `/schedule/<slug>/available-slots` | **public** (csrf=False) | slots |
| http POST | `/schedule/<slug>/book` | **public** (csrf) | public booking |
| http | `/schedule/manage/<token>` | **public** | `public_manage_page` |
| http POST | `/schedule/manage/<token>/cancel` · `/reschedule` | **public** (csrf) | self-service |
| jsonrpc | `/schedule/manage/<token>/available-slots` | **public** (csrf=False) | slots |
Backend (ORM, not HTTP), called from the calendar UI patch:
`fusion.calendar.account.get_user_accounts_status()` and `.sync_current_user()`.
**Slot generation** (`_generate_available_slots`) is the shared core for *all* slot
endpoints: honours the staff user's work hours / break / travel-buffer, intersects with
appointment-type recurring slots, removes past times, and rejects slots that overlap any
existing event **plus the travel buffer** after it.
**Timezone resolution** (`_resolve_timezone`): `user.tz``tz` cookie (set by the frontend
JS / fusion_portal, §4.5) → `company.resource_calendar_id.tz` → UTC.
### 9.1 Authenticated portal vs public booking are TWO separate implementations
This is the single most important structural fact the templates reveal — the two booking
flows do **not** share code and behave differently:
| | Authenticated `/my/schedule/book` | Public `/schedule/<slug>` |
|---|---|---|
| Layout | `portal.portal_layout` (portal chrome + breadcrumbs) | `website.layout` (public site chrome) |
| Slot/booking JS | the **registered asset files** (`portal_schedule_booking.js`, `portal_schedule_accounts.js`) | **inline `<script>`** embedded in `public_booking.xml` (a *second copy* of the slot-render + Places-autocomplete logic) |
| Brand gradient | `portal_gradient` from `fusion_claims.*` params | **hardcoded** `linear-gradient(135deg,#5ba848,#3a8fb7)` — ignores the brand params |
| Event creation | `appointment_type._prepare_calendar_event_values(...)` → a real **appointment** with booking lines/capacity | a **raw `calendar.event`** dict (no appointment lines, no capacity) |
| Slot re-validation on submit | **yes** — re-runs `_generate_available_slots` and rejects stale slots | **no** — trusts the posted `slot_datetime` (double-book risk) |
| Week-calendar preview + AI suggest/optimize | yes | no |
So "fix the booking form" almost always means **edit two places**. Changing slot logic in
the Python `_generate_available_slots` covers both (it's shared server-side), but any
client-side change to slot rendering, autocomplete, or validation must be mirrored between
`portal_schedule_booking.js` and the inline script in `public_booking.xml`.
### 9.2 Two share links, one of them dead
- `schedule_page` computes `share_url = appointment.invite.book_url` (native appointment
share, looked up by `staff_user_ids`) **and** `public_booking_url = <base>/schedule/<slug>`.
Only **`public_booking_url`** is actually rendered (the "Share Booking Link" card/button).
`share_url` is passed to the template but **never used** — and the only seeded
`appointment.invite` (`default_appointment_invite`) has empty `appointment_type_ids`/no
staff, so it would be blank anyway. The slug link is the real share mechanism.
- There is **no `_prepare_home_portal_values` override**, so `/my/schedule` has **no portal
home counter** and no portal breadcrumb registration — the injected tile (§4.4) is the
only discoverable entry point besides the calendar-view cog button (§11).
---
## 10. ICP parameters (full list)
**Owned by this module:**
- OAuth creds: `fusion_schedule_google_client_id`, `fusion_schedule_google_client_secret`,
`fusion_schedule_microsoft_client_id`, `fusion_schedule_microsoft_client_secret`
- Sync: `fusion_schedule_sync_interval` (minutes; **note:** the cron interval is set in XML,
this param is currently informational — changing it does not re-write the cron)
- Defaults: `fusion_schedule.default_work_start` / `_work_end` / `_break_start` /
`_break_duration` / `_travel_buffer`
**Fallbacks read from elsewhere (not owned here):**
- Native Odoo: `google_calendar_client_id`, `google_calendar_client_secret`,
`microsoft_calendar_client_id`, `microsoft_calendar_client_secret`, `web.base.url`
- **fusion_claims namespace:** `fusion_claims.portal_gradient_start/_mid/_end`,
`fusion_claims.google_maps_api_key`, `fusion_claims.ai_api_key` (see §4.2)
---
## 11. Frontend / assets
Registered in `__manifest__.py` `assets`:
**`web.assets_backend`** — patches the native calendar:
- `static/src/views/fusion_calendar_controller.js``patch(AttendeeCalendarController…)`:
loads connected accounts (`get_user_accounts_status`) and adds a "Sync now"
(`sync_current_user`) action.
- `static/src/views/fusion_calendar_controller.xml` — t-inherits
`calendar.AttendeeCalendarController`, **hides** `#header_synchronization_settings` (the
native Google/Outlook sync UI, kept in DOM so other xpaths survive) and injects Fusion's
account chips + sync button + a cog link to `/my/schedule`.
**`web.assets_frontend`** — portal pages:
- `static/src/css/portal_schedule.css`
- `static/src/js/portal_schedule_booking.js` — booking form: sets the `tz` cookie, week
calendar strip, slot fetch + morning/afternoon grouping, AI suggestions, **Google Places
address autocomplete** (`country: 'ca'`, writes hidden lat/lng), submit guards.
- `static/src/js/portal_schedule_accounts.js` — the `/my/schedule` dashboard: reusable
`fusionConfirm` modal + `fusionToast`, disconnect/sync-now, share-link (Web Share /
clipboard), save-preferences, cancel/reschedule modals, AI "optimize my day" modal.
These are **plain IIFE scripts** (not Odoo `Interaction` classes) that bind to **DOM element
IDs** in the QWeb templates. If you rename an element id in the templates you must update the
JS, and vice-versa. Key ids the JS expects: `bookingDate`, `appointmentTypeSelect`,
`slotsContainer/slotsGrid/slotsLoading/noSlots`, `slotDatetime`, `slotDuration`,
`weekCalendar*`, `aiSuggest*`, `clientStreet/clientCity/clientProvince/clientPostal/clientLat/clientLng`,
`rescheduleModal` (+ children), `optimizeModal` (+ children), `schedulePrefsForm`,
`fusionConfirmModal`.
**Templates** (QWeb):
- `views/portal_schedule.xml``portal_schedule_page`, `portal_schedule_book`
(both `portal.portal_layout`).
- `views/public_booking.xml``public_booking_page`, `public_manage_page`
(both `website.layout`; **carry their own inline `<script>`** — see §9.1).
- `views/portal_schedule_tile.xml``portal_my_home_schedule` (the fusion_portal tile).
Frontend wiring notes:
- **Google Maps loader handshake.** The booking templates inject the Maps Places script with
`&callback=initScheduleAddressAutocomplete` (public: `initPublicAddressAutocomplete`). Because
the async script can land before *or* after the IIFE in `portal_schedule_booking.js`, they
coordinate via `window._googleMapsReady` / `window._scheduleAutocompleteInit`. Maps only
loads when a `google_maps_api_key` resolved (§4.2/§4.3) — no key ⇒ no autocomplete, fields
still work manually.
- **Dead toast markup.** `portal_schedule.xml` ships a Bootstrap `#fusionToast` /
`#fusionToastMessage` element, but `portal_schedule_accounts.js` defines its own
`fusionToast()` that builds a fresh `#fusionToastLive` node and **ignores** the template
one. Don't wire new code to `#fusionToast`; call the JS `fusionToast(msg, type)` helper.
- **CSS** (`portal_schedule.css`) is tiny: collapse-chevron rotation, a `.min-width-0`
truncation helper, and mobile sizing for slot buttons / tables / modals. No theming —
colours come from the inline `portal_gradient` styles and Bootstrap utility classes.
---
## 12. Settings UI
`views/res_config_settings_views.xml` adds a **"Fusion Schedule"** app block to
Settings (`base.res_config_settings_view_form`, priority 90) with: Sync Interval, Google
OAuth creds (+ "using Odoo default" hint via `x_fc_google_has_fallback`), Microsoft OAuth
creds (+ fallback hint), and Schedule Defaults (work hours / break / travel buffer, all
`float_time` widgets). The compute fields `x_fc_*_has_fallback` light up when no dedicated
key is set but a native `*_calendar_client_id` exists.
Backend list/form for accounts: `views/fusion_calendar_account_views.xml`
action + menu **Settings → Technical → Calendar Accounts** (`base.menu_custom`).
---
## 13. Security
`security/security.xml` — two record rules (both additive on `base.group_user`):
- users see only their own `fusion.calendar.account` (`x_fc_user_id = user.id`);
- users see only event links for their own accounts.
`security/ir.model.access.csv` — account: full CRUD for `group_user`, none for
`group_public`; event link: CRU for `group_user`, full for `group_system`.
OAuth secrets (`x_fc_rtoken/x_fc_token/x_fc_token_validity/x_fc_sync_token`) are
`groups='base.group_system'` so non-admin users can't read them even on their own rows;
sync code uses `.sudo()` to access them.
---
## 14. Footguns & gotchas (read before editing)
1. **The silent-context flags are load-bearing.** Any time you create/write/unlink a
`calendar.event` *during sync or travel-block creation*, pass `_silent_ctx()` (or at
least `no_calendar_sync=True, dont_notify=True`). Otherwise the `calendar.event`
`write/unlink` overrides will try to **push back to external calendars** → pull → push
feedback loop and/or attendee emails. The whole sync path already does this; mirror it.
2. **MS delta `@removed` reason matters.** `@removed` with reason `'deleted'` (or
`isCancelled`) → archive + unlink. `@removed` with any other reason (typically
`'changed'`) → **return `'skipped'`, do NOT archive** — the event merely drifted out of
the delta window and still exists upstream. This exact distinction was the
`f1cea2fb` bug fix ("stop archiving valid events on @removed=changed"). Don't regress it.
3. **`cr.commit()` / `cr.rollback()` in the cron will raise inside `TransactionCase`.**
Per repo rule #14, Odoo 19 test cursors refuse commit/rollback. There are no tests today,
but if you add any that exercise `_cron_sync_all_accounts` / `sync_current_user`, refactor
to `with self.env.cr.savepoint():` per iteration instead of commit/rollback, or the test
cursor will break.
4. **Declarative SQL objects only** (rule #9): this module already uses
`models.Constraint(...)` for the unique constraints — keep that style, never
`_sql_constraints` or `init()`.
5. **`google_account`/`microsoft_account` ≠ native calendar sync.** Don't "simplify" by
reusing `google_calendar`/`microsoft_calendar` sync — this module intentionally owns its
OAuth + sync and hides the native UI. The native client-id params are only a credential
fallback.
6. **Public endpoints.** `/schedule/<slug>` and `/schedule/manage/<token>` are
`auth='public'`. The manage token is `secrets.token_hex(16)` (32 chars) and
`_get_event_by_token` enforces `len == 32`. Public booking requires both
`x_fc_booking_enabled=True` **and** the user having an `appointment.type` with them as
staff. Keep CSRF on the POST forms; the slot JSON-RPC endpoints are `csrf=False` by design.
7. **`data/appointment_invite_data.xml` is `noupdate=1`** and ships
`default_appointment_invite` with **empty** `appointment_type_ids` — the generic
`/book/book-appointment` share link won't resolve to a real type until configured. The
`/my/schedule` page separately resolves an `appointment.invite` by `staff_user_ids`.
8. **`data/mail_template_data.xml` is NOT `noupdate`** — the booking confirmation template
(`fusion_schedule_booking_confirmation`, on `calendar.event`) reloads on every `-u`.
It renders the manage link from `company.website or get_base_url()`.
9. **`graphify-out/` is a Cursor artifact**, not part of the module. It's not in the
manifest and Odoo never loads it. Safe to ignore or delete; don't treat its
`GRAPH_REPORT.md` as authoritative (it's a heuristic code-graph, ~87% extracted).
10. **Soft-dependency discipline.** Never assume `fusion_api` is installed — keep the
`try/except` + ICP fallback pattern in `_get_maps_api_key` / `_call_ai`. Adding
`fusion_api`/`fusion_claims` to `depends` would change the install graph; only do it
deliberately.
11. **Public booking does NOT re-validate the slot.** `schedule_book_submit` (authenticated)
re-runs `_generate_available_slots` and rejects a slot that's no longer free;
`public_book_submit` does **not** — it trusts the posted `slot_datetime`. Two visitors
hitting the same public slot can double-book. If you tighten this, add the same
re-validation to the public path.
12. **The two booking flows diverge** (§9.1): authenticated bookings are real `appointment`
events (`_prepare_calendar_event_values`); public bookings are raw `calendar.event`
rows. Reporting/automation that assumes every booking is an `appointment.type` booking
will miss public ones. Client-side changes must be made twice (asset file **and** the
inline script in `public_booking.xml`).
13. **`public_booking_page` references `today` but the controller never passes it.** The
template has `t-att-min="today"` on the date picker, yet
`PortalSchedule.public_booking_page()`'s values dict omits `today`. Either the website
render context happens to supply it or the `min` is silently empty (no past-date guard on
the public picker). **Verify / fix** by passing `today` from the controller if you touch
this page. (The authenticated book page correctly uses `now.strftime('%Y-%m-%d')`.)
14. **Public pages ignore the brand gradient.** They hardcode the default green/blue; only
the authenticated portal pages pick up `fusion_claims.portal_gradient_*`. If branding
must reach the public booking page, thread `portal_gradient` through
`public_booking_page` / `public_manage_page` values.
---
## 15. Deployment & history
- Built in **Cursor**; now maintained in Claude Code.
- Lives wherever **`fusion_portal`** lives (the authorizer/sales-rep portal — the **Westin**
Enterprise environment per the repo `CLAUDE.md` *Westin Prod* section). **Verify the
current target before shipping** — there's no in-module deploy note and nothing else
depends on it.
- Notable recent commits touching it:
- `f1cea2fb` — fix: stop archiving valid events on MS `@removed=changed` (the §14.2 bug).
- `747c8142``fusion_portal` renamed from `fusion_authorizer_portal` (this module's
`depends`/tile `inherit_id` already reference the **new** name `fusion_portal`).
- **Renaming the technical name** would require the full DB-rename procedure in repo rule #16
(it's a `fusion_*` module with external IDs, view keys, and a cron baked into the DB).
---
## 16. Audit findings — confirmed bugs, gaps & risks (2026-06-03 deep dive)
These were found by reading the code, not by running it. None are fixed yet — they're
recorded so the next change can address (or consciously accept) them. **The slot `datetime`
emitted by `_generate_available_slots` is UTC** (line 520: `slot_start_utc.strftime(...)`);
hold that fact while reading #1.
### 🔴 Bugs
1. **Timezone double-conversion on 3 of the 4 booking write-paths.** The slot's hidden
`datetime` is **UTC**, but only the authenticated *booking* path consumes it as UTC:
-`schedule_book_submit` (`portal_schedule.py:661`) — `datetime.strptime(...)` used
directly as UTC. **Correct.**
-`schedule_event_reschedule` (`:801803`)
-`public_book_submit` (`:15051507`)
-`public_manage_reschedule` (`:889891`)
The three ❌ paths do `tz.localize(naive).astimezone(utc)` — i.e. they treat an
already-UTC string as *local* and convert **again**, shifting the appointment by the
user's UTC offset. It is **silent when the resolved tz is UTC** (UTC server, no `tz`
cookie / `user.tz`), which is why it can pass casual testing — but with the
`tz`-cookie set by fusion_portal (e.g. `America/Toronto`, §4.5) a reschedule or **any**
public booking lands 45 h off. **Fix:** in those three paths, treat the slot string as
UTC exactly like `schedule_book_submit` (drop the `localize`/`astimezone`).
2. **Google pull is coupled to the server's OS timezone.** In
`_google_event_to_odoo_vals` (`fusion_calendar_account.py:530`):
`start_dt.astimezone(tz=None).replace(tzinfo=None)``astimezone(None)` converts an
aware datetime to the **system local** zone, not UTC. Odoo stores naive **UTC**, so
pulled Google events are correct **only if the container runs UTC**. The Microsoft path
parses as naive-UTC and is fine. **Fix:** `.astimezone(pytz.utc).replace(tzinfo=None)`.
3. **Public booking does not re-validate the slot** (`public_book_submit`) — see §14.11.
Combined with #1 it means the public path can both mis-time *and* double-book.
### 🟠 Gaps between documented intent and implementation
4. **"Busy on one, blocked on all" is enforced at *portal-booking time*, not by syncing
events between external calendars.** `_cross_calendar_push` **skips any event that
already has a link** (`if existing_links: continue`), and every *pulled* event has a
link — so a Google event is **never** pushed into the user's Outlook (and vice-versa).
What actually delivers "blocked on all" is `_generate_available_slots`, which searches
**all** of the user's `calendar.event` rows (everything pulled from every calendar) when
computing free slots — so booking **through `/my/schedule`** respects every connected
calendar. Booking *directly* in Google will not block Outlook. `_cross_calendar_push`
only mirrors **Odoo-native** events to the **first** active account. The manifest's
"busy on one, blocked on all" oversells the cross-external behaviour — state it as
*portal-booking-time* blocking.
### 🟡 Risks / abuse vectors
5. **Slug generation can block user creation.** `res.users.create` sets
`x_fc_schedule_slug` for **every** new user, guarded by `UNIQUE(x_fc_schedule_slug)`. The
4-hex suffix gives 1/65536 collision odds per name-base; a collision raises the
constraint and **fails the whole user-creation transaction** (no retry). Low probability,
high blast radius — consider a retry/uniqueness loop if user-creation volume grows.
6. **Unthrottled public booking.** `/schedule/<slug>/book` creates a `res.partner`, a
`calendar.event`, and force-sends an email for any visitor with **no captcha / rate
limit**. A scripted abuser can spam partners + events + outbound mail. Consider a
throttle / honeypot if the slug links are widely shared.
7. **Synchronous external HTTP inside `calendar.event.write()/unlink()`.** Because
fusion_schedule is the **sole** `calendar.event` extender (verified — see below), its
overrides fire for **every** event in the system. For a *linked* event, a write that
touches a sync field makes a **blocking** Google/Microsoft API call inside the caller's
transaction; a bulk write/delete over many linked events ⇒ N serial HTTP round-trips,
potentially stalling that request/transaction. Keep this in mind before bulk-editing
calendar events in any module.
### 🔬 Deep-dive #5 additions — sync-dedup cluster + public-endpoint security
Found by an adversarial re-read (all verified against code). Full detail + fixes in Supabase
`fusionapps.issues` (project Fusion Schedule). The **dedup cluster (810) is the most serious
— it corrupts data across users**:
8. **🔴 `_find_existing_event` merges events across users + resurrects archived ones.**
`fusion_calendar_account.py:401-417` dedups by **name+start+stop only**, on `.sudo()`
(record rules bypassed), **unscoped** by user/partner/company. Two staff with a same-titled
same-time event (Standup, Lunch, an org-wide invite) → user B's sync **reuses user A's
`calendar.event`** and links B's account onto it; also **reactivates a deliberately-archived
event**. Runs as root in cron → crosses companies. Fix: scope to
`partner_ids in [self.x_fc_user_id.partner_id]` + `x_fc_source_account_id in [self.id, False]`;
never auto-reactivate an event with no surviving link to this account.
9. **🔴 iCalUID cross-link is unscoped.** `fusion_calendar_account.py:482-489` (Google) /
`715-724` (MS) match `x_fc_universal_id` across **all** accounts/users. A real invite sent to
two staff shares one iCalUID → user B's account links onto user A's event; B never gets their
own row. Fix: scope the lookup to `x_fc_account_id.x_fc_user_id = self.x_fc_user_id.id`.
10. **🔴 No per-row isolation in the sync loop.** `_sync_pull_google/_microsoft` loop
`_process_*_event` with no savepoint and write `sync_token` **after** the loop. One row
exception (e.g. an IntegrityError — `_upsert_event_link` branches on `(account,event_id)` at
`:419-445` but the UNIQUE is `(account,external_id)` at `fusion_calendar_event_link.py:32`)
rolls back the whole page and **never advances `sync_token`** → deterministic errors wedge
the account forever. Fix: `with self.env.cr.savepoint():` per row; branch the upsert on
`(account, external_id)`.
11. **🔴 MS delta page-cap stalls large calendars.** `_sync_pull_microsoft` caps at 2000/5000
and `break`s without the `@odata.deltaLink` (`:601-606`), writing back the old token → a
>2000-event window re-fetches the same 2000 forever and never delivers the rest. The
410/`fullSyncRequired` recursion (`:318-321`, `:588-591`) has **no depth guard**.
12. **🟡 Public booking mutates/attaches an existing partner by email.**
`public_book_submit` (`portal_schedule.py:1516-1525`) does
`Partner.search([('email','=ilike', visitor_email)])` then writes `phone` onto the match and
attaches it as an attendee. An anonymous visitor can pollute an arbitrary contact (incl.
staff), pull internal partners into an event, and mail arbitrary addresses. Fix: on the
public path, never mutate/attach a partner matched only by attacker-supplied email.
13. **🟡 Manage-token leaks via redirect URL + no re-validation + no throttle.** The success
redirect puts the 32-char bearer token in an in-page URL query string
(`portal_schedule.py:1590-1594`) → leaks via history + `Referer` to Google Maps assets.
`public_manage_reschedule` (`:876-903`) also skips slot re-validation; public routes are
unthrottled. (Token entropy itself is fine.) Fix: keep the token in the emailed link only,
add `Referrer-Policy: no-referrer`, re-validate, throttle.
14. **🟡 `sync_current_user` commits mid-loop** (`:1097`) — non-atomic inside an interactive
RPC; reports `{success: False}` after already persisting earlier accounts.
15. **🟡 Dead imports** trip pyflakes: `import secrets` (`calendar_event.py:4`) and
`import hashlib` (`controllers/portal_schedule.py:4`) are unused. (`res_users.py` is fine —
it uses `uuid`.)
> Refinement to #4: `_cross_calendar_push` is **also** gated by `len(user_accounts) > 1`
> (`:1149`), so **single-account users never get their Odoo-native events pushed out at all**,
> and the `start >= now-1d` filter excludes all-day events. So even the portal-side mirroring is
> partial.
### 🧱 Deep-dive #6 — install / render / Odoo-19-API correctness (the AI-codegen layer)
**Clean meta-result:** a grep for every repo-documented Odoo-19 anti-pattern came back empty —
no `type="json"`, `groups_id`, `_sql_constraints`, `numbercall`, `useService('rpc')`,
`category_id`, `fields.Date` in settings, or SCSS `@import`; `models.Constraint`/`models.Index`,
`@api.model_create_multi`, the OWL import path, and route types are all **correct** Odoo 19. So
the AI (Cursor + Claude 4.5 Opus) got the *syntax/idioms* right; the defects are semantic
(logic/integration/tz), plus these render/version items:
16. **🟡 `today` undefined on the public booking page.** `public_booking.xml:79`
(`t-att-min="today"`) but `public_booking_page` (`:1418-1426`) never passes `today`
(the authenticated page correctly passes `now`). At minimum the public date picker loses its
min-date guard (visitor can pick a past date → server returns 0 slots). **Confirm on Odoo 19
whether QWeb omits the attr or 500s** — the public page looks untested. Copy-paste drift.
17. **🟡 Confirmation email renders UTC times + wrong language.** `mail_template_data.xml`
`t-out object.start/stop` with the `datetime` widget renders in the **renderer's tz** (UTC on
`force_send` from a portal request) → email shows UTC, not the client's local time. And
`lang = {{ object.partner_ids[:1].lang }}` picks the **first** partner = the **staff** user,
not the client. (Mail body is otherwise rule-17-safe — no `url_encode`/undefined names;
`res.company.website` + `get_base_url()` resolve.)
18. **🟡 Address-autocomplete drift.** The asset JS stores province as full name
(`portal_schedule_booking.js:546`, `long_name` → "Ontario"); the public inline JS stores the
2-letter code (`public_booking.xml:318`, `short_name` → "ON"). Same field, two formats. The
asset version also omits the Places `fields:[...]` filter → Google all-fields billing tier.
19. **🟠 `_prepare_calendar_event_values` signature unverified.** `portal_schedule.py:717-730`
calls this **private Enterprise** method (signature shifted across 16→19). A mismatched kwarg
raises `TypeError`, swallowed by the `except` at `:766` → **authenticated bookings silently
never get created**. The public path builds vals by hand (a tell). **Needs a booking
smoke-test on Enterprise** — couldn't byte-verify (Docker/Odoo source unreachable).
**Version-fragility notes (work now, but verify on Odoo point-upgrades — not logged as bugs):**
- The backend patch xpaths `//div[@id='header_synchronization_settings']`
(`fusion_calendar_controller.xml:10,15`) against `calendar.AttendeeCalendarController`. It
resolves on the deployed version (else the *entire* `web.assets_backend` bundle would be dead),
but a future Odoo restructure of that template would brick the bundle. Prefer a stabler
selector when next touched.
- The `appointment.invite` seed (`appointment_invite_data.xml:8`) has empty
`appointment_type_ids` **and** no `staff_user_ids`, so `schedule_page`'s `share_url`
(`invite.book_url`) never resolves for anyone — the seed is inert (the `/schedule/<slug>` flow
is the real share). Reconcile or drop it.
### ✅ Audit results that came back clean (good to know)
- **No `x_fc_*` field-name collisions.** None of `x_fc_schedule_slug / _booking_enabled /
_work_start / _work_end / _break_start / _travel_buffer / _home_address / _home_lat`
appears in any other module.
- **`calendar.event` is inherited by `fusion_schedule` alone** (whole repo). Its
`write/unlink` overrides are the only custom hooks on that model — but they run for every
calendar event once installed (see risk #7).
- **No conflicting `res.users.create()` override in the dependency chain.** `fusion_portal`
only overrides `_generate_tutorial_articles` / `portal.wizard.user`; `fusion_tasks` adds
`x_fc_is_field_staff / x_fc_start_address / x_fc_tech_sync_id` (no `create`, no overlap).
So the `@api.model_create_multi create()` slug hook chains cleanly via `super()`.
---
## 17. File index
```
fusion_schedule/
├── __manifest__.py # deps, data load order, assets (v19.0.2.1.0)
├── controllers/portal_schedule.py # ALL routes + slot gen + travel + AI + OAuth (~1600 lines)
├── models/
│ ├── fusion_calendar_account.py # OAuth + sync engine (the core)
│ ├── fusion_calendar_event_link.py # Odoo↔external join (unique per account)
│ ├── calendar_event.py # inherit: source/links/manage-token/travel + write/unlink push
│ ├── res_users.py # inherit: slug, booking flag, work prefs, auto-slug
│ └── res_config_settings.py # OAuth creds + sync interval + schedule defaults
├── data/
│ ├── ir_cron_data.xml # 5-min sync cron
│ ├── mail_template_data.xml # booking confirmation email (NOT noupdate)
│ └── appointment_invite_data.xml # default share invite (noupdate, empty types)
├── security/{security.xml, ir.model.access.csv}
├── views/
│ ├── fusion_calendar_account_views.xml # backend list/form + Technical menu
│ ├── res_config_settings_views.xml # Settings app block
│ ├── portal_schedule_tile.xml # tile into fusion_portal.portal_my_home_authorizer
│ ├── portal_schedule.xml # portal_schedule_page + portal_schedule_book
│ └── public_booking.xml # public_booking_page + public_manage_page
├── static/src/
│ ├── css/portal_schedule.css
│ ├── js/portal_schedule_booking.js # booking form + Places autocomplete + AI suggest
│ ├── js/portal_schedule_accounts.js # dashboard modals/toasts + optimize
│ └── views/fusion_calendar_controller.{js,xml} # backend calendar patch
├── utils/__init__.py # empty placeholder
└── graphify-out/ # Cursor code-graph artifact — NOT loaded by Odoo
```

386
fusion_schedule/CODE_MAP.md Normal file
View File

@@ -0,0 +1,386 @@
# fusion_schedule — CODE MAP (where-is-what index)
> Precise symbol-level index for the whole module. Companion to `CLAUDE.md` (which is the
> narrative/guidance doc). **This file = "where is X".** Line numbers are exact at the time of
> writing (2026-06-03); re-grep `def `/`fields.`/`@http.route`/`<template id=` if they drift.
> Audit findings live in `CLAUDE.md §16` and in Supabase `fusionapps.issues`
> (project **Fusion Schedule** = `576de219-57e6-4596-8c8c-0c093e4cb54a`).
## 0. File tree (sizes approximate, by cat -n)
```
fusion_schedule/
├── __manifest__.py 61 deps, data load order, assets (v19.0.2.1.0)
├── __init__.py 3 → controllers, models
├── controllers/
│ ├── __init__.py 2 → portal_schedule
│ └── portal_schedule.py ~1607 PortalSchedule(CustomerPortal): 23 routes + helpers
├── models/
│ ├── __init__.py 6 load order (see below)
│ ├── fusion_calendar_account.py ~1191 sync engine + OAuth + cron (THE core)
│ ├── fusion_calendar_event_link.py 30 Odoo↔external join table
│ ├── calendar_event.py 89 inherit: sync fields + write/unlink push
│ ├── res_users.py 69 inherit: slug + work prefs + auto-slug create()
│ └── res_config_settings.py 74 inherit: OAuth creds + sync interval + defaults
├── data/
│ ├── ir_cron_data.xml 13 5-min sync cron
│ ├── mail_template_data.xml 155 booking confirmation email
│ └── appointment_invite_data.xml 10 default share invite (noupdate)
├── security/
│ ├── security.xml 17 2 record rules
│ └── ir.model.access.csv 5 4 ACL rows
├── views/
│ ├── fusion_calendar_account_views.xml 64 backend list/form/action/menu
│ ├── res_config_settings_views.xml 148 Settings app block
│ ├── portal_schedule_tile.xml 25 tile into fusion_portal home
│ ├── portal_schedule.xml 833 portal_schedule_page + portal_schedule_book
│ └── public_booking.xml 586 public_booking_page + public_manage_page (inline JS)
├── static/src/
│ ├── css/portal_schedule.css 48 responsive helpers only
│ ├── js/portal_schedule_booking.js ~575 booking form (authenticated)
│ ├── js/portal_schedule_accounts.js ~489 dashboard modals/toasts/optimize
│ └── views/fusion_calendar_controller.{js,xml} 68/44 backend AttendeeCalendarController patch
├── utils/__init__.py 1 empty placeholder
└── graphify-out/ — Cursor artifact, NOT loaded by Odoo
```
Model load order (`models/__init__.py`): `fusion_calendar_account → fusion_calendar_event_link
→ calendar_event → res_users → res_config_settings`.
---
## 1. Models
### 1.1 `fusion.calendar.account` — `models/fusion_calendar_account.py`
`_order = 'x_fc_provider, x_fc_email'`. Module constants (top of file): `TIMEOUT=20` (14),
`MAX_THROTTLE_RETRIES=3` (15), `DEFAULT_RETRY_SECONDS=10` (16); Google endpoints 1923,
Microsoft endpoints 2634.
**Fields**
| line | field | type / notes |
|---|---|---|
| 42 | `x_fc_user_id` | m2o res.users · required · cascade · default=current user · index |
| 46 | `x_fc_provider` | sel google/microsoft · required |
| 50 | `x_fc_email` | char |
| 51 | `x_fc_name` | char · compute `_compute_name` · store |
| 52 | `x_fc_active` | bool · default True |
| 55 | `x_fc_rtoken` | char · **groups=base.group_system** |
| 56 | `x_fc_token` | char · **group_system** |
| 57 | `x_fc_token_validity` | datetime · **group_system** |
| 60 | `x_fc_sync_token` | char · **group_system** (delta/sync token) |
| 61 | `x_fc_calendar_id` | char · default `'primary'` |
| 62 | `x_fc_last_sync` | datetime |
| 63 | `x_fc_sync_status` | sel active/error/paused · default active |
| 68 | `x_fc_error_message` | text |
| 71 | `x_fc_link_ids` | o2m → fusion.calendar.event.link |
**Methods**
| line | method | purpose |
|---|---|---|
| 76 | `_compute_name` | "Provider — email" label |
| 85/92/99/106 | `_get_{google,microsoft}_client_{id,secret}` | creds: `fusion_schedule_*` ICP → native `*_calendar_*` ICP fallback |
| 117 | `_get_valid_token` | return token, refresh if <1 min to expiry |
| 130 | `_refresh_token` | dispatch to provider refresh; on 400/401 mark error + clear |
| 149 / 170 | `_refresh_google_token` / `_refresh_microsoft_token` | OAuth refresh (MS may rotate rtoken) |
| 200 / 213 | `_exchange_{google,microsoft}_code` | code→tokens (called from callback) |
| 232 / 243 | `_fetch_{google,microsoft}_email` | `@api.model` · whoami email |
| 258 | `_sync_pull` | entry: dispatch pull per provider, catch+record errors |
| 293 | `_sync_pull_google` | events.list paging; 410→drop token+full resync; window 14/+30d |
| 362 | `_google_request_with_retry` | GET w/ 429/503 + connection retry |
| 389 | `_silent_ctx` | context flags that suppress mail + re-push (load-bearing) |
| 401 | `_find_existing_event` | dedup by name+start+stop (incl. archived) |
| 419 | `_upsert_event_link` | create/update the join row |
| 447 | `_process_google_event` | upsert one Google event (3-tier dedup) |
| 503 | `_google_event_to_odoo_vals` | ⚠ uses `astimezone(None)` — server-tz bug (CLAUDE §16.2) |
| 550 | `_sync_pull_microsoft` | Graph `calendarView/delta`; page cap 2000/5000 |
| 642 | `_microsoft_request_with_retry` | GET w/ retry |
| 671 | `_process_microsoft_event` | upsert one MS event; `@removed=changed``'skipped'` (don't archive) |
| 738 | `_microsoft_event_to_odoo_vals` | MS dict→Odoo vals |
| 798 | `_fetch_microsoft_event_subject` | fallback fetch when delta omits subject |
| 821 | `_sync_push_event` | push one Odoo event (insert/patch per provider) |
| 870/884/896 | `_google_{insert,patch,delete}_event` | Google write API |
| 908/924/938 | `_microsoft_{insert,patch,delete}_event` | Graph write API |
| 953 / 977 | `_odoo_event_to_{google,microsoft}` | Odoo→external format |
| 1022 | `_cross_calendar_push` | push **unlinked Odoo-native** events to **first** account (CLAUDE §16.4) |
| 1066 | `get_user_accounts_status` | `@api.model` **[backend RPC]** — account chips |
| 1081 | `sync_current_user` | `@api.model` **[backend RPC]** — "Sync now" (commits per account) |
| 1116 | `_cron_sync_all_accounts` | `@api.model` **[cron]** — sync all, then cross-push per multi-acct user |
| 1161 | `action_disconnect` | delete pushed external events, unlink, pause |
### 1.2 `fusion.calendar.event.link` — `models/fusion_calendar_event_link.py`
`_order = 'x_fc_last_synced desc'`. Fields: `x_fc_event_id` (11, m2o calendar.event, cascade),
`x_fc_account_id` (15, m2o account, cascade), `x_fc_external_id` (19, req, index),
`x_fc_universal_id` (22, iCalUID, index), `x_fc_last_synced` (25), `x_fc_sync_direction`
(26, pull/push/both). **Constraint** `_unique_account_external` = `UNIQUE(x_fc_account_id,
x_fc_external_id)` (32).
### 1.3 `calendar.event` (inherit) — `models/calendar_event.py`
**Sole extender of `calendar.event` in the whole repo.** Fields: `x_fc_source_account_id`
(14), `x_fc_is_external` (18, compute+store), `x_fc_link_ids` (21), `x_fc_manage_token`
(24, index, copy=False), `x_fc_client_email` (28), `x_fc_client_phone` (29),
`x_fc_address_lat` (30), `x_fc_address_lng` (31), `x_fc_travel_minutes_before` (32),
`x_fc_is_travel_block` (36). Methods: `_compute_is_external` (42), `_skip_fc_sync` (46),
`unlink` (51, deletes from external), `write` (76, pushes to external) — both gated by
`_skip_fc_sync()` + presence of links. ⚠ external HTTP is synchronous (CLAUDE §16.7).
### 1.4 `res.users` (inherit) — `models/res_users.py`
Fields: `x_fc_calendar_account_ids` (12), `x_fc_schedule_slug` (16), `x_fc_booking_enabled`
(21), `x_fc_work_start` (26), `x_fc_work_end` (30), `x_fc_break_start` (34),
`x_fc_break_duration` (38), `x_fc_travel_buffer` (42), `x_fc_home_address` (46),
`x_fc_home_lat` (50), `x_fc_home_lng` (51). **Constraint** `_unique_schedule_slug` =
`UNIQUE(x_fc_schedule_slug)` (53). Methods: `create` (59, `@api.model_create_multi`,
auto-slug — ⚠ collision risk CLAUDE §16.5), `_generate_schedule_slug` (66).
### 1.5 `res.config.settings` (inherit) — `models/res_config_settings.py`
Fields (12): `x_fc_google_client_id` (10), `_secret` (14), `_has_fallback` (18);
`x_fc_microsoft_client_id` (24), `_secret` (28), `_has_fallback` (32);
`x_fc_sync_interval_minutes` (38, **not wired to cron**); `x_fc_default_work_start` (45),
`_work_end` (50), `_break_start` (55), `_break_duration` (60), `_travel_buffer` (65).
Methods: `_compute_google_has_fallback` (72), `_compute_microsoft_has_fallback` (79).
---
## 2. Controller — `controllers/portal_schedule.py` (`PortalSchedule(CustomerPortal)`)
**Helper methods**
| line | method | purpose |
|---|---|---|
| 30 | `_get_schedule_values` | portal gradient (fusion_claims params) + maps key |
| 43 | `_get_user_timezone` | → `_resolve_timezone(env.user)` |
| 46 | `_resolve_timezone` | user.tz → `tz` cookie → company cal → UTC |
| 69 | `_get_appointment_types` | types where current user is staff |
| 75 | `_get_user_prefs` | per-user prefs w/ company-default fallback |
| 101 | `_get_maps_api_key` | `fusion.api.service``fusion_claims.google_maps_api_key` |
| 114 | `_call_ai` | `fusion.api.service.call_openai` → direct OpenAI HTTP |
| 147 | `_get_travel_time` | Google Distance Matrix (min) |
| 178 | `_geocode_address` | Google Geocoding (lat,lng) |
| 200 | `_create_travel_blocks` | insert "Travel to …" placeholder events |
| 425 | `_format_hour` | staticmethod · 13.5 → "1:30 PM" |
| 435 | `_generate_available_slots` | **shared slot core**; emits UTC `datetime` (line 520) |
| 825 | `_get_event_by_token` | manage-token lookup (len==32) |
| 932 | `_build_schedule_context` | AI prompt context builder |
| 1336 | `_find_recently_connected_account` | OAuth callback resilience |
**Routes** (23 total)
| line | verb | path | auth | handler |
|---|---|---|---|---|
| 288 | http | `/my/schedule` | user | `schedule_page` |
| 363 | jsonrpc | `/my/schedule/preferences` | user | `schedule_save_preferences` |
| 397 | http | `/my/schedule/book` | user | `schedule_book` |
| 530 | jsonrpc | `/my/schedule/available-slots` | user | `schedule_available_slots` |
| 560 | jsonrpc | `/my/schedule/week-events` | user | `schedule_week_events` |
| 630 | http POST | `/my/schedule/book/submit` | user | `schedule_book_submit` ✅ tz-correct |
| 777 | jsonrpc | `/my/schedule/event/cancel` | user | `schedule_event_cancel` |
| 792 | jsonrpc | `/my/schedule/event/reschedule` | user | `schedule_event_reschedule` ⚠ tz-bug |
| 834 | http | `/schedule/manage/<token>` | public | `public_manage_page` |
| 860 | http POST | `/schedule/manage/<token>/cancel` | public | `public_manage_cancel` |
| 876 | http POST | `/schedule/manage/<token>/reschedule` | public | `public_manage_reschedule` ⚠ tz-bug |
| 907 | jsonrpc | `/schedule/manage/<token>/available-slots` | public (csrf=False) | `public_manage_slots` |
| 982 | jsonrpc | `/my/schedule/ai/suggest` | user | `schedule_ai_suggest` |
| 1093 | jsonrpc | `/my/schedule/ai/optimize` | user | `schedule_ai_optimize` |
| 1155 | http | `/my/schedule/connect/google` | user | `connect_google` |
| 1192 | http | `/my/schedule/connect/microsoft` | user | `connect_microsoft` |
| 1230 | http | `/my/schedule/oauth/callback` | user | `oauth_callback` |
| 1356 | jsonrpc | `/my/schedule/disconnect` | user | `schedule_disconnect` |
| 1370 | jsonrpc | `/my/schedule/sync-now` | user | `schedule_sync_now` |
| 1398 | http | `/schedule/<slug>` | public | `public_booking_page` |
| 1431 | jsonrpc | `/schedule/<slug>/available-slots` | public (csrf=False) | `public_available_slots` |
| 1465 | http POST | `/schedule/<slug>/book` | public (csrf) | `public_book_submit` ⚠ tz-bug + no re-validate |
| 1602 | jsonrpc | `/my/schedule/toggle-booking` | user | `schedule_toggle_booking` |
---
## 3. Frontend JS
### 3.1 backend patch — `static/src/views/fusion_calendar_controller.js`
`patch(AttendeeCalendarController.prototype)`: `setup`, getters `fusionAccounts` /
`fusionSyncing`, `_loadFusionAccounts` (→ `get_user_accounts_status`), `onFusionSyncNow`
(→ `sync_current_user`). Template `.xml` hides `#header_synchronization_settings`, injects
account chips + sync button + cog→`/my/schedule`.
### 3.2 `static/src/js/portal_schedule_booking.js` (authenticated book page)
`setTzCookie` (4), `getAppointmentTypeId` (35), `truncate` (41), `formatDateStr` (46),
`addDays` (53), `getMonday` (59), `selectDay` (67), `fetchWeekEvents` (77) →
`/my/schedule/week-events`, `navigateWeek` (120), `renderWeekCalendar` (140), `fetchSlots`
(260) → `/my/schedule/available-slots`, `renderGroup` (319, nested), `fetchAiSuggestions`
(364) → `/my/schedule/ai/suggest`, `setupAddressAutocomplete` (516, Google Places).
### 3.3 `static/src/js/portal_schedule_accounts.js` (dashboard)
Utils: `localDateStr` (4), `setTzCookie` (12), `jsonRpc` (21), `fusionConfirm` (30),
`fusionToast` (87, builds `#fusionToastLive` — template `#fusionToast` is dead),
`closeRescheduleModal` (304), `closeOptimizeModal` (474). Event bindings: disconnect (112)
`/disconnect`, sync (141) → `/sync-now`, share (160), save-prefs (186) → `/preferences`,
cancel (231) → `/event/cancel`, reschedule open (274) + date-change (321) +
confirm (375) → `/event/reschedule`, optimize (413) → `/ai/optimize`.
> Public pages (`public_booking_page`, `public_manage_page`) carry their **own inline
> `<script>`** in `public_booking.xml` (a 2nd copy of slot-render + Places autocomplete +
> reschedule) — they do **not** use the files above. See CLAUDE §9.1.
### 3.4 DOM-id contract (templates ↔ JS)
Book page: `bookingDate`, `appointmentTypeSelect`, `slotsContainer/slotsGrid/slotsLoading/
noSlots`, `slotDatetime`, `slotDuration`, `weekCalendar{Container,Loading,Grid,Header,Body,
Empty,Nav}`, `btnPrevWeek/btnNextWeek/weekRangeLabel`, `aiSuggest{Section,Loading,Grid}`,
`btnAiSuggest`, `clientStreet/City/Province/Postal/Lat/Lng`, `btnSubmitBooking`.
Dashboard: `fusionConfirmModal`(+Title/Message/Ok), `rescheduleModal`(+Date/SlotsContainer/
SlotsGrid/EventId/SlotDatetime/EventDuration/EventName/AppTypeId/btnConfirmReschedule),
`optimizeModal`(+Loading/Result/CurrentTravel/NewTravel/Savings/ScheduleList/Error),
`schedulePrefsForm`/`btnSavePrefs`/`prefsSavedMsg`, `btnOptimizeSchedule`, `.js-*` classes.
Public: `publicBookingDate`, `publicSlots*`, `publicSlotDatetime/Duration`, `publicBtnSubmit`,
`publicAppointmentType`, `publicClient*`, `publicReschedule*`.
---
## 4. Templates / data / security / settings
**Templates**
| id | file:line | base layout |
|---|---|---|
| `portal_schedule_page` | portal_schedule.xml:6 | `portal.portal_layout` |
| `portal_schedule_book` | portal_schedule.xml:605 | `portal.portal_layout` |
| `public_booking_page` | public_booking.xml:6 | `website.layout` (+inline JS) |
| `public_manage_page` | public_booking.xml:345 | `website.layout` (+inline JS) |
| `portal_my_home_schedule` | portal_schedule_tile.xml:5 | inherit `fusion_portal.portal_my_home_authorizer` |
| `FusionCalendarController` | fusion_calendar_controller.xml | t-inherit `calendar.AttendeeCalendarController` |
| `res_config_settings_view_form_fusion_schedule` | res_config_settings_views.xml:4 | inherit `base.res_config_settings_view_form` |
**Backend views**`fusion_calendar_account_views.xml`: list (5), form (24),
`action_fusion_calendar_account` (56), `menu_fusion_calendar_account` (63, under
`base.menu_custom`).
**Data**`ir_cron_fusion_calendar_sync` (ir_cron_data.xml:4, 5 min, `_cron_sync_all_accounts`);
`fusion_schedule_booking_confirmation` (mail_template_data.xml:4, model `calendar.event`,
NOT noupdate); `default_appointment_invite` (appointment_invite_data.xml:8, noupdate,
short_code `book-appointment`, empty types).
**Security** — rules `fusion_calendar_account_user_rule` (security.xml:5),
`fusion_calendar_event_link_user_rule` (security.xml:13); ACL: 4 rows in
`ir.model.access.csv` (account: CRUD user / none public; link: CRU user / full system).
---
## 5. Config parameters (`ir.config_parameter`)
**Owned**`fusion_schedule_google_client_id`, `_google_client_secret`,
`fusion_schedule_microsoft_client_id`, `_microsoft_client_secret`,
`fusion_schedule_sync_interval`; `fusion_schedule.default_work_start` / `_work_end` /
`_break_start` / `_break_duration` / `_travel_buffer`.
**Fallback (not owned)**`google_calendar_client_id` / `_secret`,
`microsoft_calendar_client_id` / `_secret`, `web.base.url`; and the fusion_claims namespace
`fusion_claims.portal_gradient_start/_mid/_end`, `fusion_claims.google_maps_api_key`,
`fusion_claims.ai_api_key`.
---
## 6. External HTTP it talks to
- **Google** OAuth (`accounts.google.com/o/oauth2/auth`, `oauth2.googleapis.com/token`),
Calendar v3 (`googleapis.com/calendar/v3`), userinfo, Distance Matrix + Geocoding
(`maps.googleapis.com`). Scopes: `calendar` + `userinfo.email`.
- **Microsoft** OAuth (`login.microsoftonline.com/common/oauth2/v2.0/*`), Graph
(`graph.microsoft.com/v1.0``me/calendarView/delta`, `me/events`, `me`). Scopes:
`offline_access openid Calendars.ReadWrite User.Read`.
- **OpenAI** `api.openai.com/v1/chat/completions` (`gpt-4o-mini`) — fallback only.
---
## 7. Cross-module touchpoints (full detail in CLAUDE §4)
| direction | what | where |
|---|---|---|
| depends ↓ | `fusion_portal` (→ fusion_claims stack) | __manifest__.py:35 |
| inherit ↓ | `fusion_portal.portal_my_home_authorizer` | portal_schedule_tile.xml:6 |
| soft-call ↓ | `fusion.api.service` (fusion_api) | portal_schedule.py:104,117 |
| ICP read ↓ | `fusion_claims.{portal_gradient_*,google_maps_api_key,ai_api_key}` | portal_schedule.py:33-35,111,126 |
| cookie ← | `tz` set by `fusion_portal/.../timezone_detect.js` | portal_schedule.py:_resolve_timezone |
| shared table | `calendar.event` (also written by fusion_claims schedule wizard / appointment) | models/calendar_event.py |
| reverse | **none** (only fusion_repairs lists it as *deferred*) | — |
---
## 8. Audit cross-reference
**19 findings** logged → Supabase `fusionapps.issues`, project **Fusion Schedule**
(`576de219-57e6-4596-8c8c-0c093e4cb54a`), all `status='open'`. Detail + fixes in
`CLAUDE.md §16` (deep dives #1#6). Provenance: AI-generated (Cursor + Claude 4.5 Opus) —
Odoo-19 syntax clean, bugs are semantic. Headlines: (a) timezone double-conversion on `schedule_event_reschedule` /
`public_book_submit` / `public_manage_reschedule` (slot string is UTC but they re-localize);
(b) the **sync-dedup cluster**`_find_existing_event` (`:401`) and the iCalUID lookup
(`:482`/`:715`) are unscoped by user, so same-titled / shared-invite events **merge across
different users** and resurrect archived ones; (c) public booking mutates an existing
`res.partner` by attacker-supplied email.
---
## 9. Consumed contracts — the OTHER side of each cross-module link (integration boundary)
### 9.1 `fusion.api.service` broker (`fusion_api`, **not a manifest dep**)
`request.env['fusion.api.service']`**`KeyError` if `fusion_api` absent** (caught by
fusion_schedule's bare `except` → fallback). 7 models: `fusion.api.service` (AbstractModel,
broker), `fusion.api.{provider,key,consumer,access,usage,user.limit}` + `usage.daily`.
Public methods fusion_schedule uses: `get_api_key(provider_type, consumer, feature)`
`api_service.py:394`; `call_openai(consumer, feature, messages, model)``:278`. **Raises
`UserError` on 14 conditions** (no active provider `:62`; consumer disabled `:129`; access
disabled `:141`; monthly/daily budget `:157/167`; rpm/rpd `:185/194`; user blocked/budget/rpd
`:218/224/234`; no key `:81`; package missing `:280/335`; downstream API error `:319/381`) —
**any** of these (or KeyError) triggers fusion_schedule's ICP fallback. `provider_type` enum:
`openai, anthropic, google_maps, google_oauth, microsoft_oauth, twilio, custom`. Consumer
auto-registers when `fusion_api.auto_detect_consumers` (default True).
> ⚠ **`get_api_key` returns `key.api_key` (a `group_admin`-gated field) on a *non-sudo*
> recordset (`api_service.py:407`).** For a portal/public request (non-admin/public user) this
> likely raises `AccessError` → fusion_schedule's fallback fires **every time** → in practice
> the maps key for portal/public callers comes from `fusion_claims.google_maps_api_key`, not the
> broker. The broker path may effectively never succeed for raw-key access from the portal.
### 9.2 `portal_gradient` / `fc_gradient` / the tile target (`fusion_portal`)
- `portal_gradient` computed in `fusion_portal/controllers/portal_main.py:81-87` from
`fusion_claims.portal_gradient_{start,mid,end}` (defaults `#5ba848/#3a8fb7/#2e7aad`) — **only
set for portal personas** (`is_authorizer/is_sales_rep_portal/is_client_portal/is_technician_portal`).
fusion_schedule computes its **own identical copy** in `portal_schedule.py:33-36`, so its pages
don't need the controller — only the **tile** does (via `fc_gradient`, set at
`portal_templates.xml:10` = `portal_gradient or <default>`).
- **Tile xpath fragility:** the tiles grid is `<div class="row g-3 mb-4">` (`portal_templates.xml:52-295`);
the anchor is the `/my/funding-claims` card (`:277-294`). fusion_schedule's tile xpath
(`portal_schedule_tile.xml:8`) needs **both** the `/my/funding-claims` `<a>` and the exact
`row g-3 mb-4` class triple — change either and the tile **ParseErrors at install** of
fusion_schedule.
- **`tz` cookie** set by `fusion_portal/static/src/js/timezone_detect.js:25`: name `tz`, value
raw IANA (`America/Toronto`), `path=/ max-age=31536000 SameSite=Lax`. Read at
`portal_schedule.py:_resolve_timezone` (2nd priority after `user.tz`).
### 9.3 The borrowed `fusion_claims.*` params — ownership (defaults all match)
| ICP key | owning field | file:line | default |
|---|---|---|---|
| `fusion_claims.portal_gradient_start/_mid/_end` | `fc_portal_gradient_*` | `fusion_claims/.../res_config_settings.py:461-474` | `#5ba848/#3a8fb7/#2e7aad` |
| `fusion_claims.ai_api_key` | `fc_ai_api_key` | `fusion_claims/.../res_config_settings.py:355` | empty |
| `fusion_claims.google_maps_api_key` | `fc_google_maps_api_key` | **`fusion_tasks`/.../res_config_settings.py:12-16** | empty |
> ⚠ The maps key is **owned by `fusion_tasks`, not `fusion_claims`** (the `fusion_claims.*`
> prefix is kept for data continuity). Grepping `fusion_claims/` for it finds nothing. Both
> owners are transitive deps via fusion_portal, so the params are always present.
---
## 10. Sibling scheduling surfaces & how they interact with this module
**Baseline:** fusion_schedule is the **only** `calendar.event` extender; its `write/unlink`
push to external is gated by `_skip_fc_sync()` (context `no_calendar_sync`/`dont_notify`) +
presence of links, and `_cross_calendar_push` (cron) mirrors **unlinked Odoo-native** events
(1d…+90d, on the user's partner) to the **first** account **only if the user has >1 account**.
| Writer | what it creates | interaction with fusion_schedule |
|---|---|---|
| `fusion_claims` `schedule_assessment_wizard.py:186` | 1 `calendar.event`/manual schedule (assessor partner, optional email alarm), **plain create** | eligible for cron mirror; **later edits fire the synchronous `write()` push** |
| `fusion_portal` `portal_assessment.py:1194` | 1 `calendar.event`/public booking (sales-rep partner; sets `accessibility.assessment.calendar_event_id`), **plain sudo create** | same as above |
| `fusion_tasks` `technician_task.py:1572` (`_sync_calendar_event`) | **HIGH volume** — 1 event/task, re-synced on every schedule-field write/create; **writes with `silent_ctx` (`dont_notify=True`)** | synchronous push **suppressed**; external mirror deferred to the 5-min cron. **Protection hinges on `dont_notify` staying in `silent_ctx`** — drop it and every task edit becomes an inline Google/Outlook round-trip |
- **Reverse coupling:** `fusion_tasks` slot scheduler reads `calendar.event` for busy intervals
(`technician_task.py:495-540`) and **excludes its own task-linked events**, so
externally-synced calendar entries (pulled by fusion_schedule) correctly block technician
availability.
- **Repo sweep:** only these **4** modules touch `calendar.event`/appointments; **only
fusion_schedule uses Enterprise `appointment.*`** (the others create raw `calendar.event`).
`fusion_repairs` maintenance booking is still *planned*. Stale vendored copies of the task
engine exist under `Entech Plating/` and `fusion_plating/`**not** the canonical install
path; flag for cleanup.
- **Maps key consumers:** `fusion_tasks` travel-time (`_calculate_travel_time`) and
`fusion_claims` `google_address_autocomplete.js` both read `fusion_claims.google_maps_api_key`
(owned by fusion_tasks) — same key fusion_schedule falls back to.

View File

@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import json import json
import hashlib
import logging import logging
import secrets import secrets
@@ -796,12 +795,12 @@ class PortalSchedule(CustomerPortal):
if not event.exists() or partner not in event.partner_ids: if not event.exists() or partner not in event.partner_ids:
return {'success': False, 'error': 'Event not found or access denied.'} return {'success': False, 'error': 'Event not found or access denied.'}
tz = self._get_user_timezone() # The slot datetime sent by the client is already UTC (the slot
# generator emits UTC); parse it directly — do NOT re-localize, which
# would double-shift the appointment by the user's UTC offset.
try: try:
start_naive = datetime.strptime(new_datetime, '%Y-%m-%d %H:%M:%S') start_utc = datetime.strptime(new_datetime, '%Y-%m-%d %H:%M:%S')
start_local = tz.localize(start_naive) except (ValueError, Exception):
start_utc = start_local.astimezone(pytz.utc).replace(tzinfo=None)
except (ValueError, Exception) as e:
return {'success': False, 'error': 'Invalid date/time format.'} return {'success': False, 'error': 'Invalid date/time format.'}
duration = float(new_duration) if new_duration else event.duration duration = float(new_duration) if new_duration else event.duration
@@ -883,12 +882,10 @@ class PortalSchedule(CustomerPortal):
if not slot_datetime: if not slot_datetime:
return request.redirect('/schedule/manage/%s?error=Please+select+a+new+time+slot' % token) return request.redirect('/schedule/manage/%s?error=Please+select+a+new+time+slot' % token)
tz = self._resolve_timezone(event.user_id) # The slot datetime is already UTC (the slot generator emits UTC); parse
# directly — do NOT re-localize (that double-shifts by the tz offset).
try: try:
start_naive = datetime.strptime(slot_datetime, '%Y-%m-%d %H:%M:%S') start_utc = datetime.strptime(slot_datetime, '%Y-%m-%d %H:%M:%S')
start_local = tz.localize(start_naive)
start_utc = start_local.astimezone(pytz.utc).replace(tzinfo=None)
except (ValueError, Exception): except (ValueError, Exception):
return request.redirect('/schedule/manage/%s?error=Invalid+time+slot' % token) return request.redirect('/schedule/manage/%s?error=Invalid+time+slot' % token)
@@ -1499,12 +1496,10 @@ class PortalSchedule(CustomerPortal):
'/schedule/%s?error=Name,+email,+and+time+slot+are+required' % slug '/schedule/%s?error=Name,+email,+and+time+slot+are+required' % slug
) )
tz = self._resolve_timezone(user) # The slot datetime is already UTC (the slot generator emits UTC); parse
# directly — do NOT re-localize (that double-shifts by the tz offset).
try: try:
start_dt_naive = datetime.strptime(slot_datetime, '%Y-%m-%d %H:%M:%S') start_dt_utc = datetime.strptime(slot_datetime, '%Y-%m-%d %H:%M:%S')
start_dt_local = tz.localize(start_dt_naive)
start_dt_utc = start_dt_local.astimezone(pytz.utc).replace(tzinfo=None)
except (ValueError, Exception) as e: except (ValueError, Exception) as e:
_logger.error("Failed to parse slot datetime %s: %s", slot_datetime, e) _logger.error("Failed to parse slot datetime %s: %s", slot_datetime, e)
return request.redirect('/schedule/%s?error=Invalid+time+slot' % slug) return request.redirect('/schedule/%s?error=Invalid+time+slot' % slug)
@@ -1512,17 +1507,22 @@ class PortalSchedule(CustomerPortal):
duration = float(slot_duration) duration = float(slot_duration)
stop_dt_utc = start_dt_utc + timedelta(hours=duration) stop_dt_utc = start_dt_utc + timedelta(hours=duration)
# Find or create partner for the visitor # Find or create a contact for the visitor. SECURITY: this is an
# unauthenticated endpoint and visitor_email is attacker-controlled, so
# never reuse/attach a partner that backs a login user (staff/internal),
# and never write onto an existing contact. Reuse only a plain non-user
# contact (avoids duplicates for genuine repeat visitors).
Partner = request.env['res.partner'].sudo() Partner = request.env['res.partner'].sudo()
partner = Partner.search([('email', '=ilike', visitor_email)], limit=1) partner = Partner.search([
('email', '=ilike', visitor_email),
('user_ids', '=', False),
], limit=1)
if not partner: if not partner:
partner = Partner.create({ partner = Partner.create({
'name': visitor_name, 'name': visitor_name,
'email': visitor_email, 'email': visitor_email,
'phone': visitor_phone, 'phone': visitor_phone or False,
}) })
elif visitor_phone and not partner.phone:
partner.phone = visitor_phone
address_parts = [p for p in [visitor_street, visitor_city, visitor_province, visitor_postal] if p] address_parts = [p for p in [visitor_street, visitor_city, visitor_province, visitor_postal] if p]
location = ', '.join(address_parts) location = ', '.join(address_parts)

View File

@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import logging import logging
import secrets
from odoo import api, fields, models from odoo import api, fields, models

View File

@@ -4,7 +4,7 @@ import json
import logging import logging
import time import time
import requests import requests
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from odoo import api, fields, models, _ from odoo import api, fields, models, _
from odoo.exceptions import UserError from odoo.exceptions import UserError
@@ -338,7 +338,17 @@ class FusionCalendarAccount(models.Model):
updated = 0 updated = 0
deleted = 0 deleted = 0
for event_data in all_events: for event_data in all_events:
result = self._process_google_event(event_data) # Per-row savepoint: one bad event must not abort the whole page
# (which would leave sync_token unadvanced and re-fail every cron).
try:
with self.env.cr.savepoint():
result = self._process_google_event(event_data)
except Exception as e:
_logger.warning(
"Skipping Google event %s on account %s: %s",
event_data.get('id'), self.id, e,
)
continue
if result == 'created': if result == 'created':
created += 1 created += 1
elif result == 'updated': elif result == 'updated':
@@ -409,7 +419,15 @@ class FusionCalendarAccount(models.Model):
stop_val = vals.get('stop') or vals.get('stop_date') stop_val = vals.get('stop') or vals.get('stop_date')
if not (start_val and stop_val and vals.get('name')): if not (start_val and stop_val and vals.get('name')):
return None return None
domain = [('name', '=', vals['name']), ('active', 'in', [True, False])] # Scope to THIS account's owner so a same-titled, same-time event that
# belongs to a DIFFERENT user is never merged in. Reuse only this
# account's own pulled events, or the user's native (sourceless) events.
domain = [
('name', '=', vals['name']),
('active', 'in', [True, False]),
('partner_ids', 'in', [self.x_fc_user_id.partner_id.id]),
('x_fc_source_account_id', 'in', [self.id, False]),
]
if vals.get('allday'): if vals.get('allday'):
domain += [('start_date', '=', start_val), ('stop_date', '=', stop_val)] domain += [('start_date', '=', start_val), ('stop_date', '=', stop_val)]
else: else:
@@ -417,20 +435,20 @@ class FusionCalendarAccount(models.Model):
return CalendarEvent.search(domain, limit=1) return CalendarEvent.search(domain, limit=1)
def _upsert_event_link(self, EventLink, odoo_event_id, external_id, ical_uid): def _upsert_event_link(self, EventLink, odoo_event_id, external_id, ical_uid):
"""Create or update a link between an Odoo event and an external event. """Create or update the link for this (account, external event).
If this account already has a link to the same Odoo event, update the Branches on the table's real UNIQUE key (account, external_id) so it can
external_id rather than creating a duplicate link row. Returns the never raise an IntegrityError; if the external event is already linked,
link record. re-point it at the given Odoo event. Returns the link record.
""" """
existing = EventLink.search([ existing = EventLink.search([
('x_fc_account_id', '=', self.id), ('x_fc_account_id', '=', self.id),
('x_fc_event_id', '=', odoo_event_id), ('x_fc_external_id', '=', external_id),
], limit=1) ], limit=1)
now = fields.Datetime.now() now = fields.Datetime.now()
if existing: if existing:
existing.write({ existing.write({
'x_fc_external_id': external_id, 'x_fc_event_id': odoo_event_id,
'x_fc_universal_id': ical_uid or existing.x_fc_universal_id, 'x_fc_universal_id': ical_uid or existing.x_fc_universal_id,
'x_fc_last_synced': now, 'x_fc_last_synced': now,
}) })
@@ -481,7 +499,7 @@ class FusionCalendarAccount(models.Model):
existing_link = EventLink.search([ existing_link = EventLink.search([
('x_fc_universal_id', '=', ical_uid), ('x_fc_universal_id', '=', ical_uid),
('x_fc_universal_id', '!=', False), ('x_fc_account_id.x_fc_user_id', '=', self.x_fc_user_id.id),
], limit=1) if ical_uid else None ], limit=1) if ical_uid else None
if existing_link and existing_link.x_fc_event_id: if existing_link and existing_link.x_fc_event_id:
@@ -527,8 +545,8 @@ class FusionCalendarAccount(models.Model):
start_dt = datetime.fromisoformat(start_str.replace('Z', '+00:00')) start_dt = datetime.fromisoformat(start_str.replace('Z', '+00:00'))
end_dt = datetime.fromisoformat(end_str.replace('Z', '+00:00')) end_dt = datetime.fromisoformat(end_str.replace('Z', '+00:00'))
# Convert to naive UTC for Odoo # Convert to naive UTC for Odoo
start_utc = start_dt.astimezone(tz=None).replace(tzinfo=None) if start_dt.tzinfo else start_dt start_utc = start_dt.astimezone(timezone.utc).replace(tzinfo=None) if start_dt.tzinfo else start_dt
end_utc = end_dt.astimezone(tz=None).replace(tzinfo=None) if end_dt.tzinfo else end_dt end_utc = end_dt.astimezone(timezone.utc).replace(tzinfo=None) if end_dt.tzinfo else end_dt
except (ValueError, KeyError): except (ValueError, KeyError):
return None return None
vals = { vals = {
@@ -567,10 +585,12 @@ class FusionCalendarAccount(models.Model):
MICROSOFT_GRAPH_API, MICROSOFT_SELECT_FIELDS, start_dt, end_dt, MICROSOFT_GRAPH_API, MICROSOFT_SELECT_FIELDS, start_dt, end_dt,
) )
all_events = []
next_sync_token = self.x_fc_sync_token next_sync_token = self.x_fc_sync_token
page_num = 0 page_num = 0
max_events = 5000 if self.x_fc_sync_token else 2000 created = 0
updated = 0
deleted = 0
processed = 0
while url: while url:
page_num += 1 page_num += 1
@@ -594,16 +614,28 @@ class FusionCalendarAccount(models.Model):
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
# Process each page as it arrives — no unbounded accumulation and no
# event cap that would silently drop everything past the limit. Each
# event gets its own savepoint so one bad row can't abort the page.
page_events = data.get('value', []) page_events = data.get('value', [])
all_events.extend(page_events) for event_data in page_events:
_logger.warning("MS sync account %s page %d: %d events (total %d)", self.id, page_num, len(page_events), len(all_events)) try:
with self.env.cr.savepoint():
if len(all_events) >= max_events: result = self._process_microsoft_event(event_data)
_logger.warning( except Exception as e:
"MS sync account %s: hit event limit (%d/%d), stopping fetch", _logger.warning(
self.id, len(all_events), max_events, "Skipping MS event %s on account %s: %s",
) event_data.get('id'), self.id, e,
break )
continue
if result == 'created':
created += 1
elif result == 'updated':
updated += 1
elif result == 'deleted':
deleted += 1
processed += 1
_logger.warning("MS sync account %s page %d: %d events (processed %d total)", self.id, page_num, len(page_events), processed)
url = data.get('@odata.nextLink') url = data.get('@odata.nextLink')
if not url: if not url:
@@ -611,21 +643,6 @@ class FusionCalendarAccount(models.Model):
if '$deltatoken=' in delta_link: if '$deltatoken=' in delta_link:
next_sync_token = delta_link.split('$deltatoken=')[-1] next_sync_token = delta_link.split('$deltatoken=')[-1]
_logger.warning("MS sync account %s: processing %d events...", self.id, len(all_events))
created = 0
updated = 0
deleted = 0
for i, event_data in enumerate(all_events):
result = self._process_microsoft_event(event_data)
if result == 'created':
created += 1
elif result == 'updated':
updated += 1
elif result == 'deleted':
deleted += 1
if (i + 1) % 25 == 0:
_logger.warning("MS sync account %s: processed %d/%d events", self.id, i + 1, len(all_events))
self.sudo().write({ self.sudo().write({
'x_fc_sync_token': next_sync_token, 'x_fc_sync_token': next_sync_token,
'x_fc_last_sync': fields.Datetime.now(), 'x_fc_last_sync': fields.Datetime.now(),
@@ -714,7 +731,7 @@ class FusionCalendarAccount(models.Model):
existing_link = EventLink.search([ existing_link = EventLink.search([
('x_fc_universal_id', '=', ical_uid), ('x_fc_universal_id', '=', ical_uid),
('x_fc_universal_id', '!=', False), ('x_fc_account_id.x_fc_user_id', '=', self.x_fc_user_id.id),
], limit=1) if ical_uid else None ], limit=1) if ical_uid else None
if existing_link and existing_link.x_fc_event_id: if existing_link and existing_link.x_fc_event_id:

View File

@@ -0,0 +1,163 @@
#!/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
TEST_TAGS="${TEST_TAGS:-/fusion_tasks,/fusion_claims}"
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. ORPHAN-TAX-FK CLEANUP (clone only) ---------
# westin-v19 has ~3300 orphaned tax m2m rows under validated FKs; a plain
# pg_dump|psql clone can't rebuild the validating FK over them -> Odoo fails to
# load the registry. Safe to delete ON THE CLONE only. (CLAUDE.md gotcha.)
c "Orphaned-tax-FK cleanup (clone only)"
psql_clone -c "DELETE FROM product_taxes_rel WHERE tax_id NOT IN (SELECT id FROM account_tax);" >>"$LOG" 2>&1 || true
psql_clone -c "DELETE FROM product_supplier_taxes_rel WHERE tax_id NOT IN (SELECT id FROM account_tax);" >>"$LOG" 2>&1 || true
# sweep any other %_rel table carrying a tax_id column
psql_clone -t -A -c "SELECT table_name FROM information_schema.columns WHERE column_name='tax_id' AND table_name LIKE '%\\_rel';" 2>/dev/null \
| while read -r t; do [[ -n "$t" ]] && psql_clone -c "DELETE FROM ${t} WHERE tax_id NOT IN (SELECT id FROM account_tax);" >>"$LOG" 2>&1 || true; done
ok "Orphan FKs cleared on clone."
# ----------------------------- 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
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 $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