Compare commits

...

11 Commits

Author SHA1 Message Date
gsinghpal
ba7c028c30 CHANGES 2026-06-04 09:49:51 -04:00
gsinghpal
41ce3784d7 fix(fusion_plating_reports): page-break-inside on CoC part rows
Final-review follow-up: per the entech wkhtmltopdf rule, page-break-inside
must sit on each <tr>, not just the <table>, so a large multi-part cert
can't split a part row mid-row under the company header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:59:29 -04:00
gsinghpal
98873c4e39 feat(fusion_plating): cert backfill migration + version bumps
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 22:54:54 -04:00
gsinghpal
28e5e7f9de feat(fusion_plating_jobs): group WOs by recipe step structure
Replace the old 5-tuple (recipe.id, part, spec, thickness, serial) grouping
key with a structural signature so multiple parts that share the same recipe
step tree (ENP clones) collapse onto one combined work order. Add three
helpers: _fp_recipe_signature, _fp_line_express_signature, _fp_line_group_key.
Add TransactionCase test covering merge, non-merge, and masking-split cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 22:49:51 -04:00
gsinghpal
c71c60350b feat(fusion_plating_jobs): traveller lists all parts in the batch
Additive 'Batch parts' roster in the Item Information cell, shown when a
WO covers 2+ distinct parts (the labeled block still details the primary
part). Keeps the existing table layout intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:41:16 -04:00
gsinghpal
ba1e15da07 feat(fusion_plating_reports): CoC parts table loops part_line_ids 2026-06-03 22:39:20 -04:00
gsinghpal
f1bf5b214c feat(fusion_plating_jobs): multi-part cert creation + requirement union
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:37:35 -04:00
gsinghpal
983e576fdc feat(fusion_plating_certificates): Parts page on certificate form 2026-06-03 22:28:27 -04:00
gsinghpal
7cbf4f25df feat(fusion_plating_certificates): add fp.certificate.part child model + ACL
Adds the fp.certificate.part model (one row per part on a combined CoC),
the part_line_ids O2M on fp.certificate, and ACL rows for all three
plating roles. No views yet — Task 2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:27:00 -04:00
gsinghpal
e35c120af8 docs(fusion_plating): implementation plan - WO grouping by recipe + combined CoC
8 bite-sized tasks (cert part-line model -> form -> creation -> CoC report
-> traveller -> grouping switch -> migration -> verify). Cert multi-part
support lands before the grouping flip so it is never a compliance
regression. Tests are committed TransactionCase artifacts run on an
Enterprise env (local Community cannot install fusion_plating); plus a
read-only entech signature smoke.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:01:12 -04:00
gsinghpal
e34892f5c0 docs(fusion_plating): spec - WO grouping by recipe structure + combined multi-part CoC
Group SO lines into one fp.job per distinct plating process (identical
recipe step structure) instead of one WO per line; make the Certificate
of Conformance multi-part via a new fp.certificate.part child model + CoC
parts-table loop + migration backfill.

Grounded in a read-only entech audit: 13 WOs -> 4 on real orders;
per-part recipe clones are structurally identical (same node_type +
kind_code + name sequence). cloned_from_id/process_type_id are empty on
existing data, so grouping keys off the step structure.

Phase 1 (this spec): grouping + combined cert + report + traveller +
migration. Phase 2 (deferred): per-part thickness + per-part stickers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:52:50 -04:00
29 changed files with 5258 additions and 128 deletions

View File

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

View File

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

View File

