diff --git a/docs/superpowers/EXECUTE-technician-service-booking.md b/docs/superpowers/EXECUTE-technician-service-booking.md new file mode 100644 index 00000000..1c06c26d --- /dev/null +++ b/docs/superpowers/EXECUTE-technician-service-booking.md @@ -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 ` and + `python -m pyflakes ` (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". diff --git a/docs/superpowers/mockups/technician-booking-wizard.html b/docs/superpowers/mockups/technician-booking-wizard.html new file mode 100644 index 00000000..731e7a04 --- /dev/null +++ b/docs/superpowers/mockups/technician-booking-wizard.html @@ -0,0 +1,325 @@ + + + + + +Book a Service — Mockup v2 + + + +
+
+
+

Book a Service

Repair · delivery · pickup — captures the job and creates the priced repair order
+ +
+
+ ScheduledEn Route + In ProgressCompleted + ● Draft repair SO will be created +
+ +
+
+ +
+

Customer

+
+
+ + +
+
+
+
+ + +
Inbound call? Type the phone number — we match the contact & their history.
+
+
+
+
+
+
+
+
+
+
📍
+
+
+
+
+
+
+
Contact is created & linked on save — all from this page.
+
+
+ + +
+

Service & Pricing$ REVENUE

+
+
+ + +
+
+ + +
+
+
+ + +
Auto-suggested from the device — change if needed.
+
+
+
Call-out feeStandard · includes 30 min labour
+
$95
+
+ +
+ + +
+

Schedule

+
+
+
+
+
+
+ +
+ + +
+
+
Ends at 10:00 AM · your local time
+
+
+ + + ● 3 open slots before 5:00 PM +
+
+ + +
+

Location

+
+
In-shop jobAt the store — no call-out, labour @ $75/hr
+
+
+
+
+
📍
+
+
+
+
+
+
+
+ + +
+

Job details

+
+
+
+
+
Under manufacturer warrantyParts not billed when covered
+
POD requiredCapture proof of delivery on completion
+
Send client confirmation (email/SMS)Booked · en-route · completed
+
Request Google review after completion
+
+ + +
+
+
Call-out
$95
+
Est. labour
$85 · 1h
+ +
+
Estimated total
$180
+
+ parts as used · pre-tax · a draft SO is created
+
+
+
+ +
+ Local time · America/Toronto · 13 km away + + +
+
+
+ +
+ Mockup v2 — demo-wired (theme, customer mode, device→call-type, in-shop, AM/PM, switches, live estimate). + Real build = an OWL client action; Book & Create SO calls one server method that find-or-creates the + contact, creates the fusion.technician.task + a draft sale.order 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 top-right for dark/light. +
+ + + + diff --git a/docs/superpowers/plans/2026-06-03-service-booking-wizard-plan.md b/docs/superpowers/plans/2026-06-03-service-booking-wizard-plan.md new file mode 100644 index 00000000..8b1cbf51 --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-service-booking-wizard-plan.md @@ -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 + + + + + Service Repair + + + +``` + +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 ``. **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) | +| `` | 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 ` +
Inbound call? Type the phone number — we match the contact & their history.
+ + +
+
+
+
+
+
+
+
📍
+
+
+
+
+
+
+
Contact is created & linked on save — all from this page.
+
+ + + +
+

Service & Pricing$ REVENUE

+
+
+ + +
+
+ + +
+
+
+ + +
Auto-suggested from the device — change if needed.
+
+
+
Call-out fee · + travel · includes 30 min labour
+
$
+
+
In-shop job — no call-out fee; labour billed at $/hr.
+
+ + +
+

Schedule

+
+
+
+ +
+
+
+ +
+ + +
+ + +
+
+
Ends at · your local time
+
+
+ + +
+
+ + +
+

Location

+
+
In-shop jobAt the store — no call-out, labour @ $/hr
+
+
+
+
+
📍
+
+
+
+
+
+
+
+ + +
+

Job details

+
+
+
+
+
Under manufacturer warrantyParts not billed when covered
+
POD requiredCapture proof of delivery on completion
+
Send client confirmation (email/SMS)Booked · en-route · completed
+
Request Google review after completion
+
+ + +
+
+
Call-out
$
+
Est. labour
$ · h @ $
+
Travel ($/km ×2)
$
+
+
Estimated total
$
+
+ parts as used · pre-tax · a draft SO is created
+
+ + + +
+ Local time · America/Toronto · km away + + +
+ + + +
+ + diff --git a/fusion_claims/tests/__init__.py b/fusion_claims/tests/__init__.py index 561d21da..da3b5e31 100644 --- a/fusion_claims/tests/__init__.py +++ b/fusion_claims/tests/__init__.py @@ -3,3 +3,5 @@ from . import test_signed_pages_gate from . import test_application_received_wizard from . import test_dashboard +from . import test_service_rate +from . import test_service_booking diff --git a/fusion_claims/tests/test_service_booking.py b/fusion_claims/tests/test_service_booking.py new file mode 100644 index 00000000..7ddb0526 --- /dev/null +++ b/fusion_claims/tests/test_service_booking.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +from datetime import date, timedelta +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestServiceBooking(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Task = cls.env['fusion.technician.task'] + # technician_id is required on a task (domain x_fc_is_field_staff=True). + cls.tech = cls.env['res.users'].create({ + 'name': 'Service Booking Tech', + 'login': 'svcbook_tech', + 'x_fc_is_field_staff': True, + }) + + def test_task_without_order_is_allowed(self): + # No SO/PO must NOT raise after the relax. description is required and a + # non-in-store task needs an address, so set both here to isolate the test + # to the order-link relaxation (not those unrelated base constraints). + t = self.Task.create({ + 'task_type': 'repair', + 'technician_id': self.tech.id, + 'scheduled_date': date.today() + timedelta(days=7), + 'description': 'Test repair', + 'is_in_store': True, + }) + self.assertTrue(t.id) + + def test_sale_order_has_service_repair_flag(self): + so = self.env['sale.order'].new({}) + self.assertIn('x_fc_is_service_repair', so._fields) + + def test_resolve_service_lines_standard_rush(self): + Task = self.Task + lines = Task._resolve_service_lines('standard', 'rush', in_shop=False, distance_km=10.0) + # call-out $120 + per-km line qty 20 @ $0.70 + callout = [l for l in lines if l['price_unit'] == 120.0] + per_km = [l for l in lines if l['name_is_km']] + self.assertTrue(callout) + self.assertEqual(per_km[0]['product_uom_qty'], 20.0) + self.assertEqual(per_km[0]['price_unit'], 0.70) + + def test_resolve_service_lines_in_shop_empty_callout(self): + lines = self.Task._resolve_service_lines('standard', 'normal', in_shop=True, distance_km=5.0) + self.assertEqual(lines, []) + + def test_build_service_so(self): + partner = self.env['res.partner'].create({'name': 'Walk-in Wanda'}) + so = self.Task._build_service_so(partner, 'standard', 'normal', False, 0.0) + self.assertEqual(so.state, 'draft') + self.assertTrue(so.x_fc_is_service_repair) + self.assertEqual(so.partner_id, partner) + self.assertEqual(so.order_line[0].price_unit, 95.0) + + def test_action_book_creates_contact_task_and_so(self): + payload = { + 'cust_mode': 'new', + 'customer': {'name': 'Nina New', 'phone': '4165550199', 'email': 'nina@x.com', + 'street': '88 Bloor St E', 'city': 'Toronto'}, + 'category': 'standard', 'timing': 'normal', 'in_shop': False, + 'device': 'scooter', 'issue': "won't power on", + 'date': (date.today() + timedelta(days=7)).strftime('%Y-%m-%d'), 'time_start': 9.0, 'duration_hr': 1.0, + 'technician_id': self.tech.id, 'description': 'check battery', + } + res = self.Task.action_book_from_wizard(payload) + self.assertTrue(res['task_id'] and res['order_id']) + task = self.Task.browse(res['task_id']) + self.assertEqual(task.sale_order_id.id, res['order_id']) + self.assertEqual(task.sale_order_id.order_line[0].price_unit, 95.0) + partner = self.env['res.partner'].search([('email', '=ilike', 'nina@x.com')], limit=1) + self.assertTrue(partner) diff --git a/fusion_claims/tests/test_service_rate.py b/fusion_claims/tests/test_service_rate.py new file mode 100644 index 00000000..d621825f --- /dev/null +++ b/fusion_claims/tests/test_service_rate.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestServiceRate(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Rate = cls.env['fusion.service.rate'] + cls.product = cls.env['product.product'].create({ + 'name': 'Test Service Product', 'type': 'service', + }) + + def _make(self, **kw): + vals = dict(name='Rate', code='c1', rate_kind='callout', category='standard', + timing='normal', product_id=self.product.id, price=95.0, unit='fixed') + vals.update(kw) + return self.Rate.create(vals) + + def test_get_callout_matches_category_and_timing(self): + # Assert against the real seed (codes are unique, so creating colliding + # standard/normal rows would violate the UNIQUE(code) constraint). + r = self.Rate.get_callout('standard', 'normal') + self.assertTrue(r) + self.assertEqual(r.code, 'callout_standard_normal') + self.assertEqual(r.rate_kind, 'callout') + + def test_get_callout_in_shop_returns_empty(self): + self._make(code='callout_standard_normal_b') + self.assertFalse(self.Rate.get_callout('standard', 'normal', in_shop=True)) + + def test_get_rate_by_code(self): + # 'per_km' is a seeded code; the resolver returns that row. + r = self.Rate.get_rate('per_km') + self.assertTrue(r) + self.assertEqual(r.unit, 'per_km') + + def test_code_must_be_unique(self): + self._make(code='dup') + with self.assertRaises(Exception): + self._make(code='dup') + self.env.flush_all() + + def test_seeded_callouts_exist(self): + # standard normal $95, lift after-hours $205 are the canonical seeds + std = self.env.ref('fusion_claims.rate_callout_standard_normal') + self.assertEqual(std.price, 95.0) + self.assertEqual(std.rate_kind, 'callout') + self.assertTrue(std.product_id) + lift_ah = self.env.ref('fusion_claims.rate_callout_lift_afterhours') + self.assertEqual(lift_ah.price, 205.0) + self.assertTrue(lift_ah.adds_per_km) + + def test_seeded_per_km(self): + km = self.env['fusion.service.rate'].get_rate('per_km') + self.assertTrue(km) + self.assertEqual(km.unit, 'per_km') + self.assertEqual(km.price, 0.70) diff --git a/fusion_claims/views/service_booking_action.xml b/fusion_claims/views/service_booking_action.xml new file mode 100644 index 00000000..86e6812a --- /dev/null +++ b/fusion_claims/views/service_booking_action.xml @@ -0,0 +1,18 @@ + + + + + Book a Service + fusion_claims.service_booking + + + + diff --git a/fusion_claims/views/service_rate_views.xml b/fusion_claims/views/service_rate_views.xml new file mode 100644 index 00000000..4830e072 --- /dev/null +++ b/fusion_claims/views/service_rate_views.xml @@ -0,0 +1,101 @@ + + + + + + + + fusion.service.rate.list + fusion.service.rate + + + + + + + + + + + + + + + + + + + + + + + + + fusion.service.rate.form + fusion.service.rate + +
+ +
+

+
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + + + Service Rates + fusion.service.rate + list,form + {'active_test': False} + +

+ No service rates found. +

+

+ Add rates used for booking service calls, labour, travel, and delivery. +

+
+
+ + + + + + +
diff --git a/fusion_tasks/models/technician_task.py b/fusion_tasks/models/technician_task.py index 484c86a8..61e1c177 100644 --- a/fusion_tasks/models/technician_task.py +++ b/fusion_tasks/models/technician_task.py @@ -781,7 +781,7 @@ class FusionTechnicianTask(models.Model): def _inverse_datetime_start(self): """When datetime_start is changed (e.g. from calendar drag), update date + time.""" import pytz - user_tz = pytz.timezone(self.env.user.tz or 'UTC') + 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) @@ -791,7 +791,7 @@ class FusionTechnicianTask(models.Model): def _inverse_datetime_end(self): """When datetime_end is changed (e.g. from calendar resize), update time_end.""" import pytz - user_tz = pytz.timezone(self.env.user.tz or 'UTC') + 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) diff --git a/fusion_tasks/tests/__init__.py b/fusion_tasks/tests/__init__.py new file mode 100644 index 00000000..08755748 --- /dev/null +++ b/fusion_tasks/tests/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import test_task_tz diff --git a/fusion_tasks/tests/test_task_tz.py b/fusion_tasks/tests/test_task_tz.py new file mode 100644 index 00000000..3171d088 --- /dev/null +++ b/fusion_tasks/tests/test_task_tz.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +from datetime import date +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestTaskTz(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + # _compute_datetimes resolves company resource-calendar tz FIRST, then user tz. + # Set BOTH to Toronto so the UTC assertion and the round-trip are deterministic. + cls.env.user.tz = 'America/Toronto' + cal = cls.env.company.resource_calendar_id + if cal: + cal.tz = 'America/Toronto' + # technician_id is required (domain x_fc_is_field_staff=True) -> make a field tech. + cls.tech = cls.env['res.users'].create({ + 'name': 'TZ Test Tech', + 'login': 'tz_test_tech_svcbook', + 'x_fc_is_field_staff': True, + }) + # A FUTURE date in July so the task is not "in the past" (the base + # _check_no_overlap constraint rejects past dates) and Toronto is firmly + # in EDT (-4), keeping the 9:00 -> 13:00 UTC assertion deterministic. + cls.task = cls.env['fusion.technician.task'].create({ + 'technician_id': cls.tech.id, + 'scheduled_date': date(date.today().year + 1, 7, 1), + 'time_start': 9.0, + 'time_end': 10.0, + 'description': 'TZ round-trip test', # description is required (NOT NULL) + 'is_in_store': True, # avoids the address-required constraint + }) + + def test_local_to_utc_compute(self): + # 9:00 local Toronto (EDT, -4) -> 13:00 UTC stored + self.assertEqual(self.task.datetime_start.hour, 13) + + def test_inverse_round_trips_with_same_tz(self): + # writing datetime_start back recovers the same local time_start + self.task.datetime_start = self.task.datetime_start # force inverse + self.task.flush_recordset(['datetime_start']) + self.assertAlmostEqual(self.task.time_start, 9.0, places=2) diff --git a/scripts/verify_service_booking.sh b/scripts/verify_service_booking.sh new file mode 100755 index 00000000..99ae0ec6 --- /dev/null +++ b/scripts/verify_service_booking.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +# ============================================================================= +# verify_service_booking.sh +# +# HANDS-OFF clone-verify (and, behind a flag, deploy) for the Technician +# Service Booking feature (fusion_tasks + fusion_claims) on the Westin host. +# +# It automates the documented "Westin Prod — Clone-Verify / Deploy" procedure +# (see Odoo-Modules/CLAUDE.md) end-to-end: +# 1. refresh the branch checkout on the host +# 2. clone the live DB to a throwaway test DB (+ the orphaned-tax-FK cleanup) +# 3. stage the branch modules into the _test shadow prefix (prod untouched) +# 4. install/upgrade + run the module tests on the clone (PASS/FAIL gate) +# 5. (only with --deploy AND green tests) back up, swap, -u prod, restart +# 6. always clean up the clone + staging +# +# Verify-only by default. Deploy is OFF unless you pass --deploy. +# +# RUN IT ON THE WESTIN HOST: +# ssh odoo-westin # (via your usual jump) +# # one-time: put the branch on the host, e.g. +# # git clone /opt/odoo/staging/Odoo-Modules (or scp the tree there) +# bash verify_service_booking.sh # verify only +# DEPLOY=1 bash verify_service_booking.sh --deploy # verify, then deploy on green +# +# Prereq: the feature code must already be implemented on $BRANCH. This script +# does NOT write code — it verifies/deploys what's on the branch. +# ============================================================================= +set -Eeuo pipefail + +# ----------------------------- CONFIG (env-overridable) ---------------------- +APP="${APP:-odoo-dev-app}" # Odoo app container +DBC="${DBC:-odoo-dev-db}" # Postgres container +PROD_DB="${PROD_DB:-westin-v19}" # live DB (cloned, never -u'd unless --deploy) +CLONE_DB="${CLONE_DB:-westin-v19-svcbook}" # throwaway verify DB +PGPW="${PGPW:-DevSecure2025!}" +PGUSER="${PGUSER:-odoo}" + +MODULES="${MODULES:-fusion_tasks,fusion_claims}" # comma list for -u +# Scope to THIS feature's test classes — the broad /fusion_claims tag also runs +# pre-existing dashboard/wizard tests that fail in this prod-config runner +# (CLAUDE.md fusion_repairs note: post_install trips on a pre-existing module), +# which is unrelated to this feature. Override TEST_TAGS to widen if desired. +TEST_TAGS="${TEST_TAGS:-/fusion_tasks:TestTaskTz,/fusion_claims:TestServiceRate,/fusion_claims:TestServiceBooking}" +MOD_DIRS=(fusion_tasks fusion_claims) # dirs to stage/deploy + +BRANCH="${BRANCH:-claude/technician-service-booking}" +SRC="${SRC:-/opt/odoo/staging/Odoo-Modules}" # host checkout of the branch +STAGE="${STAGE:-/opt/odoo/custom-addons/_test}" # shadow prefix (CLAUDE.md) +LIVE_ADDONS="${LIVE_ADDONS:-/opt/odoo/custom-addons}" +BACKUPS="${BACKUPS:-/opt/odoo/backups}" # OUTSIDE the addons path +CONF="${CONF:-/etc/odoo/odoo.conf}" + +# _test prefix SHADOWS prod (first match wins); deps load from the real path. +ADDONS_PATH="/usr/lib/python3/dist-packages/odoo/addons,/usr/lib/python3/dist-packages/addons,${STAGE},/mnt/enterprise-addons,/mnt/extra-addons" +LIVE_ADDONS_PATH="/usr/lib/python3/dist-packages/odoo/addons,/usr/lib/python3/dist-packages/addons,/mnt/enterprise-addons,/mnt/extra-addons" + +DEPLOY=0 +[[ "${1:-}" == "--deploy" || "${DEPLOY:-0}" == "1" ]] && DEPLOY=1 +STAMP="$(date +%Y%m%d-%H%M%S 2>/dev/null || echo manual)" +LOG="/tmp/svcbook_verify_${STAMP}.log" + +c() { printf '\n\033[1;36m== %s ==\033[0m\n' "$*"; } # section +ok() { printf '\033[1;32m%s\033[0m\n' "$*"; } +err() { printf '\033[1;31m%s\033[0m\n' "$*" >&2; } +dexec() { docker exec "$@"; } +psql_clone() { dexec -e PGPASSWORD="$PGPW" "$DBC" psql -U "$PGUSER" -d "$CLONE_DB" -v ON_ERROR_STOP=1 "$@"; } + +# ----------------------------- CLEANUP TRAP ---------------------------------- +cleanup() { + c "Cleanup" + rm -rf "${STAGE:?}/"* 2>/dev/null || true + dexec -e PGPASSWORD="$PGPW" "$DBC" dropdb -U "$PGUSER" --if-exists "$CLONE_DB" 2>/dev/null || true + ok "Dropped clone $CLONE_DB, cleared $STAGE" +} +trap 'err "FAILED (line $LINENO). See $LOG"; cleanup' ERR +trap 'cleanup' EXIT + +# ----------------------------- 0. SANITY ------------------------------------- +c "Pre-flight" +docker ps --format '{{.Names}}' | grep -qx "$APP" || { err "container $APP not running"; exit 1; } +docker ps --format '{{.Names}}' | grep -qx "$DBC" || { err "container $DBC not running"; exit 1; } +if [[ -d "$SRC/.git" ]]; then + git -C "$SRC" fetch --quiet origin "$BRANCH" && git -C "$SRC" checkout --quiet "$BRANCH" && git -C "$SRC" pull --quiet --ff-only origin "$BRANCH" + ok "Branch $BRANCH @ $(git -C "$SRC" rev-parse --short HEAD)" +else + err "WARNING: $SRC is not a git checkout — staging whatever is on disk there." +fi +for m in "${MOD_DIRS[@]}"; do [[ -d "$SRC/$m" ]] || { err "missing module dir: $SRC/$m"; exit 1; }; done + +# ----------------------------- 1. CLONE THE DB ------------------------------- +c "Clone $PROD_DB -> $CLONE_DB (read-only on prod)" +dexec -e PGPASSWORD="$PGPW" "$DBC" sh -c \ + "dropdb -U $PGUSER --if-exists $CLONE_DB; createdb -U $PGUSER -O $PGUSER $CLONE_DB && pg_dump -U $PGUSER $PROD_DB | psql -U $PGUSER -q -d $CLONE_DB" \ + >>"$LOG" 2>&1 +ok "Cloned." + +# ----------------------------- 2. ORPHANED-FK CLEANUP (clone only) ----------- +# westin-v19 has orphaned rows under VALIDATED FKs (deleted taxes, companies, +# journals, ...). A plain pg_dump|psql clone cannot rebuild a validating FK over +# orphans, so the clone is MISSING those FKs; Odoo's check_foreign_keys then +# re-adds them and fails (e.g. payslip_tags_table.res_company_id=3, +# account_payment_method_line.journal_id=35). Generate an orphan-delete for EVERY +# single-column FK that exists on PROD (read-only SELECT on prod) and apply it to +# the clone. The clone is a throwaway; prod is never modified. +# (CLAUDE.md orphan-FK gotcha, generalised beyond the tax tables.) +c "Orphaned-FK cleanup (clone only) — general sweep from prod's FK definitions" +FKSQL="/tmp/svcbook_fkclean_${STAMP}.sql" +printf '%s\n' '\set ON_ERROR_STOP off' > "$FKSQL" +dexec -e PGPASSWORD="$PGPW" "$DBC" psql -U "$PGUSER" -d "$PROD_DB" -t -A -c "SELECT format('DELETE FROM %I a WHERE a.%I IS NOT NULL AND NOT EXISTS (SELECT 1 FROM %I b WHERE b.%I = a.%I);', src.relname, srcatt.attname, tgt.relname, tgtatt.attname, srcatt.attname) FROM pg_constraint con JOIN pg_class src ON src.oid=con.conrelid JOIN pg_namespace ns ON ns.oid=src.relnamespace AND ns.nspname='public' JOIN pg_class tgt ON tgt.oid=con.confrelid JOIN pg_attribute srcatt ON srcatt.attrelid=con.conrelid AND srcatt.attnum=con.conkey[1] JOIN pg_attribute tgtatt ON tgtatt.attrelid=con.confrelid AND tgtatt.attnum=con.confkey[1] WHERE con.contype='f' AND array_length(con.conkey,1)=1;" >> "$FKSQL" 2>>"$LOG" || true +dexec -i -e PGPASSWORD="$PGPW" "$DBC" psql -U "$PGUSER" -d "$CLONE_DB" < "$FKSQL" >>"$LOG" 2>&1 || true +ok "Orphan FKs cleared on clone (general sweep, $(grep -c '^DELETE' "$FKSQL" 2>/dev/null || echo 0) FK relations)." + +# ----------------------------- 3. STAGE MODULES (shadow) --------------------- +c "Stage modules into $STAGE (shadows prod, prod files untouched)" +mkdir -p "$STAGE" +for m in "${MOD_DIRS[@]}"; do rm -rf "${STAGE:?}/$m"; cp -r "$SRC/$m" "$STAGE/$m"; done +ok "Staged: ${MOD_DIRS[*]}" + +# ----------------------------- 4. INSTALL/UPGRADE + TESTS (clone) ----------- +# Test-runner gotchas on the prod-config container (CLAUDE.md / fusion_repairs): +# --test-enable SILENTLY SKIPS without --workers 0; log_level=warn hides test +# output -> add --log-level=test. The EXIT CODE is authoritative. +run_odoo() { # $1 = extra args + # --test-enable forces http_spawn() even with --no-http (Odoo 19), so the test + # run binds 8069 (held by the live app) and dies with "Address already in use". + # --http-port=0 --gevent-port=0 makes it pick ephemeral ports. (CLAUDE.md gotcha.) + dexec "$APP" odoo -d "$CLONE_DB" \ + --db_host db --db_port 5432 --db_user "$PGUSER" --db_password "$PGPW" \ + --addons-path="$ADDONS_PATH" --stop-after-init --no-http --http-port=0 --gevent-port=0 $1 +} + +c "Install/upgrade on clone (catches install/render errors)" +if run_odoo "-u $MODULES" >>"$LOG" 2>&1; then ok "Upgrade OK"; else err "UPGRADE FAILED — see $LOG"; tail -40 "$LOG"; exit 2; fi + +c "Run module tests on clone" +if run_odoo "-u $MODULES --test-enable --test-tags $TEST_TAGS --workers 0 --log-level=test" >>"$LOG" 2>&1; then + TESTS_OK=1; ok "TESTS PASSED" +else + TESTS_OK=0; err "TESTS FAILED (exit $?)"; grep -E 'FAIL|ERROR|Traceback' "$LOG" | tail -40 || true +fi + +echo +c "VERIFY RESULT" +if [[ "${TESTS_OK:-0}" == "1" ]]; then ok "✅ Clone-verify GREEN (full log: $LOG)"; else err "❌ Clone-verify RED (full log: $LOG)"; fi + +# ----------------------------- 5. DEPLOY (gated) ----------------------------- +if [[ "$DEPLOY" == "1" ]]; then + if [[ "${TESTS_OK:-0}" != "1" ]]; then err "Not deploying — tests are red."; exit 3; fi + c "DEPLOY to $PROD_DB (tests green)" + mkdir -p "$BACKUPS" + # DB backup (-Fc) + module dir backups OUTSIDE the addons path + dexec -e PGPASSWORD="$PGPW" "$DBC" pg_dump -Fc -U "$PGUSER" "$PROD_DB" > "$BACKUPS/${PROD_DB}_${STAMP}.dump" + for m in "${MOD_DIRS[@]}"; do [[ -d "$LIVE_ADDONS/$m" ]] && cp -r "$LIVE_ADDONS/$m" "$BACKUPS/${m}_${STAMP}"; done + ok "Backed up DB + module dirs to $BACKUPS" + # swap branch modules into the real addons + for m in "${MOD_DIRS[@]}"; do rm -rf "${LIVE_ADDONS:?}/$m"; cp -r "$SRC/$m" "$LIVE_ADDONS/$m"; done + # -u prod, gated on exit 0 + if dexec "$APP" odoo -d "$PROD_DB" --db_host db --db_port 5432 --db_user "$PGUSER" --db_password "$PGPW" \ + --addons-path="$LIVE_ADDONS_PATH" -u "$MODULES" --stop-after-init --no-http >>"$LOG" 2>&1; then + dexec -e PGPASSWORD="$PGPW" "$DBC" psql -U "$PGUSER" -d "$PROD_DB" -c \ + "DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';" >>"$LOG" 2>&1 || true + docker restart "$APP" >>"$LOG" 2>&1 + ok "🚀 Deployed + assets cleared + $APP restarted." + else + err "PROD -u FAILED — restoring module dirs, NOT restarting." + for m in "${MOD_DIRS[@]}"; do rm -rf "${LIVE_ADDONS:?}/$m"; [[ -d "$BACKUPS/${m}_${STAMP}" ]] && cp -r "$BACKUPS/${m}_${STAMP}" "$LIVE_ADDONS/$m"; done + err "Restore the DB if needed: pg_restore from $BACKUPS/${PROD_DB}_${STAMP}.dump" + exit 4 + fi +else + echo + ok "Verify-only run (no deploy). Re-run with --deploy to ship on green." +fi