Compare commits
11 Commits
main
...
claude/fus
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba7c028c30 | ||
|
|
41ce3784d7 | ||
|
|
98873c4e39 | ||
|
|
28e5e7f9de | ||
|
|
c71c60350b | ||
|
|
ba1e15da07 | ||
|
|
f1bf5b214c | ||
|
|
983e576fdc | ||
|
|
7cbf4f25df | ||
|
|
e35c120af8 | ||
|
|
e34892f5c0 |
127
docs/superpowers/EXECUTE-technician-service-booking.md
Normal file
127
docs/superpowers/EXECUTE-technician-service-booking.md
Normal 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".
|
||||||
325
docs/superpowers/mockups/technician-booking-wizard.html
Normal file
325
docs/superpowers/mockups/technician-booking-wizard.html
Normal 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 & 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 & linked on save — all from this page.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SERVICE & PRICING -->
|
||||||
|
<div class="card">
|
||||||
|
<h3><span class="dot"></span>Service & 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 & 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 & 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 & 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>
|
||||||
737
docs/superpowers/plans/2026-06-03-service-booking-wizard-plan.md
Normal file
737
docs/superpowers/plans/2026-06-03-service-booking-wizard-plan.md
Normal 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 (1–4) are TDD; the OWL task (5) is build-then-smoke.
|
||||||
|
|
||||||
|
**Pre-flight (rule #1 — never code from memory):** before Tasks 1, 3, 4, read the real signatures:
|
||||||
|
```bash
|
||||||
|
docker exec odoo-dev-app sed -n '760,800p;975,1010p;2725,2775p' \
|
||||||
|
/mnt/extra-addons/fusion_tasks/models/technician_task.py
|
||||||
|
```
|
||||||
|
Confirm `_get_local_tz`, `_compute_datetimes`/inverses, `_calculate_travel_time(origin_lat, origin_lng)` (sets `travel_distance_km`), and `_quick_travel_time`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
|---|---|
|
||||||
|
| `fusion_tasks/models/technician_task.py` *(modify ~781-798)* | tz-consistent inverses |
|
||||||
|
| `fusion_tasks/tests/test_task_tz.py` + `__init__.py` *(create)* | tz round-trip test |
|
||||||
|
| `fusion_claims/models/technician_task.py` *(modify)* | relax `_check_order_link`; add `x_fc_service_call_type`; pricing resolver; SO builder; `action_book_from_wizard` |
|
||||||
|
| `fusion_claims/models/sale_order.py` *(modify)* | `x_fc_is_service_repair` flag |
|
||||||
|
| `fusion_claims/data/service_repair_data.xml` *(create)* | "Service Repair" CRM tag |
|
||||||
|
| `fusion_claims/controllers/__init__.py` + `controllers/service_booking.py` *(create)* | `jsonrpc` refdata + submit routes |
|
||||||
|
| `fusion_claims/__init__.py` *(modify)* | import controllers |
|
||||||
|
| `fusion_claims/static/src/js/service_booking/service_booking.js` *(create)* | OWL client action |
|
||||||
|
| `fusion_claims/static/src/xml/service_booking.xml` *(create)* | OWL template (ported from mockup) |
|
||||||
|
| `fusion_claims/static/src/scss/_service_booking_tokens.scss` + `service_booking.scss` *(create)* | styles, dark/light |
|
||||||
|
| `fusion_claims/views/service_booking_action.xml` *(create)* | `ir.actions.client` + menu |
|
||||||
|
| `fusion_claims/__manifest__.py` *(modify)* | assets + data + version |
|
||||||
|
| `fusion_claims/tests/test_service_booking.py` *(create)* | resolver, SO builder, booking method |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Timezone-consistent inverses (`fusion_tasks`)
|
||||||
|
|
||||||
|
**Files:** Modify `fusion_tasks/models/technician_task.py`; create `fusion_tasks/tests/test_task_tz.py` (+ `tests/__init__.py` if absent).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `fusion_tasks/tests/test_task_tz.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from datetime import date
|
||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestTaskTz(TransactionCase):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
cls.env.user.tz = 'America/Toronto' # UTC-4 in summer
|
||||||
|
cls.task = cls.env['fusion.technician.task'].create({
|
||||||
|
'scheduled_date': date(2026, 6, 3),
|
||||||
|
'time_start': 9.0, 'time_end': 10.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_local_to_utc_compute(self):
|
||||||
|
# 9:00 local Toronto (DST, -4) -> 13:00 UTC stored
|
||||||
|
self.assertEqual(self.task.datetime_start.hour, 13)
|
||||||
|
|
||||||
|
def test_inverse_round_trips_with_same_tz(self):
|
||||||
|
# writing datetime_start back must recover the same local time_start
|
||||||
|
self.task.datetime_start = self.task.datetime_start # force inverse
|
||||||
|
self.task.flush_recordset(['datetime_start'])
|
||||||
|
self.assertAlmostEqual(self.task.time_start, 9.0, places=2)
|
||||||
|
```
|
||||||
|
|
||||||
|
Register in `fusion_tasks/tests/__init__.py` (create if missing):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from . import test_task_tz
|
||||||
|
```
|
||||||
|
|
||||||
|
If `fusion_tasks/tests/` doesn't exist, also add `'fusion_tasks/tests'` is auto-discovered — just ensure the `__init__.py` exists.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run — verify it fails** (on the clone, `--test-tags /fusion_tasks.TestTaskTz`). Expected: `test_inverse_round_trips` FAILS if user.tz ≠ company-calendar tz, or passes spuriously if they're equal — set the company `resource_calendar_id.tz` to `America/Toronto` in `setUpClass` too if needed to expose the mismatch.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Fix the inverses**
|
||||||
|
|
||||||
|
In `fusion_tasks/models/technician_task.py`, the two inverse methods currently use `pytz.timezone(self.env.user.tz or 'UTC')`. Change **both** to use the same resolver as `_compute_datetimes`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _inverse_datetime_start(self):
|
||||||
|
"""When datetime_start changes (calendar drag), update date + time. Uses the
|
||||||
|
SAME tz resolver as _compute_datetimes so the round-trip is consistent."""
|
||||||
|
import pytz
|
||||||
|
user_tz = self._get_local_tz()
|
||||||
|
for task in self:
|
||||||
|
if task.datetime_start:
|
||||||
|
local_dt = pytz.utc.localize(task.datetime_start).astimezone(user_tz)
|
||||||
|
task.scheduled_date = local_dt.date()
|
||||||
|
task.time_start = local_dt.hour + local_dt.minute / 60.0
|
||||||
|
|
||||||
|
def _inverse_datetime_end(self):
|
||||||
|
import pytz
|
||||||
|
user_tz = self._get_local_tz()
|
||||||
|
for task in self:
|
||||||
|
if task.datetime_end:
|
||||||
|
local_dt = pytz.utc.localize(task.datetime_end).astimezone(user_tz)
|
||||||
|
task.time_end = local_dt.hour + local_dt.minute / 60.0
|
||||||
|
```
|
||||||
|
|
||||||
|
(Only the `user_tz = …` line changes in each — from `pytz.timezone(self.env.user.tz or 'UTC')` to `self._get_local_tz()`.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run — verify it passes** (`--test-tags /fusion_tasks.TestTaskTz`). Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add fusion_tasks/models/technician_task.py fusion_tasks/tests/test_task_tz.py fusion_tasks/tests/__init__.py
|
||||||
|
git commit -m "fix(fusion_tasks): make datetime inverses use the same tz resolver as compute"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Relax SO constraint + repair-SO identity (`fusion_claims`)
|
||||||
|
|
||||||
|
**Files:** Modify `fusion_claims/models/technician_task.py`, `fusion_claims/models/sale_order.py`; create `fusion_claims/data/service_repair_data.xml`; modify `__manifest__.py`; test in `fusion_claims/tests/test_service_booking.py`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `fusion_claims/tests/test_service_booking.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from datetime import date
|
||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestServiceBooking(TransactionCase):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
cls.Task = cls.env['fusion.technician.task']
|
||||||
|
|
||||||
|
def test_task_without_order_is_allowed(self):
|
||||||
|
# repair for a brand-new client: no SO/PO must NOT raise
|
||||||
|
t = self.Task.create({'task_type': 'repair', 'scheduled_date': date(2026, 6, 3)})
|
||||||
|
self.assertTrue(t.id)
|
||||||
|
|
||||||
|
def test_sale_order_has_service_repair_flag(self):
|
||||||
|
so = self.env['sale.order'].new({})
|
||||||
|
self.assertIn('x_fc_is_service_repair', so._fields)
|
||||||
|
```
|
||||||
|
|
||||||
|
Register in `fusion_claims/tests/__init__.py` (append): `from . import test_service_booking`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run — verify it fails** (`--test-tags /fusion_claims.TestServiceBooking`). Expected: `test_task_without_order_is_allowed` FAILS with the ValidationError from `_check_order_link`; `test_sale_order_has_service_repair_flag` FAILS (field missing).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Relax the constraint**
|
||||||
|
|
||||||
|
In `fusion_claims/models/technician_task.py`, replace the body of `_check_order_link` so it no longer requires an order (the wizard auto-creates one; in-shop/walk-in legitimately have none):
|
||||||
|
|
||||||
|
```python
|
||||||
|
@api.constrains('sale_order_id', 'purchase_order_id')
|
||||||
|
def _check_order_link(self):
|
||||||
|
# Relaxed 2026-06: service bookings auto-create their SO, and in-shop /
|
||||||
|
# walk-in tasks may have none. No order link is required anymore.
|
||||||
|
return
|
||||||
|
```
|
||||||
|
|
||||||
|
(Keep the method as a no-op rather than deleting it, so any external `super()`/override chains stay intact.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add the repair flag + tag**
|
||||||
|
|
||||||
|
In `fusion_claims/models/sale_order.py`, add to the `sale.order` class:
|
||||||
|
|
||||||
|
```python
|
||||||
|
x_fc_is_service_repair = fields.Boolean(
|
||||||
|
string='Service Repair', copy=False,
|
||||||
|
help='Auto-created from the technician service booking wizard.',
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `fusion_claims/data/service_repair_data.xml`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?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.
|
||||||
@@ -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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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': """
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -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
|
||||||
|
|||||||
|
@@ -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">
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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 '
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) > 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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -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')
|
||||||
@@ -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': [
|
||||||
|
|||||||
@@ -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
799
fusion_schedule/CLAUDE.md
Normal 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 | Mon–Sun 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` (`:801–803`)
|
||||||
|
- ❌ `public_book_submit` (`:1505–1507`)
|
||||||
|
- ❌ `public_manage_reschedule` (`:889–891`)
|
||||||
|
|
||||||
|
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 4–5 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 (8–10) 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
386
fusion_schedule/CODE_MAP.md
Normal 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 19–23,
|
||||||
|
Microsoft endpoints 26–34.
|
||||||
|
|
||||||
|
**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.
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
163
scripts/verify_service_booking.sh
Normal file
163
scripts/verify_service_booking.sh
Normal 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
|
||||||
Reference in New Issue
Block a user