@@ -0,0 +1,737 @@
# Service Booking Wizard + Auto-Quote — Implementation Plan (Plan 2 of 2)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax.
**Goal:** A polished OWL "Book a Service" wizard that captures the client (incl. new clients inline), books the technician task, prices the call-out from the Plan-1 rate table, and auto-creates a draft repair Sale Order — with a correct, consistent timezone conversion.
**Architecture:** TZ fix in `fusion_tasks`; everything else in `fusion_claims` (it owns the SO + the `technician.task` SO-link + Plan 1's rates). A server method `action_book_from_wizard` does the work (contact + task + SO); an OWL client action is the UI and calls it through two `jsonrpc` controller routes. Pricing is read from `fusion.service.rate` (Plan 1) — never hardcoded.
**Tech Stack:** Odoo 19 (ORM, `TransactionCase`), OWL (`@odoo/owl`, standalone `rpc` from `@web/core/network/rpc`, `registry.category("actions")`), SCSS branching on `$o-webclient-color-scheme`.
**Depends on:** Plan 1 (`fusion.service.rate` + `get_callout`/`get_rate`). **Spec:** `…/specs/2026-06-03-technician-service-booking-design.md`. **Mockup (UI source of truth):** `…/mockups/technician-booking-wizard.html`.
---
## ⚠️ Testing reality
`fusion_claims` is Enterprise-only → not installable on local Community. `TransactionCase` tests run on a **Westin Enterprise clone** (see Plan 1's testing note + repo `CLAUDE.md`). OWL UI has **no unit test** — verify by manual smoke on the clone browser. Pure-Python tasks (14) are TDD; the OWL task (5) is build-then-smoke.
**Pre-flight (rule #1 — never code from memory):** before Tasks 1, 3, 4, read the real signatures:
```bash
docker exec odoo-dev-app sed -n '760,800p;975,1010p;2725,2775p' \
/mnt/extra-addons/fusion_tasks/models/technician_task.py
```
Confirm `_get_local_tz`, `_compute_datetimes`/inverses, `_calculate_travel_time(origin_lat, origin_lng)` (sets `travel_distance_km`), and `_quick_travel_time`.
---
## File structure
| File | Responsibility |
|---|---|
| `fusion_tasks/models/technician_task.py` *(modify ~781-798)* | tz-consistent inverses |
| `fusion_tasks/tests/test_task_tz.py` + `__init__.py` *(create)* | tz round-trip test |
| `fusion_claims/models/technician_task.py` *(modify)* | relax `_check_order_link`; add `x_fc_service_call_type`; pricing resolver; SO builder; `action_book_from_wizard` |
| `fusion_claims/models/sale_order.py` *(modify)* | `x_fc_is_service_repair` flag |
| `fusion_claims/data/service_repair_data.xml` *(create)* | "Service Repair" CRM tag |
| `fusion_claims/controllers/__init__.py` + `controllers/service_booking.py` *(create)* | `jsonrpc` refdata + submit routes |
| `fusion_claims/__init__.py` *(modify)* | import controllers |
| `fusion_claims/static/src/js/service_booking/service_booking.js` *(create)* | OWL client action |
| `fusion_claims/static/src/xml/service_booking.xml` *(create)* | OWL template (ported from mockup) |
| `fusion_claims/static/src/scss/_service_booking_tokens.scss` + `service_booking.scss` *(create)* | styles, dark/light |
| `fusion_claims/views/service_booking_action.xml` *(create)* | `ir.actions.client` + menu |
| `fusion_claims/__manifest__.py` *(modify)* | assets + data + version |
| `fusion_claims/tests/test_service_booking.py` *(create)* | resolver, SO builder, booking method |
---
## Task 1: Timezone-consistent inverses (`fusion_tasks`)
**Files:** Modify `fusion_tasks/models/technician_task.py`; create `fusion_tasks/tests/test_task_tz.py` (+ `tests/__init__.py` if absent).
- [ ] **Step 1: Write the failing test**
Create `fusion_tasks/tests/test_task_tz.py`:
```python
# -*- coding: utf-8 -*-
from datetime import date
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestTaskTz(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env.user.tz = 'America/Toronto' # UTC-4 in summer
cls.task = cls.env['fusion.technician.task'].create({
'scheduled_date': date(2026, 6, 3),
'time_start': 9.0, 'time_end': 10.0,
})
def test_local_to_utc_compute(self):
# 9:00 local Toronto (DST, -4) -> 13:00 UTC stored
self.assertEqual(self.task.datetime_start.hour, 13)
def test_inverse_round_trips_with_same_tz(self):
# writing datetime_start back must recover the same local time_start
self.task.datetime_start = self.task.datetime_start # force inverse
self.task.flush_recordset(['datetime_start'])
self.assertAlmostEqual(self.task.time_start, 9.0, places=2)
```
Register in `fusion_tasks/tests/__init__.py` (create if missing):
```python
from . import test_task_tz
```
If `fusion_tasks/tests/` doesn't exist, also add `'fusion_tasks/tests'` is auto-discovered — just ensure the `__init__.py` exists.
- [ ] **Step 2: Run — verify it fails** (on the clone, `--test-tags /fusion_tasks.TestTaskTz`). Expected: `test_inverse_round_trips` FAILS if user.tz ≠ company-calendar tz, or passes spuriously if they're equal — set the company `resource_calendar_id.tz` to `America/Toronto` in `setUpClass` too if needed to expose the mismatch.
- [ ] **Step 3: Fix the inverses**
In `fusion_tasks/models/technician_task.py`, the two inverse methods currently use `pytz.timezone(self.env.user.tz or 'UTC')`. Change **both** to use the same resolver as `_compute_datetimes`:
```python
def _inverse_datetime_start(self):
"""When datetime_start changes (calendar drag), update date + time. Uses the
SAME tz resolver as _compute_datetimes so the round-trip is consistent."""
import pytz
user_tz = self._get_local_tz()
for task in self:
if task.datetime_start:
local_dt = pytz.utc.localize(task.datetime_start).astimezone(user_tz)
task.scheduled_date = local_dt.date()
task.time_start = local_dt.hour + local_dt.minute / 60.0
def _inverse_datetime_end(self):
import pytz
user_tz = self._get_local_tz()
for task in self:
if task.datetime_end:
local_dt = pytz.utc.localize(task.datetime_end).astimezone(user_tz)
task.time_end = local_dt.hour + local_dt.minute / 60.0
```
(Only the `user_tz = …` line changes in each — from `pytz.timezone(self.env.user.tz or 'UTC')` to `self._get_local_tz()`.)
- [ ] **Step 4: Run — verify it passes** (`--test-tags /fusion_tasks.TestTaskTz`). Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add fusion_tasks/models/technician_task.py fusion_tasks/tests/test_task_tz.py fusion_tasks/tests/__init__.py
git commit -m "fix(fusion_tasks): make datetime inverses use the same tz resolver as compute"
```
---
## Task 2: Relax SO constraint + repair-SO identity (`fusion_claims`)
**Files:** Modify `fusion_claims/models/technician_task.py`, `fusion_claims/models/sale_order.py`; create `fusion_claims/data/service_repair_data.xml`; modify `__manifest__.py`; test in `fusion_claims/tests/test_service_booking.py`.
- [ ] **Step 1: Write the failing test**
Create `fusion_claims/tests/test_service_booking.py`:
```python
# -*- coding: utf-8 -*-
from datetime import date
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestServiceBooking(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.Task = cls.env['fusion.technician.task']
def test_task_without_order_is_allowed(self):
# repair for a brand-new client: no SO/PO must NOT raise
t = self.Task.create({'task_type': 'repair', 'scheduled_date': date(2026, 6, 3)})
self.assertTrue(t.id)
def test_sale_order_has_service_repair_flag(self):
so = self.env['sale.order'].new({})
self.assertIn('x_fc_is_service_repair', so._fields)
```
Register in `fusion_claims/tests/__init__.py` (append): `from . import test_service_booking`.
- [ ] **Step 2: Run — verify it fails** (`--test-tags /fusion_claims.TestServiceBooking`). Expected: `test_task_without_order_is_allowed` FAILS with the ValidationError from `_check_order_link`; `test_sale_order_has_service_repair_flag` FAILS (field missing).
- [ ] **Step 3: Relax the constraint**
In `fusion_claims/models/technician_task.py`, replace the body of `_check_order_link` so it no longer requires an order (the wizard auto-creates one; in-shop/walk-in legitimately have none):
```python
@api.constrains('sale_order_id', 'purchase_order_id')
def _check_order_link(self):
# Relaxed 2026-06: service bookings auto-create their SO, and in-shop /
# walk-in tasks may have none. No order link is required anymore.
return
```
(Keep the method as a no-op rather than deleting it, so any external `super()`/override chains stay intact.)
- [ ] **Step 4: Add the repair flag + tag**
In `fusion_claims/models/sale_order.py`, add to the `sale.order` class:
```python
x_fc_is_service_repair = fields.Boolean(
string='Service Repair', copy=False,
help='Auto-created from the technician service booking wizard.',
)
```
Create `fusion_claims/data/service_repair_data.xml`:
```xml
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="tag_service_repair" model="crm.tag">
<field name="name">Service Repair</field>
</record>
</data>
</odoo>
```
Register it in `__manifest__.py` `data` (after the service-rate data from Plan 1):
```python
'data/service_repair_data.xml',
```
> `crm.tag` requires the `sale_crm`/`crm` dependency. If `fusion_claims` doesn't pull `crm`, use `sale.order.tag` — verify which tag model exists: `docker exec odoo-dev-app odoo shell -d westin-v19-ratetest -c "print('crm.tag' in env, 'sale.order' in env)"`. Default to `crm.tag` (Westin has CRM); fall back to skipping the tag and relying on the boolean flag if neither is clean.
- [ ] **Step 5: Run — verify it passes.** Expected: both tests PASS.
- [ ] **Step 6: Commit**
```bash
git add fusion_claims/models/technician_task.py fusion_claims/models/sale_order.py \
fusion_claims/data/service_repair_data.xml fusion_claims/__manifest__.py \
fusion_claims/tests/test_service_booking.py fusion_claims/tests/__init__.py
git commit -m "feat(fusion_claims): allow order-less tasks + service-repair SO flag/tag"
```
---
## Task 3: `x_fc_service_call_type` + pricing resolver + SO builder (`fusion_claims`)
**Files:** Modify `fusion_claims/models/technician_task.py`; test in `test_service_booking.py`.
- [ ] **Step 1: Write the failing test** (append to `TestServiceBooking`):
```python
def test_resolve_service_lines_standard_rush(self):
Task = self.Task
lines = Task._resolve_service_lines('standard', 'rush', in_shop=False, distance_km=10.0)
# call-out $120 + per-km line qty 20 @ $0.70
callout = [l for l in lines if l['price_unit'] == 120.0]
per_km = [l for l in lines if l['name_is_km']]
self.assertTrue(callout)
self.assertEqual(per_km[0]['product_uom_qty'], 20.0)
self.assertEqual(per_km[0]['price_unit'], 0.70)
def test_resolve_service_lines_in_shop_empty_callout(self):
lines = self.Task._resolve_service_lines('standard', 'normal', in_shop=True, distance_km=5.0)
self.assertEqual(lines, [])
def test_build_service_so(self):
partner = self.env['res.partner'].create({'name': 'Walk-in Wanda'})
so = self.Task._build_service_so(partner, 'standard', 'normal', False, 0.0)
self.assertEqual(so.state, 'draft')
self.assertTrue(so.x_fc_is_service_repair)
self.assertEqual(so.partner_id, partner)
self.assertEqual(so.order_line[0].price_unit, 95.0)
```
- [ ] **Step 2: Run — verify it fails** (methods undefined).
- [ ] **Step 3: Add the field + resolver + builder**
In `fusion_claims/models/technician_task.py`, add the field to the class:
```python
x_fc_service_call_type = fields.Char(
string='Service Call Type',
help='Rate code resolved by the booking wizard (e.g. callout_standard_rush).',
)
```
Add these methods (model methods; rely on Plan 1's `fusion.service.rate`):
```python
@api.model
def _resolve_service_lines(self, category, timing, in_shop, distance_km):
"""Return a list of sale.order.line vals dicts for a service booking,
priced from fusion.service.rate. Empty when in-shop (labour-only, added later)."""
Rate = self.env['fusion.service.rate']
lines = []
callout = Rate.get_callout(category, timing, in_shop=in_shop)
if not callout:
return lines
lines.append({
'product_id': callout.product_id.id,
'name': callout.name,
'product_uom_qty': 1.0,
'price_unit': callout.price,
'name_is_km': False,
})
if callout.adds_per_km and distance_km:
per_km = Rate.get_rate('per_km')
if per_km:
lines.append({
'product_id': per_km.product_id.id,
'name': '%s%.1f km × 2-way' % (per_km.name, distance_km),
'product_uom_qty': round(distance_km * 2.0, 1),
'price_unit': per_km.price,
'name_is_km': True,
})
return lines
@api.model
def _build_service_so(self, partner, category, timing, in_shop, distance_km):
"""Create a draft repair sale.order with the resolved call-out (+per-km) lines."""
line_vals = self._resolve_service_lines(category, timing, in_shop, distance_km)
order_lines = [(0, 0, {k: v for k, v in l.items() if k != 'name_is_km'}) for l in line_vals]
so_vals = {
'partner_id': partner.id,
'x_fc_is_service_repair': True,
'order_line': order_lines,
}
tag = self.env.ref('fusion_claims.tag_service_repair', raise_if_not_found=False)
if tag and 'tag_ids' in self.env['sale.order']._fields:
so_vals['tag_ids'] = [(4, tag.id)]
return self.env['sale.order'].create(so_vals)
```
> The `name_is_km` key is a test-only marker stripped before create. If `sale.order` has no `tag_ids` (no CRM), the guard skips the tag.
- [ ] **Step 4: Run — verify it passes.**
- [ ] **Step 5: Commit**
```bash
git add fusion_claims/models/technician_task.py fusion_claims/tests/test_service_booking.py
git commit -m "feat(fusion_claims): service pricing resolver + draft-SO builder from rate table"
```
---
## Task 4: `action_book_from_wizard` + controller routes (`fusion_claims`)
**Files:** Modify `fusion_claims/models/technician_task.py`; create `fusion_claims/controllers/__init__.py`, `controllers/service_booking.py`; modify `fusion_claims/__init__.py`; test in `test_service_booking.py`.
- [ ] **Step 1: Write the failing test** (append):
```python
def test_action_book_creates_contact_task_and_so(self):
payload = {
'cust_mode': 'new',
'customer': {'name': 'Nina New', 'phone': '4165550199', 'email': 'nina@x.com',
'street': '88 Bloor St E', 'city': 'Toronto'},
'category': 'standard', 'timing': 'normal', 'in_shop': False,
'device': 'scooter', 'issue': "won't power on",
'date': '2026-06-03', 'time_start': 9.0, 'duration_hr': 1.0,
'technician_id': False, 'description': 'check battery',
}
res = self.Task.action_book_from_wizard(payload)
self.assertTrue(res['task_id'] and res['order_id'])
task = self.Task.browse(res['task_id'])
self.assertEqual(task.sale_order_id.id, res['order_id'])
self.assertEqual(task.sale_order_id.order_line[0].price_unit, 95.0)
partner = self.env['res.partner'].search([('email', '=ilike', 'nina@x.com')], limit=1)
self.assertTrue(partner)
```
- [ ] **Step 2: Run — verify it fails.**
- [ ] **Step 3: Implement `action_book_from_wizard`**
Add to `fusion_claims/models/technician_task.py` (read the travel method first — pre-flight). Distance: create the task, run its travel calc to populate `travel_distance_km`, read it for the per-km line, then attach the SO:
```python
@api.model
def action_book_from_wizard(self, payload):
"""Single entry point for the OWL booking wizard:
resolve/create contact -> create task -> compute distance -> build SO -> link."""
Partner = self.env['res.partner']
# 1. contact
cust = payload.get('customer') or {}
if payload.get('cust_mode') == 'new':
partner = False
email = (cust.get('email') or '').strip()
phone = (cust.get('phone') or '').strip()
if email:
partner = Partner.search([('email', '=ilike', email)], limit=1)
if not partner and phone:
partner = Partner.search([('phone', '=', phone)], limit=1)
if not partner:
partner = Partner.create({
'name': cust.get('name') or 'Walk-in',
'phone': phone or False, 'email': email or False,
'street': cust.get('street') or False, 'city': cust.get('city') or False,
})
else:
partner = Partner.browse(int(payload.get('partner_id'))) if payload.get('partner_id') else Partner
category = payload.get('category', 'standard')
timing = payload.get('timing', 'normal')
in_shop = bool(payload.get('in_shop'))
# 2. task
dur = float(payload.get('duration_hr') or 1.0)
t_start = float(payload.get('time_start') or 9.0)
task_vals = {
'task_type': 'repair',
'scheduled_date': payload.get('date'),
'time_start': t_start, 'time_end': t_start + dur, 'duration_hours': dur,
'in_store': in_shop,
'x_fc_service_call_type': '%s_%s' % (category, timing),
'description': payload.get('description') or payload.get('issue') or '',
}
if payload.get('technician_id'):
task_vals['technician_id'] = int(payload['technician_id'])
if partner:
task_vals['client_name'] = partner.name
task_vals['client_phone'] = partner.phone or False
task = self.create(task_vals)
# 3. distance (km) for per-km, if the rate adds it and the job has a location
distance_km = 0.0
callout = self.env['fusion.service.rate'].get_callout(category, timing, in_shop=in_shop)
if callout and callout.adds_per_km and not in_shop and task.address_lat and task.address_lng:
try:
task._calculate_travel_time(task.address_lat, task.address_lng) # sets travel_distance_km
distance_km = task.travel_distance_km or 0.0
except Exception:
distance_km = 0.0
# 4. SO + link
order = self._build_service_so(partner, category, timing, in_shop, distance_km) if partner else False
if order:
task.sale_order_id = order.id
return {'task_id': task.id, 'order_id': order.id if order else False}
```
> Verify field names against the model during the pre-flight read: `in_store` vs `in_shop`, `client_name`/`client_phone`, `address_lat`/`address_lng`, `technician_id`. Adjust the vals keys to the real field names (the screenshot shows In-Store, Client Name/Phone, Task Address). If `_calculate_travel_time` needs a different origin, pass the shop/technician start coords instead.
- [ ] **Step 4: Create the controller**
Create `fusion_claims/controllers/__init__.py`:
```python
from . import service_booking
```
Create `fusion_claims/controllers/service_booking.py`:
```python
# -*- coding: utf-8 -*-
from odoo import http
from odoo.http import request
class ServiceBookingController(http.Controller):
@http.route('/fusion_claims/service_booking/refdata', type='jsonrpc', auth='user')
def refdata(self, **kw):
env = request.env
techs = env['res.users'].search([('x_fc_is_field_staff', '=', True)]) \
if 'x_fc_is_field_staff' in env['res.users']._fields else env['res.users'].search([])
rates = env['fusion.service.rate'].search([('rate_kind', '=', 'callout'), ('active', '=', True)])
per_km = env['fusion.service.rate'].get_rate('per_km')
def labour(code):
r = env['fusion.service.rate'].get_rate(code)
return r.price if r else 0.0
return {
'technicians': [{'id': t.id, 'name': t.name} for t in techs],
'callout_rates': [{
'code': r.code, 'category': r.category, 'timing': r.timing,
'name': r.name, 'price': r.price, 'adds_per_km': r.adds_per_km,
} for r in rates],
'per_km': per_km.price if per_km else 0.70,
'labour': {'onsite': labour('labour_onsite'), 'inshop': labour('labour_inshop'),
'lift': labour('labour_lift')},
}
@http.route('/fusion_claims/service_booking/submit', type='jsonrpc', auth='user')
def submit(self, payload=None, **kw):
try:
return request.env['fusion.technician.task'].action_book_from_wizard(payload or {})
except Exception as e:
return {'error': str(e)}
```
Modify `fusion_claims/__init__.py` (append):
```python
from . import controllers
```
- [ ] **Step 5: Run — verify it passes** (`--test-tags /fusion_claims.TestServiceBooking`). Also `pyflakes` the controller: `docker exec odoo-dev-app python3 -m pyflakes /mnt/extra-addons/fusion_claims/controllers/service_booking.py`.
- [ ] **Step 6: Commit**
```bash
git add fusion_claims/models/technician_task.py fusion_claims/controllers/ fusion_claims/__init__.py fusion_claims/tests/test_service_booking.py
git commit -m "feat(fusion_claims): action_book_from_wizard + jsonrpc booking routes"
```
---
## Task 5: OWL booking wizard + SCSS (`fusion_claims`)
**Files:** create `static/src/js/service_booking/service_booking.js`, `static/src/xml/service_booking.xml`, `static/src/scss/_service_booking_tokens.scss`, `static/src/scss/service_booking.scss`; modify `__manifest__.py` (assets). **No unit test — manual smoke.**
- [ ] **Step 1: Write the OWL component**
Create `fusion_claims/static/src/js/service_booking/service_booking.js`:
```javascript
/** @odoo-module **/
import { Component, useState, onWillStart } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
export class ServiceBookingWizard extends Component {
static template = "fusion_claims.ServiceBookingWizard";
static props = ["*"];
setup() {
this.action = useService("action");
this.notification = useService("notification");
this.state = useState({
custMode: "existing", customer: {name:"",phone:"",email:"",street:"",unit:"",buzz:"",city:""},
partnerId: false, soSearch: "",
device: "standard", category: "standard", timing: "normal", inShop: false, issue: "",
date: "", hour: 9, minute: 0, ampm: "AM", durationHr: 1.0, technicianId: false,
warranty: false, pod: false, emailConfirm: true, googleReview: true,
description: "", materials: "",
technicians: [], calloutRates: [], perKm: 0.70,
labour: {onsite:85, inshop:75, lift:110}, distanceKm: 13, saving: false,
});
onWillStart(async () => {
const r = await rpc("/fusion_claims/service_booking/refdata", {});
Object.assign(this.state, {
technicians: r.technicians, calloutRates: r.callout_rates,
perKm: r.per_km, labour: r.labour,
});
});
}
get callout() {
if (this.state.inShop) return null;
return this.state.calloutRates.find(
r => r.category === this.state.category && r.timing === this.state.timing) || null;
}
get labourRate() {
if (this.state.inShop) return this.state.labour.inshop;
return this.state.category === "lift" ? this.state.labour.lift : this.state.labour.onsite;
}
get estimate() {
const c = this.callout;
const callout = c ? c.price : 0;
const incl = (c && !c.adds_per_km) ? 0.5 : 0;
const billHr = Math.max(0, this.state.durationHr - incl);
const labour = billHr * this.labourRate;
const km = (c && c.adds_per_km) ? this.state.distanceKm * 2 * this.state.perKm : 0;
return { callout, labour, billHr, km, total: callout + labour + km, addsKm: !!(c && c.adds_per_km) };
}
get endLabel() {
let h = (this.state.hour % 12) + (this.state.ampm === "PM" ? 12 : 0);
let m = h * 60 + this.state.minute + this.state.durationHr * 60;
let eh = Math.floor(m / 60) % 24, em = m % 60, ap = eh >= 12 ? "PM" : "AM";
return `${eh % 12 || 12}:${String(em).padStart(2, "0")} ${ap}`;
}
onDevice(ev) { this.state.device = ev.target.value; this.state.category = ev.target.value === "lift" ? "lift" : "standard"; }
setCust(m) { this.state.custMode = m; }
setTiming(t) { this.state.timing = t; }
setAmpm(v) { this.state.ampm = v; }
toggleInShop() { this.state.inShop = !this.state.inShop; }
_timeStartFloat() { return (this.state.hour % 12) + (this.state.ampm === "PM" ? 12 : 0) + this.state.minute / 60; }
async submit() {
if (this.state.saving) return;
const s = this.state;
if (s.custMode === "new" && (!s.customer.name || !s.customer.phone)) {
this.notification.add("Client name and phone are required.", { type: "danger" }); return;
}
s.saving = true;
const payload = {
cust_mode: s.custMode, customer: s.customer, partner_id: s.partnerId, so_search: s.soSearch,
category: s.category, timing: s.timing, in_shop: s.inShop, device: s.device, issue: s.issue,
date: s.date, time_start: this._timeStartFloat(), duration_hr: s.durationHr,
technician_id: s.technicianId, warranty: s.warranty, pod: s.pod,
email_confirm: s.emailConfirm, google_review: s.googleReview,
description: s.description, materials: s.materials,
};
try {
const res = await rpc("/fusion_claims/service_booking/submit", { payload });
if (res.error) { this.notification.add(res.error, { type: "danger" }); s.saving = false; return; }
this.notification.add("Service booked — draft repair SO created.", { type: "success" });
this.action.doAction({
type: "ir.actions.act_window", res_model: "fusion.technician.task",
res_id: res.task_id, views: [[false, "form"]], target: "current",
});
} catch (e) {
this.notification.add("Booking failed: " + (e.message || e), { type: "danger" });
s.saving = false;
}
}
}
registry.category("actions").add("fusion_claims.service_booking", ServiceBookingWizard);
```
- [ ] **Step 2: Write the OWL template** — port the mockup
Create `fusion_claims/static/src/xml/service_booking.xml` with `<t t-name="fusion_claims.ServiceBookingWizard">`. **Port each section from the mockup** (`docs/superpowers/mockups/technician-booking-wizard.html`) converting static HTML → OWL bindings, per this exact mapping:
| Mockup element | OWL binding |
|---|---|
| `class="theme-btn"` | *remove* — Odoo handles dark/light via the bundle (Step 4) |
| Customer `Existing/New` seg buttons | `t-att-class="{on: state.custMode==='existing'}"` + `t-on-click="() => setCust('existing')"` |
| New-client inputs | `t-model="state.customer.name"` etc. (name, phone, email, street, unit, buzz, city) |
| `<select id="device">` | `t-on-change="onDevice"` (options: scooter/powerchair/wheelchair→standard, stairlift/lift→lift, …) |
| `<select id="callType">` | render from `state.calloutRates` with `t-foreach`; bind selection to category+timing |
| timing seg | `t-on-click``setTiming('normal'|'rush'|'afterhours')` |
| `feeAmt` / `eCall`/`eLab`/`eKm`/`eTotal` | `t-esc="estimate.callout"` etc. (format with a `fmt(n)` helper or `t-out`) |
| in-shop switch | `t-att-class="{on: state.inShop}"` + `t-on-click="toggleInShop"` |
| AM/PM buttons | `t-on-click``setAmpm('AM'|'PM')`; hour/minute `t-model.number` |
| `endlbl` | `t-esc="endLabel"` |
| technician `<select>` | `t-foreach="state.technicians"` + `t-model.number="state.technicianId"` |
| switches (warranty/pod/email/review) | `t-att-class="{on: state.warranty}"` + `t-on-click="() => state.warranty = !state.warranty"` |
| footer `Book & Create SO` | `t-on-click="submit"` `t-att-disabled="state.saving"` |
Keep the mockup's class names so the SCSS (Step 3) applies unchanged. Wrap the root in `<div class="o_service_booking">…</div>`.
- [ ] **Step 3: Port the SCSS (dark/light)**
Create `fusion_claims/static/src/scss/_service_booking_tokens.scss` — the mockup's `:root`/`[data-theme]` token values, converted to the repo's compile-time branch (per `CLAUDE.md` dark-mode rule):
```scss
$o-webclient-color-scheme: bright !default;
$_page:#eef0f3; $_panel:#e6e9ed; $_card:#ffffff; $_border:#d8dadd; $_text:#1f2430;
$_muted:#6b7280; $_field:#ffffff; $_money:#0f7d4e; $_money-soft:#e7f6ee; // …copy the rest from the mockup :root
@if $o-webclient-color-scheme == dark {
$_page:#14161b !global; $_panel:#1b1e24 !global; $_card:#22262d !global; $_border:#343a42 !global;
$_text:#e7eaef !global; $_muted:#9aa3af !global; $_field:#1a1d23 !global;
$_money:#34d27f !global; $_money-soft:#15281f !global; // …copy the dark values from the mockup [data-theme="dark"]
}
.o_service_booking {
--sb-page:#{$_page}; --sb-panel:#{$_panel}; --sb-card:#{$_card}; --sb-border:#{$_border};
--sb-text:#{$_text}; --sb-muted:#{$_muted}; --sb-field:#{$_field};
--sb-money:#{$_money}; --sb-money-soft:#{$_money-soft}; /* …rest */
}
```
Create `fusion_claims/static/src/scss/service_booking.scss` — the mockup's component CSS, scoped under `.o_service_booking` and using the `--sb-*` vars instead of the mockup's `--page` etc. (mechanical rename). Drop the `.theme-btn` rule.
- [ ] **Step 4: Register assets** in `__manifest__.py`:
```python
'assets': {
'web.assets_backend': [
# … existing entries …
'fusion_claims/static/src/scss/_service_booking_tokens.scss',
'fusion_claims/static/src/scss/service_booking.scss',
'fusion_claims/static/src/js/service_booking/service_booking.js',
'fusion_claims/static/src/xml/service_booking.xml',
],
'web.assets_web_dark': [
# dark bundle recompiles the same tokens with the dark scheme
'fusion_claims/static/src/scss/_service_booking_tokens.scss',
'fusion_claims/static/src/scss/service_booking.scss',
],
},
```
- [ ] **Step 5: Smoke (manual, on the clone)**
`-u fusion_claims`, hard-refresh. Trigger the action (Task 6) → the wizard renders; toggle a user dark-mode profile to confirm the dark bundle; book a new client → task form opens, draft SO exists with the right call-out line.
- [ ] **Step 6: Commit**
```bash
git add fusion_claims/static/ fusion_claims/__manifest__.py
git commit -m "feat(fusion_claims): OWL service-booking wizard + dark/light SCSS"
```
---
## Task 6: Entry point + version bump
**Files:** create `fusion_claims/views/service_booking_action.xml`; modify `__manifest__.py`.
- [ ] **Step 1: Create the client action + menu**
Create `fusion_claims/views/service_booking_action.xml`:
```xml
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_service_booking_wizard" model="ir.actions.client">
<field name="name">Book a Service</field>
<field name="tag">fusion_claims.service_booking</field>
</record>
<menuitem id="menu_service_booking"
name="Book a Service"
parent="PARENT_MENU_XMLID"
action="action_service_booking_wizard"
sequence="1"/>
</odoo>
```
Use the same Field-Service menu parent identified in Plan 1 Task 4 Step 2 (e.g. the technician-task app menu). Register in `__manifest__.py` `data` after the views.
- [ ] **Step 2: Bump version** in `__manifest__.py` (e.g. `19.0.9.3.0``19.0.9.4.0`).
- [ ] **Step 3: Full upgrade + all tests** (clone): `--test-tags /fusion_claims,/fusion_tasks`. Expected: all PASS.
- [ ] **Step 4: End-to-end smoke (clone browser)***Book a Service* menu → existing customer path (SO search prefill) and new-client path; confirm task + draft repair SO + correct call-out; rush/after-hours adds the per-km line; reschedule lands at the right local time (Task 1).
- [ ] **Step 5: Commit**
```bash
git add fusion_claims/views/service_booking_action.xml fusion_claims/__manifest__.py
git commit -m "feat(fusion_claims): Book a Service entry point + version bump"
```
---
## Self-Review (done while writing)
- **Spec coverage:** tz fix §8 ✓ (T1); constraint relax §6.3 ✓ (T2); repair-SO flag/tag §6.3 ✓ (T2); resolver reads rate table §7 ✓ (T3); SO builder + per-km §7 ✓ (T3); `action_book_from_wizard` (contact→task→distance→SO) §5 ✓ (T4); OWL wizard + dark/light SCSS §5 ✓ (T5); entry point §11 ✓ (T6). Estimate-as-UI-only §9 ✓ (component `estimate` getter, not written to SO).
- **Placeholders:** none for logic. Two deliberate lookups — the menu parent xmlid (T6/Plan-1) and the field-name verification in T4 (real "read the model first" per rule #1), both concrete actions, not vague TODOs. The template/SCSS port references the **mockup** (a complete existing artifact) with an explicit element→binding mapping — concrete, not "similar to".
- **Type/name consistency:** `_resolve_service_lines(category, timing, in_shop, distance_km)` and `_build_service_so(partner, category, timing, in_shop, distance_km)` match across T3 tests, T4 caller, and the controller. Rate codes (`callout_standard_rush`, `per_km`, `labour_onsite/inshop/lift`) match Plan 1's seed. Controller routes `/fusion_claims/service_booking/{refdata,submit}` match the OWL `rpc()` calls. `action_book_from_wizard` return shape `{task_id, order_id}` matches the component's `res.task_id`.
- **Flagged for execution:** verify real task field names in T4 (`in_store`/`client_name`/`address_lat`…) and the `crm.tag` vs `sale.order` tag model in T2 — both have explicit verify steps.
---
## Execution Handoff
Both plans are written:
- **Plan 1** — `…/plans/2026-06-03-service-rates-foundation-plan.md`
- **Plan 2** — this file.
**Order:** Plan 1 → Plan 2 (Plan 2 consumes Plan 1's rate table). First move the work to a dedicated branch: `git checkout -b claude/technician-service-booking` (off `main`, *not* the fusion_schedule-fix branch).
Two execution options (per the writing-plans skill):
1. **Subagent-Driven (recommended)** — a fresh subagent per task, reviewed between tasks. Best given the Enterprise-clone test loop.
2. **Inline Execution** — execute tasks in this session with checkpoints.
**Caveat:** verification requires the Westin Enterprise clone (no local Community install). Plan to run the test/smoke steps there.

View File

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

View File

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

View File

@@ -0,0 +1,869 @@
# WO Grouping by Recipe + Combined Multi-Part Certificate — Implementation Plan
> **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:** Group sale-order plating lines into one work order (`fp.job`) per distinct plating process, and make the Certificate of Conformance multi-part so a combined WO certifies every part truthfully.
**Architecture:** Spec → [docs/superpowers/specs/2026-06-03-wo-grouping-by-recipe-combined-cert-design.md](../specs/2026-06-03-wo-grouping-by-recipe-combined-cert-design.md). Lines whose resolved recipes share an identical *step structure* (and identical masking/bake toggles) collapse onto one `fp.job`. A new `fp.certificate.part` child model holds one row per SO line; `_fp_create_certificates` fills it; the CoC report loops it. The cert multi-part support lands **before** the grouping switch so flipping the grouping is never a compliance regression.
**Tech Stack:** Odoo 19 (Python ORM, QWeb PDF reports), modules `fusion_plating_jobs`, `fusion_plating_certificates`, `fusion_plating_reports`.
---
## Testing model (read this first — the env is unusual)
These modules **cannot install on the local Community box** (`fusion_plating` needs Enterprise deps; `installed=0` on `modsdev`). So:
- **Local per-task gate (always runnable):**
- Python: `docker exec odoo-modsdev-app python3 -m pyflakes /mnt/odoo-modules/fusion_plating/<path>.py`
(Adjust the `/mnt/odoo-modules/fusion_plating` prefix if your bind mount differs; `K:\Github\Odoo-Modules``/mnt/odoo-modules`, and the plating modules live under its `fusion_plating/` subdir.)
- XML: `docker exec odoo-modsdev-app python3 -c "import lxml.etree as e; e.parse('/mnt/odoo-modules/fusion_plating/<path>.xml'); print('XML OK')"`
- **Odoo unit tests** (TransactionCase, committed as real artifacts): run on an **Enterprise env where `fusion_plating` is installed**`odoo-trial` (VM 316) if present, otherwise a throwaway **entech clone** (do NOT run `--test-enable -u` against prod `admin`). Command shape:
```
odoo -d <enterprise_test_db> --test-enable --test-tags /fusion_plating_jobs \
-u fusion_plating_jobs --stop-after-init --http-port=0 --gevent-port=0
```
- **Live read-only smoke (safe on entech prod):** re-run the recipe-signature audit (Task 8) to confirm SO-30092/30083/30079/30071 collapse to one group each. Read-only — no writes.
- **Write-path smoke (clone / odoo-trial only):** create a test SO with same-structure lines, confirm, check one WO + one multi-part cert + render the CoC PDF.
Every "run the test" step below shows the command; if the Enterprise test env is not yet available, write + commit the test and run the suite at the Task 8 verification gate.
---
## File structure
| File | Module | Responsibility |
|------|--------|----------------|
| `fusion_plating_certificates/models/fp_certificate_part.py` | certificates | NEW — one row per part on a cert. |
| `fusion_plating_certificates/models/fp_certificate.py` | certificates | ADD `part_line_ids` O2M. |
| `fusion_plating_certificates/models/__init__.py` | certificates | import new model. |
| `fusion_plating_certificates/security/ir.model.access.csv` | certificates | ACL for `fp.certificate.part`. |
| `fusion_plating_certificates/views/fp_certificate_views.xml` | certificates | "Parts" notebook page. |
| `fusion_plating_certificates/__manifest__.py` | certificates | version bump. |
| `fusion_plating_jobs/models/fp_job.py` | jobs | requirement union + part-line build in `_fp_create_certificates`. |
| `fusion_plating_jobs/models/sale_order.py` | jobs | grouping signature + key (the switch). |
| `fusion_plating_jobs/report/report_fp_job_traveller.xml` | jobs | Item Information loops all parts. |
| `fusion_plating_jobs/migrations/19.0.12.2.0/post-migrate.py` | jobs | backfill one part-line per existing cert. |
| `fusion_plating_jobs/__manifest__.py` | jobs | version bump. |
| `fusion_plating_jobs/tests/test_wo_recipe_grouping.py` | jobs | NEW — signature + grouping tests. |
| `fusion_plating_jobs/tests/test_combined_cert_creation.py` | jobs | NEW — multi-part cert creation tests. |
| `fusion_plating_reports/report/report_coc.xml` | reports | parts-table loop. |
| `fusion_plating_reports/__manifest__.py` | reports | version bump. |
> **Migration location note:** the spec listed the backfill under `fusion_plating_certificates`. It is **moved to `fusion_plating_jobs`** here because the backfill reads `x_fc_job_id` (a jobs-module field) and runs cert helpers — both guaranteed present only after jobs loads (jobs depends on certificates). The `fp.certificate.part` table is created by the certificates upgrade, which Odoo runs first.
**Build order:** cert model → cert form → cert creation → CoC report → traveller → **grouping switch (last)** → migration + verify. This way the multi-part cert is ready before any WO ever carries multiple parts.
---
### Task 1: `fp.certificate.part` model + `part_line_ids` + ACL
**Files:**
- Create: `fusion_plating_certificates/models/fp_certificate_part.py`
- Modify: `fusion_plating_certificates/models/fp_certificate.py` (add O2M near the existing `thickness_reading_ids` at line 87)
- Modify: `fusion_plating_certificates/models/__init__.py`
- Modify: `fusion_plating_certificates/security/ir.model.access.csv`
- [ ] **Step 1: Create the model**
```python
# fusion_plating_certificates/models/fp_certificate_part.py
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# One row per part on a Certificate of Conformance. A work order can
# cover several parts that share the same plating process (see
# fusion_plating_jobs sale_order._fp_line_group_key); the combined CoC
# lists each part with its own identity + spec + quantities.
from odoo import fields, models
class FpCertificatePart(models.Model):
_name = 'fp.certificate.part'
_description = 'Certificate Part Line'
_order = 'certificate_id, sequence, id'
certificate_id = fields.Many2one(
'fp.certificate', string='Certificate',
required=True, ondelete='cascade', index=True)
sequence = fields.Integer(default=10)
sale_order_line_id = fields.Many2one(
'sale.order.line', string='Source SO Line',
help='The order line this part row was built from (traceability).')
part_catalog_id = fields.Many2one('fp.part.catalog', string='Part')
part_number = fields.Char(string='Part Number') # snapshot
part_name = fields.Char(string='Part Name') # snapshot
description = fields.Char(string='Description') # customer-facing snapshot
serial = fields.Char(string='Serial Number(s)') # comma-joined snapshot
customer_spec_id = fields.Many2one(
'fusion.plating.customer.spec', string='Customer Spec')
spec_reference = fields.Char(string='Spec Reference') # snapshot 'CODE Rev X'
quantity_shipped = fields.Integer(string='Qty Shipped')
nc_quantity = fields.Integer(string='NC Qty')
```
- [ ] **Step 2: Register the import**
In `fusion_plating_certificates/models/__init__.py`, add (alphabetical / near the other cert imports):
```python
from . import fp_certificate_part
```
- [ ] **Step 3: Add the O2M on `fp.certificate`**
In `fusion_plating_certificates/models/fp_certificate.py`, immediately after the `thickness_reading_ids` field (line 87-89):
```python
part_line_ids = fields.One2many(
'fp.certificate.part', 'certificate_id', string='Parts',
help='One row per part covered by this certificate. Populated at '
'cert creation from the work order\'s sale-order lines.')
```
- [ ] **Step 4: Add ACL rows**
Append to `fusion_plating_certificates/security/ir.model.access.csv` (mirror the existing `fp.certificate` group grants):
```csv
access_fp_certificate_part_operator,fp.certificate.part.operator,model_fp_certificate_part,fusion_plating.group_fp_technician,1,1,0,0
access_fp_certificate_part_supervisor,fp.certificate.part.supervisor,model_fp_certificate_part,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
access_fp_certificate_part_manager,fp.certificate.part.manager,model_fp_certificate_part,fusion_plating.group_fp_manager,1,1,1,1
```
- [ ] **Step 5: Static checks**
Run:
```
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/odoo-modules/fusion_plating/fusion_plating_certificates/models/fp_certificate_part.py /mnt/odoo-modules/fusion_plating/fusion_plating_certificates/models/fp_certificate.py
```
Expected: no output (clean).
- [ ] **Step 6: Commit**
```bash
git add fusion_plating/fusion_plating_certificates/models/fp_certificate_part.py \
fusion_plating/fusion_plating_certificates/models/fp_certificate.py \
fusion_plating/fusion_plating_certificates/models/__init__.py \
fusion_plating/fusion_plating_certificates/security/ir.model.access.csv
git commit -m "feat(fusion_plating_certificates): add fp.certificate.part child model + ACL"
```
---
### Task 2: "Parts" page on the certificate form
**Files:**
- Modify: `fusion_plating_certificates/views/fp_certificate_views.xml` (notebook at line 154)
- [ ] **Step 1: Add the Parts page as the first notebook page**
Insert immediately after `<notebook>` (line 154), before the existing `<page string="Thickness Readings" ...>`:
```xml
<page string="Parts" name="parts">
<field name="part_line_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="part_number"/>
<field name="part_name"/>
<field name="description"/>
<field name="serial"/>
<field name="customer_spec_id"/>
<field name="spec_reference"/>
<field name="quantity_shipped"/>
<field name="nc_quantity"/>
</list>
</field>
</page>
```
- [ ] **Step 2: Static check (XML parse)**
Run:
```
docker exec odoo-modsdev-app python3 -c "import lxml.etree as e; e.parse('/mnt/odoo-modules/fusion_plating/fusion_plating_certificates/views/fp_certificate_views.xml'); print('XML OK')"
```
Expected: `XML OK`.
- [ ] **Step 3: Commit**
```bash
git add fusion_plating/fusion_plating_certificates/views/fp_certificate_views.xml
git commit -m "feat(fusion_plating_certificates): Parts page on certificate form"
```
---
### Task 3: `_fp_create_certificates` fills part-lines + requirement union
**Files:**
- Modify: `fusion_plating_jobs/models/fp_job.py` (`_resolve_required_cert_types` ~line 611; `_fp_create_certificates` build of `vals` before `Cert.create(vals)` at line 2784)
- Test: `fusion_plating_jobs/tests/test_combined_cert_creation.py`
- [ ] **Step 1: Write the failing test**
```python
# fusion_plating_jobs/tests/test_combined_cert_creation.py
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase
class TestCombinedCertCreation(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({
'name': 'CertCust',
'x_fc_send_coc': True, # drives the coc requirement
})
self.product = self.env['product.product'].create({'name': 'W'})
self.part_a = self.env['fp.part.catalog'].create({
'name': 'PartA', 'partner_id': self.partner.id, 'part_number': 'A-1'})
self.part_b = self.env['fp.part.catalog'].create({
'name': 'PartB', 'partner_id': self.partner.id, 'part_number': 'B-2'})
self.so = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [
(0, 0, {'product_id': self.product.id, 'product_uom_qty': 3,
'x_fc_part_catalog_id': self.part_a.id}),
(0, 0, {'product_id': self.product.id, 'product_uom_qty': 2,
'x_fc_part_catalog_id': self.part_b.id}),
],
})
def test_combined_cert_has_one_line_per_so_line(self):
job = self.env['fp.job'].create({
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 5.0,
'sale_order_id': self.so.id,
'part_catalog_id': self.part_a.id,
'sale_order_line_ids': [(6, 0, self.so.order_line.ids)],
})
job._fp_create_certificates()
cert = self.env['fp.certificate'].search([('x_fc_job_id', '=', job.id)])
self.assertEqual(len(cert), 1, 'one combined CoC')
self.assertEqual(len(cert.part_line_ids), 2, 'one part-line per SO line')
self.assertEqual(
set(cert.part_line_ids.mapped('part_number')), {'A-1', 'B-2'})
a = cert.part_line_ids.filtered(lambda p: p.part_number == 'A-1')
self.assertEqual(a.quantity_shipped, 3, 'shipped qty from the line')
```
- [ ] **Step 2: Run it (Enterprise test env) — expect FAIL**
Run:
```
odoo -d <enterprise_test_db> --test-enable \
--test-tags /fusion_plating_jobs:TestCombinedCertCreation \
-u fusion_plating_jobs --stop-after-init --http-port=0 --gevent-port=0
```
Expected: FAIL — `cert.part_line_ids` is empty (creation doesn't fill it yet).
- [ ] **Step 3: Add helper methods on `fp.job`**
Add near `_fp_create_certificates` in `fusion_plating_jobs/models/fp_job.py`:
```python
def _fp_cert_source_lines(self):
"""Plating SO lines this job covers (one cert part-line each)."""
self.ensure_one()
lines = self.sale_order_line_ids
if not lines and self.sale_order_id:
lines = self.sale_order_id.order_line
return lines.filtered(
lambda l: not l.display_type
and ('x_fc_part_catalog_id' in l._fields and l.x_fc_part_catalog_id))
def _fp_format_spec_ref(self, spec):
"""Format 'CODE Rev X' from a customer spec (or '')."""
if not spec:
return ''
ref = spec.code or ''
if 'revision' in spec._fields and spec.revision:
ref = (f'{ref} Rev {spec.revision}' if ref
else f'Rev {spec.revision}')
return ref
def _fp_build_cert_part_commands(self):
"""O2M create commands for fp.certificate.part — one per line."""
self.ensure_one()
cmds, seq = [], 10
for sol in self._fp_cert_source_lines():
part = sol.x_fc_part_catalog_id
spec = (sol.x_fc_customer_spec_id
if 'x_fc_customer_spec_id' in sol._fields else False)
serials = ''
if 'x_fc_serial_ids' in sol._fields and sol.x_fc_serial_ids:
serials = ', '.join(sol.x_fc_serial_ids.mapped('name'))
desc = (sol.fp_customer_description()
if hasattr(sol, 'fp_customer_description')
else (sol.name or ''))
cmds.append((0, 0, {
'sequence': seq,
'sale_order_line_id': sol.id,
'part_catalog_id': part.id if part else False,
'part_number': (part.part_number if part else '') or '',
'part_name': (part.name if part else '') or '',
'description': desc or '',
'serial': serials,
'customer_spec_id': spec.id if spec else False,
'spec_reference': self._fp_format_spec_ref(spec),
'quantity_shipped': int(sol.product_uom_qty or 0),
'nc_quantity': 0,
}))
seq += 10
return cmds
```
- [ ] **Step 4: Fill `part_line_ids` in `_fp_create_certificates`**
In `_fp_create_certificates`, immediately before `cert = Cert.create(vals)` (line 2784), add:
```python
if 'part_line_ids' in Cert._fields:
part_cmds = self._fp_build_cert_part_commands()
if part_cmds:
vals['part_line_ids'] = part_cmds
```
- [ ] **Step 5: Requirement union over all parts**
In `_resolve_required_cert_types` (Step 1, ~line 611-642), replace the single-part read with a union across all parts on the job. Change the Step-1 block so `wanted` is the union of each line's part-level requirement (falling back to the partner inherit set computed once):
```python
# ---- Step 1 — partner + part baseline (union across all parts) ----
def _partner_inherit_set():
s = set()
p = self.partner_id
if p:
if p.x_fc_send_coc:
s.add('coc')
if p.x_fc_send_thickness_report:
s.add('thickness_report')
if 'x_fc_send_nadcap_cert' in p._fields and p.x_fc_send_nadcap_cert:
s.add('nadcap_cert')
if 'x_fc_send_mill_test' in p._fields and p.x_fc_send_mill_test:
s.add('mill_test')
if 'x_fc_send_customer_specific' in p._fields and p.x_fc_send_customer_specific:
s.add('customer_specific')
return s
def _explicit_set(req):
return {
'none': set(), 'coc': {'coc'},
'coc_thickness': {'coc', 'thickness_report'},
}.get(req, {'coc'})
parts = self._fp_cert_source_lines().mapped('x_fc_part_catalog_id')
if not parts and self.part_catalog_id:
parts = self.part_catalog_id
wanted = set()
inherit = None
for part in (parts or [False]):
req = (part.certificate_requirement
if part and 'certificate_requirement' in part._fields
else 'inherit') or 'inherit'
if req == 'inherit':
if inherit is None:
inherit = _partner_inherit_set()
wanted |= inherit
else:
wanted |= _explicit_set(req)
```
Leave Step 2 (recipe suppression) and Step 3 (CoC/thickness bundling) unchanged — they already operate on `wanted`.
- [ ] **Step 6: Run the test — expect PASS**
Run:
```
odoo -d <enterprise_test_db> --test-enable \
--test-tags /fusion_plating_jobs:TestCombinedCertCreation \
-u fusion_plating_jobs --stop-after-init --http-port=0 --gevent-port=0
```
Expected: PASS.
- [ ] **Step 7: Static check**
Run:
```
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/odoo-modules/fusion_plating/fusion_plating_jobs/models/fp_job.py /mnt/odoo-modules/fusion_plating/fusion_plating_jobs/tests/test_combined_cert_creation.py
```
Expected: clean.
- [ ] **Step 8: Commit**
```bash
git add fusion_plating/fusion_plating_jobs/models/fp_job.py \
fusion_plating/fusion_plating_jobs/tests/test_combined_cert_creation.py
git commit -m "feat(fusion_plating_jobs): multi-part cert creation + requirement union"
```
---
### Task 4: CoC report renders the parts table as a loop
**Files:**
- Modify: `fusion_plating_reports/report/report_coc.xml` (tbody at lines 297-321)
- [ ] **Step 1: Replace the single hard-coded row with a loop + fallback**
Replace the `<tbody>...</tbody>` block (lines 297-322) with:
```xml
<tbody>
<t t-foreach="doc.part_line_ids" t-as="pl">
<tr>
<td class="text-center" style="line-height: 1.3;">
<div><t t-esc="pl.part_number or '-'"/></div>
<div><t t-esc="pl.part_name or '-'"/></div>
<div><t t-esc="pl.serial or '-'"/></div>
</td>
<td>
<t t-esc="pl.description or doc.process_description or ''"/>
<t t-if="pl.spec_reference">
<br/><em t-esc="pl.spec_reference"/>
</t>
</td>
<td class="text-center"><t t-esc="doc.po_number or '-'"/></td>
<td class="text-center"><t t-esc="pl.quantity_shipped or 0"/></td>
<td class="text-center"><t t-esc="pl.nc_quantity or 0"/></td>
<td class="text-center"><t t-esc="doc.customer_job_no or '-'"/></td>
</tr>
</t>
<tr t-if="not doc.part_line_ids">
<td class="text-center" style="line-height: 1.3;">
<t t-set="pid" t-value="doc._fp_resolve_part_identity()"/>
<div><t t-esc="pid[0] or '-'"/></div>
<div><t t-esc="pid[1] or '-'"/></div>
<div><t t-esc="pid[2] or '-'"/></div>
</td>
<td>
<t t-set="cust_desc" t-value="doc._fp_resolve_customer_facing_description()"/>
<t t-esc="cust_desc or doc.process_description or ''"/>
<t t-if="doc.spec_reference">
<br/><em t-esc="doc.spec_reference"/>
</t>
</td>
<td class="text-center"><t t-esc="doc.po_number or '-'"/></td>
<td class="text-center"><t t-esc="doc.quantity_shipped or 0"/></td>
<td class="text-center"><t t-esc="doc.nc_quantity or 0"/></td>
<td class="text-center"><t t-esc="doc.customer_job_no or '-'"/></td>
</tr>
</tbody>
```
> Keep `page-break-inside: avoid` on the parent table (line 271-272) unchanged. Each part row is short; the table-level rule already prevents mid-row splits for the typical 1-4 part case.
- [ ] **Step 2: Static check (XML parse)**
Run:
```
docker exec odoo-modsdev-app python3 -c "import lxml.etree as e; e.parse('/mnt/odoo-modules/fusion_plating/fusion_plating_reports/report/report_coc.xml'); print('XML OK')"
```
Expected: `XML OK`.
- [ ] **Step 3: Commit**
```bash
git add fusion_plating/fusion_plating_reports/report/report_coc.xml
git commit -m "feat(fusion_plating_reports): CoC parts table loops part_line_ids"
```
---
### Task 5: Traveller lists every part in the batch
**Files:**
- Modify: `fusion_plating_jobs/report/report_fp_job_traveller.xml` (Item Information block, ~lines 116-160)
- [ ] **Step 1: Loop the plating lines in the Item Information cell**
The Item Information `<td>` currently renders `job.part_catalog_id` once (singular). Wrap the per-part rows in a loop over the job's plating lines, falling back to the singular part when no lines are linked. Replace the singular part-number / revision / material / name reads (lines ~127-157) with:
```xml
<t t-set="trav_lines"
t-value="job.sale_order_line_ids.filtered(lambda l: not l.display_type and ('x_fc_part_catalog_id' in l._fields and l.x_fc_part_catalog_id)) if 'sale_order_line_ids' in job._fields else job.browse([])"/>
<t t-if="not trav_lines and 'part_catalog_id' in job._fields and job.part_catalog_id">
<t t-set="trav_parts" t-value="[job.part_catalog_id]"/>
</t>
<t t-else="">
<t t-set="trav_parts" t-value="trav_lines.mapped('x_fc_part_catalog_id')"/>
</t>
<t t-foreach="trav_parts" t-as="tp">
<div style="margin-bottom: 2px;">
<strong t-esc="tp.part_number or '—'"/>
<t t-if="'revision' in tp._fields and tp.revision">
<span> Rev <t t-esc="tp.revision"/></span>
</t>
<t t-if="'base_material' in tp._fields and tp.base_material">
<span> · <t t-esc="tp.base_material"/></span>
</t>
<span> · <t t-esc="tp.name or '—'"/></span>
</div>
</t>
```
> This preserves the existing field reads (`part_number`, `revision`, `base_material`, `name`) but emits one line per part. The routing/process table below (one shared recipe) is unchanged. Verify the surrounding `<td>`/column structure still balances after the edit — keep the edit inside the existing Item Information cell.
- [ ] **Step 2: Static check (XML parse)**
Run:
```
docker exec odoo-modsdev-app python3 -c "import lxml.etree as e; e.parse('/mnt/odoo-modules/fusion_plating/fusion_plating_jobs/report/report_fp_job_traveller.xml'); print('XML OK')"
```
Expected: `XML OK`.
- [ ] **Step 3: Commit**
```bash
git add fusion_plating/fusion_plating_jobs/report/report_fp_job_traveller.xml
git commit -m "feat(fusion_plating_jobs): traveller lists all parts in the batch"
```
---
### Task 6: Grouping by recipe structural signature (the switch)
**Files:**
- Modify: `fusion_plating_jobs/models/sale_order.py` (`_fp_auto_create_job` groups block, lines 439-470)
- Test: `fusion_plating_jobs/tests/test_wo_recipe_grouping.py`
- [ ] **Step 1: Write the failing tests**
```python
# fusion_plating_jobs/tests/test_wo_recipe_grouping.py
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase
class TestWoRecipeGrouping(TransactionCase):
def setUp(self):
super().setUp()
self.SO = self.env['sale.order']
self.Node = self.env['fusion.plating.process.node']
def _recipe(self, name, step_names):
root = self.Node.create({'name': name, 'node_type': 'recipe'})
seq = 10
for sn in step_names:
self.Node.create({
'name': sn, 'node_type': 'step',
'parent_id': root.id, 'sequence': seq})
seq += 10
return root
def test_identical_structure_same_signature(self):
r1 = self._recipe('ENP — PART-A', ['Soak Clean', 'Rinse', 'E-Nickel'])
r2 = self._recipe('ENP — PART-B', ['Soak Clean', 'Rinse', 'E-Nickel'])
self.assertEqual(
self.SO._fp_recipe_signature(r1),
self.SO._fp_recipe_signature(r2),
'clones with identical steps share a signature')
def test_different_structure_different_signature(self):
r1 = self._recipe('ENP — A', ['Soak Clean', 'Rinse', 'E-Nickel'])
r2 = self._recipe('CHROME — B', ['Etch', 'Plate'])
self.assertNotEqual(
self.SO._fp_recipe_signature(r1),
self.SO._fp_recipe_signature(r2))
def test_so_groups_same_structure_into_one_wo(self):
partner = self.env['res.partner'].create({'name': 'G'})
product = self.env['product.product'].create({'name': 'P'})
pa = self.env['fp.part.catalog'].create({
'name': 'A', 'partner_id': partner.id, 'part_number': 'A'})
pb = self.env['fp.part.catalog'].create({
'name': 'B', 'partner_id': partner.id, 'part_number': 'B'})
pc = self.env['fp.part.catalog'].create({
'name': 'C', 'partner_id': partner.id, 'part_number': 'C'})
r1 = self._recipe('ENP — A', ['Soak Clean', 'Rinse'])
r2 = self._recipe('ENP — B', ['Soak Clean', 'Rinse']) # same structure
r3 = self._recipe('CHROME — C', ['Etch', 'Plate']) # different
so = self.env['sale.order'].create({
'partner_id': partner.id,
'order_line': [
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pa.id,
'x_fc_process_variant_id': r1.id}),
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pb.id,
'x_fc_process_variant_id': r2.id}),
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pc.id,
'x_fc_process_variant_id': r3.id}),
],
})
so._fp_auto_create_job()
jobs = self.env['fp.job'].search([('sale_order_id', '=', so.id)])
self.assertEqual(len(jobs), 2, 'A+B merge, C separate')
sizes = sorted(len(j.sale_order_line_ids) for j in jobs)
self.assertEqual(sizes, [1, 2])
def test_masking_toggle_splits_same_structure(self):
partner = self.env['res.partner'].create({'name': 'M'})
product = self.env['product.product'].create({'name': 'P'})
pa = self.env['fp.part.catalog'].create({
'name': 'A', 'partner_id': partner.id, 'part_number': 'A'})
pb = self.env['fp.part.catalog'].create({
'name': 'B', 'partner_id': partner.id, 'part_number': 'B'})
r1 = self._recipe('ENP — A', ['Soak Clean', 'Rinse'])
r2 = self._recipe('ENP — B', ['Soak Clean', 'Rinse'])
so = self.env['sale.order'].create({
'partner_id': partner.id,
'order_line': [
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pa.id,
'x_fc_process_variant_id': r1.id,
'x_fc_masking_enabled': True}),
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pb.id,
'x_fc_process_variant_id': r2.id,
'x_fc_masking_enabled': False}),
],
})
so._fp_auto_create_job()
jobs = self.env['fp.job'].search([('sale_order_id', '=', so.id)])
self.assertEqual(len(jobs), 2, 'masking on vs off must not merge')
```
- [ ] **Step 2: Run them — expect FAIL**
Run:
```
odoo -d <enterprise_test_db> --test-enable \
--test-tags /fusion_plating_jobs:TestWoRecipeGrouping \
-u fusion_plating_jobs --stop-after-init --http-port=0 --gevent-port=0
```
Expected: FAIL — `_fp_recipe_signature` does not exist yet.
- [ ] **Step 3: Add the signature helpers on `sale.order`**
In `fusion_plating_jobs/models/sale_order.py`, add these methods (near `_fp_resolve_recipe_for_line`):
```python
def _fp_recipe_signature(self, recipe):
"""Hashable structural signature of a recipe's step tree.
Two recipes with the same signature have identical processing
steps and can share one work order. Excludes the recipe ROOT
(its name carries the per-part ' — <part#>' suffix) and all
numeric targets — those are per-part attestation data on the
cert, not a batch splitter. Returns None for a missing recipe.
"""
if not recipe:
return None
Node = self.env['fusion.plating.process.node']
kids = Node.search(
[('id', 'child_of', recipe.id),
('node_type', 'in', ('sub_process', 'operation', 'step'))],
order='parent_path, sequence')
return tuple(
(k.node_type,
(k.kind_id.code if k.kind_id else '') or '',
(k.name or '').strip().lower())
for k in kids)
def _fp_line_express_signature(self, line):
"""Per-line Express toggles that change which steps exist:
masking on/off and bake present/absent. Lines differing here
must not merge (the shared WO would silently drop one part's
masking or bake step). Free-text bake instructions are NOT in
the signature — both-present lines merge and the bake step
carries the last applied line's text (known Phase-1 limit)."""
F = line._fields
masking = bool(line.x_fc_masking_enabled) if 'x_fc_masking_enabled' in F else True
has_bake = bool((line.x_fc_bake_instructions or '').strip()) \
if 'x_fc_bake_instructions' in F else False
return (masking, has_bake)
def _fp_line_group_key(self, line):
"""WO grouping key. Lines with the same key ride one work order."""
recipe = self._fp_resolve_recipe_for_line(line)
if not recipe:
return ('no_recipe', line.id) # never merges
return ('recipe',
self._fp_recipe_signature(recipe),
self._fp_line_express_signature(line))
```
- [ ] **Step 4: Replace the grouping loop**
In `_fp_auto_create_job`, replace the `groups`-building block (lines 445-470, the `unrecipe_idx`/5-tuple-key logic) with:
```python
# Group by recipe structural signature (+ per-line masking/bake
# toggles). Lines whose recipes have identical steps collapse onto
# one WO; no-recipe lines stay separate. See spec
# 2026-06-03-wo-grouping-by-recipe-combined-cert-design.md.
groups = {}
for line in plating_lines:
key = self._fp_line_group_key(line)
groups[key] = groups.get(key, self.env['sale.order.line']) | line
```
Everything after (the `ordered_keys = sorted(...)` block at line 473 onward) is unchanged — it still derives `n_groups`, names WOs `WO-<parent>` / `WO-<parent>-NN`, and builds one job per group carrying `sale_order_line_ids`.
- [ ] **Step 5: Run the tests — expect PASS**
Run:
```
odoo -d <enterprise_test_db> --test-enable \
--test-tags /fusion_plating_jobs:TestWoRecipeGrouping \
-u fusion_plating_jobs --stop-after-init --http-port=0 --gevent-port=0
```
Expected: PASS (4 tests).
- [ ] **Step 6: Static check**
Run:
```
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/odoo-modules/fusion_plating/fusion_plating_jobs/models/sale_order.py /mnt/odoo-modules/fusion_plating/fusion_plating_jobs/tests/test_wo_recipe_grouping.py
```
Expected: clean.
- [ ] **Step 7: Commit**
```bash
git add fusion_plating/fusion_plating_jobs/models/sale_order.py \
fusion_plating/fusion_plating_jobs/tests/test_wo_recipe_grouping.py
git commit -m "feat(fusion_plating_jobs): group WOs by recipe step structure"
```
---
### Task 7: Migration backfill + version bumps
**Files:**
- Create: `fusion_plating_jobs/migrations/19.0.12.2.0/post-migrate.py`
- Modify: `fusion_plating_jobs/__manifest__.py` (`19.0.12.1.6` → `19.0.12.2.0`)
- Modify: `fusion_plating_certificates/__manifest__.py` (`19.0.9.3.0` → `19.0.10.0.0`)
- Modify: `fusion_plating_reports/__manifest__.py` (`19.0.11.34.0` → `19.0.11.35.0`)
- [ ] **Step 1: Write the backfill migration**
```python
# fusion_plating_jobs/migrations/19.0.12.2.0/post-migrate.py
# -*- coding: utf-8 -*-
# Backfill one fp.certificate.part per existing certificate from its
# legacy singular fields, so pre-existing certs render identically under
# the new multi-part CoC. Lives in fusion_plating_jobs (not certificates)
# because it reads x_fc_job_id, a jobs-module field; the part-line table
# itself is created by the certificates upgrade, which runs first.
import logging
from odoo import api, SUPERUSER_ID
_logger = logging.getLogger(__name__)
def migrate(cr, version):
env = api.Environment(cr, SUPERUSER_ID, {})
if 'fp.certificate.part' not in env:
return
certs = env['fp.certificate'].search([])
made = 0
for cert in certs:
if cert.part_line_ids:
continue
try:
pid = cert._fp_resolve_part_identity() # (number, name, serials)
except Exception:
pid = ('', '', '')
job = cert.x_fc_job_id if 'x_fc_job_id' in cert._fields else False
part = job.part_catalog_id if (job and 'part_catalog_id' in job._fields) else False
try:
desc = cert._fp_resolve_customer_facing_description() or cert.process_description or ''
except Exception:
desc = cert.process_description or ''
env['fp.certificate.part'].create({
'certificate_id': cert.id, 'sequence': 10,
'part_catalog_id': part.id if part else False,
'part_number': cert.part_number or (pid[0] or ''),
'part_name': pid[1] or '',
'description': desc,
'serial': pid[2] or '',
'customer_spec_id': cert.customer_spec_id.id if cert.customer_spec_id else False,
'spec_reference': cert.spec_reference or '',
'quantity_shipped': cert.quantity_shipped or 0,
'nc_quantity': cert.nc_quantity or 0,
})
made += 1
_logger.info('fp.certificate.part backfill: created %s part-line(s)', made)
```
- [ ] **Step 2: Bump versions**
`fusion_plating_jobs/__manifest__.py`: `'version': '19.0.12.1.6',` → `'version': '19.0.12.2.0',`
`fusion_plating_certificates/__manifest__.py`: `'version': '19.0.9.3.0',` → `'version': '19.0.10.0.0',`
`fusion_plating_reports/__manifest__.py`: `'version': '19.0.11.34.0',` → `'version': '19.0.11.35.0',`
- [ ] **Step 3: Static check**
Run:
```
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/odoo-modules/fusion_plating/fusion_plating_jobs/migrations/19.0.12.2.0/post-migrate.py
```
Expected: clean.
- [ ] **Step 4: Commit**
```bash
git add fusion_plating/fusion_plating_jobs/migrations/19.0.12.2.0/post-migrate.py \
fusion_plating/fusion_plating_jobs/__manifest__.py \
fusion_plating/fusion_plating_certificates/__manifest__.py \
fusion_plating/fusion_plating_reports/__manifest__.py
git commit -m "feat(fusion_plating): cert backfill migration + version bumps"
```
---
### Task 8: Verification (Enterprise env + read-only entech smoke)
**Files:** none (verification only).
- [ ] **Step 1: Full suite on the Enterprise test env**
Run:
```
odoo -d <enterprise_test_db> --test-enable --test-tags /fusion_plating_jobs \
-u fusion_plating_jobs,fusion_plating_certificates,fusion_plating_reports \
--stop-after-init --http-port=0 --gevent-port=0
```
Expected: exit 0; the new grouping + cert tests pass; no regressions in existing `fusion_plating_jobs` tests.
- [ ] **Step 2: Read-only signature re-run on entech (prod-safe)**
Confirm the four real orders collapse. In `odoo shell -d admin` on entech (read-only — no commit):
```python
SO = env['sale.order']
for name in ('SO-30092', 'SO-30083', 'SO-30079', 'SO-30071'):
so = SO.search([('name', '=', name)], limit=1)
if not so:
continue
lines = so.order_line.filtered(lambda l: l.x_fc_part_catalog_id)
keys = {SO._fp_line_group_key(l) for l in lines}
print(name, 'lines=%d' % len(lines), 'groups=%d' % len(keys))
# Expect: each prints groups=1
```
- [ ] **Step 3: Write-path smoke (clone / odoo-trial — NOT prod)**
On a non-prod Enterprise DB: create an SO with 3 lines (2 sharing a structurally-identical recipe, 1 different) for a partner with `x_fc_send_coc=True`; confirm it; verify (a) **2** `fp.job` records, (b) the merged job has 2 `sale_order_line_ids`, (c) closing the merged job produces **one** CoC with **2** `part_line_ids`, (d) the rendered CoC PDF shows 2 part rows, (e) a migrated legacy single-part cert still renders one row.
- [ ] **Step 4: Mark plan complete**
All boxes checked, suite green, entech smoke shows `groups=1` for the four orders → ready to deploy (entech upgrade of the three modules, per the standard deploy recipe in CLAUDE.md).
---
## Self-review (completed by plan author)
- **Spec coverage:** grouping signature (Task 6) ✓; combined cert + per-part lines (Tasks 1-3) ✓; CoC report loop (Task 4) ✓; traveller (Task 5) ✓; migration backfill (Task 7) ✓; requirement union (Task 3) ✓; locked decisions (NC=0 editable, union lists all parts, masking/bake split) encoded in Tasks 3 & 6 ✓. Phase 2 (per-part thickness, per-part stickers) intentionally out of scope.
- **Placeholder scan:** no TBD/TODO; every code step shows complete code; `<enterprise_test_db>` is an explicit env parameter (documented in the Testing model), not a code placeholder.
- **Type/name consistency:** `_fp_recipe_signature` / `_fp_line_express_signature` / `_fp_line_group_key` (Task 6) match their uses; `fp.certificate.part` fields (Task 1) match the part-line build (Task 3), the report (Task 4), and the migration (Task 7); `part_line_ids` used consistently across Tasks 1-4 & 7.
- **Known limitation (documented in code):** two same-structure lines that both have bake instructions but different text merge; the shared bake step carries the last applied line's text. Acceptable for Phase 1.

View File

@@ -0,0 +1,425 @@
# WO Grouping by Recipe + Combined Multi-Part Certificate
**Date:** 2026-06-03
**Module(s):** `fusion_plating_jobs`, `fusion_plating_certificates`, `fusion_plating_reports`
**Author:** Gurpreet (Nexa Systems Inc.)
**Status:** Approved — ready for implementation plan
## Summary
Today a confirmed sale order with N plating lines creates N work orders
(`fp.job` / "WO-NNN"), even when every line runs the same plating
process. The shop wants **one work order per recipe** — different parts
that go through the same process should ride one traveller and one
physical batch, splitting into separate WOs **only when the process
actually differs**.
The blocker is the **Certificate of Conformance**: a `fp.job` carries a
single `part_catalog_id` / `customer_spec_id`, and the CoC PDF renders
exactly one part row. Collapsing four parts onto one WO would certify
only the first and silently ship the other three uncertified — the exact
"silent mis-attestation" the 2026-05-13 sticker spec was built to
prevent.
This spec resolves that by making the **certificate multi-part**: one
combined CoC per WO that lists every part in a table, each with its own
part #, spec, serial, and quantities. The grouping change and the
multi-part cert ship together because neither is safe alone.
## Audit findings (live entech, db=admin, read-only, 2026-06-03)
Pulled the real numbers before designing — they overturned the obvious
"group by `recipe_id`" approach.
| Order | Lines | WOs today | Distinct recipes | WOs after |
|-------|-------|-----------|------------------|-----------|
| SO-30092 | 2 | 2 | 2 (`ENP ALUM BASIC HP`) | **1** |
| SO-30083 | 4 | 4 | 4 (`ENP-STEEL-MP-BASIC`) | **1** |
| SO-30079 | 4 | 4 | 4 (2 parts × 2 lines) | **1** |
| SO-30071 | 3 | 3 | 3 (`ENP-STEEL-MP-BASIC`) | **1** |
- 23 confirmed SOs total; 4 are multi-plating-line. 13 plating lines
across those 4 orders collapse from **13 WOs → 4 WOs**.
- **Root cause:** every part gets its own *clone* of a base recipe,
renamed `<BASE> — <part#>` (the ` — <suffix>` is stamped by
`_clone_subtree` in `fp_part_composer_controller.py`). So each line
resolves to a *distinct* `fusion.plating.process.node` record →
grouping by `recipe_id` merges **nothing**.
- The clones are **byte-identical in structure** — 9 (or 11) descendant
nodes, same `node_type` + `kind_id.code` + name in the same order.
Verified across all 4 orders. So merging is **faithful**: every part
follows the identical steps.
- `process_type_id` is **empty** on all of them → not a usable signal.
- `cloned_from_id` exists as a field but is **empty on all 13** lines →
not usable for existing data without a backfill.
- **13 existing `fp.certificate` rows** → migration size.
**Conclusion:** the only signals that work on real data are *identical
step structure* and *shared base-name prefix*. We group by **identical
step structure** (truthful, naming-independent, no backfill).
## Locked decisions (from brainstorming, 2026-06-03)
| Q | Decision |
|---|----------|
| One WO covers many parts — how do certs work? | **One combined cert** listing every part in a table. |
| How much varies between parts in one order? | **Varies by order** → build the full per-part model (handles uniform and per-part-divergent orders). |
| Is "same recipe" one shared record or per-part copies? | **Audited:** per-part clones, structurally identical. Group by structure, not record id. |
| Grouping signal? | **Identical step structure** (recipe structural signature). |
| Two recipes "the same"? | Same `node_type` + `kind_id.code` + name sequence across descendant steps. Numeric targets (thickness/temp/time) are **excluded** — they're per-part attestation data on the cert, not a batch splitter. |
## Goals / non-goals
**Goals**
- One WO per distinct plating process; same-process parts share one WO.
- A single combined CoC per WO listing each part's own identity + spec +
quantities.
- No silent loss of any part's certification when parts share a WO.
- Per-part masking/bake differences split the WO (never silently merge).
- Existing WOs and certs keep working unchanged; the 13 existing certs
render identically after migration.
**Non-goals**
- Re-grouping already-created WOs (only new confirmations regroup).
- Removing the per-part recipe-cloning mechanism (root-cause fix to the
Part Composer — separate, larger, riskier; out of scope).
- Per-part thickness rendering, per-part box stickers, per-part issue
gate → **Phase 2** (see below).
- Per-physical-box serial tracking (unchanged from prior specs).
## Architecture
### Phase 1 — compliance-safe MVP
#### Change 1 — Grouping by recipe structural signature
File: `fusion_plating_jobs/models/sale_order.py`, method
`_fp_auto_create_job` (the `groups` block around line 439-470).
Replace the 5-tuple key `(recipe, part, spec, thickness, serial)` with a
**structural signature** key. New helpers on `sale.order`:
```python
def _fp_recipe_signature(self, recipe):
"""Hashable structural signature of a recipe's step tree.
Two recipes with the same signature have identical processing
steps and can share one work order. Excludes the recipe ROOT name
(carries the per-part ' — <part#>' suffix) and all numeric targets
(thickness/temp/time/voltage) — those are per-part attestation
data captured on the cert, not a reason to split the batch.
Returns None for a missing recipe.
"""
if not recipe:
return None
Node = self.env['fusion.plating.process.node']
kids = Node.search(
[('id', 'child_of', recipe.id),
('node_type', 'in', ('sub_process', 'operation', 'step'))],
order='parent_path, sequence')
return tuple(
(k.node_type,
(k.kind_id.code if k.kind_id else '') or '',
(k.name or '').strip().lower())
for k in kids)
def _fp_line_express_signature(self, line):
"""Per-line Express override flags that change physical processing
(masking on/off, bake setpoint/duration, etc.). Lines that differ
here must NOT merge even when the recipe structure matches, or the
shared WO would silently drop one part's masking/bake.
The exact field set is enumerated from sale.order.line's Express
Orders fields at implementation time (x_fc_masking_enabled + the
bake override fields); all reads are field-guarded.
"""
F = line._fields
bits = []
for fname in self._FP_EXPRESS_OVERRIDE_FIELDS:
if fname in F:
bits.append((fname, line[fname]))
return tuple(bits)
def _fp_line_group_key(self, line):
recipe = self._fp_resolve_recipe_for_line(line)
if not recipe:
return ('no_recipe', line.id) # never merges
return ('recipe',
self._fp_recipe_signature(recipe),
self._fp_line_express_signature(line))
```
The grouping loop becomes:
```python
groups = {}
for line in plating_lines:
key = self._fp_line_group_key(line)
groups[key] = groups.get(key, self.env['sale.order.line']) | line
```
Everything downstream of `groups` is unchanged: `ordered_keys` still
sorts by min line sequence, `n_groups` still drives single-vs-suffixed
WO naming (`WO-<parent>` vs `WO-<parent>-NN`), and the per-group job
create loop already sums qty, carries `sale_order_line_ids`, and copies
SO header fields.
**Representative recipe:** the WO's `recipe_id` is the first line's
recipe in the group. Because every recipe in the group is structurally
identical, step generation (`fp.job.action_confirm`
`_generate_steps_from_recipe`) produces the correct steps for all parts.
**Job singular fields:** `part_catalog_id` / `customer_spec_id` keep
pointing at the first line's values (display + back-compat). The
per-part truth lives in `sale_order_line_ids` and the cert part-lines.
#### Change 2 — `fp.certificate.part` (new child model)
File: `fusion_plating_certificates/models/fp_certificate_part.py` (new).
```python
class FpCertificatePart(models.Model):
_name = 'fp.certificate.part'
_description = 'Certificate Part Line'
_order = 'certificate_id, sequence, id'
certificate_id = fields.Many2one(
'fp.certificate', required=True, ondelete='cascade', index=True)
sequence = fields.Integer(default=10)
sale_order_line_id = fields.Many2one('sale.order.line') # traceability
part_catalog_id = fields.Many2one('fp.part.catalog')
part_number = fields.Char() # snapshot
part_name = fields.Char() # snapshot of catalog .name
description = fields.Char() # customer-facing description snapshot
serial = fields.Char() # comma-joined serial names snapshot
customer_spec_id = fields.Many2one('fusion.plating.customer.spec')
spec_reference = fields.Char() # snapshot 'CODE Rev X'
quantity_shipped = fields.Integer()
nc_quantity = fields.Integer()
# Phase 2: thickness_reading_ids (inverse certificate_part_id)
```
On `fp.certificate`:
```python
part_line_ids = fields.One2many(
'fp.certificate.part', 'certificate_id', string='Parts')
```
Views: add an editable `part_line_ids` list to the certificate form
(so the issuer can review/adjust before issuing). ACL rows for
`fp.certificate.part` mirror `fp.certificate`'s groups (operator read +
manager write, matching the existing cert ACL).
#### Change 3 — `_fp_create_certificates` fills part-lines
File: `fusion_plating_jobs/models/fp_job.py` (method around line 2716).
- **Requirement union** — `_resolve_required_cert_types` currently reads
the *first* part's `certificate_requirement`. Walk **all** plating
lines on the job; union each part's wanted set (part-level override
else partner inherit). Recipe suppression + CoC/thickness bundling are
unchanged (uniform — one recipe per WO).
- **Cert create** — still one cert per resulting type. Cert-level fields
(po_number, customer_job_no, process_description = base recipe name,
certified_by_id, contact, entech_wo_number, sale_order_id, x_fc_job_id)
unchanged. **Legacy singular fields** (part_number, spec_reference,
quantity_shipped, nc_quantity) keep being set from the **first** line
for back-compat.
- **Part-lines** — build one `fp.certificate.part` per plating line on
the job (`_fp_cert_source_lines()` = `sale_order_line_ids` filtered to
lines with a part):
```python
seq = 10
part_cmds = []
for sol in self._fp_cert_source_lines():
part = sol.x_fc_part_catalog_id
spec = sol.x_fc_customer_spec_id if 'x_fc_customer_spec_id' in sol._fields else False
part_cmds.append((0, 0, {
'sequence': seq,
'sale_order_line_id': sol.id,
'part_catalog_id': part.id if part else False,
'part_number': (part.part_number if part else '') or '',
'part_name': (part.name if part else '') or '',
'description': sol.fp_customer_description()
if hasattr(sol, 'fp_customer_description') else (sol.name or ''),
'serial': ', '.join(sol.x_fc_serial_ids.mapped('name'))
if 'x_fc_serial_ids' in sol._fields else '',
'customer_spec_id': spec.id if spec else False,
'spec_reference': self._fp_format_spec_ref(spec),
'quantity_shipped': int(sol.product_uom_qty or 0),
'nc_quantity': 0,
}))
seq += 10
vals['part_line_ids'] = part_cmds
```
**Per-part quantities:** `quantity_shipped` defaults to the **line**
qty (naturally per-part). `nc_quantity` defaults to **0** — scrap /
visual rejects are tracked at job level only, not per part, so we do not
auto-split them; the issuer edits per-part NC at issue if needed. The
job-level NC total remains on the cert's legacy `nc_quantity` field.
**Idempotency:** the existing per-type idempotency guard is unchanged;
re-running `_fp_create_certificates` does not duplicate certs or lines.
#### Change 4 — CoC report renders the parts table as a loop
File: `fusion_plating_reports/report/report_coc.xml` (tbody at line
297-321).
```xml
<tbody>
<t t-foreach="doc.part_line_ids" t-as="pl">
<tr>
<td class="text-center" style="line-height: 1.3;">
<div><t t-esc="pl.part_number or '-'"/></div>
<div><t t-esc="pl.part_name or '-'"/></div>
<div><t t-esc="pl.serial or '-'"/></div>
</td>
<td>
<t t-esc="pl.description or doc.process_description or ''"/>
<t t-if="pl.spec_reference"><br/><em t-esc="pl.spec_reference"/></t>
</td>
<td class="text-center"><t t-esc="doc.po_number or '-'"/></td>
<td class="text-center"><t t-esc="pl.quantity_shipped or 0"/></td>
<td class="text-center"><t t-esc="pl.nc_quantity or 0"/></td>
<td class="text-center"><t t-esc="doc.customer_job_no or '-'"/></td>
</tr>
</t>
<!-- Defensive fallback: legacy cert with no part-lines (should not
occur post-migration) renders the old single row. -->
<tr t-if="not doc.part_line_ids">
... existing _fp_resolve_part_identity() / _fp_resolve_customer_facing_description() row ...
</tr>
</tbody>
```
Process / PO / Customer-Job columns: PO and Customer Job No. are SO-level
(uniform), kept cert-level. The Process column shows each part's own
customer-facing description + spec_reference (per 2026-05-28 policy).
`page-break-inside: avoid` stays on each `<tr>` (per CLAUDE.md) so a part
row never splits across a page.
#### Change 5 — Traveller lists all parts
File: `fusion_plating_jobs/report/report_fp_job_traveller.xml`.
The Item Information block today shows one part (`job.part_catalog_id`).
Loop `job.sale_order_line_ids` (plating lines) so the operator sees every
part in the batch with its qty. The routing/process table is unchanged
(one shared recipe). Field reads stay defensively guarded.
#### Change 6 — Migration backfill
File: `fusion_plating_certificates/migrations/<new-version>/post-migrate.py`.
For each existing `fp.certificate` with no `part_line_ids`, create one
part-line from its current singular fields so old certs render
identically:
```python
for cert in env['fp.certificate'].search([]):
if cert.part_line_ids:
continue
pid = cert._fp_resolve_part_identity() # (number, name, serials)
env['fp.certificate.part'].create({
'certificate_id': cert.id, 'sequence': 10,
'part_catalog_id': (cert.x_fc_job_id.part_catalog_id.id
if cert.x_fc_job_id and cert.x_fc_job_id.part_catalog_id else False),
'part_number': cert.part_number or (pid[0] or ''),
'part_name': pid[1] or '',
'description': cert._fp_resolve_customer_facing_description() or cert.process_description or '',
'serial': pid[2] or '',
'customer_spec_id': cert.customer_spec_id.id if cert.customer_spec_id else False,
'spec_reference': cert.spec_reference or '',
'quantity_shipped': cert.quantity_shipped or 0,
'nc_quantity': cert.nc_quantity or 0,
})
```
Idempotent (skips certs that already have part-lines). 13 certs → 13
single-part certs.
### Phase 2 — per-part refinement (separate plan)
- **Per-part thickness:** add `certificate_part_id` to
`fp.thickness.reading`; associate readings + page-2 Fischerscope PDF
merges per part; render a per-part thickness block under each part row;
extend the `action_issue` thickness gate to require data on each part
that needs thickness.
- **Per-part box stickers:** today's consolidated "Multiple Line Items"
sticker gains per-part detail / per-part labels.
- **Cert form polish:** richer part-line editing UX.
Phase 2 is deferred and gets its own spec + plan once Phase 1 is live and
validated on entech.
## Files touched (Phase 1)
| # | File | Change |
|---|------|--------|
| 1 | `fusion_plating_jobs/models/sale_order.py` | New `_fp_recipe_signature` / `_fp_line_express_signature` / `_fp_line_group_key`; rewrite the `groups` key; define `_FP_EXPRESS_OVERRIDE_FIELDS`. |
| 2 | `fusion_plating_certificates/models/fp_certificate_part.py` | New model. |
| 3 | `fusion_plating_certificates/models/fp_certificate.py` | `part_line_ids` O2M. |
| 4 | `fusion_plating_certificates/models/__init__.py` | import new model. |
| 5 | `fusion_plating_certificates/security/ir.model.access.csv` | ACL for `fp.certificate.part`. |
| 6 | `fusion_plating_certificates/views/fp_certificate_views.xml` | Part-lines list on the cert form. |
| 7 | `fusion_plating_jobs/models/fp_job.py` | `_resolve_required_cert_types` union over all parts; `_fp_cert_source_lines`; `_fp_format_spec_ref`; part-line build in `_fp_create_certificates`. |
| 8 | `fusion_plating_reports/report/report_coc.xml` | tbody loop over `part_line_ids` + legacy fallback row. |
| 9 | `fusion_plating_jobs/report/report_fp_job_traveller.xml` | Item Information loops all parts. |
| 10 | `fusion_plating_certificates/migrations/<ver>/post-migrate.py` | Backfill one part-line per existing cert. |
| 11 | `__manifest__.py` × (jobs, certificates, reports) | Version bumps. |
## Migration
- New `fp.certificate.part` table created on install/upgrade.
- Post-migrate backfills the 13 existing certs (idempotent).
- Existing jobs/WOs untouched — `_fp_auto_create_job`'s `if existing:
return` guard means only **new** confirmations regroup.
- No re-grouping tool for open orders in Phase 1 (out of scope; can be a
one-off odoo-shell script later if the shop wants it).
## Testing
These modules require Enterprise deps and **cannot install on the local
Community box** (`fusion_plating` shows `installed=0` on `modsdev`), so:
- **Static checks (local):** `pyflakes` on every changed `.py`; lxml
parse on changed XML; `node --check` not needed (no JS).
- **Unit (where installable):** the grouping helpers are pure functions
of a recipe/line — `_fp_recipe_signature` returns equal tuples for two
structurally-identical recipes and unequal for divergent ones;
`_fp_line_group_key` merges same-structure lines and splits on
differing express overrides.
- **Live verification (entech via odoo shell, read-only first):**
1. Re-run the audit signature on SO-30083/30079/30071/30092 →
confirm each collapses to 1 group.
2. On a **clone** (or a fresh test SO), confirm SO with 4 same-process
lines → 1 WO carrying 4 `sale_order_line_ids`; SO with 2 different
processes → 2 WOs.
3. Confirm `_fp_create_certificates` produces one CoC with 4
part-lines; render the CoC PDF → 4 part rows, correct per-part
part#/serial/spec/qty.
4. Render an existing (migrated) single-part cert → identical to
before.
5. A line with masking ON + a line with masking OFF, same recipe →
**2** WOs (express-signature split).
## Edge cases & open questions
| Item | Decision |
|------|----------|
| No-recipe lines | Each its own WO (unchanged). |
| Same recipe structure, different express masking/bake | **Split** (express signature in the key). |
| Repeated same part across lines (SO-30079) | One cert part-line **per line** (not per distinct part) — each carries that line's serial/qty. |
| Part with `certificate_requirement='none'` on a WO whose other part needs a CoC | Combined CoC is produced (union) and **lists all shipped parts** — the cert documents the physical shipment. (Confirmed 2026-06-03.) |
| Per-part NC qty | Default 0 (job-level scrap not split per part); editable at issue. (Confirmed 2026-06-03.) |
| Job `part_catalog_id` when multi-part | First line (display/back-compat). |
| WO naming | `WO-<parent>` (1 group) / `WO-<parent>-NN` (N groups) — unchanged. |
| Existing open multi-line SOs already split into WOs | Left as-is; no auto re-group. |
**Confirmed during review (2026-06-03):** the union-cert "list all
shipped parts even if one part opted out" behaviour, and the "per-part
NC defaults to 0, editable at issue" behaviour are both approved.

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating — Certificates', 'name': 'Fusion Plating — Certificates',
'version': '19.0.9.3.0', 'version': '19.0.10.0.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.', 'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
'description': """ 'description': """

View File

@@ -5,6 +5,7 @@
from . import fp_thickness_reading from . import fp_thickness_reading
from . import fp_certificate from . import fp_certificate
from . import fp_certificate_part
from . import res_config_settings from . import res_config_settings
from . import res_partner from . import res_partner
from . import fp_delivery from . import fp_delivery

View File

@@ -87,6 +87,10 @@ class FpCertificate(models.Model):
thickness_reading_ids = fields.One2many( thickness_reading_ids = fields.One2many(
'fp.thickness.reading', 'certificate_id', string='Thickness Readings', 'fp.thickness.reading', 'certificate_id', string='Thickness Readings',
) )
part_line_ids = fields.One2many(
'fp.certificate.part', 'certificate_id', string='Parts',
help='One row per part covered by this certificate. Populated at '
'cert creation from the work order\'s sale-order lines.')
# ----- Inline Fischerscope PDF upload (cert-local) ---------------------- # ----- Inline Fischerscope PDF upload (cert-local) ----------------------
# The merge pipeline normally pulls the Fischerscope/XDAL PDF from the # The merge pipeline normally pulls the Fischerscope/XDAL PDF from the

View File

@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
class FpCertificatePart(models.Model):
"""One row per part on a combined Certificate of Conformance.
A work order can cover several parts that share the same plating
process; the combined CoC lists each with its own identity, spec,
and quantities. Fields are snapshots taken at cert-creation time.
"""
_name = 'fp.certificate.part'
_description = 'Certificate Part Line'
_order = 'certificate_id, sequence, id'
_rec_name = 'part_number'
certificate_id = fields.Many2one(
'fp.certificate', string='Certificate',
required=True, ondelete='cascade', index=True,)
sequence = fields.Integer(default=10)
sale_order_line_id = fields.Many2one(
'sale.order.line', string='Source SO Line',
help='The order line this part row was built from (traceability).',)
part_catalog_id = fields.Many2one('fp.part.catalog', string='Part')
part_number = fields.Char(string='Part Number') # snapshot
part_name = fields.Char(string='Part Name') # snapshot
description = fields.Char(string='Description') # customer-facing snapshot
serial = fields.Char(string='Serial Number(s)') # comma-joined snapshot
customer_spec_id = fields.Many2one(
'fusion.plating.customer.spec', string='Customer Spec',)
spec_reference = fields.Char(string='Spec Reference') # snapshot 'CODE Rev X'
# Per-part; the parent fp.certificate keeps cert-level legacy totals.
quantity_shipped = fields.Integer(string='Qty Shipped')
nc_quantity = fields.Integer(string='NC Qty')

View File

@@ -11,3 +11,6 @@ access_fp_thickness_upload_wiz_sup,fp.thickness.upload.wiz.supervisor,model_fp_t
access_fp_thickness_upload_wiz_mgr,fp.thickness.upload.wiz.manager,model_fp_thickness_upload_wizard,fusion_plating.group_fp_manager,1,1,1,1 access_fp_thickness_upload_wiz_mgr,fp.thickness.upload.wiz.manager,model_fp_thickness_upload_wizard,fusion_plating.group_fp_manager,1,1,1,1
access_fp_thickness_upload_wiz_line_sup,fp.thickness.upload.wiz.line.supervisor,model_fp_thickness_upload_wizard_line,fusion_plating.group_fp_shop_manager_v2,1,1,1,1 access_fp_thickness_upload_wiz_line_sup,fp.thickness.upload.wiz.line.supervisor,model_fp_thickness_upload_wizard_line,fusion_plating.group_fp_shop_manager_v2,1,1,1,1
access_fp_thickness_upload_wiz_line_mgr,fp.thickness.upload.wiz.line.manager,model_fp_thickness_upload_wizard_line,fusion_plating.group_fp_manager,1,1,1,1 access_fp_thickness_upload_wiz_line_mgr,fp.thickness.upload.wiz.line.manager,model_fp_thickness_upload_wizard_line,fusion_plating.group_fp_manager,1,1,1,1
access_fp_certificate_part_operator,fp.certificate.part.operator,model_fp_certificate_part,fusion_plating.group_fp_technician,1,1,0,0
access_fp_certificate_part_supervisor,fp.certificate.part.supervisor,model_fp_certificate_part,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
access_fp_certificate_part_manager,fp.certificate.part.manager,model_fp_certificate_part,fusion_plating.group_fp_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
11 access_fp_thickness_upload_wiz_mgr fp.thickness.upload.wiz.manager model_fp_thickness_upload_wizard fusion_plating.group_fp_manager 1 1 1 1
12 access_fp_thickness_upload_wiz_line_sup fp.thickness.upload.wiz.line.supervisor model_fp_thickness_upload_wizard_line fusion_plating.group_fp_shop_manager_v2 1 1 1 1
13 access_fp_thickness_upload_wiz_line_mgr fp.thickness.upload.wiz.line.manager model_fp_thickness_upload_wizard_line fusion_plating.group_fp_manager 1 1 1 1
14 access_fp_certificate_part_operator fp.certificate.part.operator model_fp_certificate_part fusion_plating.group_fp_technician 1 1 0 0
15 access_fp_certificate_part_supervisor fp.certificate.part.supervisor model_fp_certificate_part fusion_plating.group_fp_shop_manager_v2 1 1 1 0
16 access_fp_certificate_part_manager fp.certificate.part.manager model_fp_certificate_part fusion_plating.group_fp_manager 1 1 1 1

View File

@@ -152,6 +152,21 @@
invisible="trend_alert == 'ok'"/> invisible="trend_alert == 'ok'"/>
</group> </group>
<notebook> <notebook>
<page string="Parts" name="parts">
<field name="part_line_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="part_number"/>
<field name="part_name"/>
<field name="description"/>
<field name="serial"/>
<field name="customer_spec_id"/>
<field name="spec_reference"/>
<field name="quantity_shipped"/>
<field name="nc_quantity"/>
</list>
</field>
</page>
<page string="Thickness Readings" name="readings"> <page string="Thickness Readings" name="readings">
<field name="thickness_reading_ids"> <field name="thickness_reading_ids">
<list editable="bottom"> <list editable="bottom">

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
{ {
'name': 'Fusion Plating — Native Jobs', 'name': 'Fusion Plating — Native Jobs',
'version': '19.0.12.1.6', 'version': '19.0.12.2.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.', 'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.', 'author': 'Nexa Systems Inc.',

View File

@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
# Backfill one fp.certificate.part per existing certificate from its
# legacy singular fields, so pre-existing certs render identically under
# the new multi-part CoC. Lives in fusion_plating_jobs (not certificates)
# because it reads x_fc_job_id, a jobs-module field; the part-line table
# itself is created by the certificates upgrade, which runs first.
import logging
from odoo import api, SUPERUSER_ID
_logger = logging.getLogger(__name__)
def migrate(cr, version):
env = api.Environment(cr, SUPERUSER_ID, {})
if 'fp.certificate.part' not in env:
return
certs = env['fp.certificate'].search([])
made = 0
for cert in certs:
if cert.part_line_ids:
continue
try:
pid = cert._fp_resolve_part_identity() # (number, name, serials)
except Exception:
pid = ('', '', '')
job = cert.x_fc_job_id if 'x_fc_job_id' in cert._fields else False
part = job.part_catalog_id if (job and 'part_catalog_id' in job._fields) else False
try:
desc = cert._fp_resolve_customer_facing_description() or cert.process_description or ''
except Exception:
desc = cert.process_description or ''
spec = cert.customer_spec_id if 'customer_spec_id' in cert._fields else False
env['fp.certificate.part'].create({
'certificate_id': cert.id, 'sequence': 10,
'part_catalog_id': part.id if part else False,
'part_number': cert.part_number or (pid[0] or ''),
'part_name': pid[1] or '',
'description': desc,
'serial': pid[2] or '',
'customer_spec_id': spec.id if spec else False,
'spec_reference': cert.spec_reference or '',
'quantity_shipped': cert.quantity_shipped or 0,
'nc_quantity': cert.nc_quantity or 0,
})
made += 1
_logger.info('fp.certificate.part backfill: created %s part-line(s)', made)

View File

@@ -609,38 +609,47 @@ class FpJob(models.Model):
matches the defensive pattern used elsewhere in this file. matches the defensive pattern used elsewhere in this file.
""" """
self.ensure_one() self.ensure_one()
# ---- Step 1 — partner + part baseline ---- # ---- Step 1 — partner + part baseline (union across all parts) ----
req = ( def _partner_inherit_set():
self.part_catalog_id s = set()
and self.part_catalog_id.certificate_requirement
) or 'inherit'
if req == 'inherit':
wanted = set()
p = self.partner_id p = self.partner_id
if p: if p:
if p.x_fc_send_coc: if p.x_fc_send_coc:
wanted.add('coc') s.add('coc')
if p.x_fc_send_thickness_report: if p.x_fc_send_thickness_report:
wanted.add('thickness_report') s.add('thickness_report')
# Three aerospace/defence partner toggles. Field guards if 'x_fc_send_nadcap_cert' in p._fields and p.x_fc_send_nadcap_cert:
# let this module load even if fusion_plating_certificates s.add('nadcap_cert')
# is at an older version that pre-dates the new fields. if 'x_fc_send_mill_test' in p._fields and p.x_fc_send_mill_test:
if ('x_fc_send_nadcap_cert' in p._fields s.add('mill_test')
and p.x_fc_send_nadcap_cert): if 'x_fc_send_customer_specific' in p._fields and p.x_fc_send_customer_specific:
wanted.add('nadcap_cert') s.add('customer_specific')
if ('x_fc_send_mill_test' in p._fields return s
and p.x_fc_send_mill_test):
wanted.add('mill_test') def _explicit_set(req):
if ('x_fc_send_customer_specific' in p._fields return {
and p.x_fc_send_customer_specific): 'none': set(), 'coc': {'coc'},
wanted.add('customer_specific')
else:
wanted = {
'none': set(),
'coc': {'coc'},
'coc_thickness': {'coc', 'thickness_report'}, 'coc_thickness': {'coc', 'thickness_report'},
}.get(req, {'coc'}) }.get(req, {'coc'})
parts = self._fp_cert_source_lines().mapped('x_fc_part_catalog_id')
if not parts and self.part_catalog_id:
parts = self.part_catalog_id
if not parts:
parts = [False]
wanted = set()
inherit = None
for part in parts:
req = (part.certificate_requirement
if part and 'certificate_requirement' in part._fields
else 'inherit') or 'inherit'
if req == 'inherit':
if inherit is None:
inherit = _partner_inherit_set()
wanted |= inherit
else:
wanted |= _explicit_set(req)
# ---- Step 2 — recipe suppression (suppress-only) ---- # ---- Step 2 — recipe suppression (suppress-only) ----
recipe = self.recipe_id recipe = self.recipe_id
if recipe: if recipe:
@@ -2655,6 +2664,58 @@ class FpJob(models.Model):
self.name, e, self.name, e,
) )
def _fp_cert_source_lines(self):
"""Plating SO lines this job covers (one cert part-line each)."""
self.ensure_one()
lines = self.sale_order_line_ids
if not lines and self.sale_order_id:
lines = self.sale_order_id.order_line
return lines.filtered(
lambda l: not l.display_type
and ('x_fc_part_catalog_id' in l._fields and l.x_fc_part_catalog_id))
def _fp_format_spec_ref(self, spec):
"""Format 'CODE Rev X' from a customer spec (or '')."""
if not spec:
return ''
ref = spec.code or ''
if 'revision' in spec._fields and spec.revision:
ref = (f'{ref} Rev {spec.revision}' if ref
else f'Rev {spec.revision}')
return ref
def _fp_build_cert_part_commands(self):
"""O2M create commands for fp.certificate.part — one per line."""
self.ensure_one()
cmds, seq = [], 10
for sol in self._fp_cert_source_lines():
part = sol.x_fc_part_catalog_id
spec = (sol.x_fc_customer_spec_id
if 'x_fc_customer_spec_id' in sol._fields else False)
serials = ''
if 'x_fc_serial_ids' in sol._fields and sol.x_fc_serial_ids:
serials = ', '.join(sol.x_fc_serial_ids.mapped('name'))
# fp_customer_description() is a method (configurator), not a
# field — use hasattr, not a _fields check.
desc = (sol.fp_customer_description()
if hasattr(sol, 'fp_customer_description')
else (sol.name or ''))
cmds.append((0, 0, {
'sequence': seq,
'sale_order_line_id': sol.id,
'part_catalog_id': part.id if part else False,
'part_number': (part.part_number if part else '') or '',
'part_name': (part.name if part else '') or '',
'description': desc,
'serial': serials,
'customer_spec_id': spec.id if spec else False,
'spec_reference': self._fp_format_spec_ref(spec),
'quantity_shipped': int(sol.product_uom_qty or 0),
'nc_quantity': 0,
}))
seq += 10
return cmds
def _fp_create_certificates(self): def _fp_create_certificates(self):
"""Auto-create one draft fp.certificate per type returned by """Auto-create one draft fp.certificate per type returned by
_resolve_required_cert_types. Idempotent per type — re-running _resolve_required_cert_types. Idempotent per type — re-running
@@ -2742,10 +2803,7 @@ class FpJob(models.Model):
# spec_reference is what action_issue blocks on. # spec_reference is what action_issue blocks on.
# Format spec.code + revision for the cert text. # Format spec.code + revision for the cert text.
if spec and 'spec_reference' in Cert._fields: if spec and 'spec_reference' in Cert._fields:
ref = spec.code or '' ref = self._fp_format_spec_ref(spec)
if spec.revision:
ref = (f'{ref} Rev {spec.revision}'
if ref else f'Rev {spec.revision}')
if ref: if ref:
vals['spec_reference'] = ref vals['spec_reference'] = ref
if 'customer_spec_id' in Cert._fields: if 'customer_spec_id' in Cert._fields:
@@ -2781,6 +2839,10 @@ class FpJob(models.Model):
vals['contact_partner_id'] = contact.id vals['contact_partner_id'] = contact.id
if 'entech_wo_number' in Cert._fields: if 'entech_wo_number' in Cert._fields:
vals['entech_wo_number'] = self.name or '' vals['entech_wo_number'] = self.name or ''
if 'part_line_ids' in Cert._fields:
part_cmds = self._fp_build_cert_part_commands()
if part_cmds:
vals['part_line_ids'] = part_cmds
cert = Cert.create(vals) cert = Cert.create(vals)
self.message_post(body=Markup(_( self.message_post(body=Markup(_(
'%(t)s <b>%(n)s</b> auto-created (draft). Issuer ' '%(t)s <b>%(n)s</b> auto-created (draft). Issuer '

View File

@@ -395,6 +395,66 @@ class SaleOrder(models.Model):
return part.recipe_id return part.recipe_id
return Node return Node
def _fp_recipe_signature(self, recipe):
"""Hashable structural signature of a recipe's step tree.
Two recipes with the same signature have identical processing
steps and can share one work order. Excludes the recipe ROOT
(its name carries the per-part ' — <part#>' suffix) and all
numeric targets — those are per-part attestation data on the
cert, not a batch splitter. Returns None for a missing recipe.
"""
if not recipe:
return None
Node = self.env['fusion.plating.process.node']
kids = Node.search(
[('id', 'child_of', recipe.id),
('node_type', 'in', ('sub_process', 'operation', 'step'))],
order='parent_path, sequence')
return tuple(
(k.node_type,
(k.kind_id.code if k.kind_id else '') or '',
(k.name or '').strip().lower())
for k in kids)
def _fp_line_express_signature(self, line):
"""Per-line Express toggles that change which steps exist:
masking on/off and bake present/absent. Lines differing here
must not merge (the shared WO would silently drop one part's
masking or bake step). Free-text bake instructions are NOT in
the signature — both-present lines merge and the bake step
carries the last applied line's text (known Phase-1 limit).
When the Express fields are absent on a line's module, masking
defaults to True and bake to False, so a non-Express line groups
as masking-on / no-bake.
"""
F = line._fields
masking = bool(line.x_fc_masking_enabled) if 'x_fc_masking_enabled' in F else True
has_bake = bool((line.x_fc_bake_instructions or '').strip()) \
if 'x_fc_bake_instructions' in F else False
return (masking, has_bake)
def _fp_line_group_key(self, line, sig_cache=None):
"""WO grouping key. Lines with the same key ride one work order.
`sig_cache` (optional) memoises recipe-id -> signature so a
multi-line SO doesn't re-search the same recipe tree per line.
"""
recipe = self._fp_resolve_recipe_for_line(line)
if not recipe:
return ('no_recipe', line.id) # never merges
if sig_cache is None:
sig = self._fp_recipe_signature(recipe)
else:
if recipe.id not in sig_cache:
sig_cache[recipe.id] = self._fp_recipe_signature(recipe)
sig = sig_cache[recipe.id]
if not sig:
# A recipe with no step nodes has no structure to share —
# don't let empty-tree shells silently merge into one WO.
return ('no_recipe', line.id)
return ('recipe', sig, self._fp_line_express_signature(line))
def _fp_auto_create_job(self): def _fp_auto_create_job(self):
"""Create fp.job(s) from the SO's plating lines. """Create fp.job(s) from the SO's plating lines.
@@ -436,37 +496,14 @@ class SaleOrder(models.Model):
_logger.info('SO %s: no plating lines, skipping job creation.', self.name) _logger.info('SO %s: no plating lines, skipping job creation.', self.name)
return return
# Group by (recipe, part, spec, thickness, serial). Lines that # Group by recipe structural signature (+ per-line masking/bake
# share ALL FIVE collapse into one WO. Bundling lines with # toggles). Lines whose recipes have identical steps collapse onto
# different specs / thicknesses / serials under one WO would # one WO; no-recipe lines stay separate. See spec
# carry the first line's values onto the cert + sticker — # 2026-06-03-wo-grouping-by-recipe-combined-cert-design.md.
# silent mis-attestation. No-recipe lines still get their own
# group each.
groups = {} groups = {}
unrecipe_idx = 0 _sig_cache = {}
for line in plating_lines: for line in plating_lines:
recipe = self._fp_resolve_recipe_for_line(line) key = self._fp_line_group_key(line, sig_cache=_sig_cache)
part_id = (
'x_fc_part_catalog_id' in line._fields
and line.x_fc_part_catalog_id.id
) or False
spec_id = (
'x_fc_customer_spec_id' in line._fields
and line.x_fc_customer_spec_id.id
) or False
thickness_key = (
'x_fc_thickness_range' in line._fields
and (line.x_fc_thickness_range or '').strip()
) or False
serial_id = (
'x_fc_serial_id' in line._fields
and line.x_fc_serial_id.id
) or False
if recipe:
key = (recipe.id, part_id, spec_id, thickness_key, serial_id)
else:
unrecipe_idx += 1
key = ('no_recipe', unrecipe_idx)
groups[key] = groups.get(key, self.env['sale.order.line']) | line groups[key] = groups.get(key, self.env['sale.order.line']) | line
# Order groups by min line sequence so dash-suffixes mirror SO # Order groups by min line sequence so dash-suffixes mirror SO

View File

@@ -142,6 +142,16 @@
<span t-esc="(job.recipe_id and job.recipe_id.name) or '—'"/><br/> <span t-esc="(job.recipe_id and job.recipe_id.name) or '—'"/><br/>
<strong>S/N:</strong> <strong>S/N:</strong>
<t t-if="'serial_number' in job._fields"><span t-esc="job.serial_number or ''"/></t> <t t-if="'serial_number' in job._fields"><span t-esc="job.serial_number or ''"/></t>
<!-- Multi-part batch: list every distinct part on this WO
(the labeled block above details the primary part). -->
<t t-set="trav_lines" t-value="job.sale_order_line_ids.filtered(lambda l: not l.display_type and ('x_fc_part_catalog_id' in l._fields and l.x_fc_part_catalog_id)) if 'sale_order_line_ids' in job._fields else False"/>
<t t-set="trav_parts" t-value="trav_lines.mapped('x_fc_part_catalog_id') if trav_lines else False"/>
<t t-if="trav_parts and len(trav_parts) &gt; 1">
<br/><strong>Batch parts:</strong>
<t t-foreach="trav_parts" t-as="tp">
<div style="font-size: 7pt;"><span t-esc="tp.part_number or '—'"/><t t-if="'revision' in tp._fields and tp.revision"> Rev <span t-esc="tp.revision"/></t></div>
</t>
</t>
</td> </td>
<td> <td>
<strong> <strong>

View File

@@ -10,3 +10,5 @@ from . import test_autopause_cron
from . import test_post_shop_states from . import test_post_shop_states
from . import test_recipe_cert_suppression from . import test_recipe_cert_suppression
from . import test_order_ship_state from . import test_order_ship_state
from . import test_combined_cert_creation
from . import test_wo_recipe_grouping

View File

@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase
class TestCombinedCertCreation(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({
'name': 'CertCust',
'x_fc_send_coc': True, # drives the coc requirement
})
self.product = self.env['product.product'].create({'name': 'W'})
self.part_a = self.env['fp.part.catalog'].create({
'name': 'PartA', 'partner_id': self.partner.id, 'part_number': 'A-1'})
self.part_b = self.env['fp.part.catalog'].create({
'name': 'PartB', 'partner_id': self.partner.id, 'part_number': 'B-2'})
self.so = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [
(0, 0, {'product_id': self.product.id, 'product_uom_qty': 3,
'x_fc_part_catalog_id': self.part_a.id}),
(0, 0, {'product_id': self.product.id, 'product_uom_qty': 2,
'x_fc_part_catalog_id': self.part_b.id}),
],
})
def test_combined_cert_has_one_line_per_so_line(self):
job = self.env['fp.job'].create({
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 5.0,
'sale_order_id': self.so.id,
'part_catalog_id': self.part_a.id,
'sale_order_line_ids': [(6, 0, self.so.order_line.ids)],
})
job._fp_create_certificates()
cert = self.env['fp.certificate'].search([('x_fc_job_id', '=', job.id)])
self.assertEqual(len(cert), 1, 'one combined CoC')
self.assertEqual(len(cert.part_line_ids), 2, 'one part-line per SO line')
self.assertEqual(
set(cert.part_line_ids.mapped('part_number')), {'A-1', 'B-2'})
a = cert.part_line_ids.filtered(lambda p: p.part_number == 'A-1')
self.assertEqual(a.quantity_shipped, 3, 'shipped qty from the line')
def test_part_lines_fall_back_to_so_order_line(self):
# Job without an explicit sale_order_line_ids M2M still builds
# one part-line per plating line via the SO order_line fallback.
job = self.env['fp.job'].create({
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 5.0,
'sale_order_id': self.so.id,
'part_catalog_id': self.part_a.id,
})
job._fp_create_certificates()
cert = self.env['fp.certificate'].search([('x_fc_job_id', '=', job.id)])
self.assertEqual(len(cert), 1)
self.assertEqual(len(cert.part_line_ids), 2,
'falls back to SO order_line when no M2M lines set')

View File

@@ -0,0 +1,101 @@
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase
class TestWoRecipeGrouping(TransactionCase):
def setUp(self):
super().setUp()
self.SO = self.env['sale.order']
self.Node = self.env['fusion.plating.process.node']
# kind_id is required on process.node; reuse any seeded kind so
# node creation doesn't depend on the default lookup resolving.
self.kind = self.env['fp.step.kind'].search([], limit=1)
def _node_vals(self, name, node_type):
v = {'name': name, 'node_type': node_type}
if self.kind:
v['kind_id'] = self.kind.id
return v
def _recipe(self, name, step_names):
root = self.Node.create(self._node_vals(name, 'recipe'))
seq = 10
for sn in step_names:
v = self._node_vals(sn, 'step')
v.update({'parent_id': root.id, 'sequence': seq})
self.Node.create(v)
seq += 10
return root
def test_identical_structure_same_signature(self):
r1 = self._recipe('ENP — PART-A', ['Soak Clean', 'Rinse', 'E-Nickel'])
r2 = self._recipe('ENP — PART-B', ['Soak Clean', 'Rinse', 'E-Nickel'])
self.assertEqual(
self.SO._fp_recipe_signature(r1),
self.SO._fp_recipe_signature(r2),
'clones with identical steps share a signature')
def test_different_structure_different_signature(self):
r1 = self._recipe('ENP — A', ['Soak Clean', 'Rinse', 'E-Nickel'])
r2 = self._recipe('CHROME — B', ['Etch', 'Plate'])
self.assertNotEqual(
self.SO._fp_recipe_signature(r1),
self.SO._fp_recipe_signature(r2))
def test_so_groups_same_structure_into_one_wo(self):
partner = self.env['res.partner'].create({'name': 'G'})
product = self.env['product.product'].create({'name': 'P'})
pa = self.env['fp.part.catalog'].create({
'name': 'A', 'partner_id': partner.id, 'part_number': 'A'})
pb = self.env['fp.part.catalog'].create({
'name': 'B', 'partner_id': partner.id, 'part_number': 'B'})
pc = self.env['fp.part.catalog'].create({
'name': 'C', 'partner_id': partner.id, 'part_number': 'C'})
r1 = self._recipe('ENP — A', ['Soak Clean', 'Rinse'])
r2 = self._recipe('ENP — B', ['Soak Clean', 'Rinse']) # same structure
r3 = self._recipe('CHROME — C', ['Etch', 'Plate']) # different
so = self.env['sale.order'].create({
'partner_id': partner.id,
'order_line': [
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pa.id,
'x_fc_process_variant_id': r1.id}),
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pb.id,
'x_fc_process_variant_id': r2.id}),
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pc.id,
'x_fc_process_variant_id': r3.id}),
],
})
so._fp_auto_create_job()
jobs = self.env['fp.job'].search([('sale_order_id', '=', so.id)])
self.assertEqual(len(jobs), 2, 'A+B merge, C separate')
sizes = sorted(len(j.sale_order_line_ids) for j in jobs)
self.assertEqual(sizes, [1, 2])
def test_masking_toggle_splits_same_structure(self):
partner = self.env['res.partner'].create({'name': 'M'})
product = self.env['product.product'].create({'name': 'P'})
pa = self.env['fp.part.catalog'].create({
'name': 'A', 'partner_id': partner.id, 'part_number': 'A'})
pb = self.env['fp.part.catalog'].create({
'name': 'B', 'partner_id': partner.id, 'part_number': 'B'})
r1 = self._recipe('ENP — A', ['Soak Clean', 'Rinse'])
r2 = self._recipe('ENP — B', ['Soak Clean', 'Rinse'])
so = self.env['sale.order'].create({
'partner_id': partner.id,
'order_line': [
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pa.id,
'x_fc_process_variant_id': r1.id,
'x_fc_masking_enabled': True}),
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pb.id,
'x_fc_process_variant_id': r2.id,
'x_fc_masking_enabled': False}),
],
})
so._fp_auto_create_job()
jobs = self.env['fp.job'].search([('sale_order_id', '=', so.id)])
self.assertEqual(len(jobs), 2, 'masking on vs off must not merge')

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
{ {
'name': 'Fusion Plating — Reports', 'name': 'Fusion Plating — Reports',
'version': '19.0.11.34.0', 'version': '19.0.11.35.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.', 'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
'depends': [ 'depends': [

View File

@@ -295,7 +295,26 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <t t-foreach="doc.part_line_ids" t-as="pl">
<tr style="page-break-inside: avoid;">
<td class="text-center" style="line-height: 1.3;">
<div><t t-esc="pl.part_number or '-'"/></div>
<div><t t-esc="pl.part_name or '-'"/></div>
<div><t t-esc="pl.serial or '-'"/></div>
</td>
<td>
<t t-esc="pl.description or doc.process_description or ''"/>
<t t-if="pl.spec_reference">
<br/><em t-esc="pl.spec_reference"/>
</t>
</td>
<td class="text-center"><t t-esc="doc.po_number or '-'"/></td>
<td class="text-center"><t t-esc="pl.quantity_shipped or 0"/></td>
<td class="text-center"><t t-esc="pl.nc_quantity or 0"/></td>
<td class="text-center"><t t-esc="doc.customer_job_no or '-'"/></td>
</tr>
</t>
<tr t-if="not doc.part_line_ids" style="page-break-inside: avoid;">
<td class="text-center" style="line-height: 1.3;"> <td class="text-center" style="line-height: 1.3;">
<t t-set="pid" t-value="doc._fp_resolve_part_identity()"/> <t t-set="pid" t-value="doc._fp_resolve_part_identity()"/>
<div><t t-esc="pid[0] or '-'"/></div> <div><t t-esc="pid[0] or '-'"/></div>
@@ -303,11 +322,6 @@
<div><t t-esc="pid[2] or '-'"/></div> <div><t t-esc="pid[2] or '-'"/></div>
</td> </td>
<td> <td>
<!-- Customer-facing description is the cert's
spec / certificate info (client request
2026-05-28). Falls back to the recipe-
derived process_description. spec_reference,
now optional, still prints below when set. -->
<t t-set="cust_desc" t-value="doc._fp_resolve_customer_facing_description()"/> <t t-set="cust_desc" t-value="doc._fp_resolve_customer_facing_description()"/>
<t t-esc="cust_desc or doc.process_description or ''"/> <t t-esc="cust_desc or doc.process_description or ''"/>
<t t-if="doc.spec_reference"> <t t-if="doc.spec_reference">

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