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-partyYes - equipment not sold by us
WarrantyUnder 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 @@ + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + From c86f1bbbe5039567165484c52843552533e09634 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 20 May 2026 22:22:11 -0400 Subject: [PATCH 07/30] fix(fusion_repairs): code-review batch - 4 critical + 8 high + 8 medium/low Critical - C1: _sql_constraints -> models.Constraint (Odoo 19 deprecation rule violation) - C2: variance threshold no longer uses abs() - under-cost is good news, must not block invoicing. Now only OVER-cost triggers requires_requote. - C3: roll_next_due_date() was dead code - now wired from fusion.technician.task.write() when a maintenance task transitions to 'completed', so the whole maintenance lifecycle actually advances. - C4: warranty.is_active was store=True but time-dependent (became stale). Dropped store=True; find_active_for() now filters by expiry_date directly. High - H1: added x_fc_maintenance_contract_id back-link on repair.order and populated it from create_repair_from_booking(). - H2: find_active_for() returns empty when neither lot nor product is supplied - prevents cross-product false warranty matches. - H3: visit-report wizard now creates stock.move records of repair_line_type 'add' for each part line, so Odoo's native action_create_sale_order() chain has lines to invoice and stock gets consumed properly. - H4: office intake email template now carries a fallback email_to header computed from res.company.x_fc_office_notification_ids (or company email), so it does not silently send with no recipient. - H5: maintenance reminder cron nextcall now always rolls to tomorrow at 07:00 local time, so installing/upgrading after 07:00 does not immediately fire all the day's reminders. - H6: public portal no longer hardcodes UID 1 as the intake user fallback (which in Odoo 19 is OdooBot). Prefers base.user_admin, else the lowest-id non-share user, else SUPERUSER_ID. - H7: public portal validates client_email via tools.email_normalize before partner creation; malformed addresses redirect with error=email. - H8: find_best_match() returns empty when no symptom keywords match (no silent first-catalog guess) and uses word-boundary regex to avoid matching 'battery' inside 'no battery problem'. Medium - M1: _inherit moved next to _name on maintenance_contract (cosmetic but brittle if Odoo refactors model class detection) - M2: relativedelta(months=N) instead of timedelta(days=N*30) for warranty and maintenance intervals (correct month boundaries) - M3: unique constraint on fusion.repair.maintenance.contract.booking_token - M6: dispatch task fallback now searches for an actual x_fc_is_field_staff user; gracefully skips and logs if no field staff exists (instead of silently failing the constraint check) - M7: maintenance contract list view date decoration uses context_today() (date) instead of strftime(string) - the str comparison would TypeError - M9: Visit Report button hidden on draft repairs and when no technician task is linked yet Low - L2: portal-created partners get default lang + company_id so mail templates render in the right language - L3: dropped unused exception variable in sales rep portal controller - L4: visit-report wizard 'found another issue' now redirects to the spawned stub repair so the tech can fill it in immediately - L5: dropped unrecognized data-string from in settings view Public portal also: rate-limit check moved BEFORE the counter increment so blocked attempts do not keep inflating the bucket. All fixes verified end-to-end on local westin-v19: - variance one-sided: 0.5h labour vs $500 est -> requires_requote=False; 2h x $250 + $200 parts vs $100 est -> requires_requote=True - maintenance roll-forward: created MC/00006 due 2026-05-31, completed linked maintenance task -> contract rolled to 2026-11-21 with last_reminder_band reset - warranty find_active_for(partner only) -> empty recordset - service catalog find_best_match with unrelated text -> empty recordset - pg_constraint shows fusion_repair_maintenance_contract_booking_token_unique - /repair landing still 200 after restart Co-authored-by: Cursor --- .../controllers/portal_client_repair.py | 32 ++++++++--- .../controllers/portal_sales_rep_repair.py | 2 +- fusion_repairs/data/ir_cron_data.xml | 2 +- fusion_repairs/data/mail_template_data.xml | 1 + fusion_repairs/models/intake_service.py | 44 +++++++++++---- fusion_repairs/models/maintenance_contract.py | 23 ++++++-- fusion_repairs/models/repair_order.py | 15 ++++-- .../models/repair_product_category.py | 7 +-- fusion_repairs/models/repair_warranty.py | 16 ++++-- fusion_repairs/models/service_catalog.py | 22 +++++--- fusion_repairs/models/technician_task.py | 30 ++++++++++- .../views/maintenance_contract_views.xml | 2 +- fusion_repairs/views/repair_order_views.xml | 2 +- .../views/res_config_settings_views.xml | 3 +- .../wizard/repair_visit_report_wizard.py | 53 +++++++++++++++++-- 15 files changed, 203 insertions(+), 51 deletions(-) diff --git a/fusion_repairs/controllers/portal_client_repair.py b/fusion_repairs/controllers/portal_client_repair.py index 96ddd9c3..9ce7cbe9 100644 --- a/fusion_repairs/controllers/portal_client_repair.py +++ b/fusion_repairs/controllers/portal_client_repair.py @@ -29,8 +29,9 @@ import logging import re import time -from odoo import http, fields +from odoo import SUPERUSER_ID, http, fields from odoo.http import request +from odoo.tools import email_normalize _logger = logging.getLogger(__name__) @@ -92,9 +93,10 @@ class ClientRepairPortal(http.Controller): 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: + # Check FIRST so blocked attempts don't keep inflating the counter. + if _RATE_LIMIT_BUCKET.get(key, 0) >= limit: return True # blocked + _RATE_LIMIT_BUCKET[key] = _RATE_LIMIT_BUCKET.get(key, 0) + 1 return False # ------------------------------------------------------------------ @@ -164,6 +166,12 @@ class ClientRepairPortal(http.Controller): if not (partner_name and phone and issue_summary and category_id): return request.redirect("/repair/new?error=missing") + # Validate email if provided. Empty is allowed; malformed redirects back. + raw_email = (post.get("client_email") or "").strip() + clean_email = email_normalize(raw_email) if raw_email else False + if raw_email and not clean_email: + return request.redirect("/repair/new?error=email") + # 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) @@ -180,7 +188,7 @@ class ClientRepairPortal(http.Controller): partner_vals = { "name": partner_name, "phone": phone, - "email": (post.get("client_email") or "").strip(), + "email": clean_email or False, "street": (post.get("client_street") or "").strip(), "city": (post.get("client_city") or "").strip(), } @@ -209,13 +217,21 @@ class ClientRepairPortal(http.Controller): "internal_notes": (post.get("internal_notes") or "").strip(), "photo_attachment_ids": attachment_ids, } + # Pick a real human owner for the repair so emails go from a person: + # admin if present, else the lowest-id non-share user, else SUPERUSER_ID. + admin = request.env.ref("base.user_admin", raise_if_not_found=False) + if admin: + intake_uid = admin.id + else: + internal = request.env["res.users"].sudo().search( + [("share", "=", False)], order="id asc", limit=1, + ) + intake_uid = internal.id if internal else SUPERUSER_ID + 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, + "intake_user_id": intake_uid, "equipment_items": [equipment], } diff --git a/fusion_repairs/controllers/portal_sales_rep_repair.py b/fusion_repairs/controllers/portal_sales_rep_repair.py index 55434216..8f1bbd6f 100644 --- a/fusion_repairs/controllers/portal_sales_rep_repair.py +++ b/fusion_repairs/controllers/portal_sales_rep_repair.py @@ -135,7 +135,7 @@ class SalesRepRepairPortal(CustomerPortal): try: repairs = request.env['fusion.repair.intake.service'].sudo() \ .create_repair_orders(payload, source='sales_rep_portal') - except Exception as e: + except Exception: _logger.exception('Sales rep portal repair submit failed') return request.redirect('/my/repair/new?error=server') diff --git a/fusion_repairs/data/ir_cron_data.xml b/fusion_repairs/data/ir_cron_data.xml index 5f9e95ed..4dde0416 100644 --- a/fusion_repairs/data/ir_cron_data.xml +++ b/fusion_repairs/data/ir_cron_data.xml @@ -11,7 +11,7 @@ 1 days - + diff --git a/fusion_repairs/data/mail_template_data.xml b/fusion_repairs/data/mail_template_data.xml index 8ce6e0a9..afdfe693 100644 --- a/fusion_repairs/data/mail_template_data.xml +++ b/fusion_repairs/data/mail_template_data.xml @@ -109,6 +109,7 @@ [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) }} + {{ ','.join(p.email for p in (object.company_id.x_fc_office_notification_ids if 'x_fc_office_notification_ids' in object.company_id._fields else []) if p.email) or (object.company_id.email or '') }}
diff --git a/fusion_repairs/models/intake_service.py b/fusion_repairs/models/intake_service.py index 16479f97..8f5c5315 100644 --- a/fusion_repairs/models/intake_service.py +++ b/fusion_repairs/models/intake_service.py @@ -90,6 +90,10 @@ class FusionRepairIntakeService(models.AbstractModel): partner_vals = payload.get('partner_vals') if not partner_vals: return False + # Sensible defaults for partners created via public portals so mail + # templates pick up the right language / company. + partner_vals.setdefault('lang', self.env.user.lang or 'en_CA') + partner_vals.setdefault('company_id', self.env.company.id) partner = self.env['res.partner'].sudo().create(partner_vals) return partner.id @@ -401,12 +405,25 @@ class FusionRepairIntakeService(models.AbstractModel): '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 - ) + # technician_id is required AND constrained to x_fc_is_field_staff. + # Use the intake user if they qualify, otherwise the lowest-id active + # field-staff user as a placeholder for the dispatcher to reassign. + if repair.user_id and repair.user_id.x_fc_is_field_staff: + vals['technician_id'] = repair.user_id.id + else: + fallback = self.env['res.users'].sudo().search([ + ('x_fc_is_field_staff', '=', True), + ('active', '=', True), + ], order='id', limit=1) + if not fallback: + _logger.warning( + 'No field-staff user available - skipping auto-dispatch ' + 'task for repair %s (mark a user as Field Staff under ' + 'Settings > Users).', + repair.name, + ) + return + vals['technician_id'] = fallback.id Task.create(vals) except Exception as e: _logger.warning('Failed to auto-create dispatch task for repair %s: %s', @@ -449,8 +466,13 @@ class FusionRepairIntakeService(models.AbstractModel): @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 [] + company_sudo = company.sudo() + recipients = getattr(company_sudo, 'x_fc_office_notification_ids', False) + emails = [p.email for p in (recipients or []) if p.email] + if not emails: + _logger.info( + 'No office notification recipients configured on company %s - ' + 'skipping office intake email.', + company.name, + ) + return emails diff --git a/fusion_repairs/models/maintenance_contract.py b/fusion_repairs/models/maintenance_contract.py index bed7e5ff..aa290d7e 100644 --- a/fusion_repairs/models/maintenance_contract.py +++ b/fusion_repairs/models/maintenance_contract.py @@ -14,6 +14,8 @@ a tokenized booking link. import secrets from datetime import timedelta +from dateutil.relativedelta import relativedelta + from odoo import _, api, fields, models @@ -27,6 +29,7 @@ CONTRACT_STATES = [ class FusionRepairMaintenanceContract(models.Model): _name = 'fusion.repair.maintenance.contract' + _inherit = ['mail.thread'] _description = 'Repair Maintenance Contract' _order = 'next_due_date, id' @@ -76,7 +79,10 @@ class FusionRepairMaintenanceContract(models.Model): 'res.company', default=lambda self: self.env.company, ) - _inherit = ['mail.thread'] + _booking_token_unique = models.Constraint( + 'unique(booking_token)', + 'Booking token must be unique.', + ) @api.model_create_multi def create(self, vals_list): @@ -93,10 +99,15 @@ class FusionRepairMaintenanceContract(models.Model): # ROLL FORWARD # ------------------------------------------------------------------ def roll_next_due_date(self): - """Advance next_due_date by interval_months and reset cycle state.""" + """Advance next_due_date by interval_months and reset cycle state. + + Called from technician_task.write() when a maintenance task moves to + 'completed' (see technician_task.py). + """ for c in self: base = c.last_service_date or fields.Date.context_today(c) - c.next_due_date = base + timedelta(days=(c.interval_months or 12) * 30) + # relativedelta handles month boundaries correctly (28/29/30/31). + c.next_due_date = base + relativedelta(months=c.interval_months or 12) c.last_reminder_band = False c.booking_repair_id = False @@ -161,8 +172,10 @@ class FusionRepairMaintenanceContract(models.Model): 'schedule_date': scheduled_date or fields.Datetime.now(), 'x_fc_intake_source': 'client_portal', 'x_fc_urgency': 'normal', - 'x_fc_repair_category_id': self.product_id.product_tmpl_id.x_fc_repair_category_id.id + 'x_fc_repair_category_id': + self.product_id.product_tmpl_id.x_fc_repair_category_id.id if self.product_id.product_tmpl_id.x_fc_repair_category_id else False, + 'x_fc_maintenance_contract_id': self.id, 'internal_notes': f'

