From 79fbfec61f870a674d295d5a9fc22e817de922a2 Mon Sep 17 00:00:00 2001
From: gsinghpal
Date: Wed, 20 May 2026 21:22:01 -0400
Subject: [PATCH 01/30] docs(fusion_repairs): add design spec
Comprehensive 4-phase design for fusion_repairs Odoo 19 module covering
three intake surfaces (backend wizard, sales rep portal, public client
portal), AI self-check with strict medical safety guardrails, weekend
on-call paging, repairs pricelist automation, Poynt payment collection,
and maintenance lifecycle with client self-booking. 53 features across
phases 1-4; reuses existing fusion_tasks technician model and
fusion_authorizer_portal sales rep scaffolding.
Includes Appendices A-D with seed AI system prompt + JSON schema,
15 upsell rules, voicemail scripts, and 30 deterministic self-check
rules across 7 medical equipment categories.
Co-authored-by: Cursor
---
.../specs/2026-05-20-fusion-repairs-design.md | 1351 +++++++++++++++++
1 file changed, 1351 insertions(+)
create mode 100644 docs/superpowers/specs/2026-05-20-fusion-repairs-design.md
diff --git a/docs/superpowers/specs/2026-05-20-fusion-repairs-design.md b/docs/superpowers/specs/2026-05-20-fusion-repairs-design.md
new file mode 100644
index 00000000..dd709a35
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-20-fusion-repairs-design.md
@@ -0,0 +1,1351 @@
+# fusion_repairs — Module Design Spec
+
+**Date:** 2026-05-20
+**Module:** `fusion_repairs` (new)
+**Owner:** Gurpreet
+**Status:** Approved (ready for implementation plan)
+**Scope:** Four-phase build (~8-12 weeks); three intake surfaces; 53 features
+**Sister modules:** `fusion_repair_compliance`, `fusion_repair_plans`, `fusion_repair_shop`, `fusion_repair_analytics` (Phase 4, optional split)
+
+## Summary
+
+Build `fusion_repairs` as an Odoo 19 addon that extends the standard Repairs app (`repair.order`) with:
+
+- **Three intake surfaces** sharing one service layer (`fusion.repair.intake.service`):
+ 1. Backend wizard for CS on phone calls
+ 2. Sales rep mobile portal (`/my/repair/new`) for reps on the road
+ 3. Public AI-assisted client portal (`/repair`) with self-check + upsell engine — voicemail-ready
+- **Field service dispatch** via existing `fusion.technician.task` (reuses `x_fc_is_field_staff` + `group_field_technician` — no new technician model)
+- **Maintenance lifecycle** — contracts from delivered SOs, reminder crons, tokenized client self-booking
+- **Billing + payment** — `repair.order → action_create_sale_order()` → invoice → Poynt terminal collection
+- **Pricing automation** — service catalogue, repairs pricelist, AI matching, variance reconciliation
+- **Safety + on-call** — combined weekend escalation (911 disclaimer + Monday queue + on-call manager paging)
+- **Email + activities** — `fusion.email.builder.mixin` (consistent with fusion_claims styling)
+
+Built incrementally across 4 phases; each phase ships a usable slice.
+
+## Current state
+
+- [`fusion_repairs/`](fusion_repairs/) is an **empty folder** — no `__manifest__.py`, models, or views yet.
+- No existing code in the repo extends Odoo's `repair` app.
+- Closest precedents:
+ - [`fusion_ltc_management/models/ltc_repair.py`](fusion_ltc_management/models/ltc_repair.py) — repair workflow + SO + technician task (LTC facilities only; **keep separate**)
+ - [`fusion_tasks/models/technician_task.py`](fusion_tasks/models/technician_task.py) — field service scheduling with `task_type` including `repair` / `maintenance`
+ - [`fusion_claims/models/sale_order.py`](fusion_claims/models/sale_order.py) + [`fusion_claims/views/sale_order_views.xml`](fusion_claims/views/sale_order_views.xml) — smart buttons + automated emails via [`fusion_tasks/models/email_builder_mixin.py`](fusion_tasks/models/email_builder_mixin.py)
+ - [`fusion_poynt/models/sale_order.py`](fusion_poynt/models/sale_order.py) — `action_poynt_collect_payment()` after invoice
+ - [`fusion_schedule/`](fusion_schedule/) — public appointment booking (reuse in Phase 3 for maintenance self-scheduling)
+
+## Architecture decision (confirmed)
+
+```mermaid
+flowchart TD
+ subgraph intake [Phase1_Intake]
+ Wizard["fusion.repair.intake.wizard"]
+ Template["fusion.repair.intake.template"]
+ Wizard --> Template
+ Wizard -->|"submit"| RO["repair.order"]
+ end
+
+ subgraph dispatch [Phase1_Dispatch]
+ RO --> Task["fusion.technician.task"]
+ RO --> Activity["mail.activity reminders"]
+ RO --> Email["mail.mail via email_builder_mixin"]
+ end
+
+ subgraph billing [Phase2_Billing]
+ Task -->|"visit report wizard"| RO
+ RO -->|"action_create_sale_order"| RepairSO["sale.order repair billing"]
+ RepairSO --> Invoice["account.move"]
+ Invoice --> Poynt["fusion_poynt wizard"]
+ end
+
+ subgraph maintenance [Phase3_Maintenance]
+ OrigSO["sale.order original purchase"] --> Contract["fusion.repair.maintenance.contract"]
+ Contract --> Cron["ir.cron reminders"]
+ Cron --> BookingLink["tokenized email link"]
+ BookingLink --> Task
+ Task -->|"complete"| Contract
+ end
+
+ OrigSO -.->|"x_fc_original_sale_order_id"| RO
+```
+
+**Core record:** Odoo 19 [`repair.order`](https://www.odoo.com/documentation/19.0/applications/inventory_and_mrp/repairs/repair_orders.html) (not a parallel custom repair model).
+
+**Why:** Odoo 19 repair orders already handle product/serial, warranty, operations/parts, and generate a linked `sale_order_id` for invoicing. Fusion adds intake, dispatch, maintenance, and pricing on top — matching your choice and Odoo's post-refactor billing model (repair → SO → invoice).
+
+**Separation from LTC:** `fusion_ltc_management` stays for nursing-home facilities. `fusion_repairs` targets retail/home medical equipment (beds, wheelchairs, stairlifts, porch lifts, walkers, mattresses, rollators).
+
+---
+
+## Staff productivity feature catalogue
+
+Every feature below has been accepted for inclusion (full scope). Phase assignments balance staff impact against build effort.
+
+### CS / call intake
+
+| ID | Feature | Phase | Notes |
+|----|---------|-------|-------|
+| C1 | Duplicate-call detection | 1 | Wizard step 1: open repair from last 14 d on this phone → "add note instead?" |
+| C2 | **Client history sidebar in wizard** | 1 | Last 3 repairs, maintenance status, open balance, loaner status, ADP/insurance flag — pulled lazily |
+| C3 | SMS reply from wizard | 3 | Inline "Send SMS" via [`fusion_ringcentral`](fusion_ringcentral) |
+| C4 | Canned scripts per issue | 4 | New model `fusion.repair.script.template` keyed off `x_fc_issue_category` |
+| C5 | Outstanding-balance warning | 1 | Red banner if open invoice >$X (configurable) |
+| C6 | Quote-only mode | 1 | Wizard checkbox; repair starts in `quote` state, no tech dispatch, sends quote email |
+
+### Dispatcher
+
+| ID | Feature | Phase | Notes |
+|----|---------|-------|-------|
+| D1 | Map view of open repairs by zone | 3 | Reuse fusion_tasks map; add repair-state filter |
+| D2 | **Tech skills matrix** | 2 | `res.users.x_fc_repair_skills` Many2many → `fusion.repair.product.category`. Wizard filters candidate techs |
+| D3 | Parts pre-pull checklist | 2 | Print/email next-day picklist from `catalog.default_parts_product_ids` × scheduled tasks |
+| D4 | Reschedule with client SMS approval | 3 | Two-way SMS via RC; Y/N response writes to chatter |
+
+### Technician (mobile / in-field)
+
+| ID | Feature | Phase | Notes |
+|----|---------|-------|-------|
+| T1 | **Open in Maps button on task** | 2 | `geo:` / Apple Maps URL; one-tap |
+| T2 | **AI pre-visit brief on mobile form** | 2 | Surfaces `x_fc_ai_summary` prominently; "What to bring" + safety flags |
+| T3 | Labour timer via fusion_clock | 3 | Tap Start/Pause; final time pre-fills visit report |
+| T4 | **Client signature on completion** | 2 | OWL signature pad on visit report wizard; attached to repair (pattern from [`fusion_authorizer_portal`](fusion_authorizer_portal)) |
+| T5 | "Found another issue" button | 2 | Spawn new repair from current visit, same partner, different equipment |
+| T6 | Parts replaced — serial capture | 3 | Scan/type replaced part serials; stores for OEM warranty + traceability |
+| T7 | No-show photo proof | 3 | "Client not home" → camera → photo attached → repair flagged + service-call fee added |
+| T8 | Tap-to-collect-payment (Poynt terminal) | 2 | Visit report "Save & Collect" → fires `action_poynt_collect_payment()` |
+
+### Client experience
+
+| ID | Feature | Phase | Notes |
+|----|---------|-------|-------|
+| X1 | "Tech is X min away" SMS | 3 | Auto when tech taps "On the way" on mobile task |
+| X2 | **Day-before reminder SMS + email** | 1 (email) / 3 (SMS) | Cron pre-existing; SMS adds RC integration |
+| X3 | Self-reschedule link in confirmation | 3 | Tokenized URL; client picks new date themselves |
+| X4 | Post-visit NPS / review request | 3 | Auto-email 1 d after `done`; Google Review CTA |
+| X5 | Logged-in equipment portal | 4 | `/my/equipment` — warranty, next maintenance, history, manuals |
+
+### Back-office & management
+
+| ID | Feature | Phase | Notes |
+|----|---------|-------|-------|
+| M1 | **Compliance inspection certificates** (stairlifts/porch lifts) | 4 | New `fusion.repair.inspection.certificate` model + PDF report + annual cron + email to client. Could become sister module `fusion_repair_compliance` |
+| M2 | ADP/funder coverage for repair claims | 4 | Bridge to [`fusion_claims`](fusion_claims): if original SO is ADP-funded, create repair claim instead of charging client. Soft dep |
+| M3 | Loaner auto-offer when repair >X days | 3 | Bridge to [`fusion_loaners_management`](fusion_loaners_management); offer at intake + auto-create checkout |
+| M4 | Mail-in / shop repairs workflow | 4 | New state machine: pickup label → inbound shipment → shop repair → outbound. Uses [`fusion_shipping`](fusion_shipping) / [`fusion_canada_post`](fusion_canada_post) |
+| M5 | Pre-paid service plans | 4 | New `fusion.repair.service.plan` product type; sold on SO; entitles N maintenance visits/year; burn-down counter. Candidate sister module `fusion_repair_plans` |
+| M6 | **Repair warranty tracking (30/90 day re-do free)** | 2 | New `fusion.repair.warranty.coverage` model; if same equipment + similar issue within window → wizard banner "covered, no charge" |
+| M7 | Failure rate analytics dashboard | 4 | OWL client action: failures per product, symptom clusters, first-call resolution by tech |
+| M8 | OEM warranty claim filing | 4 | PDF claim doc to manufacturer; partner stores manufacturer reps |
+| M9 | Margin per repair | 4 | Computed: revenue − (labour × tech cost rate + parts cost); shown on repair + dashboard |
+
+### Client self-service portal (public, AI-assisted, upsell-focused)
+
+| ID | Feature | Phase | Notes |
+|----|---------|-------|-------|
+| CL1 | **Public `/repair` landing page** | 1 | Anonymous; matches voicemail prompt URL |
+| CL2 | **Phone-first lookup** (optional) | 1 | Client enters phone; if match found, pre-fills name/address — never reveals other client data |
+| CL3 | **Equipment selection with QR pre-fill** | 1 | Category + product + optional serial; QR sticker on equipment → `/repair?sn=XYZ` pre-selects unit |
+| CL4 | **Same guided question flow** | 1 | Reuses backend `fusion.repair.intake.template` via shared service |
+| CL5 | **Photo / video upload from camera** | 1 | Optional but encouraged |
+| CL6 | **AI self-check engine** (non-chat) | 2 | Suggests 1-3 safe self-check steps based on symptoms (e.g. "unplug for 30 sec, plug back in"). NEVER chat-style; strict structured JSON output |
+| CL7 | **AI safety guardrails** | 2 | System prompt forbids medical advice / diagnostic claims; mandates escalation if uncertain; passes through `fusion.api.service` with feature='client_self_triage' |
+| CL8 | **Resolved / unresolved branch** | 2 | "Did this fix it?" — if yes, optional upsell + survey; if no, escalates to repair queue |
+| CL9 | **Upsell engine** | 2 | After self-check (whether resolved or not), surface 2-4 product suggestions from `fusion.repair.upsell.suggestion` rules: maintenance plan (M5), extended warranty, replacement parts, accessories. AI ranks based on intake answers |
+| CL10 | **Direct-buy parts / plans** | 3 | "Add to order" buttons go to `/shop` (website_sale) or fast-checkout via Poynt portal flow |
+| CL11 | **Save & resume tokenized link** | 1 | Mid-flow exit → email/SMS resume link; localStorage backup |
+| CL12 | **Phone verification (SMS code) — smart** | 2 | If phone matches an existing partner → skip verification (trusted). For unknown phones → send 6-digit code via RC before submit. Anti-spam + ensures we can reach unknown callers. |
+| CL13 | **reCAPTCHA / honeypot** | 1 | Anti-spam on landing + submit |
+| CL14 | **Privacy notice + PHI consent** | 1 | Inline checkbox + privacy link; no PHI stored beyond explicit answers |
+| CL15 | **After-hours auto-response + safety escalation** | 2 | Outside business hours: friendly banner "Tech available Mon — try self-check below". **Safety branch (combined):** if client picks "safety issue" → (1) prominent disclaimer "If anyone is hurt, call 911 immediately", (2) queue for Monday with high-priority flag + "we'll call first thing" message, (3) page next-on-call manager via SMS + email (uses `x_fc_on_call_priority` ordering on `res.users` filtered by `x_fc_on_call=True`) |
+| CL16 | **Voicemail integration** | 3 | RC voicemail greeting auto-updated to mention `/repair` URL (or static recording); after-hours greeting variant |
+| CL17 | **QR sticker generator** | 3 | Per-product PDF: stick on equipment → scan → `/repair?sn=` |
+| CL18 | **Self-check knowledge base** | 4 | Linked manual snippets, short videos, "what does this beep mean" guides |
+| CL19 | **Voice input → AI transcription** | 4 | Client speaks the problem into mic, AI transcribes + classifies |
+| CL20 | **Resolution survey + Google review** | 2 | After "resolved" outcome, ask "save you time today?" + Google review CTA |
+
+### Sales rep portal (mirrors fusion_authorizer_portal pattern)
+
+| ID | Feature | Phase | Notes |
+|----|---------|-------|-------|
+| S1 | **Sales rep web intake form** | 1 | `/my/repair/new` — same question flow as backend wizard, mobile-friendly. Reuses `is_sales_rep_portal` flag on `res.partner` from [`fusion_authorizer_portal/security/portal_security.xml`](fusion_authorizer_portal/security/portal_security.xml) line 11 |
+| S2 | Sales rep dashboard tile | 1 | Add "Service Calls" tile to `/my/sales-rep/dashboard` showing count of repairs they logged + recent 5 |
+| S3 | **My Service Calls** list page | 1 | `/my/repairs` — sales rep sees their submitted repairs, status, assigned tech, scheduled date |
+| S4 | View repair status from portal | 1 | `/my/repair/` — read-only timeline, chatter for non-internal messages, ability to add a comment |
+| S5 | Add a note / new symptom from portal | 2 | If client calls back with more info, sales rep can append to the existing repair without backend access |
+| S6 | Mobile photo upload | 1 | Direct camera capture on phone, attached to repair |
+| S7 | Book maintenance on behalf of client | 2 | Sales rep can drive the booking link experience while on the call |
+| S8 | View client history sidebar | 1 | Same C2 sidebar exposed to sales reps inside the portal form |
+| S9 | "Quote me a repair" mode | 2 | Sales rep flags repair as `quote_only` (matches C6); price flows back to portal when ready |
+| S10 | Sales rep gets follow-up activity | 2 | When their submitted repair changes state (scheduled / completed / invoiced), portal notification + email |
+| S11 | Signature for warranty acknowledgement | 3 | If client is in person, sales rep captures signature on portal acknowledging "service call out of warranty" |
+| S12 | Repair-aware commission view | 4 | If sales rep gets commission on repair upsells, show running total on dashboard |
+
+**Routing namespace:** `/my/repair/*` (intake + my list) and a `/my/sales-rep/repairs` summary route added to the existing sales rep dashboard.
+
+**Record rule** (mirrors [`fusion_authorizer_portal/security/portal_security.xml`](fusion_authorizer_portal/security/portal_security.xml) line 129 pattern):
+
+```xml
+
+ Sales Rep Portal: Repairs they submitted
+
+ [('x_fc_intake_user_id', '=', user.id)]
+
+
+```
+
+### Other refinements
+
+| ID | Feature | Phase | Notes |
+|----|---------|-------|-------|
+| P1 | Client preferences on `res.partner` | 1 | `x_fc_preferred_tech_id`, `x_fc_preferred_window`, `x_fc_access_notes` (dog, gate code) |
+| P2 | Multi-equipment intake loop | 1 | See decision D below |
+| P3 | Photo intake | 1 | See decision E below |
+| P4 | Phone-first partner search | 1 | See decision K below |
+
+---
+
+## Refined design decisions (from gap review)
+
+| # | Decision | Resolution |
+|---|----------|------------|
+| A | **RingCentral integration** (Phase 4, deferred) | Phase 1 wizard launched from menu only. Phase 4 adds "Start Service Call" button on RC inbound popup with partner pre-fill. No fusion_ringcentral hard dependency. |
+| B | **Third-party equipment** | Always accept. Flag `x_fc_third_party_equipment=True`; force `under_warranty=False`; auto-add a service call-out fee line from the catalogue. |
+| C | **AI assist** (Phase 2) | Use `self.env['fusion.api.service'].call_openai(consumer='fusion_repairs', feature='intake_triage', ...)` with try/fallback (no `fusion_api` dependency). Two features: (1) summarize free-text issue into technician brief, (2) suggest matching `fusion.repair.service.catalog` entry. |
+| D | **Multi-equipment per call** | One `repair.order` per unit. Wizard step 2 has "+ Add Another Equipment" loop; all repair orders share the same `x_fc_intake_session_id` so they appear grouped on the partner. |
+| E | **Photo / attachment intake** | `ir.attachment` Many2many `x_fc_photo_ids` on `repair.order`; wizard step 3 has drag-drop upload. Pattern reused from [`fusion_ltc_management/controllers/portal_repair.py`](fusion_ltc_management/controllers/portal_repair.py). |
+| F | **Warranty determination** | `product.template.x_fc_warranty_months` (default 12). On intake, auto-tick `under_warranty` if `original_sale_order_id.delivery_date + warranty_months >= today` AND not third-party. CS can override with reason. |
+| G | **Office recipients** | Reuse `res.company.x_fc_office_notification_ids` already defined in [`fusion_claims/models/res_company.py`](fusion_claims/models/res_company.py) line 56 (Many2many to `res.partner`). |
+| H | **Multi-company** (Westin Healthcare + NEXA Systems) | All new models carry `company_id` default `lambda: self.env.company`; templates/pricelists/catalogs/contracts company-scoped; menus respect current company. |
+| I | **Parts inventory** | Use Odoo native — `repair.order` operations of type `add` create stock moves automatically. No extra Fusion code needed. |
+| J | **Mobile/field UX** | Phase 1 reuses existing `fusion.technician.task` mobile views (techs already use these daily). Visit report wizard works on mobile from the task form. |
+| K | **Wizard partner search** | Step 1 = phone-first lookup using E.164 normalization (same util as `fusion_ringcentral`); show recent repairs/SOs inline; "Create New Contact" button if no match. |
+
+---
+
+## Module dependencies
+
+```python
+'depends': [
+ 'repair', # Odoo Repairs app — repair.order
+ 'sale_management',
+ 'stock', # lot/serial tracking
+ 'maintenance', # optional bridge for equipment records
+ 'mail',
+ 'portal', # sales rep + maintenance booking portal
+ 'website', # QWeb portal templates
+ 'fusion_tasks', # technician tasks + fusion.email.builder.mixin
+ 'fusion_poynt', # payment collection
+ 'fusion_authorizer_portal', # sales rep portal flag + group + dashboard scaffold
+]
+# Phase 3 soft-add: 'appointment', 'fusion_schedule' for client self-booking
+# Phase 3 soft-add: 'fusion_clock' for tech labour timer (T3)
+# Phase 3 soft-add: 'fusion_loaners_management' for loaner auto-offer (M3) — already pulled transitively
+# Phase 3 soft-add: 'website_sale' for direct-buy parts/plans (CL10)
+# Phase 3 soft-add: 'fusion_ringcentral' for SMS verify (CL12) + voicemail greeting (CL16) + caller-ID launch (Phase 4)
+# Phase 4 soft-add: 'fusion_shipping', 'fusion_canada_post' for mail-in repairs (M4)
+# Soft-call (no depend) at runtime: 'fusion.api.service' via try/except per fusion-api-integration rule
+# NOTE: fusion_authorizer_portal transitively pulls fusion_claims — accepted for portal reuse
+```
+
+Before coding any Odoo 19 view/JS, read reference files from local OrbStack Docker per project rules.
+
+---
+
+## Data model
+
+### 1. Configurable intake (answers the "what questions to ask" problem)
+
+| Model | Purpose |
+|-------|---------|
+| `fusion.repair.product.category` | Medical categories: Hospital Bed, Wheelchair, Stairlift, Porch Lift, Walker, Mattress, Rollator, Other |
+| `fusion.repair.intake.template` | Question set per category (+ default fallback) |
+| `fusion.repair.intake.question` | Question definition: type (`char`, `text`, `selection`, `boolean`, `date`), sequence, required, help text, conditional parent answer |
+| `fusion.repair.intake.answer` | Stored answers; `repair_id` → `repair.order`, `question_id`, typed value fields |
+
+**Seed data:** Pre-load templates with category-specific question banks, e.g.:
+
+- **All calls:** caller name/relationship, client name, phone, address, product category, purchase date, bought from us?, serial number, issue summary, urgency, mobility/safety concern, access instructions (stairs, parking)
+- **Stairlift/Porch lift:** power status, error codes, stuck position, outdoor exposure
+- **Hospital bed:** motor side, remote working, weight capacity concern, rails/mattress type
+- **Wheelchair/Rollator/Walker:** brake issue, tire/tube, frame damage, battery (power chairs)
+
+### 2. `repair.order` extensions ([`models/repair_order.py`](fusion_repairs/models/repair_order.py))
+
+Key fields on top of standard Odoo fields:
+
+| Field | Purpose |
+|-------|---------|
+| `x_fc_intake_template_id` | Template used |
+| `x_fc_intake_completed` | Wizard finished |
+| `x_fc_intake_user_id` | CS agent who took the call |
+| `x_fc_intake_session_id` | Groups multiple repair orders from the same call |
+| `x_fc_original_sale_order_id` | Original purchase SO (auto-match by partner + serial/lot) |
+| `x_fc_third_party_equipment` | Boolean; True if equipment not sold by us |
+| `x_fc_warranty_override_reason` | Free text when CS forces warranty status |
+| `x_fc_technician_task_ids` / `x_fc_technician_task_count` | Field service link |
+| `x_fc_maintenance_contract_id` | If spawned from maintenance schedule |
+| `x_fc_service_catalog_id` | Matched repair type for auto-pricing |
+| `x_fc_urgency` | normal / urgent / safety |
+| `x_fc_issue_category` | symptom classification for automation |
+| `x_fc_estimated_duration` | hours (from catalog or task type defaults) |
+| `x_fc_estimated_cost` | $ from catalog at intake (pre-visit) |
+| `x_fc_actual_cost` | $ from visit report (post-visit) |
+| `x_fc_cost_variance_pct` | Computed: (actual − estimated)/estimated |
+| `x_fc_requires_requote` | True if variance crosses threshold |
+| `x_fc_intake_answer_ids` | One2many answers |
+| `x_fc_photo_ids` | Many2many `ir.attachment` — call photos / videos |
+| `x_fc_ai_summary` | Optional AI-generated tech brief (Phase 2) |
+
+**Auto-link original SO:** On wizard submit, search confirmed/done SOs for `partner_id` + `lot_id`/`product_id`; if found, set `x_fc_original_sale_order_id`.
+
+**Warranty determination:** Auto-tick `under_warranty` when:
+- `x_fc_third_party_equipment == False` AND
+- `x_fc_original_sale_order_id.commitment_date` (or first delivery) exists AND
+- `today <= delivery_date + product.x_fc_warranty_months`
+
+CS can override and must enter `x_fc_warranty_override_reason`.
+
+### 3. Service catalogue & pricing ([`models/service_catalog.py`](fusion_repairs/models/service_catalog.py))
+
+| Model | Purpose |
+|-------|---------|
+| `fusion.repair.service.catalog` | Named repair/maintenance type (e.g. "Stairlift motor replacement", "Bed motor troubleshoot") |
+| Fields | `product_category_id`, `symptom_keywords`, `default_product_id` (service product), `estimated_hours`, `default_parts_product_ids`, `pricelist_id`, `auto_schedule` bool, `task_type` |
+
+**Pricelist:** Dedicated "Repairs & Maintenance" pricelist in [`data/pricelist_data.xml`](fusion_repairs/data/pricelist_data.xml). Service products are `type='service'`, invoiced on SO lines created from repair operations.
+
+**Automation flow:** Intake answers → keyword/category match → set `x_fc_service_catalog_id` → pre-fill `repair.order` operations + estimated duration → reduce CS typing.
+
+### 4. Maintenance scheduling ([`models/maintenance_contract.py`](fusion_repairs/models/maintenance_contract.py))
+
+| Model | Purpose |
+|-------|---------|
+| `fusion.repair.maintenance.contract` | One per sold unit (partner + product + lot/serial) |
+| Fields | `original_sale_order_id`, `product_id`, `lot_id`, `interval_months`, `next_due_date`, `last_service_date`, `state`, `booking_token` |
+| Cron | Daily: find contracts due within N days → send reminder email |
+| On task complete | Roll `next_due_date` forward by interval (pattern from [`fusion_plating_bridge_maintenance/models/maintenance_request.py`](fusion_plating/fusion_plating_bridge_maintenance/models/maintenance_request.py) `_maybe_schedule_from_last`) |
+
+**Trigger creation:** Extend `sale.order` — when a delivered SO line contains a product with `x_fc_maintenance_interval_months > 0`, auto-create contract.
+
+### 5. Additional models from feature catalogue
+
+| Model | Purpose | Feature ID |
+|-------|---------|------------|
+| `fusion.repair.warranty.coverage` | Our 30/90-day warranty on completed repair work; auto-detects re-do calls | M6 |
+| `fusion.repair.script.template` | Canned CS scripts per issue category | C4 |
+| `fusion.repair.inspection.certificate` | Annual safety inspection certs for stairlifts/porch lifts | M1 |
+| `fusion.repair.service.plan` | Pre-paid service contracts (N visits/year) | M5 |
+| `fusion.repair.shop.intake` | Mail-in/shop-repair workflow state machine | M4 |
+
+**Partner extensions ([`models/res_partner.py`](fusion_repairs/models/res_partner.py)):**
+
+- `x_fc_preferred_tech_id` → `res.users`
+- `x_fc_preferred_window` (selection: morning / afternoon / evening)
+- `x_fc_access_notes` (text — "dog in yard", "use side gate", etc.)
+- `x_fc_repair_count`, `x_fc_open_balance` (computed for sidebar)
+
+**User extensions ([`models/res_users.py`](fusion_repairs/models/res_users.py)) — reuses existing fusion_tasks technician definition:**
+
+Existing in [`fusion_tasks/models/res_users.py`](fusion_tasks/models/res_users.py) (reused, do NOT recreate):
+- `x_fc_is_field_staff` (Boolean) — the technician flag. fusion_repairs uses this same domain `[('x_fc_is_field_staff', '=', True)]` on every tech selector.
+- `x_fc_start_address` — route-planning start location (reused for D1 map view)
+- `x_fc_tech_sync_id` — cross-instance technician identifier
+- Group [`fusion_tasks.group_field_technician`](fusion_tasks/security/security.xml) — reused as-is; fusion_repairs adds new ir.rule entries against this group for `repair.order` access
+
+New fields added by fusion_repairs:
+- `x_fc_repair_skills` Many2many → `fusion.repair.product.category` (D2 — skills matrix; dispatcher filters candidate techs)
+- `x_fc_tech_cost_rate` Monetary (M9 margin calc)
+- `x_fc_on_call` Boolean (eligible for on-call rotation)
+- `x_fc_on_call_priority` Integer (lower = paged first when combined weekend-safety path triggers)
+- `x_fc_on_call_phone` Char (override — falls back to `partner_id.mobile` then `partner_id.phone`)
+
+**Multi-technician on a repair visit:** reuses the `technician_id` + `additional_technician_ids` Many2many pattern already on `fusion.technician.task` ([line 146](fusion_tasks/models/technician_task.py)). Two-tech installs (heavy equipment, stairlifts) just add a second tech.
+
+**Future expansion (Phase 4):** dedicated `fusion.repair.on.call.shift` model with date range + user for proper shift scheduling. Phase 2 ships with simple `x_fc_on_call_priority`-sorted lookup so it works immediately.
+
+### 6. Cross-module links
+
+**[`models/technician_task.py`](fusion_repairs/models/technician_task.py)** — extend `fusion.technician.task`:
+
+- `x_fc_repair_order_id` → `repair.order`
+- On task create from repair: copy partner, address, `task_type='repair'|'maintenance'`, `duration_hours` from catalog
+- On task completion: open visit report wizard (parts used, labour, upsell lines)
+
+**[`models/sale_order.py`](fusion_repairs/models/sale_order.py)** — extend `sale.order`:
+
+- `x_fc_repair_order_ids` (repair orders linked to this SO as original purchase)
+- `x_fc_repair_billing_order_ids` (upsell/repair SOs spawned from repairs on this unit)
+- Count fields + `action_view_*` smart buttons (pattern from [`fusion_claims/views/sale_order_views.xml`](fusion_claims/views/sale_order_views.xml))
+
+**[`models/product_template.py`](fusion_repairs/models/product_template.py):**
+
+- `x_fc_repair_category_id`, `x_fc_maintenance_interval_months`, `x_fc_intake_template_id`, `x_fc_warranty_months` (default 12)
+
+---
+
+## Backend intake wizard (Phase 1 — your chosen UX)
+
+**Model:** `fusion.repair.intake.wizard` (TransientModel)
+
+**Menu action:** Repairs → **New Service Call** (opens wizard, not blank repair form)
+
+| Step | Content |
+|------|---------|
+| 1 — Caller & client | **Phone-first lookup** (E.164 normalized), shows recent repairs/SOs inline. Buttons: "Use This Partner" / "Create New Contact". Service address with Google autocomplete (reuse [`fusion_ltc_management`](fusion_ltc_management) / [`fusion_schedule`](fusion_schedule) pattern). |
+| 2 — Equipment | Product category, product, serial/lot lookup. Toggle "Purchased from us?" — if Yes, auto-search original SO; if No, set `x_fc_third_party_equipment=True`. **"+ Add Another Equipment"** button loops steps 2-3 for multi-unit calls. |
+| 3 — Guided questions + photos | Dynamic fields rendered from `fusion.repair.intake.template`; conditional show/hide. Drag-drop photo upload area. |
+| 4 — Triage | Urgency (normal / urgent / safety), preferred date/window, safety flags, internal notes. |
+| 5 — Review & submit | Summary of all equipment items + answers; CS sees estimated cost from catalog match → confirms. |
+
+**On submit actions:**
+
+1. Create one `repair.order` per equipment item (sharing `x_fc_intake_session_id`)
+2. Store all intake answers + attach photos
+3. Match `fusion.repair.service.catalog` by category + symptom keywords → set `x_fc_service_catalog_id`, `x_fc_estimated_duration`, `x_fc_estimated_cost`, pre-fill repair operations
+4. Determine warranty per logic above
+5. If third-party: auto-add catalogue "Service Call-Out Fee" line
+6. Create activities (see "Activities & follow-ups" section below)
+7. Auto-create draft `fusion.technician.task` when `catalog.auto_schedule=True` OR `urgency='safety'`
+8. **Phase 2:** call `fusion.api.service.call_openai()` to generate `x_fc_ai_summary` for technician brief (try/fallback per [fusion-api-integration rule](.cursor/rules/fusion-api-integration.mdc))
+9. Send emails: client confirmation + office CC via `_email_build()` (same styling as [`fusion_claims/data/mail_template_data.xml`](fusion_claims/data/mail_template_data.xml): 4px accent bar, 600px max-width, dark/light safe)
+
+---
+
+## Activities & follow-ups (gap 1)
+
+Created automatically on intake submit:
+
+| Activity | Target | Deadline | Trigger |
+|----------|--------|----------|---------|
+| **CS callback** | Intake user | +24 h | Always — confirms call back to client if anything was missing |
+| **Tech dispatch** | Office notification recipients | +4 h (safety) / +24 h (urgent) / +48 h (normal) | Until a `fusion.technician.task` is created |
+| **Visit follow-up** | Assigned technician | +24 h after `scheduled_date` | Until repair moves to `done` / `cancel` |
+| **Manager review** (third-party only) | Manager group | +24 h | Informational only — equipment we didn't sell |
+
+All activities use Fusion-standard `mail.activity.type` records; new types added in [`data/mail_activity_type_data.xml`](fusion_repairs/data/mail_activity_type_data.xml).
+
+---
+
+## Email & reminders
+
+**Automated (programmatic):** Inherit `fusion.email.builder.mixin` on `repair.order` (via mixin inheritance pattern used in [`fusion_claims/models/technician_task.py`](fusion_claims/models/technician_task.py)).
+
+| Event | Recipients |
+|-------|------------|
+| Intake submitted | Client + office CC (`res.company.x_fc_office_notification_ids` pattern) |
+| Technician scheduled/rescheduled | Client + technician |
+| Maintenance due (30/7/1 day) | Client with booking link |
+| Visit complete / invoice ready | Client |
+| Payment receipt | Client (via Poynt auto-email) |
+
+**Manual send templates:** [`data/mail_template_data.xml`](fusion_repairs/data/mail_template_data.xml) — repair quotation, maintenance appointment confirmation, invoice (copy structure from fusion_claims ADP templates with Repairs branding colours).
+
+**Settings toggle:** `fusion_repairs.enable_email_notifications` in `res.config.settings` (same pattern as fusion_claims).
+
+### Office follow-up cron chain (gap 2)
+
+Four `ir.cron` jobs in [`data/ir_cron_data.xml`](fusion_repairs/data/ir_cron_data.xml), all sending to `res.company.x_fc_office_notification_ids`:
+
+| Cron | Frequency | Trigger | Email |
+|------|-----------|---------|-------|
+| **Maintenance booking unconverted** | Daily | Reminder sent ≥5 days ago + no booking made | Office digest: "X clients haven't booked maintenance" |
+| **Repair without technician** | Daily | `repair.order.state='draft'` + age >7 days + no task | Office digest: "X repairs waiting for tech assignment" |
+| **Overdue technician visit** | Hourly | Task `scheduled_date` >24 h ago + not completed | Office alert per overdue task |
+| **Unpaid repair invoice** | Daily | Repair invoice posted >14 days + unpaid | Office digest with Poynt re-send link |
+
+Crons are config-toggleable via `ir.config_parameter` (`fusion_repairs.followup_*_enabled`).
+
+---
+
+## Technician visit → billing → payment (Phase 2)
+
+### Visit report wizard
+
+**`fusion.repair.visit.report.wizard`** opened from completed technician task or repair order:
+
+- Labour hours (default from catalog, editable)
+- Parts/consumables used (adds repair operations of type `add` — natively creates stock moves)
+- Recommended upsell products (optional SO lines)
+- Apply repairs pricelist (auto-selected from catalogue)
+- Computes `x_fc_actual_cost` and `x_fc_cost_variance_pct` vs `x_fc_estimated_cost`
+
+### Pricing reconciliation (gap 4)
+
+Settings:
+- `fusion_repairs.variance_threshold_pct` (default 20)
+- `fusion_repairs.variance_threshold_amount` (default $100)
+
+When `actual_cost` exceeds `estimated_cost` by either threshold:
+
+1. Set `x_fc_requires_requote = True`
+2. Block automatic invoicing
+3. Create activity on manager + office notification recipients: "Re-quote needed — variance $X (Y%)"
+4. Send `mail.template` requote email to client showing new estimate; CTA approve link `/repairs/requote/` → on approval, invoicing unlocks
+
+If variance under threshold → invoice proceeds automatically.
+
+### Billing chain (Odoo 19 native + Fusion)
+
+```
+repair.order → action_create_sale_order() → sale.order (repair billing)
+ → confirm → invoice → action_open_poynt_payment_wizard()
+```
+
+Reuse [`fusion_poynt`](fusion_poynt) without reimplementing payment logic. Add **Collect Payment** button on repair form when linked invoice is posted and unpaid.
+
+**Original SO traceability:** Repair billing SO gets `x_fc_source_sale_order_id` = original purchase SO (existing fusion_claims invoice-link pattern).
+
+### Auto-scheduling logic (gap 3)
+
+**Phase 1 (simple):** When `catalog.auto_schedule=True` or `urgency='safety'`:
+- Create `fusion.technician.task` in `draft` state with `priority='high'`
+- No date/technician assigned — dispatcher confirms in the task kanban
+- Office notification email lists draft tasks needing dispatch
+
+**Phase 3 (smart):** Add optional `auto_assign` setting; when on:
+- Find candidate technicians with relevant `task_type` skills
+- Use existing `fusion_tasks` travel/buffer logic to find next available slot within urgency deadline
+- Create task as `scheduled` with technician + date/time
+- If no slot found in window, fall back to draft + escalate
+
+---
+
+## Client self-service portal (Phase 1-3 — public AI-assisted intake)
+
+**Goal:** A client visits `/repair` (from voicemail, website, QR sticker, or email link), answers a short guided flow with AI self-check assistance, and either fixes the problem themselves (free for the company, builds goodwill, upsell opportunity) or files a properly-triaged repair request that lands in the same queue as backend/sales-rep intake.
+
+**Why this matters:** Weekend / after-hours coverage gap. Without this, clients call, hear voicemail, wait for Monday. With this, many weekend issues get self-resolved or properly queued by Monday morning with photos + symptoms + tried-steps already attached.
+
+### Architecture
+
+```mermaid
+flowchart TD
+ Entry["Entry points:\n- /repair (voicemail URL)\n- QR sticker on equipment\n- SMS resume link\n- Website 'Need Repair?' button"]
+ Entry --> Landing["/repair landing\nLarge 'Start' button\nAfter-hours banner"]
+ Landing --> Phone["/repair/start\nPhone-first lookup\n(skippable)"]
+ Phone --> Equip["/repair/equipment\nCategory + product + serial\nQR-prefilled if applicable"]
+ Equip --> Symptoms["/repair/symptoms\nGuided questions from intake.template"]
+ Symptoms --> Photos["/repair/photos\nOptional camera capture"]
+ Photos --> AICheck["/repair/self-check\nAI suggests 1-3 safe steps\nPhase 2+"]
+ AICheck --> Worked{"Did it fix it?"}
+ Worked -->|"Yes"| Upsell["/repair/upsell\nMaintenance plan, warranty,\nparts \u2014 'avoid this next time'"]
+ Worked -->|"No"| EscalateFlow["/repair/escalate\nShow estimated cost\nfrom catalogue match"]
+ EscalateFlow --> Verify["/repair/verify\nSMS code\nPhase 2+"]
+ Verify --> Submit["POST /repair/submit\nCreates repair.order via shared service"]
+ Upsell --> Survey["Resolution survey\n+ Google review CTA"]
+ Survey --> ThanksA["/repair/thanks (resolved)"]
+ Submit --> ThanksB["/repair/thanks (escalated)\nETA + reference number"]
+```
+
+### Auth + security model
+
+| Concern | Approach |
+|---------|----------|
+| Authentication | **Public** (`auth='public'`) — no login required. Voicemail-friendly. |
+| Identity verification | Optional phone lookup; **mandatory SMS code (CL12)** before final submit (Phase 2+). Phase 1 ships without SMS verify (CAPTCHA-only). |
+| Spam | reCAPTCHA v3 invisible challenge + honeypot field + per-IP rate limit (`/repair/submit` capped at 10/hour) |
+| CSRF | All POST routes use Odoo CSRF token (same as fusion_ltc_management public form) |
+| Privacy | Inline consent checkbox; PHI not stored beyond intake answers; data retention policy in privacy notice |
+| Cross-client data leak | Phone lookup returns ONLY the matched partner's name/address mask (no other PII); never returns other partners |
+| AI prompt injection | Strict system prompt; user input sandboxed; output schema-validated; AI never sees other clients' data |
+| AI cost runaway | `fusion.api.service` budget caps; fallback to deterministic self-check rules if AI unavailable |
+| Bot abuse on AI | AI step gated behind CAPTCHA pass + valid phone format check |
+
+### Controllers ([`controllers/portal_client_repair.py`](fusion_repairs/controllers/portal_client_repair.py))
+
+| Route | Type | Auth | Purpose |
+|-------|------|------|---------|
+| `/repair` | `http` | `public` | Landing page, after-hours banner, "Start" CTA |
+| `/repair/start` | `http` | `public` | Phone lookup form (skippable) |
+| `/repair/lookup_phone` | `jsonrpc` | `public` | E.164 normalize + safe partner match (returns only name/address) |
+| `/repair/equipment` | `http` | `public` | Category/product/serial selection — accepts `?sn=` for QR pre-fill |
+| `/repair/symptoms` | `http` | `public` | Guided question flow from intake template |
+| `/repair/photos` | `http` POST | `public` | Multipart upload to staged attachments |
+| `/repair/self_check` | `jsonrpc` | `public` | Calls `fusion.repair.ai.service.suggest_self_check(payload)` (Phase 2+) |
+| `/repair/upsell` | `jsonrpc` | `public` | Returns ranked upsell suggestions |
+| `/repair/buy/` | `http` | `public` | Redirect to `/shop/product/` or fast-checkout |
+| `/repair/verify` | `http` POST | `public` | Send SMS code via RC; verify on submit |
+| `/repair/submit` | `http` POST CSRF | `public` | Calls shared `fusion.repair.intake.service.create_repair_orders(payload, source='client_portal')` |
+| `/repair/resume/` | `http` | `public` | Resume saved session token |
+| `/repair/thanks` | `http` | `public` | Confirmation with reference number + tracking URL |
+
+Each route applies a `_check_rate_limit()` decorator + `_check_captcha()` for sensitive endpoints.
+
+### AI self-check (CL6/CL7) — strict architecture
+
+**Service:** `fusion.repair.ai.service` (AbstractModel) — single entry point so prompts and guardrails live in one place.
+
+**Method signature:**
+
+```python
+def suggest_self_check(self, payload):
+ """
+ Args payload: {
+ 'product_category': str,
+ 'product_id': int|None,
+ 'symptoms': list[str],
+ 'intake_answers': list[dict],
+ }
+ Returns: {
+ 'steps': [{'instruction': str, 'expected_result': str, 'safety_note': str|None}],
+ 'escalate_immediately': bool, # if AI thinks unsafe DIY
+ 'escalation_reason': str|None,
+ 'confidence': 'high'|'medium'|'low',
+ }
+ """
+```
+
+**System prompt enforces:**
+
+- NEVER provide medical advice
+- NEVER claim definitive diagnosis
+- ONLY suggest safe, reversible self-checks (unplug, restart, check connections, replace battery, clean sensors)
+- If product = stairlift / porch lift AND symptom contains motor/safety keywords → return `escalate_immediately=True`
+- Maximum 3 steps; each step ≤1 sentence
+- If unsure, escalate
+
+**Output is JSON-schema-validated** server-side — if AI returns malformed or unsafe content (contains "diagnose", "you have", "medical condition", "stop using"), drop it and fall back to deterministic rules.
+
+**Deterministic fallback:** [`data/self_check_data.xml`](fusion_repairs/data/self_check_data.xml) seeds `fusion.repair.self.check.rule` records keyed by `(product_category, symptom_keyword)` → static steps. Used when AI fails, exceeds budget, or returns unsafe output.
+
+**Per the `fusion-api-integration.mdc` rule:** uses try/fallback pattern with `consumer='fusion_repairs', feature='client_self_triage'`. No `fusion_api` hard dependency.
+
+### Upsell engine (CL9)
+
+**Model:** `fusion.repair.upsell.rule`
+
+| Field | Purpose |
+|-------|---------|
+| `name` | "Recommend extended warranty when out of warranty" |
+| `condition_domain` | Domain expression evaluated against the intake context |
+| `product_ids` | Many2many → `product.template` |
+| `priority` | Higher = shown first |
+| `pitch` | Short HTML — why this product matters |
+| `active` | Toggle |
+
+**Phase 2 seed rules:**
+
+- Out of warranty + product category = stairlift → extended warranty + annual inspection (M1)
+- Self-resolved → maintenance plan (M5)
+- Repair scheduled → loaner equipment (M3)
+- Battery-related symptom → replacement battery part
+- Mattress / consumable symptom → replacement consumable
+
+**Ranking:** AI scores rules against intake (Phase 2 enhancement) — `fusion.repair.ai.service.rank_upsells(rules, payload)` returns top 4 with personalized pitch text.
+
+**Conversion tracking:** `fusion.repair.upsell.suggestion` model logs every offered/clicked/converted suggestion → feeds the M7 analytics dashboard later.
+
+### Weekend safety escalation (combined approach, locked in)
+
+When client portal `urgency='safety'` is submitted outside business hours (per `resource.calendar` of the company):
+
+```mermaid
+flowchart TD
+ Submit["Client submits safety repair after hours"]
+ Submit --> Disclaimer["UI shows: 'If anyone is hurt, call 911 immediately'"]
+ Disclaimer --> Queue["repair.order created with priority=high\nactivity for office: 'call client first thing Monday'"]
+ Queue --> Page["Page next on-call manager"]
+ Page --> Lookup["res.users.search([\n ('x_fc_on_call', '=', True),\n ('x_fc_is_field_staff', '=', True) OR ('groups_id', 'in', repairs_manager_group)\n], order='x_fc_on_call_priority')"]
+ Lookup --> Send["Send SMS + email to first available"]
+ Send --> Ack["Manager taps acknowledge URL\nlogs to chatter\nfalls through to next priority if no ack in 15 min"]
+ Ack --> Followup["Office digest Monday 7AM: all weekend safety cases"]
+```
+
+**Models / helpers:**
+- `res.users.x_fc_on_call`, `x_fc_on_call_priority`, `x_fc_on_call_phone` (defined above)
+- `fusion.repair.on.call.service` (AbstractModel) — `find_next_on_call()` and `page_on_call(repair_id)` helpers
+- Tokenized URL `/repair/on-call/ack/` for manager acknowledgement
+- Cron escalates to next priority after 15 min without acknowledgement; alerts office partners on full exhaustion
+- All actions logged to repair chatter for audit
+
+**Phase 4 enhancement:** replace the priority-int with a proper `fusion.repair.on.call.shift` model (date range + user) so rotations can be scheduled weeks ahead. Phase 2 ships with priority-int because it works immediately for small teams.
+
+### Voicemail integration (CL16, Phase 3)
+
+Two-tier:
+
+| Tier | Implementation |
+|------|----------------|
+| **Tier 1 — static recording** | Office records voicemail greeting once: "We're closed. For faster help, visit our website at fusionrepairs dot com slash repair. For emergencies, leave a message." Done outside the module. |
+| **Tier 2 — dynamic RC API greeting** | Cron updates RC voicemail greeting based on business hours from `resource.calendar`. After-hours greeting includes the URL + reference. Requires fusion_ringcentral. Optional. |
+
+### Data model additions
+
+| Model | Purpose |
+|-------|---------|
+| `fusion.repair.self.check.rule` | Deterministic self-check steps (CL6 fallback) |
+| `fusion.repair.upsell.rule` | Upsell catalogue rules (CL9) |
+| `fusion.repair.upsell.suggestion` | What was offered / clicked / converted per repair (analytics) |
+| `fusion.repair.intake.session` | Token-based saved session for resume + cross-route state |
+| `fusion.repair.ai.service` | AbstractModel — single guardrailed AI entry point |
+
+**New `repair.order` fields:**
+- `x_fc_self_resolved` (boolean)
+- `x_fc_self_check_attempts` (text JSON — what AI suggested, what client did)
+- `x_fc_upsell_suggestion_ids` (One2many)
+
+**New `x_fc_intake_source` values:** add `'client_portal'` to the existing selection (`backend_wizard` / `sales_rep_portal` / `client_portal`).
+
+### Templates ([`views/portal_client_repair_templates.xml`](fusion_repairs/views/portal_client_repair_templates.xml))
+
+Single-column mobile-first layout; calls `website.layout`. Progress dots at top. Sticky bottom CTA. No sidebars. Large fonts, plain language, no jargon. Empty-state friendly (works without product selection).
+
+Themes adapt via project SCSS rules — no hardcoded colours per CLAUDE.md.
+
+### What the client CAN'T do (intentional limits per your "limit the information" requirement)
+
+- Browse other clients' equipment
+- See diagnostic terminology or part numbers (only friendly names)
+- See pricing variance / margin / cost breakdowns
+- Cancel / reschedule via this form (must call or log in to portal)
+- Chat with the AI freely (NOT chat-style; structured one-shot per step)
+- See technician names / phone numbers
+- Modify a submitted repair (read-only after submit)
+
+### Phase placement
+
+- **Phase 1:** CL1, CL2, CL3, CL4, CL5, CL11, CL13, CL14 — basic public form, no AI yet. Goes live with `/repair` URL ready for voicemail mention.
+- **Phase 2:** CL6, CL7, CL8, CL9, CL12, CL15, CL20 — AI self-check, upsell engine, SMS verify, after-hours messaging, resolution survey.
+- **Phase 3:** CL10, CL16, CL17 — direct-buy parts via website_sale or Poynt, voicemail RC integration, QR sticker generator.
+- **Phase 4:** CL18, CL19 — knowledge base videos, voice input transcription.
+
+---
+
+## Sales rep portal (Phase 1 — mirrors fusion_authorizer_portal)
+
+**Goal:** A sales rep on the road takes a client call and submits a repair request from their phone — same intake flow as backend CS wizard, no Odoo login screen.
+
+### Dependency strategy
+
+| Option | Recommendation |
+|--------|----------------|
+| **Hard depend on `fusion_authorizer_portal`** | RECOMMENDED — reuses the existing `is_sales_rep_portal` flag, `group_sales_rep_portal`, sales rep dashboard scaffolding. Transitively pulls fusion_claims (already core in your stack). |
+| Soft depend (try/except + own fallback flag) | Possible but doubles the code: own `is_sales_rep_portal` mirror + own group. Only worth it if you ever want fusion_repairs standalone. |
+
+We go with hard depend. Add `fusion_authorizer_portal` to the manifest `depends` list.
+
+### Architecture
+
+```mermaid
+flowchart LR
+ SR["Sales Rep on phone with client"] --> Portal["/my/repair/new\nMobile-friendly intake form"]
+ Portal --> ReuseQ["Same intake.template + questions\nas backend wizard"]
+ ReuseQ --> Submit["POST /my/repair/submit"]
+ Submit --> Repair["repair.order created\nx_fc_intake_user_id = SR\nx_fc_intake_source = 'sales_rep_portal'"]
+ Repair --> SameFlow["Same activities, emails,\ntech dispatch as backend intake"]
+ Repair --> SRView["/my/repairs list\n/my/repair/ detail"]
+ SRView --> Status["Read-only timeline + chatter comment"]
+```
+
+### Controller layout ([`controllers/portal_sales_rep_repair.py`](fusion_repairs/controllers/portal_sales_rep_repair.py))
+
+Routes scoped to `is_sales_rep_portal` users (gate at controller top, pattern from [`fusion_authorizer_portal/controllers/portal_assessment.py`](fusion_authorizer_portal/controllers/portal_assessment.py) line 25):
+
+| Route | Type | Purpose |
+|-------|------|---------|
+| `/my/repair/new` | `http` `auth='user'` | Render mobile-friendly intake form |
+| `/my/repair/lookup_partner` | `jsonrpc` | Phone-first partner search (reuses backend service) |
+| `/my/repair/client_history` | `jsonrpc` | C2 sidebar data — last repairs, balance, warranty |
+| `/my/repair/match_catalog` | `jsonrpc` | Live catalog match while sales rep types symptoms |
+| `/my/repair/upload_photos` | `http` POST | Photo / video upload from phone camera |
+| `/my/repair/submit` | `http` POST CSRF | Validate + create `repair.order(s)` + answers + activities |
+| `/my/repairs` | `http` `auth='user'` | Sales rep's list of submitted repairs (paged) |
+| `/my/repair/` | `http` `auth='user'` | Detail view with timeline + chatter comment box |
+| `/my/repair//comment` | `http` POST | Add a follow-up note (S5) |
+
+All routes use `_check_sales_rep_access()` helper that 302-redirects non-sales-rep portal users to `/my`.
+
+### Service layer reuse
+
+The backend wizard's submit logic lives in a service method on the `fusion.repair.intake.template` model (or a dedicated `fusion.repair.intake.service` AbstractModel) — both backend wizard AND portal controller call the same `create_repair_orders(payload)` method. This guarantees identical behaviour:
+
+- One `repair.order` per equipment
+- Same activities, emails, AI summary, warranty determination, catalogue match, third-party flag, dispatch task
+
+Avoids the trap of two intake flows drifting out of sync.
+
+### Templates ([`views/portal_sales_rep_templates.xml`](fusion_repairs/views/portal_sales_rep_templates.xml))
+
+QWeb templates following [`fusion_authorizer_portal/views/portal_assessment_express.xml`](fusion_authorizer_portal/views/portal_assessment_express.xml) style:
+
+- `portal_repair_intake_form` — multi-step (accordion or stepper) with same 5 sections as backend wizard
+- `portal_repair_list` — card list with status badge, scheduled date, tech name
+- `portal_repair_detail` — timeline + chatter
+- `portal_repair_intake_thanks` — confirmation page with "Submit Another" button (common on multi-call days)
+
+Reuses portal gradient/header style via `portal_gradient` template variable already set by [`portal_main.home()`](fusion_authorizer_portal/controllers/portal_main.py) line 85.
+
+### JS ([`static/src/js/portal_repair_intake.js`](fusion_repairs/static/src/js/portal_repair_intake.js))
+
+Per [`environment-safety`](.cursor/rules/environment-safety.mdc) and project Odoo 19 rule #2: frontend JS uses the `Interaction` class from `@web/public/interaction`, registered via `registry.category("public.interactions")`. NOT IIFE/DOMContentLoaded.
+
+Interactions:
+- `repair_intake_stepper` — handles step navigation + validation
+- `repair_intake_partner_lookup` — debounced phone search → renders matched partners
+- `repair_intake_history_sidebar` — fetches C2 data when partner picked
+- `repair_intake_photo_capture` — direct camera capture + thumb preview
+- `repair_intake_catalog_match` — live symptom → catalogue suggestion
+
+### What the sales rep CAN'T do (intentional scoping)
+
+- See repairs they didn't submit (record rule blocks)
+- Edit / cancel a submitted repair (must call office)
+- Assign a technician (dispatcher's job)
+- Adjust pricing (office)
+- Mark a repair `done` (technician's job)
+
+This keeps the portal a safe single-purpose tool — submit a request, add notes, see status.
+
+### Mobile UX SCSS
+
+[`static/src/scss/portal_repair_mobile.scss`](fusion_repairs/static/src/scss/portal_repair_mobile.scss) — tap targets ≥44px, bottom-fixed submit button, no horizontal scroll, follows `_tokens` pattern per project CLAUDE.md card styling rules.
+
+---
+
+## Maintenance client self-booking (Phase 3)
+
+**Email CTA:** Signed token URL `/repairs/maintenance/book/` (controller in [`controllers/maintenance_portal.py`](fusion_repairs/controllers/maintenance_portal.py)).
+
+**Booking page options:**
+
+- **Preferred:** Integrate with [`fusion_schedule`](fusion_schedule) public booking + dedicated `appointment.type` "Equipment Maintenance" assigned to technician pool
+- **Fallback:** Lightweight slot picker (date + time windows from technician availability in `fusion.technician.task`)
+
+**On confirm:** Create `fusion.technician.task` (`task_type='maintenance'`), link to `maintenance.contract`, send confirmation email (fusion_claims style), create calendar event if `fusion_schedule` present.
+
+---
+
+## Smart buttons
+
+### On `repair.order` form
+
+| Button | Target |
+|--------|--------|
+| Technician Tasks | `fusion.technician.task` |
+| Repair Sale Order | `sale_order_id` (Odoo native) |
+| Original Purchase | `x_fc_original_sale_order_id` |
+| Maintenance Plan | `fusion.repair.maintenance.contract` |
+| Invoices | Posted invoices from repair SO |
+| Intake Answers | readonly answer list |
+
+### On original `sale.order` form
+
+| Button | Icon | Target |
+|--------|------|--------|
+| Repairs | `fa-wrench` | `repair.order` where `x_fc_original_sale_order_id = self` |
+| Maintenance | `fa-calendar-check-o` | `fusion.repair.maintenance.contract` |
+| Repair Invoices | `fa-file-text-o` | Upsell/repair SOs + invoices |
+
+Pattern: compute count + `action_view_*` + `oe_stat_button` / `statinfo` (identical to [`fusion_claims/views/sale_order_views.xml`](fusion_claims/views/sale_order_views.xml) lines ~1176–1181).
+
+---
+
+## UI / menus
+
+New app menu **Fusion Repairs**:
+
+- **Service Calls** → intake wizard launcher + repair order kanban/list
+- **Maintenance Schedules** → contract list with upcoming due filter
+- **Technician Tasks** → filtered `fusion.technician.task` (repair + maintenance)
+- **Configuration** → intake templates, service catalogue, product categories, settings
+
+Extend repair order form view with Intake tab (answers), Maintenance tab, and stat buttons.
+
+---
+
+## Security
+
+[`security/security.xml`](fusion_repairs/security/security.xml) — reuses existing fusion_tasks pattern where possible:
+
+**Reused (do NOT recreate):**
+- [`fusion_tasks.group_field_technician`](fusion_tasks/security/security.xml) — for technician access to `repair.order` (parallel to existing tech task rules). Same domain `('technician_id', '=', user.id)` adapted as `('x_fc_technician_task_ids.technician_id', '=', user.id)` on repair orders
+- [`fusion_authorizer_portal.group_sales_rep_portal`](fusion_authorizer_portal/security/portal_security.xml) — for sales rep portal access (see Sales rep portal section)
+
+**New groups specific to fusion_repairs:**
+- `group_fusion_repairs_user` — CS intake, view repairs (implied by `base.group_user`)
+- `group_fusion_repairs_dispatcher` — assign technicians, reschedule, parts pre-pull picklists
+- `group_fusion_repairs_manager` — config, pricing, maintenance contracts, on-call rotation, variance overrides
+
+**Record rules:**
+- Technicians (via `group_field_technician`): see only repairs where they are in `x_fc_technician_task_ids.all_technician_ids` for their company
+- Sales reps (via `group_sales_rep_portal`): see only repairs where `x_fc_intake_user_id = user.id`
+- Public client portal: `auth='public'` — no record rule (records created via sudo by controller, validated server-side)
+- CS / dispatcher / manager: full access scoped by `company_id`
+
+---
+
+## Implementation phases
+
+### Phase 1 — CS can take a call end-to-end + sales rep portal (MVP)
+
+**Goal:** A CS rep at their desk OR a sales rep on the road can submit a repair request; it lands in dispatch with reminders set.
+
+**Backend CS workflow:**
+- Module scaffold + manifest + security + multi-company defaults
+- Intake templates (seed question banks per product category)
+- Shared `fusion.repair.intake.service` AbstractModel used by both wizard and portal
+- 5-step backend intake wizard with: phone-first partner search (P4), client history sidebar (C2), duplicate-call detection (C1), outstanding-balance warning (C5), quote-only mode (C6), multi-equipment loop (P2), photo upload (P3), warranty auto-detect, third-party flag, AI summary (try/fallback)
+- `repair.order` extensions + intake answers + `x_fc_intake_session_id` + `x_fc_intake_source` (selection: `backend_wizard` / `sales_rep_portal`)
+- Auto-create draft `fusion.technician.task` when urgent/safety
+- 4 activities on submit (CS callback, tech dispatch, visit follow-up, manager review)
+- Automated emails (intake received, task scheduled) using `fusion.email.builder.mixin`
+- Day-before reminder email (X2 email portion)
+- Client preferences on `res.partner` (P1)
+- Repair kanban + menu + basic security groups
+
+**Sales rep portal (S1-S4, S6, S8):**
+- Portal controllers `/my/repair/new`, `/my/repairs`, `/my/repair/`
+- Mobile-friendly QWeb templates following [`fusion_authorizer_portal/views/portal_assessment_express.xml`](fusion_authorizer_portal/views/portal_assessment_express.xml) style
+- Same intake question flow as backend (via shared service layer)
+- Mobile photo / camera capture
+- Client history sidebar exposed in portal form
+- "Service Calls" tile on existing sales rep dashboard
+- Record rules scope sales rep visibility to repairs they submitted
+- `Interaction`-class JS for stepper, lookups, photo capture
+
+**Client self-service portal (CL1-CL5, CL11, CL13, CL14):**
+- Public `/repair` URL (voicemail-ready)
+- Phone-first lookup (safe partner match — masks other PII)
+- Equipment selection with QR pre-fill via `?sn=`
+- Same guided question flow (shared service)
+- Photo/camera upload
+- Save & resume tokenized link + localStorage backup
+- reCAPTCHA v3 + honeypot + per-IP rate limit
+- Privacy notice + PHI consent checkbox
+- No AI yet (Phase 2)
+
+### Phase 2 — Billing, mobile tech UX, repair warranty
+
+**Goal:** Tech can complete a visit on their phone and collect payment; the system auto-prices and tracks our own repair warranty.
+
+- Service catalogue + repairs pricelist + product seed data
+- Catalog auto-match from intake → pre-fill operations + estimated cost
+- Tech skills matrix (D2) — wizard filters candidate techs by category
+- Parts pre-pull checklist (D3) — nightly cron emails picklist
+- Visit report wizard with: labour hours, parts (native stock moves), upsells, **client signature pad (T4)**, "found another issue" spawn (T5), **Save & Collect Payment via Poynt terminal (T8)**
+- **Maps button on mobile task form (T1)** + AI brief surfaced prominently (T2)
+- Pricing reconciliation (variance → requote email + manager activity)
+- `repair.order → action_create_sale_order()` → invoice → Poynt
+- Smart buttons on `sale.order` and `repair.order`
+- **Repair warranty tracking (M6)** — `fusion.repair.warranty.coverage`; wizard banner "covered by our 30-day warranty, no charge" when same equipment + similar issue
+- AI intake summary via `fusion.api.service.call_openai()` with try/fallback (consumer='fusion_repairs')
+- Sales rep portal: add a note to existing repair (S5), book maintenance on behalf of client (S7), quote-only mode in portal (S9), follow-up activities to sales rep on state change (S10)
+- **Client self-service AI:** AI self-check engine with strict guardrails (CL6, CL7) + resolved/unresolved branch (CL8) + upsell engine + ranking (CL9) + **smart SMS verify (CL12 — skip if known partner)** + **combined weekend-safety escalation with on-call paging (CL15)** + resolution survey (CL20)
+- `res.users` extensions: `x_fc_repair_skills`, `x_fc_tech_cost_rate`, `x_fc_on_call`, `x_fc_on_call_priority`, `x_fc_on_call_phone`
+- `fusion.repair.on.call.service` AbstractModel + `/repair/on-call/ack/` acknowledgement route + escalation cron
+
+### Phase 3 — Maintenance lifecycle, SMS, smart scheduling
+
+**Goal:** Maintenance practically runs itself; client and tech communicate via SMS; rescheduling is one tap.
+
+- Maintenance contracts from delivered SOs + interval rolling
+- Reminder crons (30/7/1 d) + tokenized client booking portal
+- fusion_schedule integration for client day/time selection
+- 4 office follow-up crons (maintenance unbooked, repair no-tech, overdue visit, unpaid invoice)
+- Smart auto-scheduling option (D1 map view + skills-aware slot picking)
+- **RC SMS integration:**
+ - "Tech is X min away" SMS when tech taps On-the-way (X1)
+ - Day-before reminder SMS (X2 SMS portion)
+ - Inline "Send SMS" from intake wizard (C3)
+ - Two-way reschedule SMS Y/N (D4)
+- Self-reschedule link in confirmation email (X3)
+- Post-visit NPS / Google review email (X4)
+- Labour timer via fusion_clock (T3)
+- Parts replaced — serial capture (T6)
+- No-show photo proof (T7)
+- Loaner auto-offer when repair >X days (M3)
+- Sales rep portal: warranty acknowledgement signature when out of warranty (S11)
+- **Client self-service Phase 3:** direct-buy parts/plans via website_sale or Poynt (CL10) + voicemail RC integration (CL16) + QR sticker generator (CL17)
+
+### Phase 4 — Compliance, claims, analytics, polish
+
+**Goal:** Long-tail features that drive revenue, reduce risk, and surface insight.
+
+- **Compliance inspection certificates (M1)** — `fusion.repair.inspection.certificate` + PDF cert + annual cron + client email *(candidate sister module `fusion_repair_compliance` if it grows)*
+- **ADP/funder-covered repairs (M2)** — bridge to `fusion_claims` for funder-eligible repairs
+- Mail-in / shop repairs workflow (M4) — return label via fusion_shipping/canada_post
+- **Pre-paid service plans (M5)** — `fusion.repair.service.plan` product + burn-down counter *(candidate sister module `fusion_repair_plans`)*
+- Failure rate / first-call resolution analytics dashboard (M7)
+- OEM warranty claim filing (M8)
+- Margin per repair (M9)
+- Logged-in equipment portal `/my/equipment` (X5)
+- Canned scripts per issue (C4)
+- `fusion_ringcentral` "Start Service Call" button on inbound popup
+- AI clarifying-question suggestions during free-text intake
+- Sales rep portal: repair-aware commission view on dashboard (S12)
+- **Client self-service Phase 4:** knowledge base videos / manual snippets (CL18) + voice input AI transcription (CL19)
+
+### Potential sister modules (if scope demands)
+
+These are flagged inside Phase 4 but could spin out into their own modules:
+
+| Module | What goes in it | When to split |
+|--------|-----------------|---------------|
+| `fusion_repair_compliance` | M1 inspections (annual safety certs, jurisdiction rules, regulatory reporting) | If certificate templates per jurisdiction balloon |
+| `fusion_repair_plans` | M5 pre-paid plans, plan renewals, burn-down accounting | If plans + accounting integration grows |
+| `fusion_repair_shop` | M4 mail-in / in-shop workflow with bench states and shop-floor UI | If in-shop technicians become a distinct user group |
+| `fusion_repair_analytics` | M7, M9 dashboards + reports | If analytics need their own ETL/aggregation tables |
+
+Decision to split happens at the start of Phase 4, not before.
+
+---
+
+## Key files to create
+
+```
+fusion_repairs/
+├── __manifest__.py
+├── __init__.py
+├── models/
+│ ├── repair_order.py
+│ ├── intake_template.py
+│ ├── intake_answer.py
+│ ├── service_catalog.py
+│ ├── maintenance_contract.py
+│ ├── repair_warranty.py # M6
+│ ├── repair_inspection.py # M1 (Phase 4)
+│ ├── repair_service_plan.py # M5 (Phase 4)
+│ ├── repair_shop_intake.py # M4 (Phase 4)
+│ ├── repair_script_template.py # C4 (Phase 4)
+│ ├── repair_self_check_rule.py # CL6 deterministic fallback
+│ ├── repair_upsell_rule.py # CL9 upsell rules + suggestions
+│ ├── repair_intake_session.py # token-based resume + cross-route state
+│ ├── repair_ai_service.py # AbstractModel — guardrailed AI entry point
+│ ├── repair_intake_service.py # AbstractModel — shared by backend/sales-rep/client
+│ ├── repair_on_call_service.py # AbstractModel — find_next_on_call + page_on_call
+│ ├── sale_order.py
+│ ├── technician_task.py
+│ ├── product_template.py
+│ ├── res_partner.py # P1 client prefs + history compute
+│ ├── res_users.py # D2 skills + cost rate
+│ ├── res_company.py # office notification recipients
+│ └── res_config_settings.py
+├── wizard/
+│ ├── repair_intake_wizard.py
+│ ├── repair_intake_wizard_views.xml
+│ ├── repair_visit_report_wizard.py
+│ └── repair_reschedule_wizard.py # D4
+├── views/
+│ ├── repair_order_views.xml
+│ ├── sale_order_views.xml
+│ ├── maintenance_contract_views.xml
+│ ├── intake_template_views.xml
+│ ├── service_catalog_views.xml
+│ ├── repair_warranty_views.xml
+│ ├── repair_inspection_views.xml
+│ ├── repair_service_plan_views.xml
+│ ├── res_partner_views.xml
+│ ├── res_users_views.xml
+│ ├── technician_task_views.xml # mobile additions (Maps button, AI brief, timer)
+│ ├── repair_dashboard_views.xml # M7 (Phase 4)
+│ ├── portal_sales_rep_templates.xml # S1-S10 QWeb templates
+│ ├── portal_client_repair_templates.xml # CL1-CL20 public client form
+│ └── menus.xml
+├── controllers/
+│ ├── portal_client_repair.py # CL1-CL20 public /repair self-service
+│ ├── portal_sales_rep_repair.py # S1-S10 sales rep intake + list + detail
+│ ├── maintenance_portal.py
+│ ├── client_reschedule.py # X3
+│ ├── nps_survey.py # X4
+│ ├── equipment_portal.py # X5 (Phase 4)
+│ └── requote_portal.py # pricing variance approval
+├── report/
+│ ├── repair_visit_summary.xml
+│ ├── repair_inspection_certificate.xml # M1
+│ └── oem_warranty_claim.xml # M8
+├── static/src/
+│ ├── js/signature_pad.js # T4
+│ ├── js/parts_picker.js # D3
+│ ├── js/portal_repair_intake.js # S1 Interaction-class stepper + lookups
+│ ├── js/portal_client_repair.js # CL1-CL10 public client form Interaction class
+│ ├── js/portal_client_self_check.js # CL6 AI step renderer
+│ ├── js/portal_client_upsell.js # CL9 upsell card renderer
+│ ├── components/history_sidebar.js # C2 (backend OWL)
+│ └── scss/
+│ ├── repairs_mobile.scss # tech mobile UX
+│ ├── portal_repair_mobile.scss # sales rep portal mobile
+│ └── portal_client_repair.scss # CL1-CL20 client portal mobile-first
+├── data/
+│ ├── intake_template_data.xml
+│ ├── service_catalog_data.xml
+│ ├── pricelist_data.xml
+│ ├── mail_template_data.xml
+│ ├── sms_template_data.xml # X1/X2/D4 (Phase 3)
+│ ├── mail_activity_type_data.xml
+│ ├── ir_cron_data.xml
+│ ├── ir_config_parameter_data.xml
+│ ├── ir_sequence_data.xml
+│ ├── repair_script_data.xml # C4
+│ ├── repair_inspection_data.xml # M1
+│ ├── self_check_data.xml # CL6 deterministic self-check rules (30 seeded — Appendix D)
+│ ├── upsell_rule_data.xml # CL9 seed upsell rules (15 seeded — Appendix B)
+│ ├── ai_prompts_data.xml # CL7 system prompt + schema in ir.config_parameter (Appendix A)
+│ └── voicemail_scripts_data.xml # CL16 voicemail script text in ir.config_parameter (Appendix C)
+└── security/
+ ├── security.xml
+ └── ir.model.access.csv
+```
+
+---
+
+## Verification (local OrbStack)
+
+After implementation, test on local dev only:
+
+1. `docker exec odoo-dev-app odoo -d fusion-dev -i fusion_repairs --stop-after-init`
+2. Install/enable Odoo **Repairs** app if not already installed
+3. Run backend intake wizard for each product category — confirm repair order + answers + task
+4. **Sales rep portal:** log in as a `is_sales_rep_portal` user → `/my/repair/new` → submit same scenarios → assert identical `repair.order`, activities, emails as backend wizard
+5. **Sales rep isolation:** log in as second sales rep → `/my/repairs` should NOT show first rep's repairs
+6. **Client public portal:** in incognito (no login) → `/repair` → submit same scenarios → assert identical `repair.order`, activities, emails as backend wizard but with `x_fc_intake_source='client_portal'`
+7. **AI guardrails:** submit stairlift + motor + safety symptom → assert `escalate_immediately=True` returned and AI step skipped
+8. **AI cost/fallback:** disable `fusion.api.service` → assert client form falls back to deterministic self-check rules without error
+9. **Phone lookup safety:** look up an existing client's phone → assert response contains only masked name/address, never other PII
+10. **Rate limit:** submit 11 client forms from same IP in an hour → assert 11th is blocked with friendly error
+11. Complete technician task → visit report → SO → invoice → Poynt wizard opens
+12. Deliver a product with maintenance interval → contract created → cron sends reminder → booking link works
+13. Confirm smart buttons on original SO show linked repairs/maintenance/invoices
+14. Verify emails render correctly in light and dark mode
+15. Test all three portals on a real phone (iOS Safari + Android Chrome) — photo capture, tap targets, stepper flow
+16. QR sticker scan test: print a sticker → scan on phone → confirm `/repair?sn=` pre-fills equipment
+17. **Smart SMS verify:** submit client form from known partner phone → assert SMS skipped; submit from new phone → assert 6-digit SMS required and gate enforced
+18. **Weekend safety escalation:** submit safety repair at 11 PM Saturday → assert (a) 911 disclaimer rendered, (b) repair created with `priority=high` + Monday-followup activity, (c) on-call manager (lowest `x_fc_on_call_priority`) received SMS + email
+19. **On-call escalation:** mock no-acknowledgement → after 15 min cron → assert next-priority manager paged; full exhaustion → office partners notified
+20. **Technician filter:** create a stairlift repair → assert tech dropdown shows only users with `x_fc_is_field_staff=True` AND `x_fc_repair_skills` containing the stairlift category
+
+---
+
+## Risks & mitigations
+
+| Risk | Mitigation |
+|------|------------|
+| Odoo 19 `repair.order` API differs from docs | Read model/views from Docker before extending; verify `action_create_sale_order` in local instance |
+| `fusion_schedule` dependency chain is heavy | Phase 3 only; start with token booking controller, add schedule integration when stable |
+| Overlap with `fusion_ltc_management` | Clear separation: LTC = facilities; fusion_repairs = home/retail medical equipment |
+| Scope creep | Strict phase gates; Phase 1 must be usable by CS before any Phase 2 work begins. Optional sister-module split at Phase 4 boundary |
+| AI calls fail / exceed budget | Try/fallback per fusion-api-integration rule; `x_fc_ai_summary` is optional, never blocks intake submit |
+| Variance threshold spam | Defaults conservative; per-company override; can disable per-catalogue entry (e.g. parts-heavy repairs) |
+| Multi-equipment loop confusion | Wizard shows running list of added equipment with edit/remove before final submit |
+| Caller mismatch (wrong partner picked) | Phone-search shows last 5 repairs/SOs + address inline before confirm |
+| **Scope size from accepting all features** | Phase gating is non-negotiable; each phase must ship before next starts; Phase 4 items earmarked for sister-module split if any one balloons |
+| SMS deliverability + cost (RC) | Phase 3 only; per-event opt-out on `res.partner.x_fc_sms_opt_out`; rate-limit via cron not real-time burst |
+| Signature pad cross-browser issues | Use proven OWL library; fallback to typed-name acknowledgement |
+| Inspection certificate jurisdictional rules | Start with one jurisdiction (Ontario); model `x_fc_jurisdiction` field; add others on demand |
+| Repair warranty mis-detection (false "free re-do") | Manager approval required for warranty re-do override; clear audit trail in chatter |
+| AI hallucinates an unsafe step that passes keyword filter | All AI output ends with mandatory legal disclaimer; manager-reviewable AI incident log captures rejected + accepted outputs for ongoing prompt refinement |
+| Backend wizard and sales rep portal drift apart | Both call the same `fusion.repair.intake.service.create_repair_orders(payload)` AbstractModel method; no duplicate business logic |
+| Sales rep accidentally sees other reps' repairs | Record rule `('x_fc_intake_user_id', '=', user.id)` scoped to `base.group_portal`; integration test asserts cross-rep isolation |
+| Portal form abandoned mid-flow on call drop | Save partial state to `localStorage` keyed by partner + timestamp; "Resume" prompt on `/my/repair/new` if recent draft exists |
+| fusion_authorizer_portal install becomes mandatory | Documented in module description; if a deployment doesn't want fusion_authorizer_portal, fall back to a `fusion_repairs_portal_lite` companion module that recreates only the `is_sales_rep_portal` flag |
+| **Public form spam / abuse** | reCAPTCHA v3 + honeypot + per-IP rate limit + per-phone rate limit + SMS verify before submit (Phase 2). Block ASN ranges via Odoo's `ir.rule` if needed |
+| **AI giving unsafe medical advice** | Strict system prompt + JSON schema validation + keyword filter (rejects "diagnose", "you have", "stop using"); falls back to deterministic rules on any malformed/unsafe output; legal disclaimer "this is not medical advice" shown on every AI step |
+| **AI cost runaway from public traffic** | Hard daily/monthly budget cap via `fusion.api.service`; CAPTCHA gates AI calls; cache results for identical symptom-category pairs; deterministic fallback never costs anything |
+| **PHI exposure via public form** | Inline consent before sensitive questions; data retention policy in privacy notice; PHI-flagged fields encrypted at rest (Phase 4 enhancement) |
+| **Client uses public form when logged in already** | Detect logged-in client → offer "use your client portal instead" with one-click; still allow public flow if they prefer |
+| **Phone lookup leaks partner data** | Lookup returns only `{matched: bool, name: , address: }`; never the full partner record. Logged for audit |
+| **Voicemail URL drift** | Static recording mentions canonical `/repair`; Phase 3 dynamic update uses `ir.config_parameter` for URL so any rename is one-click |
+| **Backend/sales-rep/client intake drift apart (three surfaces now)** | All three call `fusion.repair.intake.service.create_repair_orders(payload, source=...)` exclusively; CI test asserts identical outputs for identical payloads across all three sources |
+
+---
+
+## Appendix A — AI system prompt + JSON schema (CL6 / CL7)
+
+### System prompt (verbatim, lives in [`data/ai_prompts_data.xml`](fusion_repairs/data/ai_prompts_data.xml) as `ir.config_parameter`)
+
+> You are a triage assistant for Fusion Repairs, a Canadian medical equipment service company. Your ONLY job is to suggest 1–3 safe, reversible self-check steps a client can try on their medical equipment before scheduling a technician visit.
+>
+> ABSOLUTE RULES:
+>
+> 1. NEVER provide medical advice, diagnoses, or health recommendations.
+> 2. NEVER claim a definitive cause for the problem.
+> 3. NEVER recommend stopping use of medical equipment.
+> 4. NEVER use phrases like "you have", "I diagnose", "you should stop", "medical condition", "consult your doctor".
+> 5. ONLY suggest steps that are: safe, reversible, require no tools, take under 2 minutes, and pose zero risk to the client or equipment.
+> 6. If symptoms involve smoke, sparks, burning smell, motors on stairlifts/porch lifts, OR if you are uncertain → return `escalate_immediately: true`.
+> 7. Maximum 3 steps. Each step ≤ 1 sentence. Grade-6 reading level. No technical jargon.
+> 8. NEVER reference part numbers, prices, or other clients.
+> 9. If client reports injury, equipment fire, or person trapped → `escalate_immediately: true` with `escalation_reason: "emergency"`.
+> 10. You MUST output valid JSON matching the provided schema. No prose, no markdown, no commentary.
+
+### JSON schema (server-side validated; lives in [`models/repair_ai_service.py`](fusion_repairs/models/repair_ai_service.py))
+
+```json
+{
+ "type": "object",
+ "required": ["escalate_immediately", "confidence", "steps"],
+ "additionalProperties": false,
+ "properties": {
+ "escalate_immediately": {"type": "boolean"},
+ "escalation_reason": {"type": ["string", "null"], "maxLength": 200},
+ "confidence": {"enum": ["high", "medium", "low"]},
+ "steps": {
+ "type": "array",
+ "maxItems": 3,
+ "items": {
+ "type": "object",
+ "required": ["instruction", "expected_result"],
+ "additionalProperties": false,
+ "properties": {
+ "instruction": {"type": "string", "maxLength": 200},
+ "expected_result": {"type": "string", "maxLength": 200},
+ "safety_note": {"type": ["string", "null"], "maxLength": 200}
+ }
+ }
+ }
+ }
+}
+```
+
+### Post-validation filters (Python, after AI response)
+
+1. **Schema check** — drop response if doesn't match schema
+2. **Forbidden-phrase regex** — drop if `instruction|expected_result|safety_note` matches any of:
+ - `\b(diagnose|diagnosis|diagnosed)\b`
+ - `\byou have\b`
+ - `\bmedical condition\b`
+ - `\bstop using\b`
+ - `\bconsult (your|a) (doctor|physician)\b`
+ - `\b(\$|CAD|USD)\s?\d+` (price mention)
+3. **Coherence check** — if `escalate_immediately=false` AND `steps` is empty → drop
+4. **Cost cap** — if response tokens > 500 → drop (defends against runaway prompt injection)
+5. **Cache hit check** — same `(product_category, symptom_hash)` returns cached result for 24 h
+6. **On any drop** → fall back to deterministic `fusion.repair.self.check.rule` records + log incident to `fusion.repair.ai.incident` for manager review
+
+### Always-appended UI disclaimer
+
+Below every AI step rendered to the client:
+
+> *This is not medical advice. If you're unsure, schedule a technician visit. In an emergency, call 9-1-1.*
+
+---
+
+## Appendix B — Seed upsell rules (CL9)
+
+Loaded in [`data/upsell_rule_data.xml`](fusion_repairs/data/upsell_rule_data.xml). Each row = one `fusion.repair.upsell.rule` record.
+
+| # | Condition | Product / suggestion | Pitch | Priority |
+|---|-----------|---------------------|-------|----------|
+| 1 | Self-resolved on any product | Annual Maintenance Plan (M5) | "Avoid this in the future — annual professional inspection keeps small issues from becoming repairs" | 100 |
+| 2 | Product = stairlift OR porch lift AND no active warranty | Extended Warranty + Annual Safety Certificate (M1) | "Many municipalities require annual safety certification for accessibility lifts — we handle it" | 95 |
+| 3 | Product = hospital bed AND `delivery_date` > 3 years ago | Replacement mattress (medical-grade) | "Medical mattresses lose support after 3–5 years — protect skin integrity with a fresh surface" | 90 |
+| 4 | Symptom keyword matches `battery\|charge\|won't turn on` | Replacement battery (correct model lookup) | "Genuine replacement battery — installed during your visit, no extra trip" | 85 |
+| 5 | Symptom keyword matches `remote\|controller\|button` | Spare remote control | "A spare remote on hand saves a service call when one wears out" | 80 |
+| 6 | Repair scheduled AND `estimated_duration` > 4 h AND product is mobility-critical | Loaner equipment (M3) | "Don't be without equipment — borrow ours while we repair yours" | 95 |
+| 7 | Product in (wheelchair, rollator, walker) AND symptom keyword `brake\|stop` | Brake service kit | "Brake pads and cables wear out — keep a spare kit ready" | 70 |
+| 8 | Product = walker AND symptom keyword `wobble\|wear\|drag` | Replacement tips/glides (4-pack) | "Tips wear out — extend walker life and prevent slips" | 70 |
+| 9 | Product = air mattress AND symptom keyword `leak\|deflate\|soft` | Replacement air pump | "Spare pump means no overnight surprise if yours fails" | 75 |
+| 10 | Product = stairlift AND `delivery_date` > 5 years ago | Upgrade quote — newer model | "Today's stairlifts are quieter, more energy efficient, and offer better safety features" | 60 |
+| 11 | Product = porch lift AND any symptom (outdoor exposure) | Annual weatherization service | "Outdoor lifts need yearly weather protection to avoid corrosion damage" | 75 |
+| 12 | Product = power wheelchair AND symptom matches `charge\|battery\|won't turn on` | Replacement charger | "Faulty chargers slowly damage batteries — replacing now extends battery life" | 80 |
+| 13 | Product = hospital bed AND symptom keyword `rail\|side` | Padded rail covers (consumable) | "Padded rail covers reduce skin injury risk and bruising" | 65 |
+| 14 | Partner has ≥ 2 repairs in past 12 months | Service Contract / pre-paid plan (M5) | "You've had multiple visits — our service plan locks in predictable monthly cost" | 90 |
+| 15 | Self-resolved AND product `delivery_date` > 5 years ago | Replacement / upgrade quote | "Equipment over 5 years often costs more to repair than to replace — let's compare" | 50 |
+
+**Display rule:** show top 4 by priority that pass the rule's `condition_domain`. AI ranking (Phase 2) re-orders based on intake context — e.g. if client mentioned tight budget in notes, deprioritize upgrade quote.
+
+---
+
+## Appendix C — Voicemail scripts (CL16)
+
+Stored as `ir.config_parameter` keys so the office can edit without code changes. Two tiers:
+
+### Tier 1 — static recording (office records once)
+
+**Daytime / business hours greeting:**
+
+> Hi, you've reached Fusion Repairs. We're on the line with another client right now. Please leave your name, phone number, and a brief description of your equipment issue and we'll call you back within two hours. For faster service, visit our website at **fusionrepairs dot com slash repair** to start your repair request online — our system can also help you with simple troubleshooting. Thank you.
+
+**After-hours greeting (evenings, weekends, holidays):**
+
+> Hi, you've reached Fusion Repairs. Our office is currently closed. For non-urgent repairs, please visit our website at **fusionrepairs dot com slash repair** to submit your request — our online system can also walk you through simple troubleshooting that may solve your problem tonight. We'll respond first thing on the next business day. If anyone is hurt or in immediate danger, please hang up and dial nine-one-one. Thank you.
+
+**Emergency / on-call routing prompt (press 1):**
+
+> If this is a safety emergency where someone is stuck on a stairlift or porch lift and unable to move, please press 1 now to be routed to our on-call technician. For all other repairs, please visit fusionrepairs dot com slash repair to submit your request online and we will respond on the next business day.
+
+### Tier 2 — dynamic RC API update (Phase 3, optional)
+
+Cron `_update_rc_voicemail_greeting_cron` runs at the start/end of business hours per `resource.calendar`:
+
+- 9:00 AM Mon-Fri → upload daytime greeting via `fusion_ringcentral` API
+- 6:00 PM Mon-Fri and at any time Sat/Sun/holidays → upload after-hours greeting
+- Reads scripts from `ir.config_parameter` → renders via TTS service (RC native or 11Labs) → uploads to RC
+- Office can override per `res.company`
+
+Falls back silently if RC API unreachable — static recording remains live.
+
+---
+
+## Appendix D — Seed deterministic self-check rules (CL6 fallback)
+
+Loaded in [`data/self_check_data.xml`](fusion_repairs/data/self_check_data.xml). Each row = one `fusion.repair.self.check.rule` record keyed by `(product_category, symptom_keyword)`.
+
+### Hospital bed
+
+| Symptom keyword | Self-check instruction | Expected result |
+|-----------------|------------------------|------------------|
+| `won't move\|dead\|no power` | "Check the bed is plugged in and the outlet has power — try plugging a phone charger into the same outlet to confirm" | Bed responds when controls pressed |
+| `slow\|sluggish` | "Unplug the bed for 30 seconds then plug it back in" | Movement returns to normal speed |
+| `remote\|controller` | "Replace the remote batteries with fresh AAA batteries" | Remote lights and bed responds |
+| `one section\|won't lift\|stuck` | "Check nothing is caught under the bed or jamming the mechanism (sheets, blankets, cords)" | Section moves freely |
+| `beep\|alarm\|alert` | "Check both side rails are fully locked in the raised position" | Alarm stops |
+
+### Wheelchair — manual
+
+| Symptom keyword | Self-check instruction | Expected result |
+|-----------------|------------------------|------------------|
+| `wobble\|loose wheel` | "Try turning the axle nut gently by hand to feel if it's snug" | Wheel feels firm, no play |
+| `brake\|stop` | "Push the brake lever fully to the locked position and listen for a click" | Brake holds wheel firmly |
+| `footrest\|footplate` | "Slide the footrest fully into its housing until you hear a click" | Footrest feels secure |
+| `hard to push\|drags` | "Check both tires for full inflation (firm to thumb pressure)" | Wheelchair rolls freely |
+
+### Wheelchair — power
+
+| Symptom keyword | Self-check instruction | Expected result |
+|-----------------|------------------------|------------------|
+| `won't turn on\|dead` | "Confirm the battery indicator shows charge and the key switch is in the ON position" | Display lights up |
+| `error code\|flashing` | "Note the error code shown on the joystick display, then turn off and on after 30 seconds" | Error clears or specific code logged |
+| `one side weaker\|pulls` | "Charge the batteries fully overnight before testing again" | Both sides equal power after charge |
+| `smoke\|burning\|spark` | **ESCALATE IMMEDIATELY — no self-check** | — |
+
+### Stairlift
+
+| Symptom keyword | Self-check instruction | Expected result |
+|-----------------|------------------------|------------------|
+| `won't move\|stuck` | "Check the seat is fully rotated to the forward position and the seatbelt is fastened" | Stairlift responds |
+| `stops midway\|halts` | "Check the track for items blocking the sensors — toys, slippers, debris" | Stairlift completes travel |
+| `beep\|alarm` | "Confirm the seat swivel lock is engaged in the down position" | Beeping stops |
+| `burning smell\|smoke\|person stuck` | **ESCALATE IMMEDIATELY — no self-check** | — |
+| `remote\|call station` | "Replace the remote / call-station batteries with fresh batteries" | Call station responds |
+
+### Porch lift
+
+| Symptom keyword | Self-check instruction | Expected result |
+|-----------------|------------------------|------------------|
+| `won't move\|dead` | "Check all gate and door safety switches are fully closed" | Lift responds |
+| `sticky\|stuck buttons` | "If outdoors, gently wipe controls with a dry cloth and let dry" | Controls respond |
+| `smoke\|burning smell\|stuck` | **ESCALATE IMMEDIATELY — no self-check** | — |
+| `won't stop\|overshoots` | "Note exactly which floor it stops at — do not attempt repeat use" | Information captured for technician |
+
+### Walker / rollator
+
+| Symptom keyword | Self-check instruction | Expected result |
+|-----------------|------------------------|------------------|
+| `wheel stick\|won't roll` | "Check for hair or debris wrapped around the wheel axle" | Wheel spins freely |
+| `brake won't lock` | "Push the brake lever fully down until you feel a click" | Brake holds |
+| `wobble\|loose` | "Check all height adjustment pins are fully engaged through both holes" | Frame feels solid |
+| `seat loose` (rollator) | "Tighten the seat knobs by hand until firm" | Seat feels secure |
+
+### Medical mattress (air / alternating pressure)
+
+| Symptom keyword | Self-check instruction | Expected result |
+|-----------------|------------------------|------------------|
+| `deflated\|flat\|soft` | "Confirm the pump is plugged in, powered on, and the hose is firmly attached" | Mattress inflates |
+| `hiss\|leak` | "Listen at the valve — push the valve cap in firmly to ensure it's sealed" | Hissing stops |
+| `alarm\|beep` | "Check the pump display for the error code shown, then restart the pump by unplugging for 30 seconds" | Alarm clears |
+| `cold\|won't heat` (heated mattress) | "Confirm the heat dial is set above zero and allow 15 minutes to warm" | Mattress feels warm |
+
+**Total:** 30 deterministic rules across 7 categories. Used when AI fails, exceeds budget, or returns unsafe content. Also used in Phase 1 before AI is enabled.
+
+**Each rule has** an optional `safety_note` field — when populated, displayed in red below the instruction (e.g. for any "smoke/burning" rule the note says "Do not attempt — call us or 911 immediately").
From 429084e0bf97d109207a3f6692d382f491a5b93f Mon Sep 17 00:00:00 2001
From: gsinghpal
Date: Wed, 20 May 2026 21:35:52 -0400
Subject: [PATCH 02/30] feat(fusion_repairs): Phase 1 MVP - backend intake
wizard + core models
Scaffolds the fusion_repairs module that extends Odoo 19 repair.order with
a guided medical-equipment intake workflow.
Models
- fusion.repair.product.category (8 medical equipment categories seeded)
- fusion.repair.intake.template / .question / .answer (7 templates,
32 questions seeded across hospital bed, stairlift, porch lift,
wheelchair, walker/rollator, mattress)
- fusion.repair.intake.service (AbstractModel) - single entry point used
by backend wizard, sales rep portal, and public client portal so all
three surfaces produce identical outcomes
- repair.order extensions (x_fc_intake_*, x_fc_third_party_equipment,
x_fc_photo_ids, x_fc_urgency, x_fc_estimated/actual_cost, AI summary)
- fusion.technician.task back-link (x_fc_repair_order_id)
- res.partner service preferences (preferred tech, time window, access notes)
- res.users repair extensions (skills, cost rate, on-call rotation fields)
- res.config.settings for variance thresholds, portal URL, rate limit
UI
- Backend intake wizard with multi-equipment loop, third-party flag, photos
- repair.order form: Intake tab, Photos, Pricing tab, AI tab, smart buttons
(technician tasks, intake answers, original SO)
- Kanban + list view urgency badges
- Fusion Repairs app menu (New Service Call, Repair Orders, Config)
Activities & Email
- 4 follow-up activity types (CS callback, tech dispatch, visit follow-up,
manager review) with urgency-tiered deadlines
- 2 mail templates (client confirmation + office notification) with the
same dark/light-safe styling as fusion_claims ADP templates
Security
- New res.groups.privilege + 3 groups (User, Dispatcher, Manager)
- Reuses fusion_tasks.group_field_technician (do NOT recreate)
- Reuses fusion_authorizer_portal.group_sales_rep_portal
- Multi-company global rule + technician scoping rule on repair.order
Verified end-to-end on local westin-v19 dev DB via odoo-shell - creates
multiple repairs in one session, auto-creates dispatch task for urgent,
attaches 4 activity types correctly per urgency tier and third-party flag.
Co-authored-by: Cursor
---
fusion_repairs/__init__.py | 6 +
fusion_repairs/__manifest__.py | 100 +++++
fusion_repairs/data/intake_template_data.xml | 378 ++++++++++++++++++
.../data/ir_config_parameter_data.xml | 60 +++
fusion_repairs/data/ir_sequence_data.xml | 16 +
.../data/mail_activity_type_data.xml | 54 +++
fusion_repairs/data/mail_template_data.xml | 103 +++++
.../data/repair_product_category_data.xml | 81 ++++
fusion_repairs/models/__init__.py | 15 +
fusion_repairs/models/intake_answer.py | 88 ++++
fusion_repairs/models/intake_question.py | 84 ++++
fusion_repairs/models/intake_service.py | 347 ++++++++++++++++
fusion_repairs/models/intake_template.py | 74 ++++
fusion_repairs/models/product_template.py | 34 ++
fusion_repairs/models/repair_order.py | 281 +++++++++++++
.../models/repair_product_category.py | 49 +++
fusion_repairs/models/res_config_settings.py | 64 +++
fusion_repairs/models/res_partner.py | 64 +++
fusion_repairs/models/res_users.py | 57 +++
fusion_repairs/models/technician_task.py | 42 ++
fusion_repairs/security/ir.model.access.csv | 12 +
fusion_repairs/security/security.xml | 76 ++++
.../views/intake_template_views.xml | 95 +++++
fusion_repairs/views/menus.xml | 48 +++
fusion_repairs/views/repair_order_views.xml | 143 +++++++
.../views/repair_product_category_views.xml | 55 +++
.../views/res_config_settings_views.xml | 62 +++
fusion_repairs/views/res_partner_views.xml | 29 ++
fusion_repairs/views/res_users_views.xml | 33 ++
fusion_repairs/wizard/__init__.py | 5 +
fusion_repairs/wizard/repair_intake_wizard.py | 199 +++++++++
.../wizard/repair_intake_wizard_views.xml | 69 ++++
32 files changed, 2823 insertions(+)
create mode 100644 fusion_repairs/__init__.py
create mode 100644 fusion_repairs/__manifest__.py
create mode 100644 fusion_repairs/data/intake_template_data.xml
create mode 100644 fusion_repairs/data/ir_config_parameter_data.xml
create mode 100644 fusion_repairs/data/ir_sequence_data.xml
create mode 100644 fusion_repairs/data/mail_activity_type_data.xml
create mode 100644 fusion_repairs/data/mail_template_data.xml
create mode 100644 fusion_repairs/data/repair_product_category_data.xml
create mode 100644 fusion_repairs/models/__init__.py
create mode 100644 fusion_repairs/models/intake_answer.py
create mode 100644 fusion_repairs/models/intake_question.py
create mode 100644 fusion_repairs/models/intake_service.py
create mode 100644 fusion_repairs/models/intake_template.py
create mode 100644 fusion_repairs/models/product_template.py
create mode 100644 fusion_repairs/models/repair_order.py
create mode 100644 fusion_repairs/models/repair_product_category.py
create mode 100644 fusion_repairs/models/res_config_settings.py
create mode 100644 fusion_repairs/models/res_partner.py
create mode 100644 fusion_repairs/models/res_users.py
create mode 100644 fusion_repairs/models/technician_task.py
create mode 100644 fusion_repairs/security/ir.model.access.csv
create mode 100644 fusion_repairs/security/security.xml
create mode 100644 fusion_repairs/views/intake_template_views.xml
create mode 100644 fusion_repairs/views/menus.xml
create mode 100644 fusion_repairs/views/repair_order_views.xml
create mode 100644 fusion_repairs/views/repair_product_category_views.xml
create mode 100644 fusion_repairs/views/res_config_settings_views.xml
create mode 100644 fusion_repairs/views/res_partner_views.xml
create mode 100644 fusion_repairs/views/res_users_views.xml
create mode 100644 fusion_repairs/wizard/__init__.py
create mode 100644 fusion_repairs/wizard/repair_intake_wizard.py
create mode 100644 fusion_repairs/wizard/repair_intake_wizard_views.xml
diff --git a/fusion_repairs/__init__.py b/fusion_repairs/__init__.py
new file mode 100644
index 00000000..866992e6
--- /dev/null
+++ b/fusion_repairs/__init__.py
@@ -0,0 +1,6 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from . import models
+from . import wizard
diff --git a/fusion_repairs/__manifest__.py b/fusion_repairs/__manifest__.py
new file mode 100644
index 00000000..bb20e0a0
--- /dev/null
+++ b/fusion_repairs/__manifest__.py
@@ -0,0 +1,100 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+{
+ 'name': 'Fusion Repairs',
+ 'version': '19.0.1.0.0',
+ 'category': 'Inventory/Repairs',
+ 'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal',
+ 'description': """
+Fusion Repairs
+==============
+
+Comprehensive repairs and maintenance management for medical equipment retailers
+and service providers (hospital beds, wheelchairs, stairlifts, porch lifts,
+walkers, mattresses, rollators).
+
+Phase 1 - MVP
+-------------
+- Three intake surfaces sharing one service layer:
+ * Backend wizard for CS reps on the phone
+ * Sales rep portal (/my/repair/new) for reps on the road
+ * Public client self-service portal (/repair) - voicemail ready
+- Guided question templates per medical equipment category
+- Phone-first partner lookup with duplicate-call detection
+- Multi-equipment per call (one repair.order per unit)
+- Photo / video capture during intake
+- Third-party equipment support (equipment we didn't sell)
+- Auto warranty detection from original sale order
+- Office notification recipients + 4 follow-up activities
+- repair.order extensions linked to fusion.technician.task
+
+Phase 2-4 (roadmap)
+-------------------
+- AI self-check engine with strict medical safety guardrails
+- Upsell engine and direct-buy parts/plans
+- Repair warranty tracking (free re-do window)
+- Visit report wizard with Poynt terminal payment
+- Maintenance contracts with client self-booking
+- Weekend safety on-call paging
+- SMS notifications, compliance certificates, analytics
+
+Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
+ """,
+ 'author': 'Nexa Systems Inc.',
+ 'website': 'https://www.nexasystems.ca',
+ 'maintainer': 'Nexa Systems Inc.',
+ 'support': 'support@nexasystems.ca',
+ 'license': 'OPL-1',
+ 'price': 0.00,
+ 'currency': 'CAD',
+ 'depends': [
+ 'base',
+ 'mail',
+ 'portal',
+ 'website',
+ 'sale_management',
+ 'stock',
+ 'repair',
+ 'maintenance',
+ 'fusion_tasks',
+ 'fusion_poynt',
+ 'fusion_authorizer_portal',
+ ],
+ 'data': [
+ # Security
+ 'security/security.xml',
+ 'security/ir.model.access.csv',
+ # Data (must load before views that reference records)
+ 'data/ir_sequence_data.xml',
+ 'data/ir_config_parameter_data.xml',
+ 'data/mail_activity_type_data.xml',
+ 'data/mail_template_data.xml',
+ 'data/repair_product_category_data.xml',
+ 'data/intake_template_data.xml',
+ # Views
+ 'views/repair_product_category_views.xml',
+ 'views/intake_template_views.xml',
+ 'views/repair_order_views.xml',
+ 'views/res_partner_views.xml',
+ 'views/res_users_views.xml',
+ 'views/res_config_settings_views.xml',
+ # Wizard
+ 'wizard/repair_intake_wizard_views.xml',
+ # Menus (last, after all referenced actions exist)
+ 'views/menus.xml',
+ ],
+ 'assets': {
+ 'web.assets_backend': [
+ # Phase 2+: history_sidebar.js, signature_pad.js, etc.
+ ],
+ 'web.assets_frontend': [
+ # Phase 1+: portal_client_repair.js etc.
+ ],
+ },
+ 'images': ['static/description/icon.png'],
+ 'installable': True,
+ 'application': True,
+ 'auto_install': False,
+}
diff --git a/fusion_repairs/data/intake_template_data.xml b/fusion_repairs/data/intake_template_data.xml
new file mode 100644
index 00000000..c71f3182
--- /dev/null
+++ b/fusion_repairs/data/intake_template_data.xml
@@ -0,0 +1,378 @@
+
+
+
+
+
+
+
+
+
+ Default - General Intake
+ default
+ 1
+
+ Generic question set used when no equipment-specific template is configured.
]]>
+
+
+
+
+ 10
+ Who is calling? (self / family / caregiver / other)
+ caller_relationship
+ char
+
+
+
+
+ 20
+ Is the service address the same as the contact address on file?
+ address_match
+ boolean
+
+
+
+
+ 30
+ Was this equipment purchased from us?
+ purchased_from_us
+ boolean
+
+
+
+ 40
+ Approximate purchase date (if known)
+ purchase_date
+ date
+
+
+
+ 50
+ Describe the issue in your own words
+ issue_summary
+ text
+
+
+
+
+ 60
+ Does this issue affect anyone's safety right now?
+ safety_concern
+ boolean
+
+
+
+
+ 70
+ Anything the technician should know about access? (stairs, parking, gate code, pet)
+ access_notes
+ text
+ e.g. "dog in front yard, use side gate"
+
+
+
+
+
+
+ Hospital Bed - Intake
+ hospital_bed
+ 10
+
+
+
+
+
+ 10
+ Is the bed plugged in and does it power on?
+ powered
+ selection
+ Yes - powers on normally
+No - no lights/sound at all
+Powers on but won't move
+
+
+
+
+ 20
+ Does the remote control respond when buttons are pressed?
+ remote_works
+ boolean
+
+
+
+ 30
+ Which motor seems affected? (head, foot, height, all)
+ motor_side
+ char
+ motor
+
+
+
+ 40
+ Are the side rails functioning normally?
+ rails_ok
+ boolean
+ rail,side
+
+
+
+ 50
+ Is the mattress included in this issue?
+ mattress_involved
+ boolean
+
+
+
+
+
+
+ Stairlift - Intake
+ stairlift
+ 20
+
+
+
+
+
+ 10
+ Does the stairlift power on? (any lights, beeps)
+ powered
+ boolean
+
+
+
+
+ 20
+ Is there an error code displayed? (note the number/letter shown)
+ error_code
+ char
+ error code
+
+
+
+ 30
+ Is anyone currently stuck on the stairlift?
+ person_stuck
+ boolean
+
+ If yes, this is a safety issue - escalate immediately.
+
+
+
+ 40
+ Does it stop partway up or down the track?
+ stops_midway
+ boolean
+ stops midway
+
+
+
+ 50
+ Any burning smell, smoke, or unusual noise?
+ burning_smell
+ boolean
+
+ burning smell,smoke
+
+
+
+
+
+
+ Porch Lift - Intake
+ porch_lift
+ 30
+
+
+
+
+
+ 10
+ Does the lift respond when you press the call/send button?
+ powered
+ boolean
+
+
+
+
+ 20
+ Are all gate and door safety switches fully closed?
+ gate_switches
+ boolean
+
+
+
+ 30
+ Is anyone currently stuck on the lift?
+ person_stuck
+ boolean
+
+
+
+
+ 40
+ Is the lift outdoors exposed to weather?
+ outdoor
+ boolean
+
+
+
+
+
+
+ Wheelchair - Intake
+ wheelchair
+ 40
+
+
+
+
+
+ 10
+ Do the brakes engage and hold the wheelchair?
+ brakes_ok
+ boolean
+
+ brake
+
+
+
+ 20
+ Are both tires inflated and undamaged?
+ tires_ok
+ boolean
+
+
+
+ 30
+ Is there any visible damage to the frame or footrests?
+ frame_damage
+ boolean
+
+
+
+ 40
+ For power chairs: does the battery hold a charge?
+ battery_holds_charge
+ boolean
+ battery,charge
+
+
+
+ 50
+ For power chairs: any error code shown on the joystick display?
+ joystick_error
+ char
+
+
+
+
+
+
+ Walker / Rollator - Intake
+ walker_rollator
+ 50
+
+
+
+
+
+ 10
+ Do all wheels roll freely?
+ wheels_roll
+ boolean
+
+
+
+ 20
+ Do the brakes lock when engaged? (rollator only)
+ brakes_lock
+ boolean
+
+
+
+ 30
+ Is the frame stable, with no wobble or loose parts?
+ frame_stable
+ boolean
+
+
+
+
+
+
+ Medical Mattress - Intake
+ mattress
+ 60
+
+
+
+
+
+ 10
+ Is the pump plugged in and showing any indicator lights?
+ pump_powered
+ boolean
+
+
+
+
+ 20
+ Is the mattress leaking or losing air?
+ leak
+ boolean
+ leak,deflate
+
+
+
+ 30
+ Is the pump showing an error code or alarm?
+ alarm
+ char
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fusion_repairs/data/ir_config_parameter_data.xml b/fusion_repairs/data/ir_config_parameter_data.xml
new file mode 100644
index 00000000..a3d04c17
--- /dev/null
+++ b/fusion_repairs/data/ir_config_parameter_data.xml
@@ -0,0 +1,60 @@
+
+
+
+
+
+ fusion_repairs.enable_email_notifications
+ True
+
+
+
+
+ fusion_repairs.outstanding_balance_threshold
+ 100.00
+
+
+
+
+ fusion_repairs.duplicate_call_window_days
+ 14
+
+
+
+
+ fusion_repairs.variance_threshold_pct
+ 20
+
+
+ fusion_repairs.variance_threshold_amount
+ 100.00
+
+
+
+
+ fusion_repairs.followup_maintenance_enabled
+ True
+
+
+ fusion_repairs.followup_repair_no_tech_enabled
+ True
+
+
+ fusion_repairs.followup_overdue_visit_enabled
+ True
+
+
+ fusion_repairs.followup_unpaid_invoice_enabled
+ True
+
+
+
+
+ fusion_repairs.client_portal_url
+ /repair
+
+
+ fusion_repairs.client_portal_rate_limit_per_hour
+ 10
+
+
+
diff --git a/fusion_repairs/data/ir_sequence_data.xml b/fusion_repairs/data/ir_sequence_data.xml
new file mode 100644
index 00000000..37c8f7d7
--- /dev/null
+++ b/fusion_repairs/data/ir_sequence_data.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+ Repair Intake Session
+ fusion.repair.intake.session
+ RIS
+ 6
+ 1
+ 1
+
+
+
+
diff --git a/fusion_repairs/data/mail_activity_type_data.xml b/fusion_repairs/data/mail_activity_type_data.xml
new file mode 100644
index 00000000..323594f4
--- /dev/null
+++ b/fusion_repairs/data/mail_activity_type_data.xml
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+ Repair: CS Callback
+ Call client back if any intake info was missing
+ 1
+ days
+ previous_activity
+ repair.order
+ fa-phone
+ 10
+
+
+
+
+ Repair: Assign Technician
+ Assign a technician to this repair
+ 2
+ days
+ previous_activity
+ repair.order
+ fa-wrench
+ 20
+
+
+
+
+ Repair: Visit Follow-Up
+ Confirm visit outcome and complete repair
+ 1
+ days
+ previous_activity
+ repair.order
+ fa-check-square-o
+ 30
+
+
+
+
+ Repair: Manager Review
+ Third-party equipment - manager awareness
+ 1
+ days
+ previous_activity
+ repair.order
+ fa-flag
+ 40
+
+
+
+
diff --git a/fusion_repairs/data/mail_template_data.xml b/fusion_repairs/data/mail_template_data.xml
new file mode 100644
index 00000000..06571eeb
--- /dev/null
+++ b/fusion_repairs/data/mail_template_data.xml
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+
+
+
+ Repair: Intake Received (Client)
+
+ {{ object.company_id.name }} - Service Call {{ object.name or 'received' }}
+ {{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}
+ {{ object.partner_id.id }}
+
+
+
+
+
+
+
+
We received your service request
+
+ Hello , thank you for letting us know about your equipment.
+ Your service call reference is .
+
+
+ | Service Call Details |
+ | Reference | |
+
+ | Equipment | |
+
+
+ | Scheduled | |
+
+ | Status | |
+
+
+
+ A team member will be in touch shortly to confirm the next steps.
+ If you need to reach us before then, please contact our office directly.
+
+
+
+ --
+
+
+
+
+ {{ object.partner_id.lang }}
+
+
+
+
+
+
+
+ Repair: Intake Received (Office)
+
+ [New Service Call] {{ object.partner_id.name or 'Walk-in' }} - {{ object.name or 'n/a' }}
+ {{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}
+
+
+
+
+
+ Internal: New Service Call
+
+
A new repair has been submitted
+
+ Submitted by
+ via the .
+
+
+ | Details |
+ | Reference | |
+ | Client | |
+
+ | Phone | |
+
+
+ | Equipment | |
+
+ | Urgency | |
+
+ | Third-party | Yes - equipment not sold by us |
+
+
+ | Warranty | Under warranty |
+
+
+
+
+
+
+
+
+
+
diff --git a/fusion_repairs/data/repair_product_category_data.xml b/fusion_repairs/data/repair_product_category_data.xml
new file mode 100644
index 00000000..52d36e35
--- /dev/null
+++ b/fusion_repairs/data/repair_product_category_data.xml
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+ Hospital Bed
+ hospital_bed
+ 10
+ fa-bed
+ Electric and manual hospital beds, semi-electric beds, low beds.
+
+
+
+ Wheelchair (Manual)
+ wheelchair_manual
+ 20
+ fa-wheelchair
+ Standard, transport, and tilt-in-space manual wheelchairs.
+
+
+
+ Wheelchair (Power)
+ wheelchair_power
+ 30
+ fa-wheelchair
+ Power wheelchairs, scooters, and powered mobility devices.
+
+
+
+ Stairlift
+ stairlift
+ 40
+ fa-arrows-v
+ Straight and curved indoor stairlifts. Annual safety inspection required in many jurisdictions.
+
+
+
+
+ Porch Lift
+ porch_lift
+ 50
+ fa-arrow-up
+ Vertical platform lifts for porches, decks, and accessible building entrances.
+
+
+
+
+ Walker
+ walker
+ 60
+ fa-male
+ Standard walkers, hemi-walkers, and folding walkers.
+
+
+
+ Rollator
+ rollator
+ 70
+ fa-male
+ Wheeled walkers with seats and brakes.
+
+
+
+ Medical Mattress
+ mattress
+ 80
+ fa-bed
+ Air mattresses, alternating pressure, low air loss, and pressure relief mattresses.
+
+
+
+ Other Equipment
+ other
+ 100
+ fa-question-circle
+ Any other medical equipment not in the standard categories.
+
+
+
+
diff --git a/fusion_repairs/models/__init__.py b/fusion_repairs/models/__init__.py
new file mode 100644
index 00000000..5b36b893
--- /dev/null
+++ b/fusion_repairs/models/__init__.py
@@ -0,0 +1,15 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from . import repair_product_category
+from . import intake_template
+from . import intake_question
+from . import intake_answer
+from . import product_template
+from . import res_partner
+from . import res_users
+from . import res_config_settings
+from . import technician_task
+from . import repair_order
+from . import intake_service
diff --git a/fusion_repairs/models/intake_answer.py b/fusion_repairs/models/intake_answer.py
new file mode 100644
index 00000000..0db6b64f
--- /dev/null
+++ b/fusion_repairs/models/intake_answer.py
@@ -0,0 +1,88 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from odoo import api, fields, models
+
+
+class FusionRepairIntakeAnswer(models.Model):
+ """An answer to a single intake question on a specific repair order.
+
+ Persists raw answer values for audit + reporting + AI / catalogue matching.
+ """
+
+ _name = 'fusion.repair.intake.answer'
+ _description = 'Repair Intake Answer'
+ _order = 'repair_id, sequence, id'
+
+ repair_id = fields.Many2one(
+ 'repair.order',
+ string='Repair Order',
+ required=True,
+ ondelete='cascade',
+ index=True,
+ )
+ question_id = fields.Many2one(
+ 'fusion.repair.intake.question',
+ string='Question',
+ required=True,
+ ondelete='restrict',
+ )
+ question_name = fields.Char(
+ related='question_id.name',
+ string='Question',
+ store=True,
+ )
+ question_type = fields.Selection(
+ related='question_id.question_type',
+ store=True,
+ )
+ sequence = fields.Integer(
+ related='question_id.sequence',
+ store=True,
+ )
+
+ # Typed value fields - one per supported type, plus a display string.
+ value_char = fields.Char(string='Text Answer')
+ value_text = fields.Text(string='Long Text Answer')
+ value_selection = fields.Char(string='Choice Answer')
+ value_boolean = fields.Boolean(string='Yes/No Answer')
+ value_integer = fields.Integer(string='Number Answer')
+ value_date = fields.Date(string='Date Answer')
+
+ value_display = fields.Char(
+ string='Answer',
+ compute='_compute_value_display',
+ store=True,
+ )
+
+ company_id = fields.Many2one(
+ 'res.company',
+ related='repair_id.company_id',
+ store=True,
+ index=True,
+ )
+
+ @api.depends(
+ 'question_type',
+ 'value_char', 'value_text', 'value_selection',
+ 'value_boolean', 'value_integer', 'value_date',
+ )
+ def _compute_value_display(self):
+ for answer in self:
+ if answer.question_type == 'char':
+ answer.value_display = answer.value_char or ''
+ elif answer.question_type == 'text':
+ answer.value_display = (answer.value_text or '')[:200]
+ elif answer.question_type == 'selection':
+ answer.value_display = answer.value_selection or ''
+ elif answer.question_type == 'boolean':
+ answer.value_display = 'Yes' if answer.value_boolean else 'No'
+ elif answer.question_type == 'integer':
+ answer.value_display = str(answer.value_integer or 0)
+ elif answer.question_type == 'date':
+ answer.value_display = (
+ fields.Date.to_string(answer.value_date) if answer.value_date else ''
+ )
+ else:
+ answer.value_display = ''
diff --git a/fusion_repairs/models/intake_question.py b/fusion_repairs/models/intake_question.py
new file mode 100644
index 00000000..48188f71
--- /dev/null
+++ b/fusion_repairs/models/intake_question.py
@@ -0,0 +1,84 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from odoo import fields, models
+
+
+QUESTION_TYPES = [
+ ('char', 'Short Text'),
+ ('text', 'Long Text'),
+ ('selection', 'Single Choice'),
+ ('boolean', 'Yes / No'),
+ ('integer', 'Number'),
+ ('date', 'Date'),
+]
+
+
+class FusionRepairIntakeQuestion(models.Model):
+ """A single question on an intake template.
+
+ Supports basic conditional display: a question is only shown when the
+ parent question's answer matches `parent_answer_value`. The wizard and
+ portal forms render based on these rules.
+ """
+
+ _name = 'fusion.repair.intake.question'
+ _description = 'Repair Intake Question'
+ _order = 'sequence, id'
+
+ template_id = fields.Many2one(
+ 'fusion.repair.intake.template',
+ string='Template',
+ required=True,
+ ondelete='cascade',
+ index=True,
+ )
+ sequence = fields.Integer(string='Sequence', default=10)
+ name = fields.Char(
+ string='Question',
+ required=True,
+ translate=True,
+ help='Text shown to the user.',
+ )
+ code = fields.Char(
+ string='Code',
+ help='Stable identifier for this question (used by automation rules and reporting).',
+ )
+ help_text = fields.Char(
+ string='Help Text',
+ translate=True,
+ help='Optional shorter hint shown beneath the question (e.g. "e.g. SN-12345").',
+ )
+ question_type = fields.Selection(
+ QUESTION_TYPES,
+ string='Type',
+ required=True,
+ default='char',
+ )
+ required = fields.Boolean(default=False)
+
+ selection_options = fields.Text(
+ string='Choices',
+ help='One option per line, only used when type is "Single Choice".',
+ )
+
+ # Conditional display
+ parent_question_id = fields.Many2one(
+ 'fusion.repair.intake.question',
+ string='Show Only If Question',
+ domain="[('template_id', '=', template_id), ('id', '!=', id)]",
+ ondelete='set null',
+ help='Show this question only when the parent question matches the value below.',
+ )
+ parent_answer_value = fields.Char(
+ string='Parent Answer Equals',
+ help='Value the parent answer must equal for this question to be displayed.',
+ )
+
+ # Symptom keyword classification - feeds the service catalogue matcher and AI prompt
+ symptom_keywords = fields.Char(
+ string='Symptom Keywords',
+ help='Comma-separated keywords that, when present in the answer, tag the repair '
+ 'for catalogue matching (e.g. "battery,charge").',
+ )
diff --git a/fusion_repairs/models/intake_service.py b/fusion_repairs/models/intake_service.py
new file mode 100644
index 00000000..bbe8a708
--- /dev/null
+++ b/fusion_repairs/models/intake_service.py
@@ -0,0 +1,347 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+"""Shared intake service.
+
+This AbstractModel is the SINGLE entry point for creating repair orders from
+any intake surface: the backend wizard (Phase 1), the sales rep portal
+(Phase 1+), and the public client self-service portal (Phase 1+).
+
+All three surfaces call `create_repair_orders(payload, source='...')` so that
+business logic - activities, emails, warranty determination, AI summary,
+catalogue match, third-party flag, dispatch task creation - lives in one
+place and the surfaces never drift apart.
+"""
+
+import logging
+from datetime import timedelta
+
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError
+
+_logger = logging.getLogger(__name__)
+
+
+class FusionRepairIntakeService(models.AbstractModel):
+ _name = 'fusion.repair.intake.service'
+ _description = 'Repair Intake Service (shared by backend / sales rep / client)'
+
+ # ------------------------------------------------------------------
+ # PUBLIC API
+ # ------------------------------------------------------------------
+ @api.model
+ def create_repair_orders(self, payload, source='backend_wizard'):
+ """Create one repair.order per equipment item in the payload.
+
+ :param payload: dict with keys:
+ - partner_id: int (required) or partner_vals: dict to create new partner
+ - intake_user_id: int (optional, defaults to env.user)
+ - equipment_items: list of dicts, each with:
+ - product_id: int (optional)
+ - lot_id: int (optional)
+ - repair_category_id: int (optional)
+ - intake_template_id: int (optional)
+ - third_party: bool (optional)
+ - urgency: str (optional, default 'normal')
+ - issue_summary: str (optional)
+ - internal_notes: str (optional)
+ - photo_attachment_ids: list[int] (optional)
+ - answers: list of dicts with keys
+ (question_id, value_char|value_text|value_selection|
+ value_boolean|value_integer|value_date)
+ :param source: str, one of repair_order.INTAKE_SOURCES values.
+ :return: recordset of repair.order records created.
+ """
+ partner_id = self._resolve_partner(payload)
+ if not partner_id:
+ raise UserError(_('A client is required to create a repair request.'))
+
+ intake_user = self.env['res.users'].browse(
+ payload.get('intake_user_id') or self.env.uid
+ )
+ session_ref = (
+ self.env['ir.sequence'].next_by_code('fusion.repair.intake.session')
+ or 'RIS/NEW'
+ )
+
+ equipment = payload.get('equipment_items') or [{}]
+ repairs = self.env['repair.order']
+ for item in equipment:
+ repair = self._create_single_repair(
+ partner_id=partner_id,
+ intake_user=intake_user,
+ session_ref=session_ref,
+ source=source,
+ item=item,
+ )
+ repairs |= repair
+
+ return repairs
+
+ # ------------------------------------------------------------------
+ # PARTNER RESOLUTION
+ # ------------------------------------------------------------------
+ @api.model
+ def _resolve_partner(self, payload):
+ partner_id = payload.get('partner_id')
+ if partner_id:
+ return partner_id
+ partner_vals = payload.get('partner_vals')
+ if not partner_vals:
+ return False
+ partner = self.env['res.partner'].sudo().create(partner_vals)
+ return partner.id
+
+ # ------------------------------------------------------------------
+ # CORE CREATION
+ # ------------------------------------------------------------------
+ @api.model
+ def _create_single_repair(self, partner_id, intake_user, session_ref, source, item):
+ Repair = self.env['repair.order']
+ product_id = item.get('product_id')
+
+ vals = {
+ 'partner_id': partner_id,
+ 'user_id': intake_user.id,
+ 'x_fc_intake_user_id': intake_user.id,
+ 'x_fc_intake_session_id': session_ref,
+ 'x_fc_intake_source': source,
+ 'x_fc_repair_category_id': item.get('repair_category_id') or False,
+ 'x_fc_intake_template_id': item.get('intake_template_id') or False,
+ 'x_fc_third_party_equipment': bool(item.get('third_party')),
+ 'x_fc_urgency': item.get('urgency') or 'normal',
+ 'x_fc_issue_category': item.get('issue_category') or False,
+ 'internal_notes': self._wrap_internal_notes(item),
+ }
+ if product_id:
+ vals['product_id'] = product_id
+ if item.get('lot_id'):
+ vals['lot_id'] = item['lot_id']
+ if item.get('schedule_date'):
+ vals['schedule_date'] = item['schedule_date']
+
+ repair = Repair.create(vals)
+
+ # Determine warranty AFTER creation (needs product on record).
+ if not repair.x_fc_third_party_equipment:
+ self._auto_link_original_sale_order(repair)
+ if repair._fc_compute_warranty_status():
+ repair.under_warranty = True
+
+ # Persist intake answers.
+ self._create_answers(repair, item.get('answers') or [])
+
+ # Attach photos.
+ photo_ids = item.get('photo_attachment_ids') or []
+ if photo_ids:
+ attachments = self.env['ir.attachment'].sudo().browse(photo_ids).exists()
+ attachments.write({
+ 'res_model': 'repair.order',
+ 'res_id': repair.id,
+ })
+ repair.write({'x_fc_photo_ids': [(6, 0, attachments.ids)]})
+
+ # Activities.
+ self._schedule_activities(repair)
+
+ # Optional dispatch draft task (urgent / safety).
+ if repair.x_fc_urgency in ('urgent', 'safety'):
+ self._create_dispatch_task(repair)
+
+ # Emails (client + office).
+ self._send_intake_emails(repair)
+
+ # Audit message in chatter.
+ repair.message_post(
+ body=_(
+ 'Service call submitted via %(source)s by %(user)s. '
+ 'Session reference: %(ref)s.',
+ source=dict(repair._fields['x_fc_intake_source'].selection).get(source),
+ user=intake_user.name,
+ ref=session_ref,
+ ),
+ )
+
+ return repair
+
+ @api.model
+ def _wrap_internal_notes(self, item):
+ notes = item.get('internal_notes') or ''
+ summary = item.get('issue_summary') or ''
+ if not (notes or summary):
+ return False
+ parts = []
+ if summary:
+ parts.append('Issue summary: %s
' % summary)
+ if notes:
+ parts.append('Notes: %s
' % notes)
+ return ''.join(parts)
+
+ # ------------------------------------------------------------------
+ # ORIGINAL SO AUTO-LINK
+ # ------------------------------------------------------------------
+ @api.model
+ def _auto_link_original_sale_order(self, repair):
+ if not repair.partner_id or not repair.product_id:
+ return
+ SaleOrder = self.env['sale.order'].sudo()
+ domain = [
+ ('partner_id', '=', repair.partner_id.id),
+ ('state', 'in', ('sale', 'done')),
+ ('order_line.product_id', '=', repair.product_id.id),
+ ]
+ if repair.lot_id:
+ domain.append(('order_line.lot_ids', 'in', repair.lot_id.id))
+ candidate = SaleOrder.search(domain, order='date_order desc', limit=1)
+ if candidate:
+ repair.x_fc_original_sale_order_id = candidate
+
+ # ------------------------------------------------------------------
+ # ANSWERS
+ # ------------------------------------------------------------------
+ @api.model
+ def _create_answers(self, repair, answers):
+ if not answers:
+ return
+ Answer = self.env['fusion.repair.intake.answer']
+ for ans in answers:
+ qid = ans.get('question_id')
+ if not qid:
+ continue
+ Answer.create({
+ 'repair_id': repair.id,
+ 'question_id': qid,
+ 'value_char': ans.get('value_char'),
+ 'value_text': ans.get('value_text'),
+ 'value_selection': ans.get('value_selection'),
+ 'value_boolean': bool(ans.get('value_boolean')),
+ 'value_integer': int(ans.get('value_integer') or 0),
+ 'value_date': ans.get('value_date') or False,
+ })
+
+ # ------------------------------------------------------------------
+ # ACTIVITIES
+ # ------------------------------------------------------------------
+ @api.model
+ def _schedule_activities(self, repair):
+ """Create the 4 intake activities described in the spec."""
+ try:
+ cs_callback_type = self.env.ref('fusion_repairs.mail_activity_type_cs_callback')
+ tech_dispatch_type = self.env.ref('fusion_repairs.mail_activity_type_tech_dispatch')
+ manager_review_type = self.env.ref('fusion_repairs.mail_activity_type_manager_review')
+ except ValueError:
+ _logger.warning('Repair activity types missing - skipping')
+ return
+
+ # CS callback - always, intake user
+ repair.activity_schedule(
+ activity_type_id=cs_callback_type.id,
+ summary=_('Call client back if any intake info was missing'),
+ user_id=repair.x_fc_intake_user_id.id or self.env.uid,
+ )
+
+ # Tech dispatch - assigned to responsible user, urgency-adjusted deadline
+ deadline_days = {'safety': 0, 'urgent': 1, 'normal': 2}.get(repair.x_fc_urgency, 2)
+ repair.activity_schedule(
+ activity_type_id=tech_dispatch_type.id,
+ summary=_('Assign a technician (urgency: %s)', repair.x_fc_urgency),
+ user_id=repair.user_id.id or self.env.uid,
+ date_deadline=fields.Date.context_today(self) + timedelta(days=deadline_days),
+ )
+
+ # Manager review - only for third-party equipment
+ if repair.x_fc_third_party_equipment:
+ manager_group = self.env.ref(
+ 'fusion_repairs.group_fusion_repairs_manager',
+ raise_if_not_found=False,
+ )
+ manager_user = self.env.user
+ if manager_group:
+ # res.groups has no .users field in Odoo 19;
+ # query via res.users.all_group_ids (Odoo 19 renamed groups_id).
+ candidate = self.env['res.users'].sudo().search(
+ [('all_group_ids', 'in', manager_group.ids), ('active', '=', True)],
+ limit=1,
+ )
+ if candidate:
+ manager_user = candidate
+ repair.activity_schedule(
+ activity_type_id=manager_review_type.id,
+ summary=_('Third-party equipment - manager awareness'),
+ user_id=manager_user.id,
+ )
+
+ # ------------------------------------------------------------------
+ # DISPATCH TASK
+ # ------------------------------------------------------------------
+ @api.model
+ def _create_dispatch_task(self, repair):
+ """Create a draft fusion.technician.task for urgent / safety repairs.
+
+ Phase 1 simple approach: no date/technician assigned, dispatcher confirms.
+ """
+ Task = self.env['fusion.technician.task'].sudo()
+ try:
+ vals = {
+ 'partner_id': repair.partner_id.id,
+ 'task_type': 'repair',
+ 'status': 'pending',
+ 'scheduled_date': fields.Date.context_today(self),
+ 'duration_hours': repair.x_fc_estimated_duration or 1.0,
+ 'x_fc_repair_order_id': repair.id,
+ 'description': repair.internal_notes or repair.name,
+ }
+ # technician_id is required on fusion.technician.task; we fall back to
+ # the intake user. Dispatcher will reassign.
+ vals['technician_id'] = (
+ repair.user_id.id if repair.user_id and repair.user_id.x_fc_is_field_staff
+ else self.env.uid
+ )
+ Task.create(vals)
+ except Exception as e:
+ _logger.warning('Failed to auto-create dispatch task for repair %s: %s',
+ repair.name, e)
+
+ # ------------------------------------------------------------------
+ # EMAILS
+ # ------------------------------------------------------------------
+ @api.model
+ def _send_intake_emails(self, repair):
+ if not self._notifications_enabled():
+ return
+ # Client confirmation
+ if repair.partner_id and repair.partner_id.email:
+ try:
+ self.env.ref('fusion_repairs.email_template_intake_received_client') \
+ .send_mail(repair.id, force_send=False)
+ except Exception as e:
+ _logger.warning('Failed to send client intake email for %s: %s',
+ repair.name, e)
+
+ # Office notification
+ office_emails = self._office_emails(repair.company_id)
+ if office_emails:
+ try:
+ tpl = self.env.ref('fusion_repairs.email_template_intake_received_office')
+ tpl.with_context(default_email_to=','.join(office_emails)) \
+ .send_mail(repair.id, force_send=False, email_values={
+ 'email_to': ','.join(office_emails),
+ })
+ except Exception as e:
+ _logger.warning('Failed to send office intake email for %s: %s',
+ repair.name, e)
+
+ @api.model
+ def _notifications_enabled(self):
+ ICP = self.env['ir.config_parameter'].sudo()
+ return ICP.get_param('fusion_repairs.enable_email_notifications', 'True') == 'True'
+
+ @api.model
+ def _office_emails(self, company):
+ # Reuse the office notification recipients defined by fusion_claims.
+ partners = company.sudo()
+ recipients = getattr(partners, 'x_fc_office_notification_ids', False)
+ if recipients:
+ return [p.email for p in recipients if p.email]
+ return []
diff --git a/fusion_repairs/models/intake_template.py b/fusion_repairs/models/intake_template.py
new file mode 100644
index 00000000..ba6a0416
--- /dev/null
+++ b/fusion_repairs/models/intake_template.py
@@ -0,0 +1,74 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from odoo import api, fields, models
+
+
+class FusionRepairIntakeTemplate(models.Model):
+ """A reusable set of intake questions per medical equipment category.
+
+ Each template contains an ordered list of questions; the intake wizard
+ (and sales-rep / client portals) render these dynamically with
+ conditional show/hide based on prior answers.
+ """
+
+ _name = 'fusion.repair.intake.template'
+ _description = 'Repair Intake Question Template'
+ _order = 'sequence, name'
+
+ name = fields.Char(string='Template Name', required=True, translate=True)
+ code = fields.Char(
+ string='Code',
+ help='Optional stable identifier for referencing this template from code/data.',
+ )
+ sequence = fields.Integer(string='Sequence', default=10)
+ active = fields.Boolean(default=True)
+ is_default = fields.Boolean(
+ string='Default Fallback',
+ help='Used when no template is explicitly configured for the selected category. '
+ 'Exactly one template should be flagged as default per company.',
+ )
+ description = fields.Html(string='Description', translate=True)
+
+ product_category_ids = fields.Many2many(
+ 'fusion.repair.product.category',
+ 'fusion_repair_intake_template_category_rel',
+ 'template_id',
+ 'category_id',
+ string='Applies to Categories',
+ help='Categories that automatically select this template during intake.',
+ )
+
+ question_ids = fields.One2many(
+ 'fusion.repair.intake.question',
+ 'template_id',
+ string='Questions',
+ copy=True,
+ )
+ question_count = fields.Integer(
+ compute='_compute_question_count',
+ string='Question Count',
+ )
+
+ company_id = fields.Many2one(
+ 'res.company',
+ string='Company',
+ default=lambda self: self.env.company,
+ )
+
+ @api.depends('question_ids')
+ def _compute_question_count(self):
+ for tpl in self:
+ tpl.question_count = len(tpl.question_ids)
+
+ def action_view_questions(self):
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': self.name,
+ 'res_model': 'fusion.repair.intake.question',
+ 'view_mode': 'list,form',
+ 'domain': [('template_id', '=', self.id)],
+ 'context': {'default_template_id': self.id},
+ }
diff --git a/fusion_repairs/models/product_template.py b/fusion_repairs/models/product_template.py
new file mode 100644
index 00000000..ea6224b0
--- /dev/null
+++ b/fusion_repairs/models/product_template.py
@@ -0,0 +1,34 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from odoo import fields, models
+
+
+class ProductTemplate(models.Model):
+ _inherit = 'product.template'
+
+ x_fc_repair_category_id = fields.Many2one(
+ 'fusion.repair.product.category',
+ string='Repair Category',
+ help='Medical equipment category - drives intake template selection and '
+ 'technician skills filter for repairs of this product.',
+ )
+ x_fc_warranty_months = fields.Integer(
+ string='Warranty (Months)',
+ default=12,
+ help='Default warranty period for new units of this product. Used to auto-detect '
+ 'warranty status on repair intake (delivery date + warranty months >= today).',
+ )
+ x_fc_maintenance_interval_months = fields.Integer(
+ string='Maintenance Interval (Months)',
+ default=0,
+ help='If > 0, delivering a unit of this product auto-creates a maintenance contract '
+ 'with this recurring interval. Phase 3 feature.',
+ )
+ x_fc_intake_template_id = fields.Many2one(
+ 'fusion.repair.intake.template',
+ string='Intake Template Override',
+ help='Optional override of the intake template normally chosen from the '
+ 'repair category. Leave empty to use category default.',
+ )
diff --git a/fusion_repairs/models/repair_order.py b/fusion_repairs/models/repair_order.py
new file mode 100644
index 00000000..91de2022
--- /dev/null
+++ b/fusion_repairs/models/repair_order.py
@@ -0,0 +1,281 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from datetime import timedelta
+
+from odoo import api, fields, models, _
+
+
+INTAKE_SOURCES = [
+ ('backend_wizard', 'Backend Wizard (CS)'),
+ ('sales_rep_portal', 'Sales Rep Portal'),
+ ('client_portal', 'Client Self-Service'),
+ ('manual', 'Manual / Other'),
+]
+
+URGENCY_LEVELS = [
+ ('normal', 'Normal'),
+ ('urgent', 'Urgent'),
+ ('safety', 'Safety Issue'),
+]
+
+
+class RepairOrder(models.Model):
+ """Extend Odoo Repairs with intake context, dispatch link, warranty
+ determination, and pricing variance tracking for Fusion Repairs."""
+
+ _inherit = 'repair.order'
+
+ # ------------------------------------------------------------------
+ # INTAKE METADATA
+ # ------------------------------------------------------------------
+ x_fc_intake_source = fields.Selection(
+ INTAKE_SOURCES,
+ string='Intake Source',
+ default='manual',
+ tracking=True,
+ help='Which intake surface created this repair (backend CS wizard, '
+ 'sales rep portal, public client portal, or manual entry).',
+ )
+ x_fc_intake_user_id = fields.Many2one(
+ 'res.users',
+ string='Intake By',
+ tracking=True,
+ index=True,
+ help='User who took the call / submitted the intake. For client portal, '
+ 'this is the OdooBot or admin user.',
+ )
+ x_fc_intake_session_id = fields.Char(
+ string='Intake Session',
+ index=True,
+ copy=False,
+ help='Reference shared by multiple repair orders created during the same call.',
+ )
+ x_fc_intake_template_id = fields.Many2one(
+ 'fusion.repair.intake.template',
+ string='Intake Template',
+ help='Question template used during intake.',
+ )
+ x_fc_intake_answer_ids = fields.One2many(
+ 'fusion.repair.intake.answer',
+ 'repair_id',
+ string='Intake Answers',
+ )
+ x_fc_intake_answer_count = fields.Integer(
+ compute='_compute_intake_answer_count',
+ )
+
+ # ------------------------------------------------------------------
+ # EQUIPMENT / WARRANTY
+ # ------------------------------------------------------------------
+ x_fc_repair_category_id = fields.Many2one(
+ 'fusion.repair.product.category',
+ string='Equipment Category',
+ tracking=True,
+ index=True,
+ help='Medical equipment category - drives intake template and tech skills filter.',
+ )
+ x_fc_third_party_equipment = fields.Boolean(
+ string='Third-Party Equipment',
+ tracking=True,
+ help='True if the equipment was not sold by us. Forces under_warranty=False '
+ 'and typically triggers a service call-out fee.',
+ )
+ x_fc_original_sale_order_id = fields.Many2one(
+ 'sale.order',
+ string='Original Purchase SO',
+ tracking=True,
+ index=True,
+ help='Sale order through which the customer originally purchased this unit. '
+ 'Auto-matched on intake by partner + lot/serial.',
+ )
+ x_fc_warranty_override_reason = fields.Char(
+ string='Warranty Override Reason',
+ help='Required when CS overrides the auto-detected warranty status.',
+ )
+
+ # ------------------------------------------------------------------
+ # TRIAGE / URGENCY
+ # ------------------------------------------------------------------
+ x_fc_urgency = fields.Selection(
+ URGENCY_LEVELS,
+ string='Urgency',
+ default='normal',
+ tracking=True,
+ index=True,
+ )
+ x_fc_issue_category = fields.Char(
+ string='Issue Category',
+ help='Symptom classification (e.g. "battery", "motor", "remote"). Used by '
+ 'service catalogue matcher and AI prompt context.',
+ )
+
+ # ------------------------------------------------------------------
+ # PHOTOS
+ # ------------------------------------------------------------------
+ x_fc_photo_ids = fields.Many2many(
+ 'ir.attachment',
+ 'fusion_repair_order_photo_rel',
+ 'repair_id',
+ 'attachment_id',
+ string='Intake Photos / Videos',
+ help='Photos and videos uploaded during intake.',
+ )
+ x_fc_photo_count = fields.Integer(
+ compute='_compute_photo_count',
+ )
+
+ # ------------------------------------------------------------------
+ # PRICING (estimate vs actual - Phase 2 reconciliation)
+ # ------------------------------------------------------------------
+ x_fc_estimated_duration = fields.Float(
+ string='Estimated Duration (h)',
+ help='Estimated visit duration from service catalogue, used to size technician slot.',
+ )
+ x_fc_estimated_cost = fields.Monetary(
+ string='Estimated Cost',
+ currency_field='company_currency_id',
+ help='Estimated total from catalogue match at intake (pre-visit).',
+ )
+ x_fc_actual_cost = fields.Monetary(
+ string='Actual Cost',
+ currency_field='company_currency_id',
+ help='Actual total recorded from the visit report (post-visit).',
+ )
+ x_fc_cost_variance_pct = fields.Float(
+ string='Cost Variance %',
+ compute='_compute_cost_variance',
+ store=True,
+ help='(actual - estimated) / estimated * 100',
+ )
+ x_fc_requires_requote = fields.Boolean(
+ string='Requires Re-Quote',
+ help='Set when actual cost exceeds estimate beyond the configured threshold; '
+ 'blocks automatic invoicing until manager approves or client re-confirms.',
+ )
+
+ company_currency_id = fields.Many2one(
+ 'res.currency',
+ related='company_id.currency_id',
+ readonly=True,
+ )
+
+ # ------------------------------------------------------------------
+ # FIELD SERVICE LINK
+ # ------------------------------------------------------------------
+ x_fc_technician_task_ids = fields.One2many(
+ 'fusion.technician.task',
+ 'x_fc_repair_order_id',
+ string='Technician Tasks',
+ )
+ x_fc_technician_task_count = fields.Integer(
+ compute='_compute_technician_task_count',
+ )
+
+ # ------------------------------------------------------------------
+ # AI SUMMARY (Phase 2)
+ # ------------------------------------------------------------------
+ x_fc_ai_summary = fields.Text(
+ string='AI Pre-Visit Brief',
+ help='AI-generated short brief for the technician based on intake answers. '
+ 'Optional - never blocks intake submit.',
+ )
+
+ # ------------------------------------------------------------------
+ # COMPUTES
+ # ------------------------------------------------------------------
+ @api.depends('x_fc_intake_answer_ids')
+ def _compute_intake_answer_count(self):
+ for repair in self:
+ repair.x_fc_intake_answer_count = len(repair.x_fc_intake_answer_ids)
+
+ @api.depends('x_fc_photo_ids')
+ def _compute_photo_count(self):
+ for repair in self:
+ repair.x_fc_photo_count = len(repair.x_fc_photo_ids)
+
+ @api.depends('x_fc_technician_task_ids')
+ def _compute_technician_task_count(self):
+ for repair in self:
+ repair.x_fc_technician_task_count = len(repair.x_fc_technician_task_ids)
+
+ @api.depends('x_fc_estimated_cost', 'x_fc_actual_cost')
+ def _compute_cost_variance(self):
+ for repair in self:
+ if repair.x_fc_estimated_cost:
+ repair.x_fc_cost_variance_pct = (
+ (repair.x_fc_actual_cost - repair.x_fc_estimated_cost)
+ / repair.x_fc_estimated_cost * 100
+ )
+ else:
+ repair.x_fc_cost_variance_pct = 0.0
+
+ # ------------------------------------------------------------------
+ # WARRANTY DETERMINATION
+ # ------------------------------------------------------------------
+ def _fc_compute_warranty_status(self):
+ """Auto-detect warranty: not third-party AND within warranty window."""
+ self.ensure_one()
+ if self.x_fc_third_party_equipment:
+ return False
+ if not self.x_fc_original_sale_order_id:
+ return False
+ original = self.x_fc_original_sale_order_id
+ delivery_date = original.commitment_date or original.date_order
+ if not delivery_date:
+ return False
+ warranty_months = (
+ self.product_id.product_tmpl_id.x_fc_warranty_months
+ if self.product_id else 0
+ )
+ if not warranty_months:
+ return False
+ # Datetime + months: use simple 30-day approximation per month for now.
+ cutoff = fields.Datetime.from_string(str(delivery_date)) + timedelta(days=warranty_months * 30)
+ return fields.Datetime.now() <= cutoff
+
+ # ------------------------------------------------------------------
+ # SMART BUTTONS
+ # ------------------------------------------------------------------
+ def action_view_intake_answers(self):
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Intake Answers'),
+ 'res_model': 'fusion.repair.intake.answer',
+ 'view_mode': 'list,form',
+ 'domain': [('repair_id', '=', self.id)],
+ 'context': {'default_repair_id': self.id},
+ }
+
+ def action_view_technician_tasks(self):
+ self.ensure_one()
+ if len(self.x_fc_technician_task_ids) == 1:
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': self.x_fc_technician_task_ids.name,
+ 'res_model': 'fusion.technician.task',
+ 'view_mode': 'form',
+ 'res_id': self.x_fc_technician_task_ids.id,
+ }
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Technician Tasks'),
+ 'res_model': 'fusion.technician.task',
+ 'view_mode': 'list,form',
+ 'domain': [('x_fc_repair_order_id', '=', self.id)],
+ 'context': {'default_x_fc_repair_order_id': self.id},
+ }
+
+ def action_view_original_sale_order(self):
+ self.ensure_one()
+ if not self.x_fc_original_sale_order_id:
+ return False
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': self.x_fc_original_sale_order_id.name,
+ 'res_model': 'sale.order',
+ 'view_mode': 'form',
+ 'res_id': self.x_fc_original_sale_order_id.id,
+ }
diff --git a/fusion_repairs/models/repair_product_category.py b/fusion_repairs/models/repair_product_category.py
new file mode 100644
index 00000000..d360f4d4
--- /dev/null
+++ b/fusion_repairs/models/repair_product_category.py
@@ -0,0 +1,49 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from odoo import api, fields, models
+
+
+class FusionRepairProductCategory(models.Model):
+ """Medical equipment categories used to route repair intake and match skills."""
+
+ _name = 'fusion.repair.product.category'
+ _description = 'Repair Product Category'
+ _order = 'sequence, name'
+
+ name = fields.Char(string='Name', required=True, translate=True)
+ code = fields.Char(
+ string='Code',
+ required=True,
+ help='Stable identifier used by code (e.g. "stairlift"). Lowercase, no spaces.',
+ )
+ sequence = fields.Integer(string='Sequence', default=10)
+ icon = fields.Char(
+ string='Icon',
+ default='fa-wrench',
+ help='Font Awesome icon class shown next to the category in pickers.',
+ )
+ description = fields.Text(string='Description', translate=True)
+ active = fields.Boolean(default=True)
+ safety_critical = fields.Boolean(
+ string='Safety-Critical',
+ help='Categories where motor / mechanical issues warrant immediate escalation '
+ '(stairlifts, porch lifts). Used by the AI self-check engine to skip '
+ 'self-help and force escalation when safety symptoms appear.',
+ )
+
+ intake_template_id = fields.Many2one(
+ 'fusion.repair.intake.template',
+ string='Default Intake Template',
+ help='Default intake question set shown when this category is selected.',
+ )
+
+ _sql_constraints = [
+ ('code_unique', 'unique(code)', 'Category code must be unique.'),
+ ]
+
+ @api.depends('name', 'code')
+ def _compute_display_name(self):
+ for cat in self:
+ cat.display_name = cat.name or cat.code or ''
diff --git a/fusion_repairs/models/res_config_settings.py b/fusion_repairs/models/res_config_settings.py
new file mode 100644
index 00000000..7bbf4f14
--- /dev/null
+++ b/fusion_repairs/models/res_config_settings.py
@@ -0,0 +1,64 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from odoo import fields, models
+
+
+class ResConfigSettings(models.TransientModel):
+ _inherit = 'res.config.settings'
+
+ # NOTE: res.config.settings only supports boolean/integer/float/char/
+ # selection/many2one/datetime types per project Odoo 19 conventions.
+
+ fc_repairs_enable_email_notifications = fields.Boolean(
+ string='Enable Repair Email Notifications',
+ config_parameter='fusion_repairs.enable_email_notifications',
+ default=True,
+ help='Master toggle for automated repair-related emails to clients and office.',
+ )
+
+ fc_repairs_outstanding_balance_threshold = fields.Float(
+ string='Outstanding Balance Warning ($)',
+ config_parameter='fusion_repairs.outstanding_balance_threshold',
+ default=100.0,
+ help='Show a warning banner during intake if the client has open invoices '
+ 'totalling more than this amount.',
+ )
+
+ fc_repairs_duplicate_call_window_days = fields.Integer(
+ string='Duplicate Call Window (Days)',
+ config_parameter='fusion_repairs.duplicate_call_window_days',
+ default=14,
+ help='When the intake wizard finds an open repair from this many days back on '
+ 'the same phone number, it offers "add note to existing repair instead".',
+ )
+
+ fc_repairs_variance_threshold_pct = fields.Integer(
+ string='Pricing Variance Threshold (%)',
+ config_parameter='fusion_repairs.variance_threshold_pct',
+ default=20,
+ help='If actual cost exceeds estimated cost by more than this percentage, '
+ 'invoicing is blocked until a manager reviews / a re-quote email is sent.',
+ )
+
+ fc_repairs_variance_threshold_amount = fields.Float(
+ string='Pricing Variance Threshold ($)',
+ config_parameter='fusion_repairs.variance_threshold_amount',
+ default=100.0,
+ help='Absolute variance amount that also triggers re-quote (whichever hits first).',
+ )
+
+ fc_repairs_client_portal_url = fields.Char(
+ string='Public Client Portal URL Path',
+ config_parameter='fusion_repairs.client_portal_url',
+ default='/repair',
+ help='URL path mentioned in voicemail greetings and printed on QR stickers. '
+ 'Phase 1 ships with the form at this path.',
+ )
+
+ fc_repairs_client_portal_rate_limit_per_hour = fields.Integer(
+ string='Client Portal Rate Limit (per hour, per IP)',
+ config_parameter='fusion_repairs.client_portal_rate_limit_per_hour',
+ default=10,
+ )
diff --git a/fusion_repairs/models/res_partner.py b/fusion_repairs/models/res_partner.py
new file mode 100644
index 00000000..83d1a4d6
--- /dev/null
+++ b/fusion_repairs/models/res_partner.py
@@ -0,0 +1,64 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from odoo import fields, models
+
+
+PREFERRED_WINDOW = [
+ ('morning', 'Morning (9 AM - 12 PM)'),
+ ('afternoon', 'Afternoon (12 PM - 5 PM)'),
+ ('evening', 'Evening (after 5 PM)'),
+ ('any', 'Any Time'),
+]
+
+
+class ResPartner(models.Model):
+ _inherit = 'res.partner'
+
+ # ------------------------------------------------------------------
+ # SERVICE PREFERENCES (P1 - shown in client history sidebar)
+ # ------------------------------------------------------------------
+ x_fc_preferred_tech_id = fields.Many2one(
+ 'res.users',
+ string='Preferred Technician',
+ domain="[('x_fc_is_field_staff', '=', True)]",
+ help='If set, this technician is suggested first on dispatch.',
+ )
+ x_fc_preferred_window = fields.Selection(
+ PREFERRED_WINDOW,
+ string='Preferred Visit Window',
+ default='any',
+ )
+ x_fc_access_notes = fields.Text(
+ string='Access Notes',
+ help='Free-form notes for technicians arriving at this address: '
+ 'gate code, dog warning, where to park, side door entry, etc.',
+ )
+
+ # ------------------------------------------------------------------
+ # CLIENT HISTORY SIDEBAR (C2 - pulled lazily on demand)
+ # ------------------------------------------------------------------
+ x_fc_repair_count = fields.Integer(
+ compute='_compute_x_fc_repair_count',
+ string='Repairs Count',
+ compute_sudo=True,
+ help='Lightweight count of repair orders for this partner. Heavier history '
+ 'data is fetched lazily by the wizard / portal sidebar via RPC.',
+ )
+
+ def _compute_x_fc_repair_count(self):
+ # Non-stored compute - safe to omit @api.depends.
+ if not self.ids:
+ for partner in self:
+ partner.x_fc_repair_count = 0
+ return
+ Repair = self.env['repair.order'].sudo()
+ data = Repair._read_group(
+ [('partner_id', 'in', self.ids)],
+ ['partner_id'],
+ ['__count'],
+ )
+ counts = {row[0].id: row[1] for row in data}
+ for partner in self:
+ partner.x_fc_repair_count = counts.get(partner.id, 0)
diff --git a/fusion_repairs/models/res_users.py b/fusion_repairs/models/res_users.py
new file mode 100644
index 00000000..59297420
--- /dev/null
+++ b/fusion_repairs/models/res_users.py
@@ -0,0 +1,57 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from odoo import fields, models
+
+
+class ResUsers(models.Model):
+ """Extends res.users with fusion_repairs specific fields.
+
+ Reuses the existing x_fc_is_field_staff Boolean from fusion_tasks
+ as the technician flag - do NOT recreate that field here.
+
+ All technician selectors in fusion_repairs use the same domain
+ [('x_fc_is_field_staff', '=', True)] for consistency with fusion_tasks.
+ """
+
+ _inherit = 'res.users'
+
+ x_fc_repair_skills = fields.Many2many(
+ 'fusion.repair.product.category',
+ 'fusion_repair_user_skill_rel',
+ 'user_id',
+ 'category_id',
+ string='Repair Skills',
+ help='Medical equipment categories this user is qualified to service. '
+ 'Used by dispatcher to filter candidate technicians for a repair.',
+ )
+
+ x_fc_tech_cost_rate = fields.Monetary(
+ string='Tech Cost Rate (/h)',
+ currency_field='company_currency_id',
+ help='Internal cost per hour - used for repair margin calculation (Phase 4).',
+ )
+
+ # On-call rotation - Phase 2 (simple priority-int approach).
+ x_fc_on_call = fields.Boolean(
+ string='On-Call Eligible',
+ help='Tick if this user is eligible for the weekend / after-hours on-call rotation.',
+ )
+ x_fc_on_call_priority = fields.Integer(
+ string='On-Call Priority',
+ default=99,
+ help='Lower number = paged first. The escalation cron picks the lowest priority '
+ 'available user when a safety repair is submitted after hours.',
+ )
+ x_fc_on_call_phone = fields.Char(
+ string='On-Call Phone Override',
+ help='Phone number to use for on-call SMS / calls. If empty, falls back to '
+ 'the user partner phone.',
+ )
+
+ company_currency_id = fields.Many2one(
+ 'res.currency',
+ related='company_id.currency_id',
+ readonly=True,
+ )
diff --git a/fusion_repairs/models/technician_task.py b/fusion_repairs/models/technician_task.py
new file mode 100644
index 00000000..f8963c83
--- /dev/null
+++ b/fusion_repairs/models/technician_task.py
@@ -0,0 +1,42 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from odoo import fields, models
+
+
+class FusionTechnicianTaskRepairs(models.Model):
+ """Adds the back-link from fusion.technician.task to repair.order so
+ repairs and tasks share one timeline.
+ """
+
+ _inherit = 'fusion.technician.task'
+
+ x_fc_repair_order_id = fields.Many2one(
+ 'repair.order',
+ string='Repair Order',
+ ondelete='set null',
+ index=True,
+ tracking=True,
+ help='Repair order this task fulfils. Set automatically when the intake '
+ 'wizard auto-creates a draft task for urgent / safety calls.',
+ )
+
+ x_fc_repair_intake_session_id = fields.Char(
+ related='x_fc_repair_order_id.x_fc_intake_session_id',
+ string='Intake Session',
+ store=True,
+ index=True,
+ )
+
+ def action_view_repair_order(self):
+ self.ensure_one()
+ if not self.x_fc_repair_order_id:
+ return False
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': self.x_fc_repair_order_id.name,
+ 'res_model': 'repair.order',
+ 'view_mode': 'form',
+ 'res_id': self.x_fc_repair_order_id.id,
+ }
diff --git a/fusion_repairs/security/ir.model.access.csv b/fusion_repairs/security/ir.model.access.csv
new file mode 100644
index 00000000..1574ea4d
--- /dev/null
+++ b/fusion_repairs/security/ir.model.access.csv
@@ -0,0 +1,12 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_repair_product_category_user,Repair Category User Read,model_fusion_repair_product_category,group_fusion_repairs_user,1,0,0,0
+access_repair_product_category_manager,Repair Category Manager Full,model_fusion_repair_product_category,group_fusion_repairs_manager,1,1,1,1
+access_repair_intake_template_user,Intake Template User Read,model_fusion_repair_intake_template,group_fusion_repairs_user,1,0,0,0
+access_repair_intake_template_manager,Intake Template Manager Full,model_fusion_repair_intake_template,group_fusion_repairs_manager,1,1,1,1
+access_repair_intake_question_user,Intake Question User Read,model_fusion_repair_intake_question,group_fusion_repairs_user,1,0,0,0
+access_repair_intake_question_manager,Intake Question Manager Full,model_fusion_repair_intake_question,group_fusion_repairs_manager,1,1,1,1
+access_repair_intake_answer_user,Intake Answer User Full,model_fusion_repair_intake_answer,group_fusion_repairs_user,1,1,1,0
+access_repair_intake_answer_manager,Intake Answer Manager Full,model_fusion_repair_intake_answer,group_fusion_repairs_manager,1,1,1,1
+access_repair_intake_answer_tech_portal,Intake Answer Technician Read,model_fusion_repair_intake_answer,fusion_tasks.group_field_technician,1,0,0,0
+access_repair_intake_wizard_user,Intake Wizard User Full,model_fusion_repair_intake_wizard,group_fusion_repairs_user,1,1,1,1
+access_repair_intake_wizard_equipment_user,Intake Wizard Equipment User Full,model_fusion_repair_intake_wizard_equipment,group_fusion_repairs_user,1,1,1,1
diff --git a/fusion_repairs/security/security.xml b/fusion_repairs/security/security.xml
new file mode 100644
index 00000000..79401dba
--- /dev/null
+++ b/fusion_repairs/security/security.xml
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+ Fusion Repairs
+ 47
+
+
+
+
+
+
+ Fusion Repairs
+ 47
+
+
+
+
+
+
+
+ Repairs: User (CS Intake)
+
+
+ CS / front-office staff who take repair intake calls and view repairs.
+
+
+
+ Repairs: Dispatcher
+
+
+ Assigns technicians to repairs, reschedules visits, manages parts pre-pull picklists.
+
+
+
+ Repairs: Manager
+
+
+ Configures intake templates, pricing, maintenance contracts, on-call rotation, variance overrides.
+
+
+
+
+
+
+
+
+ Repair Order: Multi-Company
+
+ ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]
+
+
+
+
+
+ Repair Order: Technician sees own repairs
+
+ [('x_fc_technician_task_ids.all_technician_ids', 'in', [user.id])]
+
+
+
+
+
+
+
+
+
+ Repair Intake Answer: Multi-Company
+
+ ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]
+
+
+
+
diff --git a/fusion_repairs/views/intake_template_views.xml b/fusion_repairs/views/intake_template_views.xml
new file mode 100644
index 00000000..e7e57340
--- /dev/null
+++ b/fusion_repairs/views/intake_template_views.xml
@@ -0,0 +1,95 @@
+
+
+
+
+
+ fusion.repair.intake.template.list
+ fusion.repair.intake.template
+
+
+
+
+
+
+
+
+
+
+
+
+
+ fusion.repair.intake.template.form
+ fusion.repair.intake.template
+
+
+
+
+
+
+ Intake Templates
+ fusion.repair.intake.template
+ list,form
+
+
+
diff --git a/fusion_repairs/views/menus.xml b/fusion_repairs/views/menus.xml
new file mode 100644
index 00000000..8820056a
--- /dev/null
+++ b/fusion_repairs/views/menus.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fusion_repairs/views/repair_order_views.xml b/fusion_repairs/views/repair_order_views.xml
new file mode 100644
index 00000000..9544f9e0
--- /dev/null
+++ b/fusion_repairs/views/repair_order_views.xml
@@ -0,0 +1,143 @@
+
+
+
+
+
+
+
+ repair.order.form.inherit.fusion_repairs
+ repair.order
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ repair.order.kanban.inherit.fusion_repairs
+ repair.order
+
+
+
+
+
+
+
+
+
+
+
+
+
+ repair.order.list.inherit.fusion_repairs
+ repair.order
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ New Service Call
+ fusion.repair.intake.wizard
+ form
+ new
+
+
+
diff --git a/fusion_repairs/views/repair_product_category_views.xml b/fusion_repairs/views/repair_product_category_views.xml
new file mode 100644
index 00000000..0ca1a2e7
--- /dev/null
+++ b/fusion_repairs/views/repair_product_category_views.xml
@@ -0,0 +1,55 @@
+
+
+
+
+ fusion.repair.product.category.list
+ fusion.repair.product.category
+
+
+
+
+
+
+
+
+
+
+
+
+
+ fusion.repair.product.category.form
+ fusion.repair.product.category
+
+
+
+
+
+
+ Equipment Categories
+ fusion.repair.product.category
+ list,form
+
+
+
diff --git a/fusion_repairs/views/res_config_settings_views.xml b/fusion_repairs/views/res_config_settings_views.xml
new file mode 100644
index 00000000..c86345e4
--- /dev/null
+++ b/fusion_repairs/views/res_config_settings_views.xml
@@ -0,0 +1,62 @@
+
+
+
+
+ res.config.settings.view.form.inherit.fusion_repairs
+ res.config.settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fusion_repairs/views/res_partner_views.xml b/fusion_repairs/views/res_partner_views.xml
new file mode 100644
index 00000000..5c160dee
--- /dev/null
+++ b/fusion_repairs/views/res_partner_views.xml
@@ -0,0 +1,29 @@
+
+
+
+
+ res.partner.form.inherit.fusion_repairs
+ res.partner
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fusion_repairs/views/res_users_views.xml b/fusion_repairs/views/res_users_views.xml
new file mode 100644
index 00000000..78758d91
--- /dev/null
+++ b/fusion_repairs/views/res_users_views.xml
@@ -0,0 +1,33 @@
+
+
+
+
+ res.users.form.inherit.fusion_repairs
+ res.users
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fusion_repairs/wizard/__init__.py b/fusion_repairs/wizard/__init__.py
new file mode 100644
index 00000000..3fe33326
--- /dev/null
+++ b/fusion_repairs/wizard/__init__.py
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from . import repair_intake_wizard
diff --git a/fusion_repairs/wizard/repair_intake_wizard.py b/fusion_repairs/wizard/repair_intake_wizard.py
new file mode 100644
index 00000000..f6769403
--- /dev/null
+++ b/fusion_repairs/wizard/repair_intake_wizard.py
@@ -0,0 +1,199 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+"""Backend intake wizard.
+
+A simple Phase 1 transient model that captures one-or-many equipment items
+per call, then delegates to fusion.repair.intake.service to create the
+repair.order(s). The shared service guarantees identical behaviour to the
+sales rep portal and the public client portal added in later phases.
+
+Multi-equipment per call is supported via the equipment_ids One2many.
+"""
+
+import logging
+
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError
+
+_logger = logging.getLogger(__name__)
+
+
+class RepairIntakeWizard(models.TransientModel):
+ _name = 'fusion.repair.intake.wizard'
+ _description = 'Repair Intake Wizard'
+
+ # ------------------------------------------------------------------
+ # CALLER / CLIENT
+ # ------------------------------------------------------------------
+ intake_user_id = fields.Many2one(
+ 'res.users',
+ string='Taken By',
+ default=lambda self: self.env.user,
+ required=True,
+ )
+ partner_id = fields.Many2one(
+ 'res.partner',
+ string='Client',
+ required=True,
+ help='Existing client. Use the create-and-edit dialog to add a new contact.',
+ )
+ partner_phone = fields.Char(
+ related='partner_id.phone',
+ string='Phone',
+ readonly=True,
+ )
+
+ # ------------------------------------------------------------------
+ # EQUIPMENT (one-or-many)
+ # ------------------------------------------------------------------
+ equipment_ids = fields.One2many(
+ 'fusion.repair.intake.wizard.equipment',
+ 'wizard_id',
+ string='Equipment Items',
+ required=True,
+ )
+
+ # ------------------------------------------------------------------
+ # SUBMIT
+ # ------------------------------------------------------------------
+ def action_submit(self):
+ self.ensure_one()
+ if not self.equipment_ids:
+ raise UserError(_('Please add at least one piece of equipment.'))
+
+ payload = {
+ 'partner_id': self.partner_id.id,
+ 'intake_user_id': self.intake_user_id.id,
+ 'equipment_items': [self._equipment_payload(eq) for eq in self.equipment_ids],
+ }
+
+ repairs = self.env['fusion.repair.intake.service'].create_repair_orders(
+ payload, source='backend_wizard',
+ )
+
+ if len(repairs) == 1:
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': repairs.name,
+ 'res_model': 'repair.order',
+ 'view_mode': 'form',
+ 'res_id': repairs.id,
+ }
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Service Calls Created (%(count)s)', count=len(repairs)),
+ 'res_model': 'repair.order',
+ 'view_mode': 'list,form',
+ 'domain': [('id', 'in', repairs.ids)],
+ }
+
+ def _equipment_payload(self, eq):
+ """Render an equipment record as a dict the intake service expects."""
+ return {
+ 'product_id': eq.product_id.id or False,
+ 'lot_id': eq.lot_id.id or False,
+ 'repair_category_id': eq.repair_category_id.id or False,
+ 'intake_template_id': eq.intake_template_id.id or False,
+ 'third_party': eq.third_party,
+ 'urgency': eq.urgency,
+ 'issue_summary': eq.issue_summary or '',
+ 'issue_category': eq.issue_category or '',
+ 'internal_notes': eq.internal_notes or '',
+ 'schedule_date': eq.scheduled_date or False,
+ 'photo_attachment_ids': eq.photo_ids.ids if eq.photo_ids else [],
+ 'answers': [], # Phase 1 wizard doesn't expose per-question answer rows yet
+ }
+
+
+class RepairIntakeWizardEquipment(models.TransientModel):
+ """A single piece of equipment captured in the wizard.
+
+ Multiple lines = multi-equipment intake (one repair.order per line).
+ """
+
+ _name = 'fusion.repair.intake.wizard.equipment'
+ _description = 'Repair Intake Wizard - Equipment Line'
+ _order = 'sequence, id'
+
+ wizard_id = fields.Many2one(
+ 'fusion.repair.intake.wizard',
+ string='Wizard',
+ required=True,
+ ondelete='cascade',
+ )
+ sequence = fields.Integer(default=10)
+
+ # Equipment identification
+ repair_category_id = fields.Many2one(
+ 'fusion.repair.product.category',
+ string='Category',
+ required=True,
+ )
+ product_id = fields.Many2one(
+ 'product.product',
+ string='Product',
+ help='Specific product if known. Leave blank for generic equipment.',
+ )
+ lot_id = fields.Many2one(
+ 'stock.lot',
+ string='Serial Number',
+ domain="[('product_id', '=', product_id)]",
+ help='Lot or serial number if known.',
+ )
+ third_party = fields.Boolean(
+ string='Not Purchased From Us',
+ help='Tick if this equipment was bought elsewhere - we still service it but '
+ 'warranty is not honoured and a service call-out fee applies.',
+ )
+
+ # Intake context
+ intake_template_id = fields.Many2one(
+ 'fusion.repair.intake.template',
+ string='Question Template',
+ help='Defaults to the template configured on the category if left blank.',
+ )
+
+ # Triage
+ urgency = fields.Selection(
+ [('normal', 'Normal'), ('urgent', 'Urgent'), ('safety', 'Safety Issue')],
+ string='Urgency',
+ default='normal',
+ required=True,
+ )
+ scheduled_date = fields.Datetime(
+ string='Preferred Date',
+ default=fields.Datetime.now,
+ )
+ issue_summary = fields.Char(
+ string='Issue Summary',
+ help='One-line summary of what is wrong (e.g. "stairlift stops halfway up").',
+ )
+ issue_category = fields.Char(
+ string='Symptom Category',
+ help='Optional symptom tag for catalogue matching (e.g. "battery", "motor").',
+ )
+ internal_notes = fields.Text(string='Internal Notes')
+
+ photo_ids = fields.Many2many(
+ 'ir.attachment',
+ 'fusion_repair_intake_wizard_eq_photo_rel',
+ 'eq_id',
+ 'attachment_id',
+ string='Photos / Videos',
+ )
+
+ @api.onchange('repair_category_id')
+ def _onchange_repair_category_id(self):
+ """Pre-fill the intake template from the category default."""
+ if self.repair_category_id and not self.intake_template_id:
+ self.intake_template_id = self.repair_category_id.intake_template_id
+
+ @api.onchange('product_id')
+ def _onchange_product_id(self):
+ """Pre-fill the category from the product if defined."""
+ if self.product_id and not self.repair_category_id:
+ cat = self.product_id.product_tmpl_id.x_fc_repair_category_id
+ if cat:
+ self.repair_category_id = cat
diff --git a/fusion_repairs/wizard/repair_intake_wizard_views.xml b/fusion_repairs/wizard/repair_intake_wizard_views.xml
new file mode 100644
index 00000000..65a21502
--- /dev/null
+++ b/fusion_repairs/wizard/repair_intake_wizard_views.xml
@@ -0,0 +1,69 @@
+
+
+
+
+ fusion.repair.intake.wizard.form
+ fusion.repair.intake.wizard
+
+
+
+
+
+
From ad553b1082de1ced8e587c2c6a548b2cffaf0b3c Mon Sep 17 00:00:00 2001
From: gsinghpal
Date: Wed, 20 May 2026 21:52:12 -0400
Subject: [PATCH 03/30] feat(fusion_repairs): Phase 1 sales rep + public client
portals
Both portals share the existing fusion.repair.intake.service so behaviour
stays identical across all three intake surfaces (backend wizard,
sales rep portal, public client portal).
Sales rep portal
- Hard depends on fusion_authorizer_portal (reuses is_sales_rep_portal
flag + group_sales_rep_portal scaffolding)
- /my/repair/new - mobile-friendly intake form with phone-first
partner search (jsonrpc lookup), category select, third-party flag,
urgency, photo capture
- /my/repairs - list of repairs the rep submitted (paginated)
- /my/repair/ - read-only detail with status, equipment, scheduled
visit
- Interaction-class JS (Odoo 19 public.interactions), safe DOM construction
- Mobile SCSS with 44px tap targets, sticky CTA on small screens
- Record rule scopes portal users to repairs where
x_fc_intake_user_id = user.id
Public client portal
- auth='public' - voicemail-ready /repair URL
- /repair - landing page with 911 disclaimer and Start CTA
- /repair/new - single-page form: contact, equipment, issue, urgency,
optional photos. QR pre-fill via ?sn=
- /repair/submit - CSRF + honeypot + per-IP rate limit (configurable);
finds or creates partner; calls intake service with sudo
- /repair/thanks - confirmation with reference number
- /repair/lookup_phone (jsonrpc) - safe partner match returning ONLY
masked name (first + last initial) + city (no other PII leakage)
Security fix: technician record rule on repair.order now uses STORED
fields (technician_id + additional_technician_ids) instead of the
non-stored all_technician_ids compute, which was failing SQL generation.
Verified end-to-end on local westin-v19:
- Sales rep create via intake service with the rep user context creates
the repair with x_fc_intake_source='sales_rep_portal' and proper
activities
- /repair/submit posts urlencoded data -> creates partner + repair
('BR-WA/RO/00010', source='client_portal', urgency='urgent') ->
redirects to /repair/thanks with the reference
Co-authored-by: Cursor
---
fusion_repairs/__init__.py | 1 +
fusion_repairs/__manifest__.py | 7 +-
fusion_repairs/controllers/__init__.py | 6 +
.../controllers/portal_client_repair.py | 240 +++++++++++++++
.../controllers/portal_sales_rep_repair.py | 186 ++++++++++++
fusion_repairs/security/security.xml | 17 +-
.../static/src/js/portal_repair_intake.js | 107 +++++++
.../static/src/scss/portal_client_repair.scss | 39 +++
.../static/src/scss/portal_repair_mobile.scss | 39 +++
.../views/portal_client_repair_templates.xml | 185 ++++++++++++
.../views/portal_sales_rep_templates.xml | 281 ++++++++++++++++++
11 files changed, 1105 insertions(+), 3 deletions(-)
create mode 100644 fusion_repairs/controllers/__init__.py
create mode 100644 fusion_repairs/controllers/portal_client_repair.py
create mode 100644 fusion_repairs/controllers/portal_sales_rep_repair.py
create mode 100644 fusion_repairs/static/src/js/portal_repair_intake.js
create mode 100644 fusion_repairs/static/src/scss/portal_client_repair.scss
create mode 100644 fusion_repairs/static/src/scss/portal_repair_mobile.scss
create mode 100644 fusion_repairs/views/portal_client_repair_templates.xml
create mode 100644 fusion_repairs/views/portal_sales_rep_templates.xml
diff --git a/fusion_repairs/__init__.py b/fusion_repairs/__init__.py
index 866992e6..31007106 100644
--- a/fusion_repairs/__init__.py
+++ b/fusion_repairs/__init__.py
@@ -4,3 +4,4 @@
from . import models
from . import wizard
+from . import controllers
diff --git a/fusion_repairs/__manifest__.py b/fusion_repairs/__manifest__.py
index bb20e0a0..c6450219 100644
--- a/fusion_repairs/__manifest__.py
+++ b/fusion_repairs/__manifest__.py
@@ -80,6 +80,9 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
'views/res_partner_views.xml',
'views/res_users_views.xml',
'views/res_config_settings_views.xml',
+ # Portal templates
+ 'views/portal_sales_rep_templates.xml',
+ 'views/portal_client_repair_templates.xml',
# Wizard
'wizard/repair_intake_wizard_views.xml',
# Menus (last, after all referenced actions exist)
@@ -90,7 +93,9 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
# Phase 2+: history_sidebar.js, signature_pad.js, etc.
],
'web.assets_frontend': [
- # Phase 1+: portal_client_repair.js etc.
+ 'fusion_repairs/static/src/scss/portal_repair_mobile.scss',
+ 'fusion_repairs/static/src/scss/portal_client_repair.scss',
+ 'fusion_repairs/static/src/js/portal_repair_intake.js',
],
},
'images': ['static/description/icon.png'],
diff --git a/fusion_repairs/controllers/__init__.py b/fusion_repairs/controllers/__init__.py
new file mode 100644
index 00000000..cd1bd59e
--- /dev/null
+++ b/fusion_repairs/controllers/__init__.py
@@ -0,0 +1,6 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from . import portal_sales_rep_repair
+from . import portal_client_repair
diff --git a/fusion_repairs/controllers/portal_client_repair.py b/fusion_repairs/controllers/portal_client_repair.py
new file mode 100644
index 00000000..96ddd9c3
--- /dev/null
+++ b/fusion_repairs/controllers/portal_client_repair.py
@@ -0,0 +1,240 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+"""Public client self-service portal at /repair.
+
+Phase 1 scope (no AI yet):
+- /repair Landing page with "Start" CTA
+- /repair/new Multi-step form
+- /repair/submit POST -> creates repair.order via shared intake service
+- /repair/thanks Confirmation with reference
+- /repair/lookup_phone jsonrpc safe partner match (masked PII)
+
+Security:
+- Public auth (no login) - the voicemail prompts mention this URL
+- Per-IP rate limit on submit (configurable)
+- Honeypot + CSRF
+- Phone lookup returns ONLY masked name + address slice (never other PII)
+- Records created via sudo in the controller; record rules don't apply
+ because anonymous users don't have a session
+
+Phase 2+ will add: AI self-check, upsell engine, smart SMS verify,
+safety on-call paging, reCAPTCHA v3.
+"""
+
+import base64
+import hashlib
+import logging
+import re
+import time
+
+from odoo import http, fields
+from odoo.http import request
+
+_logger = logging.getLogger(__name__)
+
+
+# In-memory rate-limit window per worker. Good enough for Phase 1
+# and matches the project's "no extra infra" goal. Resets on restart.
+_RATE_LIMIT_BUCKET = {}
+
+
+def _now_hour_bucket():
+ return int(time.time() // 3600)
+
+
+def _mask_partner_for_lookup(partner):
+ """Return ONLY safe summary fields - never the full partner record."""
+ name = partner.name or ""
+ # First name + last initial; never reveal full surname.
+ if " " in name:
+ first, last = name.split(" ", 1)
+ safe_name = f"{first} {(last or ' ')[:1]}."
+ else:
+ safe_name = name
+ return {
+ "matched": True,
+ "name": safe_name,
+ "city": partner.city or "",
+ }
+
+
+def _e164_clean(phone):
+ if not phone:
+ return ""
+ return re.sub(r"[^\d+]", "", phone)[-12:]
+
+
+class ClientRepairPortal(http.Controller):
+
+ # ------------------------------------------------------------------
+ # RATE LIMIT
+ # ------------------------------------------------------------------
+ def _check_rate_limit(self):
+ ICP = request.env["ir.config_parameter"].sudo()
+ try:
+ limit = int(ICP.get_param(
+ "fusion_repairs.client_portal_rate_limit_per_hour", "10"
+ ))
+ except (ValueError, TypeError):
+ limit = 10
+ # Use remote_addr from the proxy header if present.
+ ip = (
+ request.httprequest.headers.get("X-Forwarded-For")
+ or request.httprequest.remote_addr
+ or "unknown"
+ )
+ ip = ip.split(",")[0].strip()
+ bucket = _now_hour_bucket()
+ key = f"{ip}:{bucket}"
+ # Prune old buckets (cheap - dict is small).
+ for k in list(_RATE_LIMIT_BUCKET.keys()):
+ if not k.endswith(f":{bucket}"):
+ _RATE_LIMIT_BUCKET.pop(k, None)
+ _RATE_LIMIT_BUCKET[key] = _RATE_LIMIT_BUCKET.get(key, 0) + 1
+ if _RATE_LIMIT_BUCKET[key] > limit:
+ return True # blocked
+ return False
+
+ # ------------------------------------------------------------------
+ # LANDING
+ # ------------------------------------------------------------------
+ @http.route("/repair", type="http", auth="public", website=True, sitemap=True)
+ def repair_landing(self, **kw):
+ return request.render("fusion_repairs.portal_client_repair_landing", {
+ "page_name": "client_repair_landing",
+ })
+
+ @http.route("/repair/new", type="http", auth="public", website=True,
+ sitemap=False)
+ def repair_new(self, sn=None, **kw):
+ categories = request.env["fusion.repair.product.category"].sudo().search([
+ ("active", "=", True),
+ ], order="sequence, name")
+ prefilled_serial = (sn or "").strip()
+ return request.render("fusion_repairs.portal_client_repair_form", {
+ "page_name": "client_repair_new",
+ "categories": categories,
+ "prefilled_serial": prefilled_serial,
+ "error": kw.get("error"),
+ })
+
+ # ------------------------------------------------------------------
+ # SAFE PARTNER LOOKUP (anti-leak)
+ # ------------------------------------------------------------------
+ @http.route("/repair/lookup_phone", type="jsonrpc", auth="public",
+ website=True)
+ def repair_lookup_phone(self, phone=None, **kw):
+ if self._check_rate_limit():
+ return {"error": "rate_limited"}
+ cleaned = _e164_clean(phone)
+ if len(cleaned) < 7:
+ return {"matched": False}
+ matches = request.env["res.partner"].sudo().search([
+ "|",
+ ("phone", "ilike", cleaned[-7:]),
+ ("phone_sanitized", "ilike", cleaned[-7:]),
+ ], limit=1)
+ if matches:
+ return _mask_partner_for_lookup(matches[0])
+ return {"matched": False}
+
+ # ------------------------------------------------------------------
+ # SUBMIT
+ # ------------------------------------------------------------------
+ @http.route("/repair/submit", type="http", auth="public", methods=["POST"],
+ csrf=True, website=True)
+ def repair_submit(self, **post):
+ # Honeypot - bots tend to fill every visible field.
+ if (post.get("hp_company") or "").strip():
+ _logger.info("Client portal submit blocked by honeypot from IP=%s",
+ request.httprequest.remote_addr)
+ return request.redirect("/repair/new?error=spam")
+
+ if self._check_rate_limit():
+ return request.redirect("/repair/new?error=rate_limited")
+
+ # Required fields.
+ partner_name = (post.get("client_name") or "").strip()
+ phone = (post.get("client_phone") or "").strip()
+ issue_summary = (post.get("issue_summary") or "").strip()
+ category_id = int(post.get("category_id") or 0)
+
+ if not (partner_name and phone and issue_summary and category_id):
+ return request.redirect("/repair/new?error=missing")
+
+ # Find or create partner. Match by phone if known (safe - we already
+ # have their consent to contact via this form).
+ cleaned_phone = _e164_clean(phone)
+ partner = False
+ if len(cleaned_phone) >= 7:
+ partner = request.env["res.partner"].sudo().search([
+ "|",
+ ("phone", "ilike", cleaned_phone[-7:]),
+ ("phone_sanitized", "ilike", cleaned_phone[-7:]),
+ ], limit=1)
+
+ partner_vals = None
+ if not partner:
+ partner_vals = {
+ "name": partner_name,
+ "phone": phone,
+ "email": (post.get("client_email") or "").strip(),
+ "street": (post.get("client_street") or "").strip(),
+ "city": (post.get("client_city") or "").strip(),
+ }
+
+ # Stage uploaded photos.
+ files = request.httprequest.files.getlist("photos")
+ attachment_ids = []
+ for f in files or []:
+ if not getattr(f, "filename", None):
+ continue
+ data = f.read()
+ if not data:
+ continue
+ attachment_ids.append(request.env["ir.attachment"].sudo().create({
+ "name": f.filename,
+ "datas": base64.b64encode(data),
+ "res_model": "fusion.repair.intake.session",
+ "res_id": 0,
+ }).id)
+
+ equipment = {
+ "repair_category_id": category_id,
+ "third_party": post.get("third_party") in ("on", "true", "1"),
+ "urgency": post.get("urgency") or "normal",
+ "issue_summary": issue_summary,
+ "internal_notes": (post.get("internal_notes") or "").strip(),
+ "photo_attachment_ids": attachment_ids,
+ }
+ payload = {
+ "partner_id": partner.id if partner else None,
+ "partner_vals": partner_vals,
+ "intake_user_id": request.env.ref(
+ "base.user_admin", raise_if_not_found=False).id
+ if request.env.ref("base.user_admin",
+ raise_if_not_found=False) else 1,
+ "equipment_items": [equipment],
+ }
+
+ try:
+ repairs = request.env["fusion.repair.intake.service"].sudo() \
+ .create_repair_orders(payload, source="client_portal")
+ except Exception:
+ _logger.exception("Client portal repair submit failed")
+ return request.redirect("/repair/new?error=server")
+
+ token = hashlib.sha256(
+ f"{repairs[0].id}:{repairs[0].create_date}".encode()
+ ).hexdigest()[:16]
+ return request.redirect(f"/repair/thanks?ref={repairs[0].name}&t={token}")
+
+ @http.route("/repair/thanks", type="http", auth="public", website=True,
+ sitemap=False)
+ def repair_thanks(self, ref=None, t=None, **kw):
+ return request.render("fusion_repairs.portal_client_repair_thanks", {
+ "page_name": "client_repair_thanks",
+ "ref": ref or "",
+ })
diff --git a/fusion_repairs/controllers/portal_sales_rep_repair.py b/fusion_repairs/controllers/portal_sales_rep_repair.py
new file mode 100644
index 00000000..55434216
--- /dev/null
+++ b/fusion_repairs/controllers/portal_sales_rep_repair.py
@@ -0,0 +1,186 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+"""Sales rep portal for repair intake.
+
+Sales reps marked `is_sales_rep_portal` on their partner can:
+- /my/repair/new - submit a new service call from their phone
+- /my/repairs - list of repairs they have submitted
+- /my/repair/ - read-only detail with status timeline
+
+All routes are gated by the is_sales_rep_portal flag and use the SAME
+shared intake service (`fusion.repair.intake.service`) as the backend
+wizard - so behaviour stays consistent across surfaces.
+"""
+
+import base64
+import logging
+
+from odoo import http, fields
+from odoo.http import request
+from odoo.addons.portal.controllers.portal import CustomerPortal
+
+_logger = logging.getLogger(__name__)
+
+
+class SalesRepRepairPortal(CustomerPortal):
+
+ # ------------------------------------------------------------------
+ # ACCESS GATE
+ # ------------------------------------------------------------------
+ def _check_sales_rep_access(self):
+ partner = request.env.user.partner_id
+ if not getattr(partner, 'is_sales_rep_portal', False):
+ return request.redirect('/my')
+ return None
+
+ def _staged_attachment_ids_from_files(self, files):
+ """Stage uploaded files as ir.attachment records and return their IDs."""
+ ids = []
+ for f in files or []:
+ if not getattr(f, 'filename', None):
+ continue
+ data = f.read()
+ if not data:
+ continue
+ attachment = request.env['ir.attachment'].sudo().create({
+ 'name': f.filename,
+ 'datas': base64.b64encode(data),
+ 'res_model': 'fusion.repair.intake.session',
+ 'res_id': 0,
+ })
+ ids.append(attachment.id)
+ return ids
+
+ # ------------------------------------------------------------------
+ # NEW SERVICE CALL FORM
+ # ------------------------------------------------------------------
+ @http.route('/my/repair/new', type='http', auth='user', website=True, sitemap=False)
+ def portal_repair_new(self, **kw):
+ gate = self._check_sales_rep_access()
+ if gate:
+ return gate
+
+ categories = request.env['fusion.repair.product.category'].sudo().search([
+ ('active', '=', True),
+ ], order='sequence, name')
+
+ return request.render('fusion_repairs.portal_sales_rep_repair_form', {
+ 'page_name': 'repair_new',
+ 'categories': categories,
+ 'default_partner': False,
+ 'submitted': False,
+ })
+
+ @http.route('/my/repair/lookup_partner', type='jsonrpc', auth='user', website=True)
+ def portal_repair_lookup_partner(self, query=None, **kw):
+ gate = self._check_sales_rep_access()
+ if gate:
+ return {'error': 'access'}
+ if not query or len(query) < 3:
+ return {'matches': []}
+ Partner = request.env['res.partner'].sudo()
+ matches = Partner.search([
+ '|', '|',
+ ('name', 'ilike', query),
+ ('phone', 'ilike', query),
+ ('email', 'ilike', query),
+ ], limit=8)
+ return {
+ 'matches': [{
+ 'id': p.id,
+ 'name': p.name or '',
+ 'phone': p.phone or '',
+ 'email': p.email or '',
+ 'street': p.street or '',
+ 'city': p.city or '',
+ 'repair_count': p.x_fc_repair_count,
+ } for p in matches],
+ }
+
+ @http.route('/my/repair/submit', type='http', auth='user', methods=['POST'],
+ csrf=True, website=True)
+ def portal_repair_submit(self, **post):
+ gate = self._check_sales_rep_access()
+ if gate:
+ return gate
+
+ partner_id = int(post.get('partner_id') or 0)
+ if not partner_id:
+ return request.redirect('/my/repair/new?error=partner')
+
+ # Build single-equipment payload from the form. Multi-equipment loop
+ # is supported by adding more equipment_* groups in Phase 2.
+ files = request.httprequest.files.getlist('photos')
+ attachment_ids = self._staged_attachment_ids_from_files(files)
+
+ equipment = {
+ 'repair_category_id': int(post.get('category_id') or 0) or False,
+ 'product_id': int(post.get('product_id') or 0) or False,
+ 'third_party': post.get('third_party') in ('on', 'true', '1'),
+ 'urgency': post.get('urgency') or 'normal',
+ 'issue_summary': (post.get('issue_summary') or '').strip(),
+ 'issue_category': (post.get('issue_category') or '').strip(),
+ 'internal_notes': (post.get('internal_notes') or '').strip(),
+ 'photo_attachment_ids': attachment_ids,
+ }
+
+ payload = {
+ 'partner_id': partner_id,
+ 'intake_user_id': request.env.uid,
+ 'equipment_items': [equipment],
+ }
+
+ try:
+ repairs = request.env['fusion.repair.intake.service'].sudo() \
+ .create_repair_orders(payload, source='sales_rep_portal')
+ except Exception as e:
+ _logger.exception('Sales rep portal repair submit failed')
+ return request.redirect('/my/repair/new?error=server')
+
+ return request.redirect('/my/repair/%d?thanks=1' % repairs[0].id)
+
+ # ------------------------------------------------------------------
+ # MY REPAIRS LIST + DETAIL
+ # ------------------------------------------------------------------
+ @http.route(['/my/repairs', '/my/repairs/page/'], type='http',
+ auth='user', website=True)
+ def portal_repairs_list(self, page=1, **kw):
+ gate = self._check_sales_rep_access()
+ if gate:
+ return gate
+
+ Repair = request.env['repair.order'].sudo()
+ domain = [('x_fc_intake_user_id', '=', request.env.uid)]
+
+ total = Repair.search_count(domain)
+ page_size = 20
+ offset = (page - 1) * page_size
+ repairs = Repair.search(domain, order='create_date desc',
+ limit=page_size, offset=offset)
+
+ return request.render('fusion_repairs.portal_sales_rep_repair_list', {
+ 'page_name': 'repairs_list',
+ 'repairs': repairs,
+ 'total': total,
+ 'page': page,
+ 'page_size': page_size,
+ })
+
+ @http.route('/my/repair/', type='http', auth='user',
+ website=True)
+ def portal_repair_detail(self, repair_id, thanks=None, **kw):
+ gate = self._check_sales_rep_access()
+ if gate:
+ return gate
+
+ repair = request.env['repair.order'].sudo().browse(repair_id).exists()
+ if not repair or repair.x_fc_intake_user_id.id != request.env.uid:
+ return request.redirect('/my/repairs')
+
+ return request.render('fusion_repairs.portal_sales_rep_repair_detail', {
+ 'page_name': 'repair_detail',
+ 'repair': repair,
+ 'thanks': bool(thanks),
+ })
diff --git a/fusion_repairs/security/security.xml b/fusion_repairs/security/security.xml
index 79401dba..479b6e5f 100644
--- a/fusion_repairs/security/security.xml
+++ b/fusion_repairs/security/security.xml
@@ -53,11 +53,12 @@
-
+
Repair Order: Technician sees own repairs
- [('x_fc_technician_task_ids.all_technician_ids', 'in', [user.id])]
+ ['|', ('x_fc_technician_task_ids.technician_id', '=', user.id), ('x_fc_technician_task_ids.additional_technician_ids', 'in', [user.id])]
@@ -73,4 +74,16 @@
+
+
+ Repair Order: Sales Rep Portal - Own Repairs
+
+ [('x_fc_intake_user_id', '=', user.id)]
+
+
+
+
+
+
+
diff --git a/fusion_repairs/static/src/js/portal_repair_intake.js b/fusion_repairs/static/src/js/portal_repair_intake.js
new file mode 100644
index 00000000..7cd6b3ee
--- /dev/null
+++ b/fusion_repairs/static/src/js/portal_repair_intake.js
@@ -0,0 +1,107 @@
+/** @odoo-module **/
+// Sales rep portal - new service call form interactions.
+// Uses Odoo 19 public Interaction class per project frontend rules
+// (NOT IIFE / DOMContentLoaded). Uses only safe DOM construction
+// (textContent + createElement) - no innerHTML, no XSS risk.
+
+import { Interaction } from "@web/public/interaction";
+import { registry } from "@web/core/registry";
+import { rpc } from "@web/core/network/rpc";
+
+export class SalesRepRepairIntake extends Interaction {
+ static selector = ".o_fusion_repairs_portal";
+
+ dynamicContent = {
+ "#partner_search": {
+ "t-on-input": this._onPartnerSearchInput,
+ },
+ };
+
+ setup() {
+ this._partnerSearchTimer = null;
+ }
+
+ _onPartnerSearchInput(ev) {
+ const query = (ev.target.value || "").trim();
+ if (this._partnerSearchTimer) {
+ clearTimeout(this._partnerSearchTimer);
+ }
+ if (query.length < 3) {
+ this._renderMatches([]);
+ return;
+ }
+ this._partnerSearchTimer = setTimeout(async () => {
+ try {
+ const result = await rpc("/my/repair/lookup_partner", { query });
+ this._renderMatches(result.matches || []);
+ } catch (e) {
+ this._renderMatches([]);
+ }
+ }, 250);
+ }
+
+ _renderMatches(matches) {
+ const list = document.getElementById("partner_matches");
+ if (!list) {
+ return;
+ }
+ while (list.firstChild) {
+ list.removeChild(list.firstChild);
+ }
+ for (const m of matches) {
+ list.appendChild(this._buildMatchItem(m));
+ }
+ }
+
+ _buildMatchItem(m) {
+ const item = document.createElement("button");
+ item.type = "button";
+ item.className = "list-group-item list-group-item-action text-start";
+
+ const nameStrong = document.createElement("strong");
+ nameStrong.textContent = m.name || "";
+ item.appendChild(nameStrong);
+
+ if (m.phone) {
+ const phone = document.createElement("span");
+ phone.className = "text-muted ms-2";
+ phone.textContent = m.phone;
+ item.appendChild(phone);
+ }
+
+ if (m.repair_count) {
+ const badge = document.createElement("span");
+ badge.className = "badge bg-secondary ms-2";
+ badge.textContent = `${m.repair_count} repair(s)`;
+ item.appendChild(badge);
+ }
+
+ if (m.street) {
+ const addr = document.createElement("div");
+ addr.className = "small text-muted";
+ addr.textContent = [m.street, m.city].filter(Boolean).join(", ");
+ item.appendChild(addr);
+ }
+
+ item.addEventListener("click", () => this._selectPartner(m));
+ return item;
+ }
+
+ _selectPartner(m) {
+ document.getElementById("partner_id_input").value = m.id;
+ document.getElementById("partner_selected_name").textContent =
+ m.name + (m.phone ? ` (${m.phone})` : "");
+ document
+ .getElementById("partner_selected")
+ .classList.remove("d-none");
+ const list = document.getElementById("partner_matches");
+ while (list.firstChild) {
+ list.removeChild(list.firstChild);
+ }
+ document.getElementById("partner_search").value = m.name;
+ }
+}
+
+registry
+ .category("public.interactions")
+ .add("fusion_repairs.sales_rep_intake", SalesRepRepairIntake);
diff --git a/fusion_repairs/static/src/scss/portal_client_repair.scss b/fusion_repairs/static/src/scss/portal_client_repair.scss
new file mode 100644
index 00000000..7caaf549
--- /dev/null
+++ b/fusion_repairs/static/src/scss/portal_client_repair.scss
@@ -0,0 +1,39 @@
+/* Public client portal - mobile-first.
+ * Follows project SCSS rules: no hardcoded theme colours, large tap targets,
+ * adapts to website light/dark theme automatically.
+ */
+
+.o_fusion_repairs_client {
+ .form-control,
+ .form-select,
+ .btn {
+ min-height: 44px;
+ }
+
+ .btn-lg {
+ min-height: 56px;
+ font-size: 1.125rem;
+ }
+
+ .card {
+ border-radius: 0.75rem;
+ }
+
+ h1.display-5,
+ h2 {
+ line-height: 1.2;
+ }
+
+ @media (max-width: 575px) {
+ section {
+ padding-top: 1.5rem !important;
+ padding-bottom: 1.5rem !important;
+ }
+ .card-footer {
+ position: sticky;
+ bottom: 0;
+ background: inherit;
+ z-index: 2;
+ }
+ }
+}
diff --git a/fusion_repairs/static/src/scss/portal_repair_mobile.scss b/fusion_repairs/static/src/scss/portal_repair_mobile.scss
new file mode 100644
index 00000000..3ca83da4
--- /dev/null
+++ b/fusion_repairs/static/src/scss/portal_repair_mobile.scss
@@ -0,0 +1,39 @@
+/* Sales rep portal - mobile-first additions.
+ * Follows project CLAUDE.md rules:
+ * - Tap targets >=44px
+ * - No hardcoded theme colours
+ * - Cards float on a slightly grayer page background
+ */
+
+.o_fusion_repairs_portal {
+ .card {
+ border-radius: 0.5rem;
+ }
+
+ .form-control,
+ .form-select,
+ .btn {
+ min-height: 44px;
+ }
+
+ .btn-lg {
+ min-height: 52px;
+ }
+
+ #partner_matches {
+ .list-group-item {
+ padding: 0.75rem 1rem;
+ cursor: pointer;
+ }
+ }
+
+ /* Sticky bottom CTA on small screens for the submit form. */
+ @media (max-width: 575px) {
+ .card-footer {
+ position: sticky;
+ bottom: 0;
+ background: inherit;
+ z-index: 2;
+ }
+ }
+}
diff --git a/fusion_repairs/views/portal_client_repair_templates.xml b/fusion_repairs/views/portal_client_repair_templates.xml
new file mode 100644
index 00000000..9bd42347
--- /dev/null
+++ b/fusion_repairs/views/portal_client_repair_templates.xml
@@ -0,0 +1,185 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
Need a repair?
+
+ Tell us about your equipment and what's going wrong.
+ We'll respond on the next business day - or sooner if it's urgent.
+
+
+ Start a Service Request
+
+
+
+ Your information is private and used only to schedule your repair.
+
+
+ Is anyone hurt right now?
+ If you have a medical emergency, please hang up and dial 9-1-1.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Service Request
+
+ Fill in the form below. A team member will follow up shortly.
+
+
+
+ Please fill in all required fields.
+
+
+ Submission blocked. If this is a mistake, please call our office.
+
+
+ Too many requests from your location. Please try again in an hour.
+
+
+ Something went wrong. Please try again or call us directly.
+
+
+
+
+
+
+
+
+
+
+
+
+
1. Your contact details
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
2. What equipment needs service?
+
+
+
+
+
+
+
+
+
+
+
+
3. What's wrong?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
4. How urgent is it?
+
+
+
+ If anyone is hurt, hang up and call 9-1-1.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Got it!
+
+ Your service request was received.
+ We'll get back to you on the next business day or sooner if you marked it urgent.
+
+
Back to home
+
+
+
+
+
+
+
+
diff --git a/fusion_repairs/views/portal_sales_rep_templates.xml b/fusion_repairs/views/portal_sales_rep_templates.xml
new file mode 100644
index 00000000..755fc5d4
--- /dev/null
+++ b/fusion_repairs/views/portal_sales_rep_templates.xml
@@ -0,0 +1,281 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
New Service Call
+
+ Submit a repair request on behalf of a client. The office will follow up to schedule a technician.
+
+
+
+ Please select a client.
+
+
+ An error occurred saving the request. Please try again.
+
+
+
+
+
+
+
+
1. Client
+
+
+
+
+
+
+ Selected:
+
+
+
+
+
+
+
+
2. Equipment
+
+
+
+
+
+
+
+
+
+
+
+
+
3. What's the issue?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
4. Urgency
+
+
+
+
+
+
+
+
5. Photos (optional)
+
+
+ Tap to take a photo or pick from gallery.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Service call submitted.
+ The office will follow up shortly to schedule a technician.
+
+
+
+
+
+
+
+
+
+
Equipment & Issue
+
+ Category:
+
+
+
+ Product:
+
+
+
+ Urgency:
+
+
+
+ Third-party equipment
+
+
+
+
+
+
+
+
+
+
Scheduled Visit
+
+
+
+
+ with
+
+
+
+
+
+
+
+
+
+
+
+
+
+
From 7727745b7301eb21b459bd8a57c51f987448eca2 Mon Sep 17 00:00:00 2001
From: gsinghpal
Date: Wed, 20 May 2026 21:57:33 -0400
Subject: [PATCH 04/30] feat(fusion_repairs): Phase 2 - service catalogue,
visit report, warranty, Poynt
Service catalogue
- New fusion.repair.service.catalog model: named service entries per
equipment category with symptom keywords, estimated hours / cost,
default parts, auto_schedule flag, optional pricelist override
- find_best_match() scores candidates by symptom-keyword overlap against
intake text hints (issue summary + category + notes)
- Intake service wires it in: on submit, the matcher sets
x_fc_service_catalog_id + x_fc_estimated_duration + x_fc_estimated_cost
and (when auto_schedule=True) creates a draft dispatch task
- Double-task guard: if catalogue match already created a task, the
urgency-based dispatch skips so we never duplicate
Visit report wizard
- fusion.repair.visit.report.wizard with labour hours + parts lines +
technician notes + 'found another issue' branch
- Computes actual cost = (labour x service_product.list_price) + parts
- Compares against estimate -> sets requires_requote when variance
exceeds configured threshold (% or $); shows warning banner inline
- On confirm: writes actuals back to repair, posts notes to chatter,
optionally spawns a follow-up repair (T5 'found another issue')
Repair warranty
- New fusion.repair.warranty.coverage model (start/expiry, partner,
product, lot, active flag)
- find_active_for(partner, product, lot) returns the most-recent active
coverage
- Intake service auto-checks: when a new repair lands on an equipment
that has active warranty coverage, posts a chatter banner so the
office knows the work may be free under our 30/90-day re-do policy
(manager review still required; never auto-zeros pricing)
Repair form
- Header: Visit Report + Collect Payment buttons (gated by group)
- action_collect_payment looks up the linked posted unpaid invoice on
the repair SO and opens the Poynt wizard (action_open_poynt_payment_wizard)
AI intake summary
- _generate_ai_summary calls self.env['fusion.api.service'].call_openai
with consumer='fusion_repairs', feature='intake_triage'
- Strict system prompt: no medical advice, no diagnoses, no recommending
stop equipment use; ~80 words; plain English
- Try/fallback per fusion-api-integration.mdc: if fusion_api not
installed or call fails -> silently skip; intake never blocked
Verified end-to-end on local westin-v19:
- Stairlift motor intake -> catalogue match -> estimated $500/2h -> auto
dispatch task (count=1, not duplicated)
- Visit report: 2.5h x $250 + $100 parts = $725 actual vs $500 estimated
= 45% variance -> requires_requote=True
- Warranty: 30-day coverage on the completed repair; second repair on
same partner triggers warranty banner in chatter
Co-authored-by: Cursor
---
fusion_repairs/__manifest__.py | 5 +-
fusion_repairs/models/__init__.py | 2 +
fusion_repairs/models/intake_service.py | 111 +++++++++-
fusion_repairs/models/repair_order.py | 41 ++++
fusion_repairs/models/repair_warranty.py | 121 +++++++++++
fusion_repairs/models/service_catalog.py | 141 +++++++++++++
fusion_repairs/security/ir.model.access.csv | 6 +
fusion_repairs/views/menus.xml | 12 ++
fusion_repairs/views/repair_order_views.xml | 16 ++
.../views/repair_warranty_views.xml | 59 ++++++
.../views/service_catalog_views.xml | 74 +++++++
fusion_repairs/wizard/__init__.py | 1 +
.../wizard/repair_visit_report_wizard.py | 194 ++++++++++++++++++
.../repair_visit_report_wizard_views.xml | 64 ++++++
14 files changed, 845 insertions(+), 2 deletions(-)
create mode 100644 fusion_repairs/models/repair_warranty.py
create mode 100644 fusion_repairs/models/service_catalog.py
create mode 100644 fusion_repairs/views/repair_warranty_views.xml
create mode 100644 fusion_repairs/views/service_catalog_views.xml
create mode 100644 fusion_repairs/wizard/repair_visit_report_wizard.py
create mode 100644 fusion_repairs/wizard/repair_visit_report_wizard_views.xml
diff --git a/fusion_repairs/__manifest__.py b/fusion_repairs/__manifest__.py
index c6450219..38d00ee4 100644
--- a/fusion_repairs/__manifest__.py
+++ b/fusion_repairs/__manifest__.py
@@ -76,6 +76,8 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
# Views
'views/repair_product_category_views.xml',
'views/intake_template_views.xml',
+ 'views/service_catalog_views.xml',
+ 'views/repair_warranty_views.xml',
'views/repair_order_views.xml',
'views/res_partner_views.xml',
'views/res_users_views.xml',
@@ -83,8 +85,9 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
# Portal templates
'views/portal_sales_rep_templates.xml',
'views/portal_client_repair_templates.xml',
- # Wizard
+ # Wizards
'wizard/repair_intake_wizard_views.xml',
+ 'wizard/repair_visit_report_wizard_views.xml',
# Menus (last, after all referenced actions exist)
'views/menus.xml',
],
diff --git a/fusion_repairs/models/__init__.py b/fusion_repairs/models/__init__.py
index 5b36b893..0f4d53fe 100644
--- a/fusion_repairs/models/__init__.py
+++ b/fusion_repairs/models/__init__.py
@@ -6,6 +6,8 @@ from . import repair_product_category
from . import intake_template
from . import intake_question
from . import intake_answer
+from . import service_catalog
+from . import repair_warranty
from . import product_template
from . import res_partner
from . import res_users
diff --git a/fusion_repairs/models/intake_service.py b/fusion_repairs/models/intake_service.py
index bbe8a708..16479f97 100644
--- a/fusion_repairs/models/intake_service.py
+++ b/fusion_repairs/models/intake_service.py
@@ -132,6 +132,15 @@ class FusionRepairIntakeService(models.AbstractModel):
# Persist intake answers.
self._create_answers(repair, item.get('answers') or [])
+ # Service catalogue auto-match.
+ self._match_service_catalog(repair, item)
+
+ # Check our own repair-warranty (30/90 day re-do free).
+ self._check_repair_warranty(repair)
+
+ # Optional AI brief generation - never blocks intake.
+ self._generate_ai_summary(repair, item)
+
# Attach photos.
photo_ids = item.get('photo_attachment_ids') or []
if photo_ids:
@@ -146,7 +155,11 @@ class FusionRepairIntakeService(models.AbstractModel):
self._schedule_activities(repair)
# Optional dispatch draft task (urgent / safety).
- if repair.x_fc_urgency in ('urgent', 'safety'):
+ # Skip if the catalogue match already auto-created one.
+ if (
+ repair.x_fc_urgency in ('urgent', 'safety')
+ and not repair.x_fc_technician_task_ids
+ ):
self._create_dispatch_task(repair)
# Emails (client + office).
@@ -178,6 +191,102 @@ class FusionRepairIntakeService(models.AbstractModel):
parts.append('Notes: %s
' % notes)
return ''.join(parts)
+ # ------------------------------------------------------------------
+ # SERVICE CATALOGUE MATCH
+ # ------------------------------------------------------------------
+ @api.model
+ def _match_service_catalog(self, repair, item):
+ category = repair.x_fc_repair_category_id
+ if not category:
+ return
+ text_hints = [
+ (item.get('issue_summary') or ''),
+ (item.get('issue_category') or ''),
+ (item.get('internal_notes') or ''),
+ ]
+ catalog = self.env['fusion.repair.service.catalog'].sudo().find_best_match(
+ category.id, text_hints,
+ )
+ if not catalog:
+ return
+ repair.write({
+ 'x_fc_service_catalog_id': catalog.id,
+ 'x_fc_estimated_duration': catalog.estimated_hours,
+ 'x_fc_estimated_cost': catalog.estimated_cost,
+ })
+ # Auto-create dispatch task if catalogue says so (in addition to urgency rule).
+ if catalog.auto_schedule and repair.x_fc_technician_task_count == 0:
+ self._create_dispatch_task(repair)
+
+ # ------------------------------------------------------------------
+ # REPAIR WARRANTY (our 30/90-day re-do free)
+ # ------------------------------------------------------------------
+ @api.model
+ def _check_repair_warranty(self, repair):
+ if not repair.partner_id:
+ return
+ warranty = self.env['fusion.repair.warranty.coverage'].sudo() \
+ .find_active_for(repair.partner_id.id, repair.product_id.id or None,
+ repair.lot_id.id or None)
+ if not warranty:
+ return
+ repair.message_post(
+ body=_(
+ 'This repair MAY be covered by our active warranty %(ref)s '
+ '(expires %(exp)s). Manager review recommended before invoicing.',
+ ref=warranty.name,
+ exp=warranty.expiry_date,
+ ),
+ message_type='comment',
+ )
+
+ # ------------------------------------------------------------------
+ # AI SUMMARY (try/fallback per fusion-api-integration rule)
+ # ------------------------------------------------------------------
+ @api.model
+ def _generate_ai_summary(self, repair, item):
+ try:
+ ApiService = self.env.get('fusion.api.service')
+ if not ApiService:
+ return
+ issue = (item.get('issue_summary') or '').strip()
+ if not issue:
+ return
+ category = repair.x_fc_repair_category_id.name or 'medical equipment'
+ urgency = repair.x_fc_urgency or 'normal'
+ messages = [
+ {
+ 'role': 'system',
+ 'content': (
+ 'You are an assistant for a medical equipment repair service. '
+ 'Given an intake note, output ONE short paragraph (under 80 words) '
+ 'briefing the technician about: likely cause, what to bring, and '
+ 'any safety considerations. NEVER provide medical advice. NEVER '
+ 'recommend stopping equipment use. NEVER claim a definitive cause. '
+ 'Plain English, no jargon.'
+ ),
+ },
+ {
+ 'role': 'user',
+ 'content': (
+ f'Equipment category: {category}\n'
+ f'Urgency: {urgency}\n'
+ f'Issue: {issue}\n'
+ f'Notes: {(item.get("internal_notes") or "").strip()}'
+ ),
+ },
+ ]
+ summary = ApiService.call_openai(
+ consumer='fusion_repairs',
+ feature='intake_triage',
+ messages=messages,
+ max_tokens=200,
+ )
+ if summary:
+ repair.x_fc_ai_summary = summary.strip()
+ except Exception as e:
+ _logger.info('AI intake summary skipped: %s', e)
+
# ------------------------------------------------------------------
# ORIGINAL SO AUTO-LINK
# ------------------------------------------------------------------
diff --git a/fusion_repairs/models/repair_order.py b/fusion_repairs/models/repair_order.py
index 91de2022..06854124 100644
--- a/fusion_repairs/models/repair_order.py
+++ b/fusion_repairs/models/repair_order.py
@@ -5,6 +5,7 @@
from datetime import timedelta
from odoo import api, fields, models, _
+from odoo.exceptions import UserError
INTAKE_SOURCES = [
@@ -62,6 +63,14 @@ class RepairOrder(models.Model):
'repair_id',
string='Intake Answers',
)
+
+ # Catalogue match (Phase 2)
+ x_fc_service_catalog_id = fields.Many2one(
+ 'fusion.repair.service.catalog',
+ string='Service Catalogue Match',
+ index=True,
+ help='Auto-matched catalogue entry that pre-fills estimated cost and duration.',
+ )
x_fc_intake_answer_count = fields.Integer(
compute='_compute_intake_answer_count',
)
@@ -279,3 +288,35 @@ class RepairOrder(models.Model):
'view_mode': 'form',
'res_id': self.x_fc_original_sale_order_id.id,
}
+
+ # ------------------------------------------------------------------
+ # WIZARDS / PAYMENT
+ # ------------------------------------------------------------------
+ def action_open_visit_report(self):
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Visit Report'),
+ 'res_model': 'fusion.repair.visit.report.wizard',
+ 'view_mode': 'form',
+ 'target': 'new',
+ 'context': {
+ 'default_repair_id': self.id,
+ 'default_labour_hours': self.x_fc_estimated_duration or 1.0,
+ },
+ }
+
+ def action_collect_payment(self):
+ """Open the Poynt payment wizard for the linked posted invoice."""
+ self.ensure_one()
+ # Resolve the linked invoice via the standard repair -> SO -> invoice chain.
+ if not self.sale_order_id:
+ raise UserError(_('Confirm a sale order from this repair first.'))
+ invoice = self.sale_order_id.invoice_ids.filtered(
+ lambda m: m.state == 'posted' and m.payment_state in ('not_paid', 'partial')
+ )[:1]
+ if not invoice:
+ raise UserError(_('No posted, unpaid invoice was found for this repair.'))
+ if hasattr(invoice, 'action_open_poynt_payment_wizard'):
+ return invoice.action_open_poynt_payment_wizard()
+ raise UserError(_('Poynt payment is not available - install or configure fusion_poynt.'))
diff --git a/fusion_repairs/models/repair_warranty.py b/fusion_repairs/models/repair_warranty.py
new file mode 100644
index 00000000..29c0756e
--- /dev/null
+++ b/fusion_repairs/models/repair_warranty.py
@@ -0,0 +1,121 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+"""Repair warranty coverage.
+
+Tracks the 30/90-day warranty we offer on completed repair work.
+When a new repair is created on the same equipment within the
+coverage window, the intake wizard / portal shows a banner:
+"This repair may be covered by our warranty - no charge".
+
+Phase 2 ships the model + manual creation from a completed repair.
+Phase 4 will add automatic creation when a repair moves to 'done'.
+"""
+
+from datetime import timedelta
+
+from odoo import api, fields, models
+
+
+class FusionRepairWarrantyCoverage(models.Model):
+ _name = 'fusion.repair.warranty.coverage'
+ _description = 'Repair Warranty Coverage'
+ _order = 'expiry_date desc, id desc'
+
+ name = fields.Char(string='Reference', compute='_compute_name', store=True)
+ repair_id = fields.Many2one(
+ 'repair.order',
+ string='Original Repair',
+ required=True,
+ ondelete='cascade',
+ index=True,
+ )
+ partner_id = fields.Many2one(
+ 'res.partner',
+ string='Client',
+ related='repair_id.partner_id',
+ store=True,
+ index=True,
+ )
+ product_id = fields.Many2one(
+ 'product.product',
+ string='Equipment',
+ related='repair_id.product_id',
+ store=True,
+ index=True,
+ )
+ lot_id = fields.Many2one(
+ 'stock.lot',
+ string='Serial Number',
+ related='repair_id.lot_id',
+ store=True,
+ )
+
+ start_date = fields.Date(
+ string='Start Date',
+ required=True,
+ default=fields.Date.context_today,
+ )
+ coverage_days = fields.Integer(
+ string='Coverage Window (days)',
+ default=30,
+ required=True,
+ )
+ expiry_date = fields.Date(
+ string='Expires',
+ compute='_compute_expiry_date',
+ store=True,
+ )
+ is_active = fields.Boolean(
+ string='Active',
+ compute='_compute_is_active',
+ store=True,
+ )
+
+ notes = fields.Text()
+ company_id = fields.Many2one(
+ 'res.company',
+ string='Company',
+ related='repair_id.company_id',
+ store=True,
+ )
+
+ @api.depends('repair_id.name', 'expiry_date')
+ def _compute_name(self):
+ for w in self:
+ w.name = (
+ f"Warranty {w.repair_id.name or '?'} (until {w.expiry_date or '?'})"
+ )
+
+ @api.depends('start_date', 'coverage_days')
+ def _compute_expiry_date(self):
+ for w in self:
+ if w.start_date and w.coverage_days:
+ w.expiry_date = w.start_date + timedelta(days=w.coverage_days)
+ else:
+ w.expiry_date = False
+
+ @api.depends('expiry_date')
+ def _compute_is_active(self):
+ today = fields.Date.context_today(self)
+ for w in self:
+ w.is_active = bool(w.expiry_date and w.expiry_date >= today)
+
+ # ------------------------------------------------------------------
+ # LOOKUP
+ # ------------------------------------------------------------------
+ @api.model
+ def find_active_for(self, partner_id, product_id=None, lot_id=None):
+ """Return active warranty coverage matching the partner + equipment, if any."""
+ if not partner_id:
+ return self.browse()
+ domain = [
+ ('partner_id', '=', partner_id),
+ ('is_active', '=', True),
+ ]
+ if lot_id:
+ domain.append(('lot_id', '=', lot_id))
+ elif product_id:
+ domain.append(('product_id', '=', product_id))
+ return self.search(domain, order='expiry_date desc', limit=1)
diff --git a/fusion_repairs/models/service_catalog.py b/fusion_repairs/models/service_catalog.py
new file mode 100644
index 00000000..fcc08488
--- /dev/null
+++ b/fusion_repairs/models/service_catalog.py
@@ -0,0 +1,141 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+"""Service catalogue.
+
+Each fusion.repair.service.catalog record is a named repair / maintenance
+service (e.g. "Stairlift motor replacement", "Bed remote troubleshoot")
+with estimated duration, estimated cost, default parts, and symptom
+keywords used to auto-match an intake to the right catalogue entry.
+
+The catalogue feeds:
+- intake auto-match -> sets x_fc_service_catalog_id +
+ x_fc_estimated_duration + x_fc_estimated_cost on the repair
+- visit report -> default labour line + parts pre-fill
+- pricing variance -> compares estimate vs actual
+"""
+
+from odoo import api, fields, models
+
+
+class FusionRepairServiceCatalog(models.Model):
+ _name = 'fusion.repair.service.catalog'
+ _description = 'Repair Service Catalogue Entry'
+ _order = 'sequence, name'
+
+ name = fields.Char(string='Service Name', required=True, translate=True)
+ code = fields.Char(string='Code', help='Stable identifier (lowercase, no spaces).')
+ sequence = fields.Integer(default=10)
+ active = fields.Boolean(default=True)
+ company_id = fields.Many2one(
+ 'res.company', string='Company',
+ default=lambda self: self.env.company,
+ )
+
+ # Routing & matching
+ product_category_id = fields.Many2one(
+ 'fusion.repair.product.category',
+ string='Equipment Category',
+ required=True,
+ index=True,
+ )
+ symptom_keywords = fields.Char(
+ string='Symptom Keywords',
+ help='Comma-separated keywords used to auto-match an intake to this catalogue entry. '
+ 'Matched against the issue summary, issue category, and intake answer text.',
+ )
+
+ # Service product (what actually gets invoiced)
+ service_product_id = fields.Many2one(
+ 'product.product',
+ string='Service Product',
+ domain=[('type', '=', 'service')],
+ help='Product line added to the repair sale order for the labour portion.',
+ )
+ default_parts_product_ids = fields.Many2many(
+ 'product.product',
+ 'fusion_repair_catalog_parts_rel',
+ 'catalog_id', 'product_id',
+ string='Default Parts',
+ help='Parts typically used. Pre-loaded onto the visit report wizard for the tech to confirm.',
+ )
+ pricelist_id = fields.Many2one(
+ 'product.pricelist',
+ string='Pricelist Override',
+ help='Optional pricelist applied to repair SOs from this catalogue entry. '
+ 'Leave blank to use the partner default pricelist.',
+ )
+
+ # Estimates
+ estimated_hours = fields.Float(
+ string='Estimated Labour (h)',
+ default=1.0,
+ help='Used to size the technician task and the visit report labour default.',
+ )
+ estimated_cost = fields.Monetary(
+ string='Estimated Cost',
+ currency_field='company_currency_id',
+ help='Headline estimate shown to the client/CS during intake. Phase 1 is a flat number; '
+ 'Phase 2+ may compute from labour + parts.',
+ )
+
+ # Automation hints
+ auto_schedule = fields.Boolean(
+ string='Auto-Create Tech Task',
+ help='When True, the intake service creates a draft technician task immediately for any '
+ 'repair matched to this catalogue entry (even at normal urgency).',
+ )
+ task_type = fields.Selection(
+ [('delivery', 'Delivery'), ('repair', 'Repair'), ('pickup', 'Pickup'),
+ ('troubleshoot', 'Troubleshoot'), ('assessment', 'Assessment'),
+ ('installation', 'Installation'), ('maintenance', 'Maintenance'),
+ ('other', 'Other')],
+ string='Default Task Type',
+ default='repair',
+ )
+
+ company_currency_id = fields.Many2one(
+ 'res.currency',
+ related='company_id.currency_id',
+ readonly=True,
+ )
+
+ @api.depends('name', 'code')
+ def _compute_display_name(self):
+ for c in self:
+ c.display_name = c.name or c.code or ''
+
+ # ------------------------------------------------------------------
+ # MATCHING
+ # ------------------------------------------------------------------
+ @api.model
+ def find_best_match(self, product_category_id, text_hints):
+ """Return the best-matching catalogue entry, or empty recordset.
+
+ :param product_category_id: int id of the equipment category
+ :param text_hints: list[str] - text snippets to look for symptom keywords in
+ (typically: issue_summary, issue_category, recent intake answer values)
+ """
+ if not product_category_id:
+ return self.browse()
+ haystack = ' '.join(s.lower() for s in (text_hints or []) if s).strip()
+ candidates = self.search([
+ ('product_category_id', '=', product_category_id),
+ ('active', '=', True),
+ ], order='sequence')
+ if not candidates:
+ return self.browse()
+ if not haystack:
+ return candidates[:1]
+ best = None
+ best_score = 0
+ for c in candidates:
+ kws = [k.strip().lower() for k in (c.symptom_keywords or '').split(',') if k.strip()]
+ score = sum(1 for kw in kws if kw and kw in haystack)
+ if score > best_score:
+ best = c
+ best_score = score
+ if best:
+ return best
+ return candidates[:1]
diff --git a/fusion_repairs/security/ir.model.access.csv b/fusion_repairs/security/ir.model.access.csv
index 1574ea4d..4d58809f 100644
--- a/fusion_repairs/security/ir.model.access.csv
+++ b/fusion_repairs/security/ir.model.access.csv
@@ -10,3 +10,9 @@ access_repair_intake_answer_manager,Intake Answer Manager Full,model_fusion_repa
access_repair_intake_answer_tech_portal,Intake Answer Technician Read,model_fusion_repair_intake_answer,fusion_tasks.group_field_technician,1,0,0,0
access_repair_intake_wizard_user,Intake Wizard User Full,model_fusion_repair_intake_wizard,group_fusion_repairs_user,1,1,1,1
access_repair_intake_wizard_equipment_user,Intake Wizard Equipment User Full,model_fusion_repair_intake_wizard_equipment,group_fusion_repairs_user,1,1,1,1
+access_repair_service_catalog_user,Catalogue User Read,model_fusion_repair_service_catalog,group_fusion_repairs_user,1,0,0,0
+access_repair_service_catalog_manager,Catalogue Manager Full,model_fusion_repair_service_catalog,group_fusion_repairs_manager,1,1,1,1
+access_repair_warranty_user,Warranty User Read,model_fusion_repair_warranty_coverage,group_fusion_repairs_user,1,0,0,0
+access_repair_warranty_manager,Warranty Manager Full,model_fusion_repair_warranty_coverage,group_fusion_repairs_manager,1,1,1,1
+access_repair_visit_report_wizard_user,Visit Report Wizard User,model_fusion_repair_visit_report_wizard,group_fusion_repairs_user,1,1,1,1
+access_repair_visit_report_wizard_line_user,Visit Report Line User,model_fusion_repair_visit_report_wizard_line,group_fusion_repairs_user,1,1,1,1
diff --git a/fusion_repairs/views/menus.xml b/fusion_repairs/views/menus.xml
index 8820056a..4a30c436 100644
--- a/fusion_repairs/views/menus.xml
+++ b/fusion_repairs/views/menus.xml
@@ -45,4 +45,16 @@
action="action_repair_intake_template"
sequence="20"/>
+
+
+
+
diff --git a/fusion_repairs/views/repair_order_views.xml b/fusion_repairs/views/repair_order_views.xml
index 9544f9e0..b49549ff 100644
--- a/fusion_repairs/views/repair_order_views.xml
+++ b/fusion_repairs/views/repair_order_views.xml
@@ -10,6 +10,22 @@
+
+
+
+
+
+