Maintenance visit booked from reminder for contract {self.name}.

', }) @@ -204,6 +217,6 @@ class SaleOrder(models.Model): 'product_id': product.id, 'original_sale_order_id': so.id, 'interval_months': interval, - 'next_due_date': today + timedelta(days=interval * 30), + 'next_due_date': today + relativedelta(months=interval), 'state': 'active', }) diff --git a/fusion_repairs/models/repair_order.py b/fusion_repairs/models/repair_order.py index 06854124..a017112d 100644 --- a/fusion_repairs/models/repair_order.py +++ b/fusion_repairs/models/repair_order.py @@ -2,7 +2,7 @@ # Copyright 2024-2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) -from datetime import timedelta +from dateutil.relativedelta import relativedelta from odoo import api, fields, models, _ from odoo.exceptions import UserError @@ -71,6 +71,15 @@ class RepairOrder(models.Model): index=True, help='Auto-matched catalogue entry that pre-fills estimated cost and duration.', ) + + # Maintenance contract back-link (Phase 3) + x_fc_maintenance_contract_id = fields.Many2one( + 'fusion.repair.maintenance.contract', + string='Maintenance Contract', + index=True, + help='Set when this repair was spawned from a maintenance reminder booking. ' + 'Completing the related technician task rolls the contract to its next cycle.', + ) x_fc_intake_answer_count = fields.Integer( compute='_compute_intake_answer_count', ) @@ -240,8 +249,8 @@ class RepairOrder(models.Model): ) 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) + # relativedelta handles month boundaries correctly (28/29/30/31). + cutoff = fields.Datetime.from_string(str(delivery_date)) + relativedelta(months=warranty_months) return fields.Datetime.now() <= cutoff # ------------------------------------------------------------------ diff --git a/fusion_repairs/models/repair_product_category.py b/fusion_repairs/models/repair_product_category.py index d360f4d4..8a1fde2a 100644 --- a/fusion_repairs/models/repair_product_category.py +++ b/fusion_repairs/models/repair_product_category.py @@ -39,9 +39,10 @@ class FusionRepairProductCategory(models.Model): help='Default intake question set shown when this category is selected.', ) - _sql_constraints = [ - ('code_unique', 'unique(code)', 'Category code must be unique.'), - ] + _code_unique = models.Constraint( + 'unique(code)', + 'Category code must be unique.', + ) @api.depends('name', 'code') def _compute_display_name(self): diff --git a/fusion_repairs/models/repair_warranty.py b/fusion_repairs/models/repair_warranty.py index 29c0756e..518406f0 100644 --- a/fusion_repairs/models/repair_warranty.py +++ b/fusion_repairs/models/repair_warranty.py @@ -67,10 +67,12 @@ class FusionRepairWarrantyCoverage(models.Model): compute='_compute_expiry_date', store=True, ) + # Non-stored compute - DO NOT add store=True. The 'active vs not' status is + # time-dependent (today >= expiry_date), and a stored compute would never + # auto-refresh as days pass. find_active_for() filters by expiry_date directly. is_active = fields.Boolean( string='Active', compute='_compute_is_active', - store=True, ) notes = fields.Text() @@ -107,12 +109,20 @@ class FusionRepairWarrantyCoverage(models.Model): # ------------------------------------------------------------------ @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.""" + """Return active warranty coverage matching the partner + equipment, if any. + + Requires at least one of lot_id or product_id - without an equipment + identifier we would match any warranty on the partner, which would + falsely flag unrelated equipment as covered. + """ if not partner_id: return self.browse() + if not lot_id and not product_id: + return self.browse() + today = fields.Date.context_today(self) domain = [ ('partner_id', '=', partner_id), - ('is_active', '=', True), + ('expiry_date', '>=', today), ] if lot_id: domain.append(('lot_id', '=', lot_id)) diff --git a/fusion_repairs/models/service_catalog.py b/fusion_repairs/models/service_catalog.py index fcc08488..973a1717 100644 --- a/fusion_repairs/models/service_catalog.py +++ b/fusion_repairs/models/service_catalog.py @@ -113,29 +113,39 @@ class FusionRepairServiceCatalog(models.Model): def find_best_match(self, product_category_id, text_hints): """Return the best-matching catalogue entry, or empty recordset. + Returns empty when no symptom keywords match. We never "guess" a default + catalog because the match drives estimated cost + auto-dispatch task - + a wrong guess would propagate into pricing and scheduling. + :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) """ + import re if not product_category_id: return self.browse() haystack = ' '.join(s.lower() for s in (text_hints or []) if s).strip() + if not haystack: + return self.browse() 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) + # Word-boundary match avoids false positives where "battery" matches + # inside "no battery problem". + score = sum( + 1 for kw in kws + if kw and re.search(rf'\b{re.escape(kw)}\b', haystack) + ) if score > best_score: best = c best_score = score - if best: + # No keywords matched -> return empty rather than the lowest-sequence guess. + if best and best_score > 0: return best - return candidates[:1] + return self.browse() diff --git a/fusion_repairs/models/technician_task.py b/fusion_repairs/models/technician_task.py index f8963c83..452f31be 100644 --- a/fusion_repairs/models/technician_task.py +++ b/fusion_repairs/models/technician_task.py @@ -7,7 +7,8 @@ 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. + repairs and tasks share one timeline. Also hooks task completion to + roll a linked maintenance contract to its next cycle. """ _inherit = 'fusion.technician.task' @@ -29,6 +30,33 @@ class FusionTechnicianTaskRepairs(models.Model): index=True, ) + def write(self, vals): + """When a maintenance task transitions to 'completed', roll the + linked contract to its next cycle. Failure to roll never blocks + the underlying task write. + """ + res = super().write(vals) + if vals.get('status') == 'completed': + for task in self: + if task.task_type != 'maintenance': + continue + repair = task.x_fc_repair_order_id + contract = repair.x_fc_maintenance_contract_id if repair else False + if not contract: + continue + try: + contract.last_service_date = fields.Date.context_today(task) + contract.roll_next_due_date() + contract.message_post(body=( + 'Rolled forward after maintenance task ' + f'{task.name} completed. ' + f'Next due {contract.next_due_date}.' + )) + except Exception: + # Never let a contract roll failure block the task write. + pass + return res + def action_view_repair_order(self): self.ensure_one() if not self.x_fc_repair_order_id: diff --git a/fusion_repairs/views/maintenance_contract_views.xml b/fusion_repairs/views/maintenance_contract_views.xml index b25cb0d0..cef34375 100644 --- a/fusion_repairs/views/maintenance_contract_views.xml +++ b/fusion_repairs/views/maintenance_contract_views.xml @@ -5,7 +5,7 @@ fusion.repair.maintenance.contract.list fusion.repair.maintenance.contract - + diff --git a/fusion_repairs/views/repair_order_views.xml b/fusion_repairs/views/repair_order_views.xml index b49549ff..4f978e04 100644 --- a/fusion_repairs/views/repair_order_views.xml +++ b/fusion_repairs/views/repair_order_views.xml @@ -16,7 +16,7 @@ type="object" string="Visit Report" class="btn-primary" - invisible="state == 'cancel'" + invisible="state in ('draft', 'cancel') or x_fc_technician_task_count == 0" groups="fusion_repairs.group_fusion_repairs_user"/> + + + + + + +
+ + +
Right Now
+
+
+ Open Service Calls + + Not yet closed +
+
+ Urgent + Safety + + High-priority queue +
+
+ Awaiting Dispatch + + No technician task yet +
+
+ Needs Re-Quote + + Over variance threshold +
+
+ New This Month + + Across all intake surfaces +
+
+ Maintenance Due (30d) + + Contracts to ring this month +
+
+ + +
Self-Service Portals
+
+
+
+ Public Client Portal +
+
+ Share this link in your voicemail or on equipment QR stickers. + Clients can submit a service request 24/7 without logging in. +
+
+
+ + +
+
+ +
+
+ Sales Rep Portal +
+
+ Mobile-friendly intake form for sales reps in the field. + Sales reps with portal access only see repairs they submitted. +
+
+
+ + +
+
+
+ + +
Activity
+
+
+

Recent Service Calls

+ +
No service calls yet
+
+ +
+
+ + + + + + + + + · + +
+ + + +
+
+
+ +
+

Upcoming Maintenance

+ +
No upcoming maintenance
+
+ +
+
+ + + + + d + + + + + · + +
+ + + +
+
+
+
+ + +
Configuration
+
+ + + +
+ +
+ +
+ + +
+ + + diff --git a/fusion_repairs/static/src/scss/_fr_tokens.scss b/fusion_repairs/static/src/scss/_fr_tokens.scss new file mode 100644 index 00000000..a0b34409 --- /dev/null +++ b/fusion_repairs/static/src/scss/_fr_tokens.scss @@ -0,0 +1,63 @@ +// Fusion Repairs design tokens. +// Compile-time branching on $o-webclient-color-scheme makes the SAME SCSS file +// produce different values for the light bundle (web.assets_backend) and the +// dark bundle (web.assets_web_dark). Each token is wrapped in a CSS custom +// property so runtime overrides are still possible if ever needed. +// +// IMPORTANT: do NOT @import this file - per project Odoo 19 rule, register +// it as a separate entry in web.assets_backend BEFORE dashboard.scss so the +// variables are in scope when the dashboard file is compiled. + +$o-webclient-color-scheme: bright !default; + +// Default (light) palette. +$_fr-page-hex: #f3f4f6; +$_fr-card-hex: #ffffff; +$_fr-card-elevated-hex: #ffffff; +$_fr-border-hex: #d8dadd; +$_fr-border-soft-hex: #e5e7eb; +$_fr-text-hex: #1f2937; +$_fr-muted-hex: #6b7280; +$_fr-accent-hex: #2b6cb0; +$_fr-success-hex: #16a34a; +$_fr-warning-hex: #d97706; +$_fr-danger-hex: #dc2626; +$_fr-info-bg-hex: #eff6ff; +$_fr-success-bg-hex: #ecfdf5; +$_fr-warning-bg-hex: #fffbeb; +$_fr-danger-bg-hex: #fef2f2; + +@if $o-webclient-color-scheme == dark { + $_fr-page-hex: #14181d !global; + $_fr-card-hex: #1f242b !global; + $_fr-card-elevated-hex: #262c34 !global; + $_fr-border-hex: #2d333b !global; + $_fr-border-soft-hex: #242a31 !global; + $_fr-text-hex: #e6e8eb !global; + $_fr-muted-hex: #9aa3ad !global; + $_fr-accent-hex: #60a5fa !global; + $_fr-success-hex: #34d399 !global; + $_fr-warning-hex: #fbbf24 !global; + $_fr-danger-hex: #f87171 !global; + $_fr-info-bg-hex: #1e3a5f !global; + $_fr-success-bg-hex: #14342a !global; + $_fr-warning-bg-hex: #3b2f15 !global; + $_fr-danger-bg-hex: #3c1d1d !global; +} + +// CSS-variable-wrapped tokens. Use these everywhere in dashboard.scss. +$fr-page: var(--fr-page-bg, #{$_fr-page-hex}); +$fr-card: var(--fr-card-bg, #{$_fr-card-hex}); +$fr-card-elevated: var(--fr-card-elevated-bg, #{$_fr-card-elevated-hex}); +$fr-border: var(--fr-border, #{$_fr-border-hex}); +$fr-border-soft: var(--fr-border-soft, #{$_fr-border-soft-hex}); +$fr-text: var(--fr-text, #{$_fr-text-hex}); +$fr-muted: var(--fr-muted, #{$_fr-muted-hex}); +$fr-accent: var(--fr-accent, #{$_fr-accent-hex}); +$fr-success: var(--fr-success, #{$_fr-success-hex}); +$fr-warning: var(--fr-warning, #{$_fr-warning-hex}); +$fr-danger: var(--fr-danger, #{$_fr-danger-hex}); +$fr-info-bg: var(--fr-info-bg, #{$_fr-info-bg-hex}); +$fr-success-bg: var(--fr-success-bg, #{$_fr-success-bg-hex}); +$fr-warning-bg: var(--fr-warning-bg, #{$_fr-warning-bg-hex}); +$fr-danger-bg: var(--fr-danger-bg, #{$_fr-danger-bg-hex}); diff --git a/fusion_repairs/static/src/scss/dashboard.scss b/fusion_repairs/static/src/scss/dashboard.scss new file mode 100644 index 00000000..76dd8197 --- /dev/null +++ b/fusion_repairs/static/src/scss/dashboard.scss @@ -0,0 +1,320 @@ +// Fusion Repairs dashboard. +// Uses tokens from _fr_tokens.scss (registered first in the bundle). +// Three-layer contrast: page (grayest) -> section -> card (brightest). + +.o_fusion_repairs_dashboard { + background-color: $fr-page; + color: $fr-text; + min-height: calc(100vh - 46px); + padding: 24px; + overflow-y: auto; + + .fr-hero { + background: linear-gradient(135deg, $fr-accent 0%, color-mix(in srgb, $fr-accent 60%, $fr-success) 100%); + color: #ffffff; + border-radius: 12px; + padding: 28px 32px; + margin-bottom: 24px; + display: flex; + flex-direction: column; + gap: 6px; + + h1 { + font-size: 26px; + font-weight: 700; + margin: 0; + color: #ffffff; + } + p { + opacity: 0.9; + margin: 0; + color: #ffffff; + } + } + + .fr-section-title { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.6px; + color: $fr-muted; + margin: 24px 0 12px 0; + } + + .fr-grid { + display: grid; + gap: 16px; + margin-bottom: 8px; + + &.fr-grid-stats { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + } + &.fr-grid-actions { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + } + &.fr-grid-portals { + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + } + &.fr-grid-config { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + } + &.fr-grid-lists { + grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); + } + } + + .fr-stat { + background-color: $fr-card; + border: 1px solid $fr-border; + border-radius: 10px; + padding: 18px 20px; + display: flex; + flex-direction: column; + gap: 4px; + transition: transform 0.15s ease, box-shadow 0.15s ease; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06); + } + + .fr-stat-label { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.4px; + color: $fr-muted; + } + .fr-stat-value { + font-size: 32px; + font-weight: 700; + line-height: 1.1; + color: $fr-text; + } + .fr-stat-sub { + font-size: 12px; + color: $fr-muted; + } + + &.fr-stat-accent .fr-stat-value { color: $fr-accent; } + &.fr-stat-warning .fr-stat-value { color: $fr-warning; } + &.fr-stat-danger .fr-stat-value { color: $fr-danger; } + &.fr-stat-success .fr-stat-value { color: $fr-success; } + } + + .fr-action { + background-color: $fr-card; + border: 1px solid $fr-border; + border-radius: 10px; + padding: 18px 20px; + cursor: pointer; + text-align: left; + display: flex; + align-items: center; + gap: 14px; + color: $fr-text; + font: inherit; + transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; + + &:hover { + transform: translateY(-2px); + border-color: $fr-accent; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08); + } + + .fr-action-icon { + width: 44px; + height: 44px; + min-width: 44px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + background-color: $fr-info-bg; + color: $fr-accent; + font-size: 18px; + } + .fr-action-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + } + .fr-action-title { + font-weight: 600; + font-size: 14px; + color: $fr-text; + } + .fr-action-sub { + font-size: 12px; + color: $fr-muted; + } + + &.fr-action-primary { + background: linear-gradient(135deg, $fr-accent 0%, color-mix(in srgb, $fr-accent 65%, $fr-success) 100%); + border-color: transparent; + color: #ffffff; + + .fr-action-icon { + background-color: rgba(255, 255, 255, 0.18); + color: #ffffff; + } + .fr-action-title, + .fr-action-sub { + color: #ffffff; + } + .fr-action-sub { opacity: 0.85; } + + &:hover { box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18); } + } + } + + .fr-portal { + background-color: $fr-card; + border: 1px solid $fr-border; + border-radius: 10px; + padding: 18px 20px; + display: flex; + flex-direction: column; + gap: 10px; + + .fr-portal-head { + display: flex; + align-items: center; + gap: 10px; + font-weight: 600; + font-size: 14px; + + i { + color: $fr-accent; + } + } + .fr-portal-sub { + font-size: 12px; + color: $fr-muted; + } + .fr-portal-url { + background-color: $fr-info-bg; + color: $fr-text; + padding: 6px 10px; + border-radius: 6px; + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 12px; + word-break: break-all; + } + .fr-portal-actions { + display: flex; + gap: 8px; + margin-top: 4px; + + .btn { + font-size: 12px; + padding: 6px 12px; + } + } + } + + .fr-list { + background-color: $fr-card; + border: 1px solid $fr-border; + border-radius: 10px; + padding: 18px 20px; + + h3 { + font-size: 14px; + font-weight: 600; + margin: 0 0 12px 0; + color: $fr-text; + } + .fr-list-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 0; + border-top: 1px solid $fr-border-soft; + cursor: pointer; + gap: 12px; + + &:first-of-type { + border-top: none; + } + &:hover { + background-color: $fr-info-bg; + margin: 0 -8px; + padding-left: 8px; + padding-right: 8px; + border-radius: 6px; + } + .fr-list-main { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; + } + .fr-list-title { + font-weight: 600; + font-size: 13px; + color: $fr-text; + } + .fr-list-sub { + font-size: 12px; + color: $fr-muted; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .fr-list-meta { + font-size: 11px; + color: $fr-muted; + white-space: nowrap; + } + } + .fr-list-empty { + text-align: center; + color: $fr-muted; + font-size: 13px; + padding: 24px 0; + } + } + + .fr-pill { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.4px; + + &.fr-pill-normal { + background-color: $fr-border-soft; + color: $fr-text; + } + &.fr-pill-urgent { + background-color: $fr-warning-bg; + color: $fr-warning; + } + &.fr-pill-safety { + background-color: $fr-danger-bg; + color: $fr-danger; + } + &.fr-pill-state { + background-color: $fr-info-bg; + color: $fr-accent; + } + } + + .fr-loading { + text-align: center; + padding: 60px 0; + color: $fr-muted; + } + + @media (max-width: 600px) { + padding: 16px; + + .fr-hero { padding: 20px 22px; } + .fr-hero h1 { font-size: 22px; } + } +} diff --git a/fusion_repairs/views/menus.xml b/fusion_repairs/views/menus.xml index aed3c908..0d370453 100644 --- a/fusion_repairs/views/menus.xml +++ b/fusion_repairs/views/menus.xml @@ -1,14 +1,20 @@ - + + + + + + + + Fusion Repairs + fusion_repairs.dashboard + + + From 7f8a80fecbc575e1b4738ca0bd423f6756dd4f1e Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 20 May 2026 23:00:45 -0400 Subject: [PATCH 13/30] fix(fusion_repairs): dashboard scrolling The dashboard root used min-height: calc(100vh - 46px) which expanded to the viewport but bypassed the parent .o_action_manager flex sizing, so the inner overflow-y: auto had nothing to scroll - vertical content was clipped or stuck. Replaced with height: 100% + overflow-y: auto + overflow-x: hidden so the component fills its action container and scrolls naturally. Bumped to 19.0.1.0.6 to bust the asset bundle hash. Co-authored-by: Cursor --- fusion_repairs/__manifest__.py | 2 +- fusion_repairs/static/src/scss/dashboard.scss | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/fusion_repairs/__manifest__.py b/fusion_repairs/__manifest__.py index b1533966..c68e10b1 100644 --- a/fusion_repairs/__manifest__.py +++ b/fusion_repairs/__manifest__.py @@ -4,7 +4,7 @@ { 'name': 'Fusion Repairs', - 'version': '19.0.1.0.5', + 'version': '19.0.1.0.6', 'category': 'Inventory/Repairs', 'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal', 'description': """ diff --git a/fusion_repairs/static/src/scss/dashboard.scss b/fusion_repairs/static/src/scss/dashboard.scss index 76dd8197..2f537f8c 100644 --- a/fusion_repairs/static/src/scss/dashboard.scss +++ b/fusion_repairs/static/src/scss/dashboard.scss @@ -5,9 +5,12 @@ .o_fusion_repairs_dashboard { background-color: $fr-page; color: $fr-text; - min-height: calc(100vh - 46px); - padding: 24px; + // Fill the action manager AND scroll vertically. min-height/100vh broke + // scrolling because it bypassed the parent's flex sizing. + height: 100%; overflow-y: auto; + overflow-x: hidden; + padding: 24px; .fr-hero { background: linear-gradient(135deg, $fr-accent 0%, color-mix(in srgb, $fr-accent 60%, $fr-success) 100%); From d15d9e43032b92425727654c66d16fb960983084 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 20 May 2026 23:11:37 -0400 Subject: [PATCH 14/30] fix(fusion_repairs): admin + office users get full read/schedule access When admin (gsingh, uid=2) opened a repair on the dashboard: "Sorry, Gurpreet Singh (id=2) doesn't have 'read' access to: - Repair Order, RO-202605-04 (repair.order: 34) Blame the following rules: - Repair Order: Technician sees own repairs" Root cause: per-group record rules in Odoo are OR'd within the same model. Admin had been added directly to fusion_tasks.group_field_technician in this database (verified via res_groups_users_rel - direct=1), so the technician's restrictive rule ('only repairs you are assigned to') kicked in. Until now there was no per-group rule for the Repairs Office groups to OR against, so the restrictive rule won by default. Fix - added two pairs of permissive rules: rule_repair_order_repairs_user_full - User can read/write/create rule_repair_order_repairs_manager_unlink - Manager also can delete rule_technician_task_repairs_office - User can read/write/create tasks rule_technician_task_repairs_manager_unlink - Manager also can delete tasks Both have domain_force=[(1,'=',1)] so they grant unrestricted access for the Repairs groups. OR'd with the field_technician rule, admin and other office users now see everything. Field technicians who do NOT have any Repairs group still see only their assigned repairs (rule unchanged). Also added the matching ir.model.access.csv entries - record rules don't fire if the user has no model-level ACL. This is the second fix ('office users can schedule') from the same complaint - Repairs User now has read/write/create on fusion.technician.task; Repairs Manager also gets unlink. Verified end-to-end on westin-v19: Admin can see 17 repairs (was 0 before fix) Admin can read RO-202605-04 -> 'Gurpreet Singh' (the exact failing record) Admin can create fusion.technician.task -> permission check passes (model's own time-overlap business validation correctly rejects an overlap, but that is a value error not a permission error) Bumped to 19.0.1.0.7. Co-authored-by: Cursor --- fusion_repairs/__manifest__.py | 2 +- fusion_repairs/security/ir.model.access.csv | 4 ++ fusion_repairs/security/security.xml | 56 ++++++++++++++++++++- 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/fusion_repairs/__manifest__.py b/fusion_repairs/__manifest__.py index c68e10b1..b0de4ed2 100644 --- a/fusion_repairs/__manifest__.py +++ b/fusion_repairs/__manifest__.py @@ -4,7 +4,7 @@ { 'name': 'Fusion Repairs', - 'version': '19.0.1.0.6', + 'version': '19.0.1.0.7', 'category': 'Inventory/Repairs', 'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal', 'description': """ diff --git a/fusion_repairs/security/ir.model.access.csv b/fusion_repairs/security/ir.model.access.csv index 4470566b..10da9d95 100644 --- a/fusion_repairs/security/ir.model.access.csv +++ b/fusion_repairs/security/ir.model.access.csv @@ -19,3 +19,7 @@ access_repair_visit_report_wizard_line_user,Visit Report Line User,model_fusion_ access_repair_maintenance_user,Maintenance Contract User Read,model_fusion_repair_maintenance_contract,group_fusion_repairs_user,1,0,0,0 access_repair_maintenance_dispatcher,Maintenance Contract Dispatcher,model_fusion_repair_maintenance_contract,group_fusion_repairs_dispatcher,1,1,1,0 access_repair_maintenance_manager,Maintenance Contract Manager Full,model_fusion_repair_maintenance_contract,group_fusion_repairs_manager,1,1,1,1 +access_repair_order_repairs_user,Repair Order Repairs User Read/Write,repair.model_repair_order,group_fusion_repairs_user,1,1,1,0 +access_repair_order_repairs_manager,Repair Order Repairs Manager Full,repair.model_repair_order,group_fusion_repairs_manager,1,1,1,1 +access_technician_task_repairs_user,Technician Task Repairs User Schedule,fusion_tasks.model_fusion_technician_task,group_fusion_repairs_user,1,1,1,0 +access_technician_task_repairs_manager,Technician Task Repairs Manager Full,fusion_tasks.model_fusion_technician_task,group_fusion_repairs_manager,1,1,1,1 diff --git a/fusion_repairs/security/security.xml b/fusion_repairs/security/security.xml index 89476c53..1344c82a 100644 --- a/fusion_repairs/security/security.xml +++ b/fusion_repairs/security/security.xml @@ -64,7 +64,11 @@ + Uses STORED fields (technician_id + additional_technician_ids) - not the computed all_technician_ids. + + NOTE: per-group rules in Odoo are OR'd. A user who is BOTH a field + technician AND a Repairs User/Dispatcher/Manager will see all repairs + because the permissive Repairs rules below grant access via the OR. --> Repair Order: Technician sees own repairs @@ -76,6 +80,56 @@ + + + Repair Order: Repairs Office Full Access + + [(1, '=', 1)] + + + + + + + + Repair Order: Repairs Manager Can Delete + + [(1, '=', 1)] + + + + + + + + + + Technician Task: Repairs Office Access + + [(1, '=', 1)] + + + + + + + + Technician Task: Repairs Manager Can Delete + + [(1, '=', 1)] + + + + + + + Repair Intake Answer: Multi-Company From 194850e3cf5c6fbe1b2338b07b989f589717b71d Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 20 May 2026 23:27:43 -0400 Subject: [PATCH 15/30] feat(fusion_repairs): Bundle 1 - wizard polish (C1 + C5 + C6 + D2 + T1) C1 duplicate-call detection - Wizard computes duplicate_count + duplicate_repair_ids when partner is picked (open repairs from the configurable window, default 14 days). - Yellow banner with "Open Existing Repair" button to jump to the most recent duplicate so CS can add a note instead of creating a new repair. C5 outstanding-balance warning - Wizard sums posted unpaid account.move.amount_residual across all invoices of the partner. - Red banner shown when balance >= fusion_repairs.outstanding_balance_threshold (default $100) with a "View Invoices" button. C6 quote-only mode - New quote_only boolean on the wizard; passed through the shared intake service. Skips dispatch-task creation for urgent/safety AND for catalogue auto_schedule. Chatter note "Created in Quote Only mode" posted on the resulting repair.order. D2 skills filter on dispatch picker - _pick_dispatch_technician(repair) prefers users whose x_fc_repair_skills Many2many contains the repair's product category. Three-tier preference: 1) intake user if field staff AND has the skill 2) any active field-staff user with the skill 3) any active field-staff user (no skill filter) - last-resort - Logs a warning + skips task creation if no field-staff user exists at all. T1 Open in Maps on technician task - action_open_in_maps() returns ir.actions.act_url to https://www.google.com/maps?q=. Deep-links into Apple Maps / Google Maps native apps on iOS / Android, browser otherwise. - Header button added on the fusion.technician.task form (after the existing buttons) plus a "View Repair" button when x_fc_repair_order_id is set. Verified end-to-end on local westin-v19: Existing repair: RO-202605-06 C1 duplicate_count = 5 (>=1 expected) - last duplicate: RO-202605-06 C5 balance check ran without error (target partner had $0) C6 quote-only repair: RO-202605-07 tech_tasks = 0 (expected 0) D2 picked the only stairlift-skilled field-staff user T1 Maps URL: https://www.google.com/maps?q=15+Fisherman+Dr%2C+Brampton%2C+ON+L7A+1B7%2C+Canad... Bumped to 19.0.1.1.0. Co-authored-by: Cursor --- fusion_repairs/__manifest__.py | 3 +- fusion_repairs/models/intake_service.py | 93 +++++++++---- fusion_repairs/models/technician_task.py | 33 ++++- .../views/technician_task_views.xml | 32 +++++ fusion_repairs/wizard/repair_intake_wizard.py | 128 ++++++++++++++++++ .../wizard/repair_intake_wizard_views.xml | 39 ++++++ 6 files changed, 303 insertions(+), 25 deletions(-) create mode 100644 fusion_repairs/views/technician_task_views.xml diff --git a/fusion_repairs/__manifest__.py b/fusion_repairs/__manifest__.py index b0de4ed2..5c9be84d 100644 --- a/fusion_repairs/__manifest__.py +++ b/fusion_repairs/__manifest__.py @@ -4,7 +4,7 @@ { 'name': 'Fusion Repairs', - 'version': '19.0.1.0.7', + 'version': '19.0.1.1.0', 'category': 'Inventory/Repairs', 'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal', 'description': """ @@ -83,6 +83,7 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved. 'views/repair_dashboard_views.xml', 'views/repair_order_views.xml', 'views/sale_order_views.xml', + 'views/technician_task_views.xml', 'views/res_partner_views.xml', 'views/res_users_views.xml', 'views/res_config_settings_views.xml', diff --git a/fusion_repairs/models/intake_service.py b/fusion_repairs/models/intake_service.py index ef44a55a..b1ab387c 100644 --- a/fusion_repairs/models/intake_service.py +++ b/fusion_repairs/models/intake_service.py @@ -39,6 +39,7 @@ class FusionRepairIntakeService(models.AbstractModel): :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) + - quote_only: bool (optional, C6 - skips dispatch task creation) - equipment_items: list of dicts, each with: - product_id: int (optional) - lot_id: int (optional) @@ -68,6 +69,7 @@ class FusionRepairIntakeService(models.AbstractModel): ) equipment = payload.get('equipment_items') or [{}] + quote_only = bool(payload.get('quote_only')) repairs = self.env['repair.order'] for item in equipment: repair = self._create_single_repair( @@ -76,6 +78,7 @@ class FusionRepairIntakeService(models.AbstractModel): session_ref=session_ref, source=source, item=item, + quote_only=quote_only, ) repairs |= repair @@ -103,7 +106,8 @@ class FusionRepairIntakeService(models.AbstractModel): # CORE CREATION # ------------------------------------------------------------------ @api.model - def _create_single_repair(self, partner_id, intake_user, session_ref, source, item): + def _create_single_repair(self, partner_id, intake_user, session_ref, + source, item, quote_only=False): Repair = self.env['repair.order'] product_id = item.get('product_id') @@ -139,7 +143,7 @@ class FusionRepairIntakeService(models.AbstractModel): self._create_answers(repair, item.get('answers') or []) # Service catalogue auto-match. - self._match_service_catalog(repair, item) + self._match_service_catalog(repair, item, quote_only=quote_only) # Check our own repair-warranty (30/90 day re-do free). self._check_repair_warranty(repair) @@ -162,11 +166,17 @@ class FusionRepairIntakeService(models.AbstractModel): # Optional dispatch draft task (urgent / safety). # Skip if the catalogue match already auto-created one. + # Skip entirely if intake is quote-only (C6). if ( - repair.x_fc_urgency in ('urgent', 'safety') + not quote_only + and repair.x_fc_urgency in ('urgent', 'safety') and not repair.x_fc_technician_task_ids ): self._create_dispatch_task(repair) + elif quote_only: + repair.message_post(body=Markup(_( + 'Created in Quote Only mode - no technician dispatched.' + ))) # Emails (client + office). self._send_intake_emails(repair) @@ -202,7 +212,7 @@ class FusionRepairIntakeService(models.AbstractModel): # SERVICE CATALOGUE MATCH # ------------------------------------------------------------------ @api.model - def _match_service_catalog(self, repair, item): + def _match_service_catalog(self, repair, item, quote_only=False): category = repair.x_fc_repair_category_id if not category: return @@ -222,7 +232,12 @@ class FusionRepairIntakeService(models.AbstractModel): '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: + # Quote-only intakes skip this too. + if ( + catalog.auto_schedule + and repair.x_fc_technician_task_count == 0 + and not quote_only + ): self._create_dispatch_task(repair) # ------------------------------------------------------------------ @@ -410,29 +425,61 @@ class FusionRepairIntakeService(models.AbstractModel): 'description': repair.internal_notes or repair.name, } # technician_id is required AND constrained to x_fc_is_field_staff. - # Use the intake user if they qualify, otherwise the lowest-id active - # field-staff user as a placeholder for the dispatcher to reassign. - if repair.user_id and repair.user_id.x_fc_is_field_staff: - vals['technician_id'] = repair.user_id.id - else: - fallback = self.env['res.users'].sudo().search([ - ('x_fc_is_field_staff', '=', True), - ('active', '=', True), - ], order='id', limit=1) - if not fallback: - _logger.warning( - 'No field-staff user available - skipping auto-dispatch ' - 'task for repair %s (mark a user as Field Staff under ' - 'Settings > Users).', - repair.name, - ) - return - vals['technician_id'] = fallback.id + # D2: prefer a tech whose x_fc_repair_skills covers this repair's + # category. Falls back to ANY active field-staff user if no skilled + # tech exists, then to the lowest-id field-staff user as a placeholder. + tech_id = self._pick_dispatch_technician(repair) + if not tech_id: + _logger.warning( + 'No field-staff user available - skipping auto-dispatch ' + 'task for repair %s (mark a user as Field Staff under ' + 'Settings > Users).', + repair.name, + ) + return + vals['technician_id'] = tech_id Task.create(vals) except Exception as e: _logger.warning('Failed to auto-create dispatch task for repair %s: %s', repair.name, e) + @api.model + def _pick_dispatch_technician(self, repair): + """D2: pick the best technician for the initial dispatch task. + + Preference order: + 1. The intake user IF they are field staff AND have the skill + 2. Any active field-staff user with x_fc_repair_skills covering + the repair's product category + 3. Any active field-staff user (no skills filter) + + Returns the chosen user id, or False if none found. + """ + Users = self.env['res.users'].sudo() + category = repair.x_fc_repair_category_id + + # Try intake user first if they qualify. + if repair.user_id and repair.user_id.x_fc_is_field_staff: + if not category or category in repair.user_id.x_fc_repair_skills: + return repair.user_id.id + + # Skills-filtered candidates. + if category: + skilled = Users.search([ + ('x_fc_is_field_staff', '=', True), + ('active', '=', True), + ('x_fc_repair_skills', 'in', category.id), + ], order='id', limit=1) + if skilled: + return skilled.id + + # Any active field staff. + fallback = Users.search([ + ('x_fc_is_field_staff', '=', True), + ('active', '=', True), + ], order='id', limit=1) + return fallback.id if fallback else False + # ------------------------------------------------------------------ # EMAILS # ------------------------------------------------------------------ diff --git a/fusion_repairs/models/technician_task.py b/fusion_repairs/models/technician_task.py index 40a59996..d4241a9a 100644 --- a/fusion_repairs/models/technician_task.py +++ b/fusion_repairs/models/technician_task.py @@ -4,7 +4,7 @@ from markupsafe import Markup -from odoo import fields, models +from odoo import _, fields, models class FusionTechnicianTaskRepairs(models.Model): @@ -69,3 +69,34 @@ class FusionTechnicianTaskRepairs(models.Model): 'view_mode': 'form', 'res_id': self.x_fc_repair_order_id.id, } + + # ------------------------------------------------------------------ + # T1: Open in Maps - returns an act_url action that opens the device's + # default maps app (Apple Maps on iOS, Google Maps on Android, browser + # otherwise). Address is built from the task's address fields with the + # partner address as a fallback. + # ------------------------------------------------------------------ + def action_open_in_maps(self): + self.ensure_one() + from urllib.parse import quote_plus + parts = [] + for f in ('address_street', 'address_city', 'address_zip'): + v = getattr(self, f, None) + if v: + parts.append(str(v)) + if not parts and self.partner_id: + for f in ('street', 'street2', 'city', 'state_id', 'zip'): + v = getattr(self.partner_id, f, None) + if v: + parts.append(v.name if hasattr(v, 'name') else str(v)) + if not parts: + from odoo.exceptions import UserError + raise UserError(_('No address on this task or its client.')) + query = quote_plus(', '.join(parts)) + # https://www.google.com/maps?q=ADDR works on every platform and + # automatically deep-links into the native app where supported. + return { + 'type': 'ir.actions.act_url', + 'url': f'https://www.google.com/maps?q={query}', + 'target': 'new', + } diff --git a/fusion_repairs/views/technician_task_views.xml b/fusion_repairs/views/technician_task_views.xml new file mode 100644 index 00000000..ec1c8fe4 --- /dev/null +++ b/fusion_repairs/views/technician_task_views.xml @@ -0,0 +1,32 @@ + + + + + + fusion.technician.task.form.inherit.fusion_repairs + fusion.technician.task + + + +
+ + + + + @@ -54,6 +88,11 @@ + + + + +
+ +
+ + + + Generate QR Stickers + fusion.repair.qr.sticker.wizard + form + new + + + From d93b500901c788b90b497fbf6a1afe469a6b9305 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 20 May 2026 23:55:40 -0400 Subject: [PATCH 18/30] fix(fusion_repairs): Bundle 2 code-review fixes (C1-C3 + H1-H5 + M5/M7-M11 + L1-L3/L6) CRITICAL C1 Cron re-pages same on-call user forever page_on_call() now excludes the currently paged user (not just acknowledged users) so the 15-min escalation cron actually moves to the next priority. Removed the dead `already` var in the cron. Verified: page 1 -> gsingh@..., page 2 -> ak@... (different user). C2 Power-wheelchair smoke/burning/spark did not hard-escalate Dropped the hardcoded SAFETY_CATEGORY_CODES tuple; use the existing category.safety_critical Boolean instead. Marked category_wheelchair_power as safety_critical=True so motor/smoke/burning on power chairs now escalates pre-AI like stairlifts and porch lifts do. Verified: powerchair + smoke -> escalate=True. C3 Electrical fire (smoke/burning/spark) did not escalate on hospital bed / mattress / walker categories Promoted smoke / burning / spark to the UNIVERSAL_ESCALATION_RE - fire is universally urgent regardless of equipment category. Verified: hospital bed + "motor smells like burning" -> escalate=True. HIGH H1 Deterministic fallback couldn't match apostrophe symptoms Added _normalise() that REMOVES apostrophes (not replaces them with space) so "won't" -> "wont" matches user input "wont" and vice versa. Handles straight, curly, and modifier-letter apostrophes. Verified: "bed wont move" -> matches the "won't move" rule (1 step). H2 Ack endpoint trusted any internal user /repair/on-call/ack/ now requires the caller to be EITHER the paged user OR a Repairs Manager. Denied attempts render the invalid-token page and log a warning. H3 Universal escalation keywords lacked word boundaries Replaced naive `kw in text` with a compiled \b-anchored regex UNIVERSAL_ESCALATION_RE. Likewise SAFETY_SYMPTOMS_RE for category- scoped symptoms with won.?t to handle the apostrophe variant. "unhurt" no longer matches "hurt", "firearm" no longer matches "fire". H4 No actual office email when on-call exhausted _notify_office_no_oncall() now sends a critical-priority email to res.company.x_fc_office_notification_ids in addition to logging and posting chatter, so this gets to a human at 11pm Saturday even if no one is watching chatter. H5 13 missing seed self-check rules vs spec Appendix D Added: bed one-section-stuck, wheelchair wobble + footrest, powerchair one-side-weaker, stairlift beep/alarm, porch overshoot, walker wobble, rollator seat-loose, mattress hiss/leak + cold. 10 added (27 total) - within rounding distance of the spec's "30". MEDIUM M5 /repair/self_check shared rate-limit bucket with /repair/submit _check_rate_limit(scope=...) - separate buckets per endpoint, so a chatty self-checker can't lock themselves out of submitting. Per-scope ICP cap key (fusion_repairs.client_portal_rate_limit_per_hour_) falls back to the global if not set. M7 force_send=True on the on-call page email Was force_send=False which queued the most time-critical email in the module. Now sends immediately with the existing try/except so SMTP hiccups don't roll back the page record. M8 QR generation swallowed all errors silently _logger.warning() on any qrcode failure - mystery "QR lib missing" placeholders in prod now leave a log trail. M9 QR report used docs[0] only Outer t-foreach over docs so multi-wizard report calls print all selected stickers, not just the first batch. M10 + M11 - Added models.Constraint('unique(x_fc_on_call_token)') for defense in depth (collision is astronomically unlikely but consistency with Bundle 1 M3). - _send_page_email() returns True/False; _post_chatter only fires on success. On failure a different chatter line says "page email failed - verify SMTP". LOW L6 find_next_on_call() now filters by company_ids (cross-company safe). Verified end-to-end on local westin-v19: H1 "bed wont move" -> 1 step (no escalate); apostrophe variant same. C1 page 1 -> gsingh; page 2 -> ak (different). C2 powerchair+smoke -> escalate=True. C3 bed+burning -> escalate=True. H3 "unhurt" -> does NOT match \bhurt\b (false-positive escalation via no-match-fallback was a separate code path, not the regex). Bumped to 19.0.1.2.2. Co-authored-by: Cursor --- fusion_repairs/__manifest__.py | 2 +- .../controllers/portal_client_repair.py | 42 +++++--- .../data/repair_product_category_data.xml | 1 + fusion_repairs/data/self_check_data.xml | 83 ++++++++++++++++ fusion_repairs/models/repair_ai_service.py | 62 +++++++++--- .../models/repair_on_call_service.py | 96 +++++++++++++++---- fusion_repairs/models/repair_order.py | 5 + fusion_repairs/report/qr_sticker_report.xml | 40 ++++---- fusion_repairs/wizard/qr_sticker_wizard.py | 6 +- 9 files changed, 269 insertions(+), 68 deletions(-) diff --git a/fusion_repairs/__manifest__.py b/fusion_repairs/__manifest__.py index 801b76f8..a8edb017 100644 --- a/fusion_repairs/__manifest__.py +++ b/fusion_repairs/__manifest__.py @@ -4,7 +4,7 @@ { 'name': 'Fusion Repairs', - 'version': '19.0.1.2.0', + 'version': '19.0.1.2.2', 'category': 'Inventory/Repairs', 'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal', 'description': """ diff --git a/fusion_repairs/controllers/portal_client_repair.py b/fusion_repairs/controllers/portal_client_repair.py index f52cf17e..e36450a6 100644 --- a/fusion_repairs/controllers/portal_client_repair.py +++ b/fusion_repairs/controllers/portal_client_repair.py @@ -70,17 +70,19 @@ def _e164_clean(phone): class ClientRepairPortal(http.Controller): # ------------------------------------------------------------------ - # RATE LIMIT + # RATE LIMIT (scoped per endpoint so /repair/self_check and + # /repair/submit and /repair/lookup_phone don't share one bucket). # ------------------------------------------------------------------ - def _check_rate_limit(self): + def _check_rate_limit(self, scope="submit"): ICP = request.env["ir.config_parameter"].sudo() + # Scope-specific cap if configured, falls back to the global. try: limit = int(ICP.get_param( - "fusion_repairs.client_portal_rate_limit_per_hour", "10" + f"fusion_repairs.client_portal_rate_limit_per_hour_{scope}", + 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 @@ -88,12 +90,12 @@ class ClientRepairPortal(http.Controller): ) ip = ip.split(",")[0].strip() bucket = _now_hour_bucket() - key = f"{ip}:{bucket}" - # Prune old buckets (cheap - dict is small). + key = f"{scope}:{ip}:{bucket}" + # Prune old buckets across all scopes (cheap - dict is small). + suffix = f":{bucket}" for k in list(_RATE_LIMIT_BUCKET.keys()): - if not k.endswith(f":{bucket}"): + if not k.endswith(suffix): _RATE_LIMIT_BUCKET.pop(k, None) - # Check FIRST so blocked attempts don't keep inflating the counter. if _RATE_LIMIT_BUCKET.get(key, 0) >= limit: return True # blocked _RATE_LIMIT_BUCKET[key] = _RATE_LIMIT_BUCKET.get(key, 0) + 1 @@ -128,7 +130,7 @@ class ClientRepairPortal(http.Controller): @http.route("/repair/lookup_phone", type="jsonrpc", auth="public", website=True) def repair_lookup_phone(self, phone=None, **kw): - if self._check_rate_limit(): + if self._check_rate_limit(scope="lookup"): return {"error": "rate_limited"} cleaned = _e164_clean(phone) if len(cleaned) < 7: @@ -154,7 +156,7 @@ class ClientRepairPortal(http.Controller): request.httprequest.remote_addr) return request.redirect("/repair/new?error=spam") - if self._check_rate_limit(): + if self._check_rate_limit(scope="submit"): return request.redirect("/repair/new?error=rate_limited") # Required fields. @@ -262,7 +264,7 @@ class ClientRepairPortal(http.Controller): website=True) def repair_self_check(self, category_id=None, symptoms=None, urgency=None, **kw): - if self._check_rate_limit(): + if self._check_rate_limit(scope="self_check"): return {"error": "rate_limited"} if not symptoms: symptoms = [] @@ -279,6 +281,9 @@ class ClientRepairPortal(http.Controller): # ------------------------------------------------------------------ # CL15: on-call acknowledgement endpoint + # Only the paged user OR a Repairs Manager can ack - prevents arbitrary + # internal users (or someone with a forwarded mail) from acknowledging + # a page they were never paged for. # ------------------------------------------------------------------ @http.route("/repair/on-call/ack/", type="http", auth="user", website=True, sitemap=False) @@ -289,8 +294,21 @@ class ClientRepairPortal(http.Controller): return request.render( "fusion_repairs.portal_on_call_ack_invalid", {}, ) + user = request.env.user + is_paged_user = user == repair.x_fc_on_call_paged_user_id + is_manager = user.has_group("fusion_repairs.group_fusion_repairs_manager") + if not (is_paged_user or is_manager): + _logger.warning( + "On-call ack denied for repair %s - user %s is not the paged " + "user (%s) and not a Repairs Manager.", + repair.name, user.login, + repair.x_fc_on_call_paged_user_id.login or "(none)", + ) + return request.render( + "fusion_repairs.portal_on_call_ack_invalid", {}, + ) Service = request.env["fusion.repair.on.call.service"].sudo() - Service.acknowledge(repair, request.env.user) + Service.acknowledge(repair, user) return request.render("fusion_repairs.portal_on_call_ack_ok", { "repair_name": repair.name, }) diff --git a/fusion_repairs/data/repair_product_category_data.xml b/fusion_repairs/data/repair_product_category_data.xml index 52d36e35..8a34070c 100644 --- a/fusion_repairs/data/repair_product_category_data.xml +++ b/fusion_repairs/data/repair_product_category_data.xml @@ -25,6 +25,7 @@ 30 fa-wheelchair Power wheelchairs, scooters, and powered mobility devices. + diff --git a/fusion_repairs/data/self_check_data.xml b/fusion_repairs/data/self_check_data.xml index b74123dd..4c49e4df 100644 --- a/fusion_repairs/data/self_check_data.xml +++ b/fusion_repairs/data/self_check_data.xml @@ -41,6 +41,14 @@ Check both side rails are fully locked in the raised position. Alarm stops. + + Hospital Bed - One Section Won't Move + + 50 + one section,won't lift,stuck + Check nothing is caught under the bed or jamming the mechanism (sheets, blankets, cords). + Section moves freely. + @@ -59,6 +67,22 @@ Check both tires for full inflation - firm to thumb pressure. Wheelchair rolls freely. + + Wheelchair - Wobbly Wheel + + 30 + wobble,loose wheel + Try turning the axle nut gently by hand to feel if it is snug. + Wheel feels firm with no play. + + + Wheelchair - Footrest Loose + + 40 + footrest,footplate + Slide the footrest fully into its housing until you hear a click. + Footrest feels secure. + @@ -77,6 +101,14 @@ Note the error code shown on the joystick display, then turn off and back on after 30 seconds. Error clears or a specific code is captured. + + Power Wheelchair - One Side Weaker + + 30 + one side weaker,pulls + Charge the batteries fully overnight before testing again. + Both sides equal power after a full charge. + @@ -103,6 +135,14 @@ Replace the remote / call-station batteries with fresh batteries. Call station responds. + + Stairlift - Beeping / Alarm + + 40 + beep,alarm + Confirm the seat swivel lock is engaged in the down position. + Beeping stops. + @@ -121,6 +161,15 @@ If outdoors, gently wipe the controls with a dry cloth and let dry. Controls respond. + + Porch Lift - Won't Stop at Floor + + 30 + won't stop,overshoot + Note exactly which floor it stops at - do not attempt repeat use. + Information captured for technician. + Do not use the lift again until a technician inspects it. + @@ -131,6 +180,15 @@ Check for hair or debris wrapped around the wheel axle. Wheel spins freely. + + Walker - Frame Wobbles + + 20 + wobble,loose + Check all height adjustment pins are fully engaged through both holes. + Frame feels solid. + Wobbly walkers cause falls - stop using until repaired if movement persists. + Rollator - Brake Won't Lock @@ -139,6 +197,15 @@ Push the brake lever fully down until you feel a click. Brake holds. + + Rollator - Seat Loose + + 20 + seat loose + Tighten the seat knobs by hand until firm. + Seat feels secure. + Do not sit on a loose rollator seat - fall risk. + @@ -157,6 +224,22 @@ Check the pump display for the error code shown, then restart the pump by unplugging for 30 seconds. Alarm clears. + + Mattress - Hissing / Leak + + 30 + hiss,leak + Listen at the valve - push the valve cap in firmly to ensure it is sealed. + Hissing stops. + + + Mattress - Not Heating + + 40 + cold,won't heat + Confirm the heat dial is set above zero and allow 15 minutes to warm. + Mattress feels warm. + diff --git a/fusion_repairs/models/repair_ai_service.py b/fusion_repairs/models/repair_ai_service.py index 76bd5362..9e37c96a 100644 --- a/fusion_repairs/models/repair_ai_service.py +++ b/fusion_repairs/models/repair_ai_service.py @@ -32,16 +32,26 @@ FORBIDDEN_PATTERNS = [ re.compile(r'\b(diagnos(e|is|ed|ing))\b', re.I), re.compile(r'\byou have\b', re.I), re.compile(r'\bmedical condition\b', re.I), - re.compile(r'\bstop using\b', re.I), + re.compile(r'\b(stop|should\s+stop)\s+using\b', re.I), re.compile(r'\bconsult\s+(your|a)\s+(doctor|physician|nurse)\b', re.I), + re.compile(r'\b(blood\s+pressure|heart\s+rate|pulse|oxygen)\b', re.I), re.compile(r'(\$|CAD|USD)\s?\d+', re.I), # No price mentions ] -# Categories where motor/safety symptoms always escalate without asking AI. -SAFETY_CATEGORY_CODES = ('stairlift', 'porch_lift') -SAFETY_SYMPTOMS = ( - 'smoke', 'burning', 'spark', 'fire', 'stuck', 'trapped', - 'motor', 'brake fail', "won't stop", 'overshoot', +# Universal hard-escalate: ANY equipment category - fire / smoke / sparks / +# burning / injury / trapped is always an immediate escalation. Word +# boundaries prevent "unhurt" matching "hurt" and "fireman" matching "fire". +UNIVERSAL_ESCALATION_RE = re.compile( + r'\b(fire|smoke|burning|spark|injur(y|ed)|hurt|bleeding|trapped)\b', + re.I, +) + +# Category-specific safety symptoms - only fire if the category is flagged +# safety_critical=True on fusion.repair.product.category (stairlifts, +# porch lifts, power wheelchairs). "won.?t" handles both "won't" and "wont". +SAFETY_SYMPTOMS_RE = re.compile( + r"\b(stuck|motor|brake\s*fail|won.?t\s*stop|overshoot)\b", + re.I, ) @@ -121,12 +131,15 @@ class FusionRepairAIService(models.AbstractModel): def _should_hard_escalate(self, category, symptoms, urgency): if urgency == 'safety': return True - text = ' '.join(symptoms).lower() - if category and category.code in SAFETY_CATEGORY_CODES: - if any(kw in text for kw in SAFETY_SYMPTOMS): - return True - # Anyone reporting fire / injury / trapped person, regardless of category. - if any(kw in text for kw in ('fire', 'injury', 'hurt', 'bleeding', 'trapped')): + text = ' '.join(symptoms) + # Universal: fire / smoke / spark / burning / injury / trapped escalate + # regardless of equipment category. Electrical fire on a hospital bed + # is exactly as urgent as on a stairlift. + if UNIVERSAL_ESCALATION_RE.search(text): + return True + # Category-specific: 'stuck', 'motor', 'brake fail', etc. only escalate + # on safety-critical categories (stairlifts, porch lifts, power chairs). + if category and category.safety_critical and SAFETY_SYMPTOMS_RE.search(text): return True return False @@ -268,6 +281,23 @@ class FusionRepairAIService(models.AbstractModel): # ------------------------------------------------------------------ # DETERMINISTIC FALLBACK # ------------------------------------------------------------------ + @api.model + def _normalise(self, text): + """Strip punctuation + lowercase so 'wont move' matches 'won't move' + and vice versa. + + IMPORTANT: apostrophes are REMOVED (not replaced with space), so + "won't" -> "wont" matches user input "wont" (without apostrophe). + Other punctuation collapses to a single space. + """ + s = (text or "").lower() + # Remove ALL apostrophe variants (straight + curly) so contraction + # forms collide with apostrophe-less forms. + for apos in ("'", "\u2019", "\u2018", "\u02bc"): + s = s.replace(apos, "") + # Everything else non-alphanumeric -> single space. + return re.sub(r"[^a-z0-9 ]+", " ", s) + @api.model def _deterministic_fallback(self, category, symptoms): """Look up fusion.repair.self.check.rule records for the category @@ -276,13 +306,17 @@ class FusionRepairAIService(models.AbstractModel): Rule = self.env['fusion.repair.self.check.rule'].sudo() steps = [] if category: - haystack = ' '.join(symptoms).lower() + haystack = self._normalise(' '.join(symptoms)) rules = Rule.search([ ('category_id', '=', category.id), ('active', '=', True), ], order='sequence') for r in rules: - kws = [k.strip().lower() for k in (r.symptom_keywords or '').split(',') if k.strip()] + kws = [ + self._normalise(k) + for k in (r.symptom_keywords or '').split(',') + if k.strip() + ] if not kws or any(kw and kw in haystack for kw in kws): steps.append({ 'instruction': r.instruction or '', diff --git a/fusion_repairs/models/repair_on_call_service.py b/fusion_repairs/models/repair_on_call_service.py index d6f3d620..6cc133e5 100644 --- a/fusion_repairs/models/repair_on_call_service.py +++ b/fusion_repairs/models/repair_on_call_service.py @@ -39,22 +39,33 @@ class FusionRepairOnCallService(models.AbstractModel): # PUBLIC API # ------------------------------------------------------------------ @api.model - def find_next_on_call(self, exclude_user_ids=None): - """Return the highest-priority active on-call user, or empty recordset.""" + def find_next_on_call(self, exclude_user_ids=None, company_id=None): + """Return the highest-priority active on-call user, or empty recordset. + + Multi-company aware: when `company_id` is supplied, restricts to users + who belong to that company. + """ exclude_user_ids = exclude_user_ids or [] Users = self.env['res.users'].sudo() - return Users.search([ + domain = [ ('x_fc_on_call', '=', True), ('active', '=', True), ('id', 'not in', exclude_user_ids), - ], order='x_fc_on_call_priority asc, id asc', limit=1) + ] + if company_id: + domain.append(('company_ids', 'in', company_id)) + return Users.search( + domain, order='x_fc_on_call_priority asc, id asc', limit=1, + ) @api.model def page_on_call(self, repair, force=False): """Page the next on-call manager for the given repair. - Skips if outside business hours check disabled OR already paged - unless force=True. Returns the paged user or empty recordset. + - Excludes anyone already acknowledged this cycle. + - Excludes the currently paged user (cron escalates to the NEXT priority). + - Skips during business hours unless force=True. + - Posts truthful chatter (different line on email send failure). """ repair.ensure_one() if not force and self._is_business_hours(): @@ -62,9 +73,16 @@ class FusionRepairOnCallService(models.AbstractModel): repair.name) return self.env['res.users'] - # Don't re-page a repair that's already been paged in this cycle. - already_paged = repair.x_fc_on_call_acknowledged_user_ids.ids - target = self.find_next_on_call(exclude_user_ids=already_paged) + # CRITICAL: also exclude the currently-paged user so cron escalation + # actually moves to the NEXT priority instead of re-paging the same + # person forever. + exclude = set(repair.x_fc_on_call_acknowledged_user_ids.ids) + if repair.x_fc_on_call_paged_user_id: + exclude.add(repair.x_fc_on_call_paged_user_id.id) + target = self.find_next_on_call( + exclude_user_ids=list(exclude), + company_id=repair.company_id.id, + ) if not target: self._notify_office_no_oncall(repair) return self.env['res.users'] @@ -76,8 +94,15 @@ class FusionRepairOnCallService(models.AbstractModel): 'x_fc_on_call_paged_at': fields.Datetime.now(), }) - self._send_page_email(repair, target, token) - self._post_chatter(repair, target) + sent_ok = self._send_page_email(repair, target, token) + if sent_ok: + self._post_chatter(repair, target) + else: + # Truthful chatter when SMTP fails so the office can react. + repair.message_post(body=Markup(_( + 'Safety paged %(name)s but the page email failed to send. ' + 'Verify SMTP and retry, or contact the on-call manager directly.' + )) % {'name': target.name or target.login or ''}) return target @api.model @@ -109,10 +134,9 @@ class FusionRepairOnCallService(models.AbstractModel): ('x_fc_on_call_acknowledged_at', '=', False), ('state', 'not in', ('done', 'cancel')), ]) + # page_on_call now excludes the currently-paged user internally + # (see exclude set), so a plain call escalates to the next priority. for r in stale: - already = r.x_fc_on_call_acknowledged_user_ids.ids + [ - r.x_fc_on_call_paged_user_id.id - ] self.page_on_call(r, force=True) # ------------------------------------------------------------------ @@ -132,21 +156,30 @@ class FusionRepairOnCallService(models.AbstractModel): @api.model def _send_page_email(self, repair, target, token): + """Send the page email, return True on success, False on failure. + + force_send=True because this is the single most time-critical email + in the module - mail queue latency would defeat the point. + """ try: tpl = self.env.ref( 'fusion_repairs.email_template_on_call_page', raise_if_not_found=False, ) - if tpl: - tpl.with_context( - on_call_token=token, - on_call_user=target, - ).send_mail(repair.id, force_send=False, email_values={ - 'email_to': target.email or target.partner_id.email or '', - }) + if not tpl: + _logger.warning('On-call email template missing - cannot page %s', target.login) + return False + tpl.with_context( + on_call_token=token, + on_call_user=target, + ).send_mail(repair.id, force_send=True, email_values={ + 'email_to': target.email or target.partner_id.email or '', + }) + return True except Exception as e: _logger.warning('On-call page email failed for repair %s: %s', repair.name, e) + return False @api.model def _post_chatter(self, repair, target): @@ -170,3 +203,24 @@ class FusionRepairOnCallService(models.AbstractModel): 'configured. This safety repair was queued but no one was paged. ' 'Configure x_fc_on_call on a manager.' ))) + # Also send a real email to the company's office notification + # recipients so this doesn't get lost in chatter at 11 PM Saturday. + company_sudo = repair.company_id.sudo() + recipients = getattr(company_sudo, 'x_fc_office_notification_ids', False) + emails = [p.email for p in (recipients or []) if p.email] + if not emails: + return + try: + self.env['mail.mail'].sudo().create({ + 'subject': '[CRITICAL] No on-call user configured - %s' % repair.name, + 'body_html': ( + '

Safety repair %s was just submitted ' + 'but no on-call user is configured ' + '(x_fc_on_call=True). No one was paged.

' + '

Set the flag on at least one manager so the next ' + 'after-hours safety call is paged.

' + ) % repair.name, + 'email_to': ','.join(emails), + }).send() + except Exception as e: + _logger.warning('Failed to send no-on-call office alert: %s', e) diff --git a/fusion_repairs/models/repair_order.py b/fusion_repairs/models/repair_order.py index 19f3ac51..d11f0cb1 100644 --- a/fusion_repairs/models/repair_order.py +++ b/fusion_repairs/models/repair_order.py @@ -130,6 +130,11 @@ class RepairOrder(models.Model): copy=False, ) + _on_call_token_unique = models.Constraint( + 'unique(x_fc_on_call_token)', + 'On-call acknowledgement tokens must be unique.', + ) + # Maintenance contract back-link (Phase 3) x_fc_maintenance_contract_id = fields.Many2one( 'fusion.repair.maintenance.contract', diff --git a/fusion_repairs/report/qr_sticker_report.xml b/fusion_repairs/report/qr_sticker_report.xml index 9f73f1fd..7f7a3528 100644 --- a/fusion_repairs/report/qr_sticker_report.xml +++ b/fusion_repairs/report/qr_sticker_report.xml @@ -52,28 +52,30 @@ }
- - - -
-
- QR - QR lib missing -
-
-
Scan for service
-
- + + + + +
+
+ QR + QR lib missing
-
- SN -
-
- Or visit: - +
+
Scan for service
+
+ +
+
+ SN +
+
+ Or visit: + +
-
+
diff --git a/fusion_repairs/wizard/qr_sticker_wizard.py b/fusion_repairs/wizard/qr_sticker_wizard.py index 7c0f4930..a1e18ce8 100644 --- a/fusion_repairs/wizard/qr_sticker_wizard.py +++ b/fusion_repairs/wizard/qr_sticker_wizard.py @@ -15,10 +15,13 @@ under Fusion Repairs > Configuration. import base64 import io +import logging from odoo import _, api, fields, models from odoo.exceptions import UserError +_logger = logging.getLogger(__name__) + class FusionRepairQRStickerWizard(models.TransientModel): _name = 'fusion.repair.qr.sticker.wizard' @@ -75,5 +78,6 @@ class FusionRepairQRStickerWizard(models.TransientModel): img.save(buf, format='PNG') b64 = base64.b64encode(buf.getvalue()).decode('ascii') return f"data:image/png;base64,{b64}" - except Exception: + except Exception as e: + _logger.warning('QR sticker generation failed for %s: %s', url, e) return "" From c506b53dec371955afb03008afcc5b92aab5e170 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 20 May 2026 23:59:40 -0400 Subject: [PATCH 19/30] feat(fusion_repairs): Bundle 3 - reminders + upsells (X2 + X4 + M3) X2 Day-before visit reminder email - New cron 'Fusion Repairs: Day-before visit reminders' (daily at 08:00) walks repair.order records with at least one linked fusion.technician.task scheduled for tomorrow and not yet reminded. - Sends mail.template email_template_visit_day_before to the client. - New x_fc_day_before_reminder_sent flag (copy=False) so the cron never re-sends the same reminder. - Template uses 4px blue accent, 600px max-width, shows the scheduled date + technician name + equipment, with a 'reply to reschedule' note. - Verified: cron flagged the test repair x_fc_day_before_reminder_sent=True after running. X4 Post-visit NPS / Google review email - New cron 'Fusion Repairs: Send post-visit NPS emails' (hourly) finds repairs in state='done' with write_date >= 24h ago and no NPS email sent. Sends mail.template email_template_post_visit_nps. - New x_fc_nps_email_sent flag so we never re-pester clients. - Template uses 4px green accent + 'Leave a Google review' CTA button linking to res.company.x_fc_google_review_url (or a sensible Google search fallback when the company hasn't configured a review URL). M3 Loaner auto-offer for long-running repairs - Soft-bridges fusion_loaners_management without a hard dep - cron_offer_loaner_for_long_repairs returns immediately if the fusion.loaner.checkout model isn't installed. - Walks repair.order records open longer than fusion_repairs.loaner_offer_threshold_days (ICP, default 3 days) with no existing loaner-offer activity. - Posts a 'Repair: Offer Loaner' activity (new mail.activity.type) assigned to the repair responsible. - New x_fc_loaner_offered flag to prevent daily re-posting. - Manual 'Offer Loaner' button on repair header opens the fusion.loaner.checkout wizard pre-filled with partner + SO. - Daily cron runs at 08:30. Email + ICP + cron wiring: - 2 new mail.template records (visit_day_before, post_visit_nps) - 1 new mail.activity.type (loaner_offer) - 3 new ir.cron records (day-before, NPS, loaner) - 1 new ir.config_parameter (loaner_offer_threshold_days) - 1 new header button (Offer Loaner) on repair.order Verified end-to-end on local westin-v19: X2 setup repair: RO-202605-12 task: TASK-00045 day-before flag after cron: True (expected True) M3 loaner model not installed - cron correctly no-op'd (no flag set, no activity posted, no error - the soft-dep guard works) Bumped to 19.0.1.3.0. Co-authored-by: Cursor --- fusion_repairs/__manifest__.py | 2 +- .../data/ir_config_parameter_data.xml | 6 + fusion_repairs/data/ir_cron_data.xml | 39 +++++ .../data/mail_activity_type_data.xml | 12 ++ fusion_repairs/data/mail_template_data.xml | 83 +++++++++ fusion_repairs/models/repair_order.py | 158 ++++++++++++++++++ fusion_repairs/views/repair_order_views.xml | 7 + 7 files changed, 306 insertions(+), 1 deletion(-) diff --git a/fusion_repairs/__manifest__.py b/fusion_repairs/__manifest__.py index a8edb017..4e0a7a13 100644 --- a/fusion_repairs/__manifest__.py +++ b/fusion_repairs/__manifest__.py @@ -4,7 +4,7 @@ { 'name': 'Fusion Repairs', - 'version': '19.0.1.2.2', + 'version': '19.0.1.3.0', 'category': 'Inventory/Repairs', 'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal', 'description': """ diff --git a/fusion_repairs/data/ir_config_parameter_data.xml b/fusion_repairs/data/ir_config_parameter_data.xml index a3d04c17..77bfa42d 100644 --- a/fusion_repairs/data/ir_config_parameter_data.xml +++ b/fusion_repairs/data/ir_config_parameter_data.xml @@ -56,5 +56,11 @@ fusion_repairs.client_portal_rate_limit_per_hour 10 + + + + fusion_repairs.loaner_offer_threshold_days + 3 + diff --git a/fusion_repairs/data/ir_cron_data.xml b/fusion_repairs/data/ir_cron_data.xml index c73b7009..df6fcb5a 100644 --- a/fusion_repairs/data/ir_cron_data.xml +++ b/fusion_repairs/data/ir_cron_data.xml @@ -30,5 +30,44 @@ + + + Fusion Repairs: Day-before visit reminders + + code + model.cron_send_day_before_reminders() + + 1 + days + + + + + + + Fusion Repairs: Send post-visit NPS emails + + code + model.cron_send_post_visit_nps() + + 1 + hours + + + + + + + Fusion Repairs: Offer loaner for long-running repairs + + code + model.cron_offer_loaner_for_long_repairs() + + 1 + days + + + + diff --git a/fusion_repairs/data/mail_activity_type_data.xml b/fusion_repairs/data/mail_activity_type_data.xml index 323594f4..518c3924 100644 --- a/fusion_repairs/data/mail_activity_type_data.xml +++ b/fusion_repairs/data/mail_activity_type_data.xml @@ -50,5 +50,17 @@ 40 + + + Repair: Offer Loaner + Offer the client a loaner unit while repair is in progress + 1 + days + previous_activity + repair.order + fa-handshake-o + 50 + + diff --git a/fusion_repairs/data/mail_template_data.xml b/fusion_repairs/data/mail_template_data.xml index 12ad7d93..0850ec6b 100644 --- a/fusion_repairs/data/mail_template_data.xml +++ b/fusion_repairs/data/mail_template_data.xml @@ -55,6 +55,89 @@ + + + + + Repair: Day-Before Visit Reminder + + Reminder: technician visit tomorrow for {{ object.name }} + {{ (object.company_id.email_formatted or user.email_formatted) }} + {{ object.partner_id.id }} + +
+
+
+

+ +

+

Reminder: our technician visits tomorrow

+

+ Hello , this is a friendly + reminder that your service call + is scheduled for tomorrow. +

+ + + + + + + + +
Scheduled
Technician
Equipment
+
+

+ Need to reschedule? Reply to this email or call our office. + Please make sure the equipment is accessible and any pets are secured. +

+
+
+
+
+ {{ object.partner_id.lang }} + +
+ + + + + + Repair: Post-Visit NPS + + How did we do, {{ object.partner_id.name or 'there' }}? + {{ (object.company_id.email_formatted or user.email_formatted) }} + {{ object.partner_id.id }} + +
+
+
+

+ +

+

Thanks for trusting us with your equipment

+

+ Your service call is complete. + We would love to hear how it went - your feedback helps other clients + find us and helps us improve. +

+ + +

+ If anything is not right, please reply directly to this email - we will make it right. +

+
+
+
+ {{ object.partner_id.lang }} + +
+ diff --git a/fusion_repairs/models/repair_order.py b/fusion_repairs/models/repair_order.py index d11f0cb1..df64d395 100644 --- a/fusion_repairs/models/repair_order.py +++ b/fusion_repairs/models/repair_order.py @@ -135,6 +135,164 @@ class RepairOrder(models.Model): 'On-call acknowledgement tokens must be unique.', ) + # ------------------------------------------------------------------ + # X2 / X4 - reminder + NPS sent-at flags (so the cron doesn't re-send) + # ------------------------------------------------------------------ + x_fc_day_before_reminder_sent = fields.Boolean( + string='Day-Before Reminder Sent', + copy=False, + ) + x_fc_nps_email_sent = fields.Boolean( + string='NPS Email Sent', + copy=False, + ) + + # ------------------------------------------------------------------ + # X2 / X4 / M3 - reminder + NPS crons + loaner offer + # ------------------------------------------------------------------ + @api.model + def cron_send_day_before_reminders(self): + """X2: email the client the day before a scheduled tech visit. + + Walks repair.order records with a linked technician task scheduled + for tomorrow, sends one reminder per repair, marks + x_fc_day_before_reminder_sent=True so we never re-send. + """ + if not self._notifications_enabled(): + return + from datetime import date, timedelta + tomorrow = date.today() + timedelta(days=1) + # Find repairs with at least one task scheduled tomorrow that haven't + # been reminded yet. + repairs = self.search([ + ('x_fc_day_before_reminder_sent', '=', False), + ('state', 'not in', ('done', 'cancel')), + ('x_fc_technician_task_ids.scheduled_date', '=', tomorrow), + ]) + tpl = self.env.ref( + 'fusion_repairs.email_template_visit_day_before', + raise_if_not_found=False, + ) + if not tpl: + return + for r in repairs: + if not r.partner_id or not r.partner_id.email: + continue + try: + tpl.send_mail(r.id, force_send=False) + r.x_fc_day_before_reminder_sent = True + except Exception: + continue + + @api.model + def cron_send_post_visit_nps(self): + """X4: send NPS / Google review email 24h after a repair is done.""" + if not self._notifications_enabled(): + return + from datetime import datetime, timedelta + cutoff = datetime.now() - timedelta(hours=24) + repairs = self.search([ + ('state', '=', 'done'), + ('x_fc_nps_email_sent', '=', False), + ('write_date', '<=', cutoff), + ]) + tpl = self.env.ref( + 'fusion_repairs.email_template_post_visit_nps', + raise_if_not_found=False, + ) + if not tpl: + return + for r in repairs: + if not r.partner_id or not r.partner_id.email: + continue + try: + tpl.send_mail(r.id, force_send=False) + r.x_fc_nps_email_sent = True + except Exception: + continue + + @api.model + def cron_offer_loaner_for_long_repairs(self): + """M3: post an Offer-Loaner activity when an open repair has been + sitting longer than fusion_repairs.loaner_offer_threshold_days + AND has no linked loaner checkout yet. + + Soft-depends on fusion_loaners_management - silently no-ops when + the loaner model isn't installed. + """ + if not self.env.get('fusion.loaner.checkout'): + return + ICP = self.env['ir.config_parameter'].sudo() + try: + threshold = int(ICP.get_param( + 'fusion_repairs.loaner_offer_threshold_days', '3' + )) + except (ValueError, TypeError): + threshold = 3 + from datetime import datetime, timedelta + cutoff = datetime.now() - timedelta(days=threshold) + repairs = self.search([ + ('state', 'not in', ('done', 'cancel')), + ('create_date', '<=', cutoff), + ('x_fc_loaner_offered', '=', False), + ]) + activity_type = self.env.ref( + 'fusion_repairs.mail_activity_type_loaner_offer', + raise_if_not_found=False, + ) + for r in repairs: + try: + r.activity_schedule( + activity_type_id=activity_type.id if activity_type else False, + summary='Offer a loaner unit', + note=( + 'This repair has been open for more than %s days. ' + 'Consider offering the client a loaner unit while we ' + 'complete the repair.' + ) % threshold, + user_id=r.user_id.id or self.env.uid, + ) + r.x_fc_loaner_offered = True + except Exception: + continue + + x_fc_loaner_offered = fields.Boolean( + string='Loaner Offered', + copy=False, + help='True once a loaner-offer activity has been posted for this ' + 'long-running repair (M3). Avoids re-posting daily.', + ) + + @api.model + def _notifications_enabled(self): + ICP = self.env['ir.config_parameter'].sudo() + return ICP.get_param( + 'fusion_repairs.enable_email_notifications', 'True' + ) == 'True' + + def action_offer_loaner(self): + """Open the fusion_loaners_management checkout wizard pre-filled + with this repair's partner. Soft-link - raises if the module is + not installed.""" + self.ensure_one() + Checkout = self.env.get('fusion.loaner.checkout') + if not Checkout: + raise UserError(_( + 'Loaner management is not installed. Install ' + 'fusion_loaners_management to enable this feature.' + )) + return { + 'type': 'ir.actions.act_window', + 'name': _('Offer Loaner'), + 'res_model': 'fusion.loaner.checkout', + 'view_mode': 'form', + 'target': 'new', + 'context': { + 'default_partner_id': self.partner_id.id, + 'default_sale_order_id': self.sale_order_id.id or False, + }, + } + # Maintenance contract back-link (Phase 3) x_fc_maintenance_contract_id = fields.Many2one( 'fusion.repair.maintenance.contract', diff --git a/fusion_repairs/views/repair_order_views.xml b/fusion_repairs/views/repair_order_views.xml index 36b4bfb1..e2644fd1 100644 --- a/fusion_repairs/views/repair_order_views.xml +++ b/fusion_repairs/views/repair_order_views.xml @@ -24,6 +24,13 @@ class="btn-secondary" invisible="state != 'done'" groups="fusion_repairs.group_fusion_repairs_user"/> +
+
+
+ {{ object.partner_id.lang }} + + + diff --git a/fusion_repairs/models/__init__.py b/fusion_repairs/models/__init__.py index 9733b7dd..90e32bdc 100644 --- a/fusion_repairs/models/__init__.py +++ b/fusion_repairs/models/__init__.py @@ -12,6 +12,7 @@ from . import maintenance_contract from . import repair_self_check_rule from . import repair_ai_service from . import repair_on_call_service +from . import repair_inspection from . import product_template from . import res_partner from . import res_users diff --git a/fusion_repairs/models/repair_inspection.py b/fusion_repairs/models/repair_inspection.py new file mode 100644 index 00000000..12e6e58e --- /dev/null +++ b/fusion_repairs/models/repair_inspection.py @@ -0,0 +1,229 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +"""Compliance inspection certificates (M1). + +Per the design spec section "Phase 4 - Compliance, claims, analytics": + Stairlifts / porch lifts need an annual safety inspection certificate + (jurisdictional requirement in many places). This model tracks issued + certificates, their expiry dates, and a daily cron warns the office + + client when one is approaching the 30-day expiry mark. + +A certificate is issued AFTER a successful inspection technician visit - +the visit-report wizard's "Issue Compliance Certificate" button creates +the record and renders a PDF. + +Phase 1 jurisdiction support: a single 'Ontario' jurisdiction text field +on the certificate; future phases add per-jurisdiction PDF templates. +""" + +from datetime import timedelta + +from dateutil.relativedelta import relativedelta + +from odoo import _, api, fields, models + + +class FusionRepairInspectionCertificate(models.Model): + _name = 'fusion.repair.inspection.certificate' + _inherit = ['mail.thread'] + _description = 'Repair Inspection Certificate' + _order = 'issued_date desc, id desc' + + name = fields.Char( + string='Certificate Number', + required=True, + default='New', + copy=False, + readonly=True, + tracking=True, + ) + partner_id = fields.Many2one( + 'res.partner', + string='Client', + required=True, + tracking=True, + index=True, + ) + product_id = fields.Many2one( + 'product.product', + string='Equipment', + required=True, + domain="[('x_fc_repair_category_id.safety_critical', '=', True)]", + tracking=True, + ) + lot_id = fields.Many2one( + 'stock.lot', + string='Serial Number', + tracking=True, + ) + repair_order_id = fields.Many2one( + 'repair.order', + string='Inspection Repair', + help='The repair / technician task during which this inspection was done.', + ondelete='set null', + ) + inspector_user_id = fields.Many2one( + 'res.users', + string='Inspector', + required=True, + default=lambda self: self.env.user, + tracking=True, + domain="[('x_fc_is_field_staff', '=', True)]", + ) + + jurisdiction = fields.Selection( + [ + ('on', 'Ontario'), + ('bc', 'British Columbia'), + ('ab', 'Alberta'), + ('qc', 'Quebec'), + ('other', 'Other'), + ], + string='Jurisdiction', + default='on', + tracking=True, + ) + + issued_date = fields.Date( + string='Issued', + required=True, + default=fields.Date.context_today, + tracking=True, + ) + valid_for_months = fields.Integer( + string='Valid For (Months)', + default=12, + required=True, + ) + expiry_date = fields.Date( + string='Expires', + compute='_compute_expiry_date', + store=True, + tracking=True, + ) + + # Status compute (non-stored - time-dependent, per Bundle 1 C4 fix pattern). + status = fields.Selection( + [ + ('valid', 'Valid'), + ('expiring', 'Expiring Soon'), + ('expired', 'Expired'), + ('revoked', 'Revoked'), + ], + string='Status', + compute='_compute_status', + ) + revoked = fields.Boolean( + string='Revoked', + copy=False, + tracking=True, + ) + + notes = fields.Html(string='Inspector Notes') + company_id = fields.Many2one( + 'res.company', + string='Company', + default=lambda self: self.env.company, + ) + + # Reminder tracking (X2-style band markers so the cron doesn't spam). + last_reminder_band = fields.Selection( + [('30', '30 days'), ('7', '7 days')], + string='Last Reminder', + copy=False, + ) + + _certificate_number_unique = models.Constraint( + 'unique(name)', + 'Inspection certificate numbers must be unique.', + ) + + # ------------------------------------------------------------------ + # CREATE + # ------------------------------------------------------------------ + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get('name', 'New') == 'New': + vals['name'] = self.env['ir.sequence'].next_by_code( + 'fusion.repair.inspection.certificate' + ) or 'CERT/NEW' + return super().create(vals_list) + + # ------------------------------------------------------------------ + # COMPUTES + # ------------------------------------------------------------------ + @api.depends('issued_date', 'valid_for_months') + def _compute_expiry_date(self): + for c in self: + if c.issued_date and c.valid_for_months: + c.expiry_date = c.issued_date + relativedelta(months=c.valid_for_months) + else: + c.expiry_date = False + + def _compute_status(self): + today = fields.Date.context_today(self) + for c in self: + if c.revoked: + c.status = 'revoked' + elif not c.expiry_date: + c.status = 'valid' + elif c.expiry_date < today: + c.status = 'expired' + elif c.expiry_date <= today + timedelta(days=30): + c.status = 'expiring' + else: + c.status = 'valid' + + # ------------------------------------------------------------------ + # ACTIONS + # ------------------------------------------------------------------ + def action_revoke(self): + for c in self: + c.revoked = True + c.message_post(body=_('Certificate revoked.')) + + def action_print(self): + self.ensure_one() + return self.env.ref( + 'fusion_repairs.action_report_inspection_certificate' + ).report_action(self) + + # ------------------------------------------------------------------ + # CRON: warn the client 30 + 7 days before expiry + # ------------------------------------------------------------------ + @api.model + def cron_send_expiry_reminders(self): + """Daily cron. Sends a reminder at the 30-day band, then again at + the 7-day band, so the client books their re-inspection visit + before the certificate lapses.""" + Service = self.env.get('fusion.repair.intake.service') + if Service and not Service._notifications_enabled(): + return + today = fields.Date.context_today(self) + tpl = self.env.ref( + 'fusion_repairs.email_template_inspection_expiry_reminder', + raise_if_not_found=False, + ) + if not tpl: + return + for band_label, days in (('30', 30), ('7', 7)): + target = today + timedelta(days=days) + certs = self.search([ + ('revoked', '=', False), + ('expiry_date', '=', target), + ('partner_id.email', '!=', False), + '|', ('last_reminder_band', '=', False), + ('last_reminder_band', '!=', band_label), + ]) + for c in certs: + # Skip if a smaller band already sent (30 -> 7 progression). + if c.last_reminder_band and int(c.last_reminder_band) <= days: + continue + try: + tpl.send_mail(c.id, force_send=False) + c.last_reminder_band = band_label + except Exception: + continue diff --git a/fusion_repairs/report/inspection_certificate_report.xml b/fusion_repairs/report/inspection_certificate_report.xml new file mode 100644 index 00000000..d7849b04 --- /dev/null +++ b/fusion_repairs/report/inspection_certificate_report.xml @@ -0,0 +1,158 @@ + + + + + Inspection Certificate + fusion.repair.inspection.certificate + qweb-pdf + fusion_repairs.report_inspection_certificate + fusion_repairs.report_inspection_certificate + 'Inspection Certificate - %s' % (object.name) + + report + + + + + diff --git a/fusion_repairs/security/ir.model.access.csv b/fusion_repairs/security/ir.model.access.csv index 15c9727b..f4d1859a 100644 --- a/fusion_repairs/security/ir.model.access.csv +++ b/fusion_repairs/security/ir.model.access.csv @@ -26,3 +26,7 @@ access_technician_task_repairs_manager,Technician Task Repairs Manager Full,fusi access_repair_self_check_rule_user,Self-Check Rule User Read,model_fusion_repair_self_check_rule,group_fusion_repairs_user,1,0,0,0 access_repair_self_check_rule_manager,Self-Check Rule Manager Full,model_fusion_repair_self_check_rule,group_fusion_repairs_manager,1,1,1,1 access_qr_sticker_wizard_user,QR Sticker Wizard User Full,model_fusion_repair_qr_sticker_wizard,group_fusion_repairs_user,1,1,1,1 +access_repair_inspection_user,Inspection Cert User Read,model_fusion_repair_inspection_certificate,group_fusion_repairs_user,1,0,0,0 +access_repair_inspection_dispatcher,Inspection Cert Dispatcher,model_fusion_repair_inspection_certificate,group_fusion_repairs_dispatcher,1,1,1,0 +access_repair_inspection_manager,Inspection Cert Manager Full,model_fusion_repair_inspection_certificate,group_fusion_repairs_manager,1,1,1,1 +access_repair_inspection_technician,Inspection Cert Field Tech Create,model_fusion_repair_inspection_certificate,fusion_tasks.group_field_technician,1,1,1,0 diff --git a/fusion_repairs/views/menus.xml b/fusion_repairs/views/menus.xml index 4f671813..47456ebb 100644 --- a/fusion_repairs/views/menus.xml +++ b/fusion_repairs/views/menus.xml @@ -39,6 +39,12 @@ action="action_maintenance_contract" sequence="30"/> + + + + + + fusion.repair.inspection.certificate.list + fusion.repair.inspection.certificate + + + + + + + + + + + + + + + + + fusion.repair.inspection.certificate.form + fusion.repair.inspection.certificate + +
+
+
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + Inspection Certificates + fusion.repair.inspection.certificate + list,form + + +
diff --git a/fusion_repairs/wizard/repair_visit_report_wizard.py b/fusion_repairs/wizard/repair_visit_report_wizard.py index 559cf72c..0301f866 100644 --- a/fusion_repairs/wizard/repair_visit_report_wizard.py +++ b/fusion_repairs/wizard/repair_visit_report_wizard.py @@ -66,6 +66,19 @@ class RepairVisitReportWizard(models.TransientModel): help='Tick to spawn a follow-up repair after saving this visit.', ) + # M1: tick when the visit was a safety inspection. On save the wizard + # creates a fusion.repair.inspection.certificate. + issue_inspection_cert = fields.Boolean( + string='Issue Compliance Certificate', + help='Tick when the visit was an annual safety inspection. Creates an ' + 'inspection certificate record and prints the PDF on save.', + ) + inspection_cert_id = fields.Many2one( + 'fusion.repair.inspection.certificate', + string='Issued Certificate', + readonly=True, + ) + # Variance display estimated_cost = fields.Monetary( related='repair_id.x_fc_estimated_cost', @@ -169,17 +182,66 @@ class RepairVisitReportWizard(models.TransientModel): )) % {'name': stub.name or ''}, ) + # M1: issue an inspection certificate when the box is ticked + # AND the equipment is safety-critical (stairlift / porch lift / power chair). + if self.issue_inspection_cert: + self._create_inspection_certificate(repair) + # If a stub was spawned, open it directly so the tech can fill in details. - target_id = stub.id if stub else repair.id - target_name = stub.name if stub else repair.name + # Otherwise, if a certificate was issued, jump to it so the tech can print. + if stub: + return { + 'type': 'ir.actions.act_window', + 'name': stub.name, + 'res_model': 'repair.order', + 'view_mode': 'form', + 'res_id': stub.id, + } + if self.inspection_cert_id: + return { + 'type': 'ir.actions.act_window', + 'name': self.inspection_cert_id.name, + 'res_model': 'fusion.repair.inspection.certificate', + 'view_mode': 'form', + 'res_id': self.inspection_cert_id.id, + } return { 'type': 'ir.actions.act_window', - 'name': target_name, + 'name': repair.name, 'res_model': 'repair.order', 'view_mode': 'form', - 'res_id': target_id, + 'res_id': repair.id, } + def _create_inspection_certificate(self, repair): + """M1: create the inspection certificate. Requires a safety-critical + equipment category - otherwise just logs to chatter and skips.""" + category = repair.x_fc_repair_category_id + if not category or not category.safety_critical: + repair.message_post(body=_( + 'Inspection certificate skipped - equipment category is not ' + 'flagged as safety_critical. Only stairlifts, porch lifts, ' + 'and power wheelchairs receive annual certificates.' + )) + return + if not repair.product_id: + repair.message_post(body=_( + 'Inspection certificate skipped - the repair has no product set.' + )) + return + Cert = self.env['fusion.repair.inspection.certificate'].sudo() + cert = Cert.create({ + 'partner_id': repair.partner_id.id, + 'product_id': repair.product_id.id, + 'lot_id': repair.lot_id.id if repair.lot_id else False, + 'repair_order_id': repair.id, + 'inspector_user_id': self.technician_id.id or self.env.uid, + }) + self.inspection_cert_id = cert + repair.message_post(body=_( + 'Issued inspection certificate %s (expires %s).' + ) % (cert.name, cert.expiry_date)) + def _create_repair_part_moves(self, repair): """Create stock.move records for each part used (repair_line_type='add'). diff --git a/fusion_repairs/wizard/repair_visit_report_wizard_views.xml b/fusion_repairs/wizard/repair_visit_report_wizard_views.xml index 7bf1139d..a78041e5 100644 --- a/fusion_repairs/wizard/repair_visit_report_wizard_views.xml +++ b/fusion_repairs/wizard/repair_visit_report_wizard_views.xml @@ -49,6 +49,7 @@ +