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"). diff --git a/fusion_repairs/__init__.py b/fusion_repairs/__init__.py new file mode 100644 index 00000000..31007106 --- /dev/null +++ b/fusion_repairs/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from . import models +from . import wizard +from . import controllers diff --git a/fusion_repairs/__manifest__.py b/fusion_repairs/__manifest__.py new file mode 100644 index 00000000..c772b91c --- /dev/null +++ b/fusion_repairs/__manifest__.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +{ + 'name': 'Fusion Repairs', + 'version': '19.0.2.1.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/ir_cron_data.xml', + 'data/mail_activity_type_data.xml', + 'data/mail_template_data.xml', + 'data/repair_product_category_data.xml', + 'data/intake_template_data.xml', + 'data/self_check_data.xml', + 'data/emergency_charge_data.xml', + 'data/callout_rate_data.xml', + 'data/delivery_charge_data.xml', + # Views + 'views/repair_product_category_views.xml', + 'views/intake_template_views.xml', + 'views/service_catalog_views.xml', + 'views/repair_warranty_views.xml', + 'views/maintenance_contract_views.xml', + 'views/repair_dashboard_views.xml', + 'views/repair_emergency_charge_views.xml', + 'views/repair_inspection_views.xml', + 'views/repair_callout_rate_views.xml', + 'views/repair_delivery_charge_views.xml', + 'views/repair_labor_warranty_views.xml', + 'views/repair_order_views.xml', + 'views/repair_part_order_views.xml', + 'views/repair_service_plan_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', + # Portal templates + 'views/portal_sales_rep_templates.xml', + 'views/portal_client_repair_templates.xml', + 'views/portal_maintenance_templates.xml', + # Wizards + 'wizard/repair_intake_wizard_views.xml', + 'wizard/repair_visit_report_wizard_views.xml', + 'wizard/qr_sticker_wizard_views.xml', + # Reports + 'report/qr_sticker_report.xml', + 'report/inspection_certificate_report.xml', + # Menus (last, after all referenced actions exist) + 'views/menus.xml', + ], + 'assets': { + 'web.assets_backend': [ + # Tokens MUST load first - dashboard.scss references its variables. + 'fusion_repairs/static/src/scss/_fr_tokens.scss', + 'fusion_repairs/static/src/scss/dashboard.scss', + 'fusion_repairs/static/src/components/dashboard/dashboard.js', + 'fusion_repairs/static/src/components/dashboard/dashboard.xml', + ], + 'web.assets_frontend': [ + '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', + 'fusion_repairs/static/src/js/portal_client_repair.js', + ], + }, + 'images': ['static/description/icon.png'], + 'installable': True, + 'application': True, + 'auto_install': False, +} diff --git a/fusion_repairs/controllers/__init__.py b/fusion_repairs/controllers/__init__.py new file mode 100644 index 00000000..441e717a --- /dev/null +++ b/fusion_repairs/controllers/__init__.py @@ -0,0 +1,7 @@ +# -*- 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 +from . import portal_maintenance_booking diff --git a/fusion_repairs/controllers/portal_client_repair.py b/fusion_repairs/controllers/portal_client_repair.py new file mode 100644 index 00000000..cd43a095 --- /dev/null +++ b/fusion_repairs/controllers/portal_client_repair.py @@ -0,0 +1,372 @@ +# -*- 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 SUPERUSER_ID, http, fields +from odoo.http import request +from odoo.tools import email_normalize + +_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 (scoped per endpoint so /repair/self_check and + # /repair/submit and /repair/lookup_phone don't share one bucket). + # ------------------------------------------------------------------ + 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( + 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 + 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"{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(suffix): + _RATE_LIMIT_BUCKET.pop(k, None) + if _RATE_LIMIT_BUCKET.get(key, 0) >= limit: + return True # blocked + _RATE_LIMIT_BUCKET[key] = _RATE_LIMIT_BUCKET.get(key, 0) + 1 + return False + + # ------------------------------------------------------------------ + # LANDING + # ------------------------------------------------------------------ + @http.route("/repair", type="http", auth="public", website=True, sitemap=True) + def repair_landing(self, sn=None, **kw): + serial_info = self._resolve_serial_info((sn or "").strip()) + # Preserve the ?sn= in the CTA so the form gets it too. + form_url = "/repair/new" + (f"?sn={sn}" if sn else "") + return request.render("fusion_repairs.portal_client_repair_landing", { + "page_name": "client_repair_landing", + "serial_info": serial_info, + "form_url": form_url, + }) + + @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") + serial_info = self._resolve_serial_info((sn or "").strip()) + return request.render("fusion_repairs.portal_client_repair_form", { + "page_name": "client_repair_new", + "categories": categories, + "serial_info": serial_info, + "error": kw.get("error"), + }) + + # ------------------------------------------------------------------ + # B4: resolve ?sn= from a QR sticker scan + # ------------------------------------------------------------------ + def _resolve_serial_info(self, serial): + if not serial: + return None + Lot = request.env["stock.lot"].sudo() + lot = Lot.search([("name", "=", serial)], limit=1) + if not lot: + return None + product = lot.product_id + category = product.product_tmpl_id.x_fc_repair_category_id + return { + "serial": lot.name, + "lot_id": lot.id, + "product_id": product.id, + "product_name": product.display_name, + "category_id": category.id if category else False, + } + + # ------------------------------------------------------------------ + # PARTNER LOOKUP (rate-limited, audited) + # The client is identifying themselves with a phone they own. We return + # enough info to pre-fill the form (name, email, street, city) plus the + # partner_id so submit can re-use the existing record instead of creating + # a duplicate. Privacy guard: rate-limited to 10/hr per IP; every match + # is logged at INFO level so abuse leaves a trail. + # ------------------------------------------------------------------ + @http.route("/repair/lookup_phone", type="jsonrpc", auth="public", + website=True) + def repair_lookup_phone(self, phone=None, **kw): + if self._check_rate_limit(scope="lookup"): + return {"error": "rate_limited"} + cleaned = _e164_clean(phone) + if len(cleaned) < 7: + return {"matched": False, "partners": []} + matches = request.env["res.partner"].sudo().search([ + "|", + ("phone", "ilike", cleaned[-7:]), + ("phone_sanitized", "ilike", cleaned[-7:]), + ], limit=3) # cap at 3 - real households rarely have more + if not matches: + return {"matched": False, "partners": []} + _logger.info( + "Portal phone lookup matched %d partner(s) for last7=%s from IP=%s", + len(matches), cleaned[-7:], request.httprequest.remote_addr, + ) + return { + "matched": True, + "partners": [{ + "id": p.id, + "name": p.name or "", + "email": p.email or "", + "street": p.street or "", + "city": p.city or "", + } for p in matches], + } + + # ------------------------------------------------------------------ + # 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(scope="submit"): + 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") + + # 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") + + # B3: trust the explicit known_partner_id from the lookup widget when + # present (client identified themselves via the lookup widget on this + # very page). Otherwise re-match by phone, otherwise create. + partner = False + try: + known_id = int(post.get("known_partner_id") or 0) + except (ValueError, TypeError): + known_id = 0 + if known_id: + partner = request.env["res.partner"].sudo().browse(known_id).exists() + + cleaned_phone = _e164_clean(phone) + if not partner and 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": clean_email or False, + "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) + + # B4: resolve ?sn= QR scan -> attach the lot to the repair + serial_info = self._resolve_serial_info((post.get("serial_number") or "").strip()) + 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, + } + if serial_info: + equipment["lot_id"] = serial_info["lot_id"] + # If client didn't override category, use what the QR identified. + if not category_id and serial_info.get("category_id"): + equipment["repair_category_id"] = serial_info["category_id"] + # 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": intake_uid, + "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 "", + }) + + # ------------------------------------------------------------------ + # CL6 / CL7: AI self-check JSONRPC endpoint + # ------------------------------------------------------------------ + @http.route("/repair/self_check", type="jsonrpc", auth="public", + website=True) + def repair_self_check(self, category_id=None, symptoms=None, + urgency=None, **kw): + if self._check_rate_limit(scope="self_check"): + return {"error": "rate_limited"} + if not symptoms: + symptoms = [] + if isinstance(symptoms, str): + symptoms = [symptoms] + # Defensive: cap input size to defend against prompt-injection bloat + symptoms = [str(s)[:500] for s in symptoms[:5]] + Service = request.env["fusion.repair.ai.service"].sudo() + return Service.suggest_self_check( + product_category_id=int(category_id or 0) or None, + symptoms=symptoms, + urgency=urgency or None, + ) + + # ------------------------------------------------------------------ + # 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) + def repair_on_call_ack(self, token, **kw): + Repair = request.env["repair.order"].sudo() + repair = Repair.search([("x_fc_on_call_token", "=", token)], limit=1) + if not repair: + 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, user) + return request.render("fusion_repairs.portal_on_call_ack_ok", { + "repair_name": repair.name, + }) diff --git a/fusion_repairs/controllers/portal_maintenance_booking.py b/fusion_repairs/controllers/portal_maintenance_booking.py new file mode 100644 index 00000000..6b1b6cec --- /dev/null +++ b/fusion_repairs/controllers/portal_maintenance_booking.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +"""Client maintenance booking portal. + +The maintenance reminder email contains a tokenized URL: + /repairs/maintenance/book/ + +Clicking it lands the client on a single-page form where they can confirm +a preferred date. On submit, a repair.order is spawned via the same +intake service (source='client_portal') and the contract's next reminder +band is locked so we don't keep nagging them. +""" + +import logging + +from odoo import _, fields, http +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +class MaintenanceBookingPortal(http.Controller): + + def _resolve_contract(self, token): + if not token: + return None + Contract = request.env['fusion.repair.maintenance.contract'].sudo() + contract = Contract.search([('booking_token', '=', token)], limit=1) + if not contract or contract.state != 'active': + return None + return contract + + @http.route('/repairs/maintenance/book/', type='http', + auth='public', website=True, sitemap=False) + def maintenance_book_get(self, token, **kw): + contract = self._resolve_contract(token) + if not contract: + return request.render('fusion_repairs.portal_maintenance_invalid_token', {}) + already = bool(contract.booking_repair_id) + return request.render('fusion_repairs.portal_maintenance_book', { + 'contract': contract, + 'already_booked': already, + 'default_date': fields.Date.context_today(request.env.user).isoformat(), + }) + + @http.route('/repairs/maintenance/book//confirm', type='http', + auth='public', methods=['POST'], csrf=True, website=True) + def maintenance_book_post(self, token, **post): + contract = self._resolve_contract(token) + if not contract: + return request.render('fusion_repairs.portal_maintenance_invalid_token', {}) + + if contract.booking_repair_id: + return request.redirect(f'/repairs/maintenance/book/{token}?ok=already') + + preferred_date = (post.get('preferred_date') or '').strip() + scheduled = False + if preferred_date: + try: + scheduled = fields.Date.from_string(preferred_date) + except ValueError: + scheduled = False + + repair = contract.create_repair_from_booking(scheduled_date=scheduled) + return request.render('fusion_repairs.portal_maintenance_thanks', { + 'contract': contract, + 'repair': repair, + }) 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..8f1bbd6f --- /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: + _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/data/callout_rate_data.xml b/fusion_repairs/data/callout_rate_data.xml new file mode 100644 index 00000000..aa9d5bdb --- /dev/null +++ b/fusion_repairs/data/callout_rate_data.xml @@ -0,0 +1,162 @@ + + + + + + + + regular + standard + 95.00 + 0.0 + 0.0 + 85.00 + 75.00 + 1.0 + 25.0 + 0.70 + STANDARD - regular business hours. Service Call ($95) includes the first 30 min of labour. Hourly Rate ($85/h on-site, $75/h in-shop) applies past 30 min, per tech, pro-rated in 30-min increments with a 1-hour minimum. + + + + rush + standard + 120.00 + 0.0 + 0.0 + 85.00 + 75.00 + 1.0 + 25.0 + 0.70 + STANDARD - rush. $120 plus $0.70 per km (2-way, past 25 km). + + + + after_hours + standard + 140.00 + 0.0 + 0.0 + 85.00 + 75.00 + 1.0 + 25.0 + 0.70 + STANDARD - after-hours (weekday evenings). $140 plus $0.70 per km (2-way, past 25 km). + + + + weekend + standard + 180.00 + 0.0 + 0.0 + 85.00 + 75.00 + 1.0 + 25.0 + 0.70 + STANDARD - weekend (extension of published card). $180 callout. + + + + holiday + standard + 220.00 + 0.0 + 0.0 + 85.00 + 75.00 + 1.0 + 25.0 + 0.70 + STANDARD - statutory holiday (extension of published card). $220 callout. + + + + + regular + lift_elevating + 160.00 + 0.0 + 0.0 + 110.00 + 110.00 + 1.0 + 25.0 + 0.70 + LIFT & ELEVATING SERVICE - regular business hours. $160 callout includes 30 min. $110/h labour past 30 min, per tech. + + + + rush + lift_elevating + 200.00 + 110.00 + 110.00 + 1.0 + 25.0 + 0.70 + LIFT & ELEVATING - rush. $200 callout plus $0.70/km (2-way, past 25 km). + + + + after_hours + lift_elevating + 240.00 + 110.00 + 110.00 + 1.0 + 25.0 + 0.70 + LIFT & ELEVATING - after-hours. $240 callout plus $0.70/km (2-way, past 25 km). + + + + weekend + lift_elevating + 300.00 + 110.00 + 110.00 + 1.0 + 25.0 + 0.70 + LIFT & ELEVATING - weekend. $300 callout. + + + + holiday + lift_elevating + 360.00 + 110.00 + 110.00 + 1.0 + 25.0 + 0.70 + LIFT & ELEVATING - statutory holiday. $360 callout. + + + + diff --git a/fusion_repairs/data/delivery_charge_data.xml b/fusion_repairs/data/delivery_charge_data.xml new file mode 100644 index 00000000..a077816c --- /dev/null +++ b/fusion_repairs/data/delivery_charge_data.xml @@ -0,0 +1,73 @@ + + + + + + + local + 35.00 + 10 + Within the Brampton service area. + + + + outside + 60.00 + 20 + Outside the local service area (per the published card). + + + + rush + 60.00 + 0.70 + 25.0 + 30 + Rush pickup or delivery. $60 plus $0.70 per km, both ways, past 25 km. + + + + lift_chair_install + 120.00 + 40 + Lift Chair delivery and on-site set-up. + + + + hospital_bed_install + 120.00 + 50 + Hospital Bed delivery and on-site assembly / set-up. + + + + stairlift_install + 300.00 + 60 + Stairlift delivery and full set-up at client home. + + + + stairlift_removal + 300.00 + 70 + Removal of an old stairlift from client home. + + + + diff --git a/fusion_repairs/data/emergency_charge_data.xml b/fusion_repairs/data/emergency_charge_data.xml new file mode 100644 index 00000000..a1a8dc6a --- /dev/null +++ b/fusion_repairs/data/emergency_charge_data.xml @@ -0,0 +1,91 @@ + + + + + + + + + same_day + 250.00 + 0.5 + Same-day stairlift dispatch. Squeezed into today's route. + + + + after_hours + 350.00 + 0.5 + + + + weekend + 450.00 + 0.5 + + + + + + same_day + 300.00 + 0.5 + + + + weekend + 500.00 + 0.5 + + + + + + same_day + 175.00 + 0.6 + Bed lifts often need 2 techs (one to hold, one to wrench). + + + + after_hours + 275.00 + 0.6 + + + + + + same_day + 200.00 + 0.5 + + + + + + same_day + 120.00 + 0.5 + + + + + + same_day + 150.00 + 0.5 + + + + weekend + 275.00 + 0.5 + + + + 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..77bfa42d --- /dev/null +++ b/fusion_repairs/data/ir_config_parameter_data.xml @@ -0,0 +1,66 @@ + + + + + + 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 + + + + + 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 new file mode 100644 index 00000000..3b256902 --- /dev/null +++ b/fusion_repairs/data/ir_cron_data.xml @@ -0,0 +1,86 @@ + + + + + + + Fusion Repairs: Send maintenance due reminders + + code + model.cron_send_due_reminders() + + 1 + days + + + + + + + Fusion Repairs: Escalate unacknowledged on-call pages + + code + model.cron_escalate_unacknowledged() + + 5 + minutes + + + + + + + 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: Inspection certificate expiry reminders + + code + model.cron_send_expiry_reminders() + + 1 + days + + + + + + + Fusion Repairs: Offer loaner for long-running repairs + + code + model.cron_offer_loaner_for_long_repairs() + + 1 + days + + + + + + diff --git a/fusion_repairs/data/ir_sequence_data.xml b/fusion_repairs/data/ir_sequence_data.xml new file mode 100644 index 00000000..4903a041 --- /dev/null +++ b/fusion_repairs/data/ir_sequence_data.xml @@ -0,0 +1,88 @@ + + + + + + + Repair Intake Session + fusion.repair.intake.session + RIS + 6 + 1 + 1 + + + + + + Repair Maintenance Contract + fusion.repair.maintenance.contract + MC/ + 5 + 1 + 1 + + + + + + Labor Warranty + fusion.repair.labor.warranty + LW- + 5 + 1 + 1 + + + + + + Repair Part Order + fusion.repair.part.order + PART- + 5 + 1 + 1 + + + + + + Service Plan Subscription + fusion.repair.service.plan.subscription + PLAN- + 5 + 1 + 1 + + + + + + Inspection Certificate + fusion.repair.inspection.certificate + CERT-%(year)s- + 4 + 1 + 1 + + + + + + + Repair Order (RO-YYYYMM-NN) + fusion.repair.order.monthly + RO-%(year)s%(month)s- + + 2 + 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..518c3924 --- /dev/null +++ b/fusion_repairs/data/mail_activity_type_data.xml @@ -0,0 +1,66 @@ + + + + + + + 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 + + + + + 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 new file mode 100644 index 00000000..562fad8c --- /dev/null +++ b/fusion_repairs/data/mail_template_data.xml @@ -0,0 +1,483 @@ + + + + + + + + + + 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: 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 }} + +
+ + + + + + Repair: Rush Squeeze - Tech Alert + + URGENT: {{ object.partner_id.name or 'rush client' }} added to your route - {{ object.name }} + {{ (object.company_id.email_formatted or user.email_formatted) }} + +
+
+
+

+ Rush stop added to your day +

+

A rush call was squeezed into your route

+

+ Office added this between your existing stops. Please re-sequence + your day and head over as soon as you can finish your current job. +

+ + + + + + + + + + + + + + + +
Repair
Client
Phone
Equipment
Address,
Rush Surcharge$
+

+ Open the task in your tech portal to see the full route and tap Start Timer when you arrive. +

+
+
+
+ +
+ + + + + + Repair: Awaiting Parts (Client) + + {{ object.company_id.name }} - update on your repair {{ object.name }} + {{ (object.company_id.email_formatted or user.email_formatted) }} + {{ object.partner_id.id }} + +
+
+
+

+ +

+

We found the problem - here's the plan

+

+ Hello , our technician + diagnosed your equipment today but needs a part we don't carry on the + truck. We're ordering it right away from the manufacturer. +

+ + + + + +
Reference
Expected return visit~
+
+

+ What happens next: +

+
    +
  1. We order the parts from the manufacturer today.
  2. +
  3. When the parts arrive at our warehouse, we'll email you with a confirmed visit date.
  4. +
  5. You don't need to do anything in the meantime.
  6. +
+
+

+ Questions? Reply to this email or call our office. Reference: . +

+
+
+
+ {{ object.partner_id.lang }} + +
+ + + + + + Repair: Parts Ordered (Client) + + Parts ordered for your {{ object.repair_order_id.x_fc_repair_category_id.name or 'equipment' }} - {{ object.repair_order_id.name }} + {{ (object.company_id.email_formatted or user.email_formatted) }} + {{ object.partner_id.id }} + +
+
+
+

Parts ordered

+

+ We've placed an order for the parts your + needs. Expected arrival: . +

+ + + + + + +
Part
Manufacturer
Ref
+

We'll email again as soon as the parts arrive at our warehouse.

+
+
+
+ {{ object.partner_id.lang }} + +
+ + + + + + Repair: Parts Received (Client) + + Parts arrived - scheduling your return visit ({{ object.repair_order_id.name }}) + {{ (object.company_id.email_formatted or user.email_formatted) }} + {{ object.partner_id.id }} + +
+
+
+

Good news - your parts arrived

+

+ The parts for your repair are in. Our office will call you in the next business day + to confirm a return-visit time. You don't need to do anything right now. +

+

Reference:

+
+
+
+ {{ object.partner_id.lang }} + +
+ + + + + + Repair: Inspection Certificate Expiry Reminder + + Your {{ object.product_id.display_name }} inspection certificate expires {{ object.expiry_date }} + {{ (object.company_id.email_formatted or user.email_formatted) }} + {{ object.partner_id.id }} + +
+
+
+

+ +

+

Annual safety inspection coming due

+

+ Hello , the safety inspection + certificate on your + (certificate ) expires + . + Annual re-inspection keeps your equipment compliant with local safety regulations. +

+ + + + + + + +
Certificate
Equipment
Serial
Expires
+
+

+ Reply to this email or call our office to book your re-inspection. We will + send our certified technician to confirm everything is safe and renew your + certificate. +

+
+
+
+
+ {{ object.partner_id.lang }} + +
+ + + + + + Repair: On-Call Safety Page + + [SAFETY PAGE] {{ object.partner_id.name or 'Unknown' }} - {{ object.name or 'n/a' }} + {{ (object.company_id.email_formatted or user.email_formatted) }} + +
+
+
+

+ URGENT - SAFETY PAGE +

+

Safety service call requires response

+

+ A client just submitted a safety-flagged service request via the + . + You have been paged as the on-call manager. +

+ + + + + + + + + +
Reference
Client
Phone
Equipment
+ +

+ If you do not acknowledge within 15 minutes, the next on-call + priority will be paged automatically. +

+
+
+
+ +
+ + + + + + Repair: Maintenance Due Reminder + + {{ object.company_id.name }} - Time to schedule your equipment maintenance + {{ (object.company_id.email_formatted or user.email_formatted) }} + {{ object.partner_id.id }} + +
+
+
+

+ +

+

Your equipment is due for maintenance

+

+ Hello , your + + is due for its next scheduled maintenance visit on + . +

+ +
+

+ Regular maintenance keeps your equipment safe and reliable. Use the + button above to confirm and we will reach out to schedule a time that works for you. +

+
+

+ Contract reference . + If you no longer have this equipment, you can ignore this email. +

+
+
+
+ {{ 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) }} + {{ ','.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 '') }} + +
+
+
+

+ 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..e00d255d --- /dev/null +++ b/fusion_repairs/data/repair_product_category_data.xml @@ -0,0 +1,94 @@ + + + + + + + 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. + + lift_elevating + + + + Porch Lift + porch_lift + 50 + fa-arrow-up + Vertical platform lifts for porches, decks, and accessible building entrances. + + lift_elevating + + + + + Lift Chair + lift_chair + 55 + fa-chair + Powered recliner / lift chairs (Pride, Golden, MedLift). Falls under Lift & Elevating Service per rate card. + lift_elevating + + + + 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/data/self_check_data.xml b/fusion_repairs/data/self_check_data.xml new file mode 100644 index 00000000..4c49e4df --- /dev/null +++ b/fusion_repairs/data/self_check_data.xml @@ -0,0 +1,245 @@ + + + + + + + + Hospital Bed - No Power + + 10 + won't move,dead,no power,no response + 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 are pressed. + + + Hospital Bed - Slow / Sluggish + + 20 + slow,sluggish + Unplug the bed for 30 seconds then plug it back in. + Movement returns to normal speed. + + + Hospital Bed - Remote Unresponsive + + 30 + remote,controller + Replace the remote batteries with fresh AAA batteries. + Remote lights up and bed responds. + + + Hospital Bed - Alarm + + 40 + beep,alarm,alert + 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. + + + + + Wheelchair - Brake + + 10 + brake,stop + Push the brake lever fully to the locked position and listen for a click. + Brake holds wheel firmly. + + + Wheelchair - Hard to Push + + 20 + hard to push,drag,slow + 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. + + + + + Power Wheelchair - No Power + + 10 + won't turn on,dead,no power,battery + Confirm the battery indicator shows charge and the key switch is in the ON position. + Display lights up. + + + Power Wheelchair - Error Code + + 20 + error,flashing,code + 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. + + + + + Stairlift - Won't Move + + 10 + won't move,stuck + Check the seat is fully rotated to the forward position and the seatbelt is fastened. + Stairlift responds. + + + Stairlift - Stops Midway + + 20 + stops midway,halts + Check the track for items blocking the sensors - toys, slippers, debris. + Stairlift completes its travel. + + + Stairlift - Call Station Unresponsive + + 30 + remote,call station + 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. + + + + + Porch Lift - Won't Move + + 10 + won't move,dead + Check all gate and door safety switches are fully closed. + Lift responds. + + + Porch Lift - Sticky Controls + + 20 + sticky,stuck button + 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. + + + + + Walker - Wheel Stuck + + 10 + wheel stick,won't roll + 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 + + 10 + brake won't lock,brake loose + 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. + + + + + Mattress - Deflated + + 10 + deflated,flat,soft + Confirm the pump is plugged in, powered on, and the hose is firmly attached. + Mattress inflates. + + + Mattress - Alarm + + 20 + alarm,beep + 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/migrations/19.0.2.1.0/post-migration.py b/fusion_repairs/migrations/19.0.2.1.0/post-migration.py new file mode 100644 index 00000000..680021be --- /dev/null +++ b/fusion_repairs/migrations/19.0.2.1.0/post-migration.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +"""Post-migration for 19.0.2.1.0 - align rate-card + categories to Westin's +printed service-rate card. + +Sites that installed any earlier Bundle 9 build have: + - Old callout.rate rows with $120/$95/0.85 values (B9 placeholder rates) + - Stairlift / porch_lift categories with equipment_class='standard' + +Both have noupdate=1 in their seed XML so a normal -u upgrade won't fix +them. This script: + 1. Wipes the four B9-only rate xml_ids and re-imports the seed + 2. Updates lift / porch / lift_chair categories to equipment_class='lift_elevating' + +After this runs once, future upgrades respect noupdate=1 normally (admin +tweaks are preserved). +""" + +from odoo.tools.sql import column_exists + + +def migrate(cr, version): + if not version: + return # fresh install - seed loads correctly + + cr.execute(""" + UPDATE fusion_repair_product_category + SET equipment_class = 'lift_elevating' + WHERE code IN ('stairlift', 'porch_lift', 'lift_chair') + AND (equipment_class IS NULL OR equipment_class = 'standard'); + """) + + # Wipe the four B9 rate rows so the new noupdate=1 seed re-creates them + # with the printed values. Only deletes rows that were originally seeded + # by this module (xml_id present) - admin-created rate rows stay put. + cr.execute(""" + DELETE FROM fusion_repair_callout_rate + WHERE id IN ( + SELECT res_id FROM ir_model_data + WHERE module = 'fusion_repairs' + AND model = 'fusion.repair.callout.rate' + AND name IN ('callout_rate_regular', 'callout_rate_after_hours', + 'callout_rate_weekend', 'callout_rate_holiday') + ); + DELETE FROM ir_model_data + WHERE module = 'fusion_repairs' + AND model = 'fusion.repair.callout.rate' + AND name IN ('callout_rate_regular', 'callout_rate_after_hours', + 'callout_rate_weekend', 'callout_rate_holiday'); + """) diff --git a/fusion_repairs/models/__init__.py b/fusion_repairs/models/__init__.py new file mode 100644 index 00000000..5810aaa9 --- /dev/null +++ b/fusion_repairs/models/__init__.py @@ -0,0 +1,30 @@ +# -*- 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 service_catalog +from . import repair_warranty +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 repair_service_plan +from . import repair_emergency_charge +from . import repair_part_order +from . import repair_callout_rate +from . import repair_labor_warranty +from . import repair_delivery_charge +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 sale_order +from . import intake_service +from . import repair_dashboard 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..2d79bf7f --- /dev/null +++ b/fusion_repairs/models/intake_service.py @@ -0,0 +1,554 @@ +# -*- 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 markupsafe import Markup + +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) + - quote_only: bool (optional, C6 - skips dispatch task creation) + - 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 [{}] + quote_only = bool(payload.get('quote_only')) + rush_requested = bool(payload.get('rush_requested')) + rush_tier = payload.get('rush_tier') or False + rush_techs_required = int(payload.get('rush_techs_required') or 1) + 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, + quote_only=quote_only, + rush_requested=rush_requested, + rush_tier=rush_tier, + rush_techs_required=rush_techs_required, + ) + 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 + # 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 + + # ------------------------------------------------------------------ + # CORE CREATION + # ------------------------------------------------------------------ + @api.model + def _create_single_repair(self, partner_id, intake_user, session_ref, + source, item, quote_only=False, + rush_requested=False, rush_tier=False, + rush_techs_required=1): + 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, + 'x_fc_is_quote_only': bool(quote_only), + 'x_fc_rush_requested': bool(rush_requested), + 'x_fc_rush_tier': rush_tier or False, + 'x_fc_rush_techs_required': rush_techs_required or 1, + '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 []) + + # Service catalogue auto-match. + 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) + + # 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: + 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). + # Skip if the catalogue match already auto-created one. + # Skip entirely if intake is quote-only (C6). + if ( + 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.' + ))) + + # CL15: page the on-call manager for safety intakes after hours. + if repair.x_fc_urgency == 'safety': + try: + self.env['fusion.repair.on.call.service'].sudo().page_on_call(repair) + except Exception as e: + _logger.warning('On-call page failed for %s: %s', repair.name, e) + + # Emails (client + office). + self._send_intake_emails(repair) + + # Audit message in chatter. + repair.message_post( + body=Markup(_( + 'Service call submitted via %(source)s by %(user)s. ' + 'Session reference: %(ref)s.' + )) % { + 'source': dict(repair._fields['x_fc_intake_source'].selection).get(source) or '', + 'user': intake_user.name or '', + 'ref': session_ref or '', + }, + ) + + 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) + + # ------------------------------------------------------------------ + # SERVICE CATALOGUE MATCH + # ------------------------------------------------------------------ + @api.model + def _match_service_catalog(self, repair, item, quote_only=False): + 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). + # 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) + + # ------------------------------------------------------------------ + # 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=Markup(_( + 'This repair MAY be covered by our active warranty %(ref)s ' + '(expires %(exp)s). Manager review recommended before invoicing.' + )) % { + 'ref': warranty.name or '', + 'exp': warranty.expiry_date and str(warranty.expiry_date) or '', + }, + 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 + # ------------------------------------------------------------------ + @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, + } + # Bundle 8: allow squeeze / re-dispatch callers to inject a + # specific scheduled_date + time_start + time_end via context so + # fusion_tasks' conflict validator doesn't reject the create. + force_sched = self._context.get('force_schedule') or {} + if force_sched: + vals.update(force_sched) + # technician_id is required AND constrained to x_fc_is_field_staff. + # 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._context.get('force_tech_id') or 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 + # ------------------------------------------------------------------ + @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. + 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/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/maintenance_contract.py b/fusion_repairs/models/maintenance_contract.py new file mode 100644 index 00000000..f9fa93b1 --- /dev/null +++ b/fusion_repairs/models/maintenance_contract.py @@ -0,0 +1,227 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +"""Maintenance contracts. + +One contract per sold unit (partner + product + lot). When the underlying +sale order is delivered and the product has x_fc_maintenance_interval_months>0, +a contract is auto-created. A daily cron walks active contracts and sends +the client a reminder email at 30, 7, and 1 days before next_due_date with +a tokenized booking link. +""" + +import secrets +from datetime import timedelta + +from dateutil.relativedelta import relativedelta +from markupsafe import Markup + +from odoo import _, api, fields, models + + +CONTRACT_STATES = [ + ('draft', 'Draft'), + ('active', 'Active'), + ('paused', 'Paused'), + ('cancelled', 'Cancelled'), +] + + +class FusionRepairMaintenanceContract(models.Model): + _name = 'fusion.repair.maintenance.contract' + _inherit = ['mail.thread'] + _description = 'Repair Maintenance Contract' + _order = 'next_due_date, id' + + name = fields.Char(string='Reference', required=True, default='New', + copy=False, readonly=True) + partner_id = fields.Many2one( + 'res.partner', + string='Client', + required=True, + index=True, + ondelete='restrict', + ) + product_id = fields.Many2one( + 'product.product', + string='Equipment', + required=True, + index=True, + ) + lot_id = fields.Many2one('stock.lot', string='Serial Number') + original_sale_order_id = fields.Many2one( + 'sale.order', + string='Original Sale Order', + index=True, + ) + + interval_months = fields.Integer(string='Interval (months)', default=12, required=True) + last_service_date = fields.Date(string='Last Service') + next_due_date = fields.Date(string='Next Due', required=True, index=True) + state = fields.Selection(CONTRACT_STATES, default='active', required=True, + tracking=True, index=True) + + booking_token = fields.Char(string='Booking Token', copy=False, index=True) + last_reminder_band = fields.Selection( + [('30', '30 days'), ('7', '7 days'), ('1', '1 day')], + string='Last Reminder Sent', + copy=False, + help='The most recent reminder band sent for the current cycle.', + ) + booking_repair_id = fields.Many2one( + 'repair.order', + string='Booked Repair', + copy=False, + help='The repair.order created when the client used the booking link for this cycle.', + ) + + company_id = fields.Many2one( + 'res.company', default=lambda self: self.env.company, + ) + + _booking_token_unique = models.Constraint( + 'unique(booking_token)', + 'Booking token must be unique.', + ) + + @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.maintenance.contract' + ) or 'MC/NEW' + if not vals.get('booking_token'): + vals['booking_token'] = secrets.token_urlsafe(20) + return super().create(vals_list) + + # ------------------------------------------------------------------ + # ROLL FORWARD + # ------------------------------------------------------------------ + def roll_next_due_date(self): + """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) + # 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 + + # ------------------------------------------------------------------ + # REMINDER CRON + # ------------------------------------------------------------------ + @api.model + def cron_send_due_reminders(self): + """Daily cron - send reminder emails at 30/7/1 days before next_due_date.""" + ICP = self.env['ir.config_parameter'].sudo() + if ICP.get_param('fusion_repairs.enable_email_notifications', 'True') != 'True': + return + today = fields.Date.context_today(self) + bands = [ + ('30', 30), + ('7', 7), + ('1', 1), + ] + tpl = self.env.ref( + 'fusion_repairs.email_template_maintenance_due_reminder', + raise_if_not_found=False, + ) + if not tpl: + return + for band_label, days in bands: + target = today + timedelta(days=days) + domain = [ + ('state', '=', 'active'), + ('next_due_date', '=', target), + ('partner_id.email', '!=', False), + ] + # Don't re-send a smaller band if we already sent a larger one + # for the same cycle - the band order is 30 -> 7 -> 1. + contracts = self.search(domain) + for c in contracts: + if c.last_reminder_band == band_label: + continue + try: + tpl.send_mail(c.id, force_send=False) + c.last_reminder_band = band_label + c.message_post( + body=Markup(_( + 'Sent %(band)s-day maintenance reminder to %(email)s.' + )) % { + 'band': band_label, + 'email': c.partner_id.email or '', + }, + ) + except Exception: + continue + + # ------------------------------------------------------------------ + # PORTAL BOOKING + # ------------------------------------------------------------------ + def create_repair_from_booking(self, scheduled_date=None): + """Spawn a repair.order from the booking link (or any manual booking).""" + self.ensure_one() + if self.booking_repair_id and self.booking_repair_id.state != 'cancel': + return self.booking_repair_id + Repair = self.env['repair.order'].sudo() + repair = Repair.create({ + 'partner_id': self.partner_id.id, + 'product_id': self.product_id.id, + 'lot_id': self.lot_id.id if self.lot_id else False, + '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 + 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}.

', + }) + self.booking_repair_id = repair + self.message_post( + body=Markup(_( + 'Maintenance visit %(ref)s booked from reminder link.' + )) % {'ref': repair.name or ''}, + ) + return repair + + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + def _spawn_maintenance_contracts(self): + """Create maintenance contracts for any delivered SO line whose + product has x_fc_maintenance_interval_months > 0.""" + Contract = self.env['fusion.repair.maintenance.contract'].sudo() + today = fields.Date.context_today(self) + for so in self: + if so.state not in ('sale', 'done'): + continue + for line in so.order_line: + product = line.product_id + if not product: + continue + interval = product.product_tmpl_id.x_fc_maintenance_interval_months or 0 + if interval <= 0: + continue + existing = Contract.search([ + ('partner_id', '=', so.partner_id.id), + ('product_id', '=', product.id), + ('original_sale_order_id', '=', so.id), + ], limit=1) + if existing: + continue + Contract.create({ + 'partner_id': so.partner_id.id, + 'product_id': product.id, + 'original_sale_order_id': so.id, + 'interval_months': interval, + 'next_due_date': today + relativedelta(months=interval), + 'state': 'active', + }) diff --git a/fusion_repairs/models/product_template.py b/fusion_repairs/models/product_template.py new file mode 100644 index 00000000..778710d9 --- /dev/null +++ b/fusion_repairs/models/product_template.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 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.', + ) + # Bundle 9: store labor warranty granted at point of sale. + x_fc_labor_warranty_years = fields.Integer( + string='Store Labor Warranty (years)', + default=0, + help='Years of store labor warranty granted when this product is sold. ' + '0 = no warranty. Setting this triggers a fusion.repair.labor.warranty ' + 'record per unit on sale-order confirm.', + ) diff --git a/fusion_repairs/models/repair_ai_service.py b/fusion_repairs/models/repair_ai_service.py new file mode 100644 index 00000000..9e37c96a --- /dev/null +++ b/fusion_repairs/models/repair_ai_service.py @@ -0,0 +1,380 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +"""Repair AI Service - single guardrailed entry point for client-portal AI. + +Per the design spec (Appendix A), this AbstractModel: +1) Builds a strict system prompt forbidding medical advice, diagnoses, + stop-using recommendations, etc. +2) Calls fusion.api.service.call_openai() if available (try/fallback per + fusion-api-integration rule - never installs as a hard dep) +3) JSON-schema validates the response and runs a forbidden-phrase regex +4) Always falls back to deterministic fusion.repair.self.check.rule + records on any failure - intake must never be blocked by AI + +System prompt + JSON schema live in ir.config_parameter so the office can +refine them without code changes. +""" + +import hashlib +import json +import logging +import re + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +# ----- Safety filters ----- +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'\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 +] + +# 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, +) + + +DEFAULT_SYSTEM_PROMPT = ( + "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.\n\n" + "ABSOLUTE RULES:\n" + "1. NEVER provide medical advice, diagnoses, or health recommendations.\n" + "2. NEVER claim a definitive cause for the problem.\n" + "3. NEVER recommend stopping use of medical equipment.\n" + "4. NEVER use phrases like 'you have', 'I diagnose', 'you should stop', " + "'medical condition', 'consult your doctor'.\n" + "5. ONLY suggest steps that are: safe, reversible, require no tools, " + "take under 2 minutes, and pose zero risk to the client or equipment.\n" + "6. If symptoms involve smoke, sparks, burning smell, motors on " + "stairlifts/porch lifts, OR if you are uncertain -> return " + "escalate_immediately: true.\n" + "7. Maximum 3 steps. Each step <= 1 sentence. Grade-6 reading level. " + "No technical jargon.\n" + "8. NEVER reference part numbers, prices, or other clients.\n" + "9. If client reports injury, equipment fire, or person trapped -> " + "escalate_immediately: true with escalation_reason: 'emergency'.\n" + "10. You MUST output valid JSON matching the provided schema. No prose, " + "no markdown, no commentary." +) + + +class FusionRepairAIService(models.AbstractModel): + _name = 'fusion.repair.ai.service' + _description = 'Repair AI Service - guardrailed self-check engine' + + # ------------------------------------------------------------------ + # PUBLIC API + # ------------------------------------------------------------------ + @api.model + def suggest_self_check(self, product_category_id=None, symptoms=None, urgency=None): + """Return a list of safe self-check steps for the client portal. + + Returns a dict with shape: + { + 'escalate_immediately': bool, + 'escalation_reason': str | None, + 'confidence': 'high' | 'medium' | 'low', + 'steps': [{'instruction': str, 'expected_result': str, + 'safety_note': str | None}, ...], + 'source': 'ai' | 'fallback' | 'escalated', + 'disclaimer': str, + } + """ + symptoms = [s for s in (symptoms or []) if s] + category = ( + self.env['fusion.repair.product.category'].sudo().browse(product_category_id) + if product_category_id else False + ) + + # Pre-check: hard-escalate for safety-critical category + symptom combos + # without consulting AI. This is BEFORE any AI call so even if AI is + # down we still escalate the right way. + if self._should_hard_escalate(category, symptoms, urgency): + return self._escalated_response('safety') + + # Try the AI, fall back to deterministic rules on any failure. + ai_result = self._try_ai(category, symptoms) + if ai_result: + ai_result['source'] = 'ai' + ai_result['disclaimer'] = self._disclaimer() + return ai_result + + return self._deterministic_fallback(category, symptoms) + + # ------------------------------------------------------------------ + # HARD ESCALATION + # ------------------------------------------------------------------ + @api.model + def _should_hard_escalate(self, category, symptoms, urgency): + if urgency == 'safety': + return True + 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 + + @api.model + def _escalated_response(self, reason): + return { + 'escalate_immediately': True, + 'escalation_reason': reason, + 'confidence': 'high', + 'steps': [], + 'source': 'escalated', + 'disclaimer': self._disclaimer(), + } + + # ------------------------------------------------------------------ + # AI CALL (try/fallback) + # ------------------------------------------------------------------ + @api.model + def _try_ai(self, category, symptoms): + try: + ApiService = self.env.get('fusion.api.service') + if not ApiService: + return None + messages = [ + {'role': 'system', 'content': self._system_prompt()}, + {'role': 'user', 'content': self._user_prompt(category, symptoms)}, + ] + cache_key = self._cache_key(category, symptoms) + cached = self._cache_get(cache_key) + if cached: + return cached + + raw = ApiService.call_openai( + consumer='fusion_repairs', + feature='client_self_triage', + messages=messages, + max_tokens=400, + ) + if not raw: + return None + parsed = self._safe_parse(raw) + if not parsed: + self._log_incident('parse_failed', raw) + return None + self._cache_set(cache_key, parsed) + return parsed + except Exception as e: + _logger.info('AI self-check skipped: %s', e) + return None + + @api.model + def _system_prompt(self): + ICP = self.env['ir.config_parameter'].sudo() + return ICP.get_param( + 'fusion_repairs.ai_self_check_system_prompt', + DEFAULT_SYSTEM_PROMPT, + ) + + @api.model + def _user_prompt(self, category, symptoms): + cat_name = category.name if category else 'medical equipment' + return ( + f"Equipment category: {cat_name}\n" + f"Reported symptoms: {'; '.join(symptoms) if symptoms else '(none provided)'}\n" + "Output the JSON object only." + ) + + # ------------------------------------------------------------------ + # SAFE PARSE + VALIDATE + # ------------------------------------------------------------------ + @api.model + def _safe_parse(self, raw): + """Parse the AI response, validate against the JSON schema, and run + forbidden-phrase regex filters. Returns None on any failure - caller + falls back to deterministic rules.""" + if not raw: + return None + text = raw.strip() + # Strip code-fence wrapping if AI added it. + if text.startswith('```'): + text = re.sub(r'^```[a-zA-Z]*\n?', '', text) + text = re.sub(r'\n?```$', '', text) + try: + data = json.loads(text) + except (ValueError, TypeError): + return None + # Schema check (minimal - we don't pull in jsonschema as a dep) + if not isinstance(data, dict): + return None + if not isinstance(data.get('escalate_immediately'), bool): + return None + confidence = data.get('confidence') + if confidence not in ('high', 'medium', 'low'): + return None + steps = data.get('steps') + if not isinstance(steps, list) or len(steps) > 3: + return None + # Coherence: not-escalated must have at least one step. + if not data['escalate_immediately'] and not steps: + return None + # Per-step validation + forbidden-phrase scan. + cleaned_steps = [] + for step in steps: + if not isinstance(step, dict): + return None + instr = step.get('instruction') + expected = step.get('expected_result') + if not isinstance(instr, str) or not instr.strip(): + return None + if not isinstance(expected, str) or not expected.strip(): + return None + if len(instr) > 200 or len(expected) > 200: + return None + if self._contains_forbidden(instr) or self._contains_forbidden(expected): + return None + note = step.get('safety_note') + if note is not None and (not isinstance(note, str) or len(note) > 200): + return None + if note and self._contains_forbidden(note): + return None + cleaned_steps.append({ + 'instruction': instr.strip(), + 'expected_result': expected.strip(), + 'safety_note': (note or '').strip() or None, + }) + return { + 'escalate_immediately': data['escalate_immediately'], + 'escalation_reason': data.get('escalation_reason') or None, + 'confidence': confidence, + 'steps': cleaned_steps, + } + + @api.model + def _contains_forbidden(self, text): + if not text: + return False + return any(p.search(text) for p in FORBIDDEN_PATTERNS) + + # ------------------------------------------------------------------ + # 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 + and return the matching steps. Used when AI is unavailable or + returns invalid / unsafe content.""" + Rule = self.env['fusion.repair.self.check.rule'].sudo() + steps = [] + if category: + haystack = self._normalise(' '.join(symptoms)) + rules = Rule.search([ + ('category_id', '=', category.id), + ('active', '=', True), + ], order='sequence') + for r in rules: + 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 '', + 'expected_result': r.expected_result or '', + 'safety_note': r.safety_note or None, + }) + if len(steps) >= 3: + break + return { + 'escalate_immediately': len(steps) == 0, + 'escalation_reason': None if steps else 'no_match', + 'confidence': 'medium' if steps else 'low', + 'steps': steps, + 'source': 'fallback', + 'disclaimer': self._disclaimer(), + } + + # ------------------------------------------------------------------ + # CACHE (in-memory per worker, 24h) + # ------------------------------------------------------------------ + _CACHE = {} + _CACHE_TTL = 24 * 3600 + + @api.model + def _cache_key(self, category, symptoms): + symptom_hash = hashlib.sha256( + ('|'.join(sorted(s.lower() for s in symptoms))).encode() + ).hexdigest()[:16] + return f"{category.code if category else 'none'}:{symptom_hash}" + + @api.model + def _cache_get(self, key): + import time + entry = self._CACHE.get(key) + if not entry: + return None + ts, value = entry + if time.time() - ts > self._CACHE_TTL: + self._CACHE.pop(key, None) + return None + return value + + @api.model + def _cache_set(self, key, value): + import time + # Bound cache size to ~512 entries. + if len(self._CACHE) > 512: + self._CACHE.clear() + self._CACHE[key] = (time.time(), value) + + # ------------------------------------------------------------------ + # MISC + # ------------------------------------------------------------------ + @api.model + def _disclaimer(self): + return ("This is not medical advice. If you're unsure, schedule a " + "technician visit. In an emergency, call 9-1-1.") + + @api.model + def _log_incident(self, kind, raw): + _logger.warning('AI self-check incident (%s): %s', kind, (raw or '')[:300]) diff --git a/fusion_repairs/models/repair_callout_rate.py b/fusion_repairs/models/repair_callout_rate.py new file mode 100644 index 00000000..3950bdd9 --- /dev/null +++ b/fusion_repairs/models/repair_callout_rate.py @@ -0,0 +1,178 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +"""Service callout rate card. + +When we dispatch a tech to a client's home (as opposed to in-store work), +this rate card answers "what do we charge?" for any combination of: + + * tier (regular hours / after-hours / weekend / statutory holiday) + * number of technicians dispatched + * actual labour hours billable (after the 30 min the callout fee covers) + * round-trip travel kilometres beyond the threshold + +The Bundle 8 emergency surcharge sits ON TOP of this when CS flags a +repair as a rush (same-day squeeze, etc.). They are separate concepts: + - callout rate = the BASELINE house-call price + - emergency surcharge = added "drop everything" premium +""" + +from odoo import _, api, fields, models + + +class FusionRepairCalloutRate(models.Model): + _name = 'fusion.repair.callout.rate' + _description = 'Service Callout Rate Card' + _order = 'effective_from desc, tier, id' + + name = fields.Char(compute='_compute_name', store=True) + tier = fields.Selection( + [ + ('regular', 'Regular Business Hours'), + ('rush', 'Rush Service'), + ('after_hours', 'After Hours (weekday evening)'), + ('weekend', 'Weekend'), + ('holiday', 'Statutory Holiday'), + ], + string='Tier', + required=True, + default='regular', + ) + + # Bundle 10: Westin's rate card splits by equipment class. Lift & + # Elevating Service ($160 callout / $110 labour) is distinct from + # Standard Service ($95 callout / $85 labour). The lookup falls back + # from (tier, equipment_class) to (tier, 'standard'). + equipment_class = fields.Selection( + [ + ('standard', 'Standard Service'), + ('lift_elevating', 'Lift & Elevating Service'), + ], + string='Equipment Class', + default='standard', + required=True, + ) + + # ---- Base callout (covers first 30 minutes of labour) ---- + base_callout_fee = fields.Monetary( + string='Base Callout Fee (1 tech)', + currency_field='currency_id', + required=True, + default=0.0, + help='Charge for dispatching one technician to the client. INCLUDES ' + 'the first 30 minutes for inspection / check / report. Repair ' + 'labour above the 30 min is charged hourly at hourly_labor_rate.', + ) + second_tech_fee = fields.Monetary( + string='Second Technician Fee', + currency_field='currency_id', + default=0.0, + help='Added to the callout when a 2nd technician is dispatched alongside ' + 'the first. Lower than a second base callout because they share ' + 'travel.', + ) + additional_tech_fee = fields.Monetary( + string='Each Additional Technician Fee', + currency_field='currency_id', + default=0.0, + help='Applied to the 3rd, 4th... technician on the same callout. ' + 'Defaults to second_tech_fee if left zero.', + ) + + # ---- Hourly labour (after the included 30 min) ---- + hourly_labor_rate = fields.Monetary( + string='On-Site Hourly Labour Rate (per tech)', + currency_field='currency_id', + required=True, + default=0.0, + help='Per-technician hourly rate applied to billable labour above the ' + '30 min the callout covers. Minimum bill is minimum_labor_hours ' + 'even if the tech finished faster.', + ) + # Bundle 10: separate IN-SHOP hourly rate. When the client brings the unit + # to the store (no callout, no travel) we charge a lower hourly rate. + in_shop_labor_rate = fields.Monetary( + string='In-Shop Hourly Labour Rate', + currency_field='currency_id', + default=0.0, + help='Hourly rate when work is done IN THE STORE (no callout fee, no ' + 'travel). Per Westin rate card: $75 standard / $110 lift.', + ) + minimum_labor_hours = fields.Float( + string='Minimum Billable Hours', + default=1.0, + help='Round-up floor for labour beyond the included 30 min. Set 1.0 ' + 'so a 20-minute fix still bills 1.0 hours. Hours beyond the floor ' + 'are pro-rated in 30-minute increments per the published card.', + ) + + # ---- Travel ---- + travel_distance_threshold_km = fields.Float( + string='Free Travel Distance (km, one-way)', + default=25.0, + help='Travel under this distance is free. Beyond it, every additional ' + 'kilometre is charged at travel_per_km_fee, BOTH WAYS (so the bill ' + 'is per-km * (one_way_km - threshold) * 2).', + ) + travel_per_km_fee = fields.Monetary( + string='Per-km Fee Over Threshold', + currency_field='currency_id', + default=0.0, + help='Per technician, per kilometre, both ways.', + ) + + # ---- Bookkeeping ---- + effective_from = fields.Date( + string='Effective From', + default=fields.Date.context_today, + required=True, + help='Rate effective from this date. Newer rates supersede older ones ' + 'for the same (tier, company).', + ) + currency_id = fields.Many2one( + 'res.currency', + default=lambda self: self.env.company.currency_id, + ) + company_id = fields.Many2one( + 'res.company', + default=lambda self: self.env.company, + ) + active = fields.Boolean(default=True) + description = fields.Text( + help='Optional notes shown to CS / dispatchers - e.g. "applies after 5 PM weekdays".', + ) + + @api.depends('tier', 'base_callout_fee', 'effective_from') + def _compute_name(self): + for r in self: + tier_label = dict(self._fields['tier'].selection).get(r.tier) or '?' + r.name = ( + f'{tier_label} - ${r.base_callout_fee:.0f} callout' + f' (from {r.effective_from})' + ) + + @api.model + def get_for_tier(self, tier, equipment_class='standard', on_date=None): + """Return the active rate row for `tier` + `equipment_class` effective + on `on_date`. Tries (tier, class) first, falls back to (tier, standard) + if no class-specific row is configured. Empty recordset if none at all. + """ + on_date = on_date or fields.Date.context_today(self) + Domain = lambda cls: [ + ('tier', '=', tier), + ('equipment_class', '=', cls), + ('active', '=', True), + ('effective_from', '<=', on_date), + ('company_id', 'in', self.env.companies.ids), + ] + hit = self.sudo().search( + Domain(equipment_class or 'standard'), + order='effective_from desc', limit=1, + ) + if not hit and equipment_class and equipment_class != 'standard': + hit = self.sudo().search( + Domain('standard'), + order='effective_from desc', limit=1, + ) + return hit diff --git a/fusion_repairs/models/repair_dashboard.py b/fusion_repairs/models/repair_dashboard.py new file mode 100644 index 00000000..bde7633e --- /dev/null +++ b/fusion_repairs/models/repair_dashboard.py @@ -0,0 +1,189 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +"""Repair dashboard data provider. + +Feeds the OWL client action `fusion_repairs.dashboard` with KPI counts, +recent activity, and upcoming maintenance. Lives as an AbstractModel +because it stores nothing - all values are computed on demand. +""" + +from datetime import datetime, timedelta + +from odoo import api, fields, models + + +class FusionRepairDashboard(models.AbstractModel): + _name = 'fusion.repair.dashboard' + _description = 'Repair Dashboard Data Provider' + + @api.model + def get_dashboard_data(self): + """Return everything the dashboard needs in a single call.""" + Repair = self.env['repair.order'] + Contract = self.env['fusion.repair.maintenance.contract'] + today = fields.Date.context_today(self) + month_start = today.replace(day=1) + thirty_days = today + timedelta(days=30) + + # ---------------- KPI counters ---------------- + open_domain = [('state', 'not in', ('done', 'cancel'))] + urgent_domain = open_domain + [('x_fc_urgency', 'in', ('urgent', 'safety'))] + new_this_month_domain = [('create_date', '>=', month_start)] + no_task_domain = open_domain + [ + ('x_fc_technician_task_ids', '=', False), + ] + requote_domain = open_domain + [('x_fc_requires_requote', '=', True)] + + stats = { + 'open_count': Repair.search_count(open_domain), + 'urgent_count': Repair.search_count(urgent_domain), + 'new_this_month': Repair.search_count(new_this_month_domain), + 'awaiting_dispatch': Repair.search_count(no_task_domain), + 'requires_requote': Repair.search_count(requote_domain), + 'maintenance_due_30d': Contract.search_count([ + ('state', '=', 'active'), + ('next_due_date', '<=', thirty_days), + ]), + 'maintenance_active_total': Contract.search_count([ + ('state', '=', 'active'), + ]), + } + + # ---------------- Source breakdown for the doughnut ---------------- + source_rows = Repair._read_group( + open_domain, + ['x_fc_intake_source'], + ['__count'], + ) + source_breakdown = [] + source_labels = dict(Repair._fields['x_fc_intake_source'].selection) + for src, count in source_rows: + source_breakdown.append({ + 'key': src or 'manual', + 'label': source_labels.get(src or 'manual', src or 'Other'), + 'count': count, + }) + + # ---------------- Urgency breakdown ---------------- + urgency_rows = Repair._read_group( + open_domain, + ['x_fc_urgency'], + ['__count'], + ) + urgency_labels = dict(Repair._fields['x_fc_urgency'].selection) + urgency_breakdown = [{ + 'key': u or 'normal', + 'label': urgency_labels.get(u or 'normal', 'Normal'), + 'count': c, + } for u, c in urgency_rows] + + # ---------------- Recent service calls (last 5) ---------------- + recent = [] + for r in Repair.search([], order='create_date desc', limit=5): + recent.append({ + 'id': r.id, + 'name': r.name, + 'partner_name': r.partner_id.name or '', + 'category': r.x_fc_repair_category_id.name or '', + 'urgency': r.x_fc_urgency, + 'state': r.state, + 'state_label': dict(Repair._fields['state'].selection).get(r.state, r.state), + 'create_date': fields.Datetime.to_string(r.create_date), + 'source': r.x_fc_intake_source or '', + 'source_label': source_labels.get(r.x_fc_intake_source, ''), + }) + + # ---------------- Upcoming maintenance (next 5 due) ---------------- + upcoming = [] + for c in Contract.search( + [('state', '=', 'active'), ('next_due_date', '!=', False)], + order='next_due_date asc', limit=5, + ): + upcoming.append({ + 'id': c.id, + 'name': c.name, + 'partner_name': c.partner_id.name or '', + 'product_name': c.product_id.display_name or '', + 'next_due_date': fields.Date.to_string(c.next_due_date), + 'days_until': (c.next_due_date - today).days if c.next_due_date else 0, + 'reminder_band': c.last_reminder_band or '', + }) + + # ---------------- Portal URLs (resolved server-side) ---------------- + ICP = self.env['ir.config_parameter'].sudo() + base_url = ICP.get_param('web.base.url', '').rstrip('/') + portals = { + 'client_portal_url': base_url + (ICP.get_param( + 'fusion_repairs.client_portal_url', '/repair' + ) or '/repair'), + 'sales_rep_portal_url': base_url + '/my/repair/new', + } + + # ---------------- M7: failure-rate analytics ---------------- + # Top products by repair count in the last 90 days (excludes draft). + ninety = datetime.now() - timedelta(days=90) + failure_rows = Repair._read_group( + [ + ('create_date', '>=', ninety), + ('product_id', '!=', False), + ('state', '!=', 'cancel'), + ], + ['product_id'], + ['__count'], + order='__count desc', + limit=8, + ) + failures_by_product = [{ + 'product_id': p.id, + 'product_name': p.display_name, + 'repair_count': c, + } for p, c in failure_rows] + + # Top symptom categories (issue_category) in the last 90 days. + symptom_rows = Repair._read_group( + [ + ('create_date', '>=', ninety), + ('x_fc_issue_category', '!=', False), + ('state', '!=', 'cancel'), + ], + ['x_fc_issue_category'], + ['__count'], + order='__count desc', + limit=8, + ) + failures_by_symptom = [{ + 'symptom': s or 'Other', + 'repair_count': c, + } for s, c in symptom_rows] + + # M9: margin summary (open + done in the last 90 days). + margin_rows = self.env['repair.order'].search([ + ('create_date', '>=', ninety), + ('state', '!=', 'cancel'), + ]) + total_revenue = sum(margin_rows.mapped('x_fc_revenue')) + total_labour = sum(margin_rows.mapped('x_fc_labour_cost')) + total_parts = sum(margin_rows.mapped('x_fc_parts_cost')) + total_margin = total_revenue - total_labour - total_parts + margin_summary = { + 'revenue': total_revenue, + 'labour_cost': total_labour, + 'parts_cost': total_parts, + 'margin': total_margin, + 'margin_pct': (total_margin / total_revenue * 100) if total_revenue else 0.0, + 'sample_size': len(margin_rows), + } + + return { + 'stats': stats, + 'urgency_breakdown': urgency_breakdown, + 'source_breakdown': source_breakdown, + 'recent': recent, + 'upcoming': upcoming, + 'portals': portals, + 'failures_by_product': failures_by_product, + 'failures_by_symptom': failures_by_symptom, + 'margin_summary': margin_summary, + } diff --git a/fusion_repairs/models/repair_delivery_charge.py b/fusion_repairs/models/repair_delivery_charge.py new file mode 100644 index 00000000..066690ad --- /dev/null +++ b/fusion_repairs/models/repair_delivery_charge.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +"""Delivery / pickup rate card (separate from repair callouts). + +Per Westin's published rate card the DELIVERY / PICKUP CHARGES section is +a distinct service from repair callouts. These are charged when we move +equipment (drop-off of a sold unit, post-repair return delivery, removal +of old equipment, etc.). +""" + +from odoo import api, fields, models + + +class FusionRepairDeliveryCharge(models.Model): + _name = 'fusion.repair.delivery.charge' + _description = 'Delivery / Pickup Rate Card' + _order = 'sequence, charge_type, id' + + name = fields.Char(compute='_compute_name', store=True) + sequence = fields.Integer(default=10) + charge_type = fields.Selection( + [ + ('local', 'Local Service Area'), + ('outside', 'Outside Local Area'), + ('rush', 'Rush Pickup / Delivery'), + ('lift_chair_install', 'Lift Chair Delivery and Set-Up'), + ('hospital_bed_install', 'Hospital Bed Delivery and Set-Up'), + ('stairlift_install', 'Stairlift Delivery and Set-Up'), + ('stairlift_removal', 'Stairlift Removal'), + ('other', 'Other'), + ], + string='Charge Type', + required=True, + ) + amount = fields.Monetary( + string='Amount', + currency_field='currency_id', + required=True, + ) + travel_per_km_fee = fields.Monetary( + string='Per-km Fee (Rush, 2-way)', + currency_field='currency_id', + default=0.0, + help='Only applies to rush pickups/deliveries. Per the published card: ' + '$60 plus $0.70 per km x 2-way.', + ) + travel_distance_threshold_km = fields.Float( + string='Free Travel Distance (km, 2-way)', + default=0.0, + help='Only applies to rush. Above this km, every additional km is ' + 'charged travel_per_km_fee BOTH WAYS.', + ) + description = fields.Text(translate=True) + currency_id = fields.Many2one( + 'res.currency', + default=lambda self: self.env.company.currency_id, + ) + company_id = fields.Many2one( + 'res.company', default=lambda self: self.env.company, + ) + active = fields.Boolean(default=True) + + @api.depends('charge_type', 'amount') + def _compute_name(self): + for r in self: + label = dict(self._fields['charge_type'].selection).get(r.charge_type) or '?' + r.name = f'{label} - ${r.amount:.0f}' + + @api.model + def get_charge(self, charge_type): + """Return the active rate row for `charge_type`, empty recordset if none.""" + return self.sudo().search([ + ('charge_type', '=', charge_type), + ('active', '=', True), + ('company_id', 'in', self.env.companies.ids), + ], limit=1) + + @api.model + def quote_rush(self, distance_km): + """Convenience: returns the total for a Rush Pickup / Delivery at + `distance_km` one-way. Returns 0.0 if no rush row configured.""" + rush = self.get_charge('rush') + if not rush: + return 0.0 + over = max(distance_km - rush.travel_distance_threshold_km, 0.0) + return rush.amount + (over * 2.0 * rush.travel_per_km_fee) diff --git a/fusion_repairs/models/repair_emergency_charge.py b/fusion_repairs/models/repair_emergency_charge.py new file mode 100644 index 00000000..ddbc569f --- /dev/null +++ b/fusion_repairs/models/repair_emergency_charge.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +"""Emergency / rush service rate card. + +The pissed-off-grumpy-client scenario: stairlift dead at 5 PM Friday, needs +service yesterday. Office bumps them into today's route OR books them +priority for tomorrow OR (if after-hours / weekend) charges an emergency +surcharge. Sometimes more than one technician is needed (e.g. lifting an +adjustable bed back onto its frame) - per_tech_multiplier handles that. + +Pricing logic on repair.order: + + surcharge = base_amount + base_amount * per_tech_multiplier * + (techs_required - 1) + +Example: same-day stairlift, 1 tech, base $250, multiplier 0.5 + -> $250 surcharge +Example: same-day stairlift, 2 techs (one to hold, one to wrench) + -> $250 + $250 * 0.5 * 1 = $375 surcharge +""" + +from odoo import _, api, fields, models + + +class FusionRepairEmergencyCharge(models.Model): + _name = 'fusion.repair.emergency.charge' + _description = 'Rush / Emergency Service Surcharge Rate' + _order = 'category_id, tier' + + name = fields.Char( + compute='_compute_name', + store=True, + ) + category_id = fields.Many2one( + 'fusion.repair.product.category', + string='Equipment Category', + required=True, + ondelete='cascade', + index=True, + ) + tier = fields.Selection( + [ + ('same_day', 'Same Day (during business hours)'), + ('next_day', 'Next Day Priority'), + ('after_hours', 'After Hours (5pm-9pm weekday)'), + ('weekend', 'Weekend'), + ('holiday', 'Statutory Holiday'), + ], + string='Tier', + required=True, + ) + base_amount = fields.Monetary( + string='Base Surcharge', + currency_field='currency_id', + required=True, + default=0.0, + help='Surcharge for ONE technician on top of the normal labour / parts cost.', + ) + per_tech_multiplier = fields.Float( + string='Additional Tech Multiplier', + default=0.5, + help='Each additional technician adds base_amount * this multiplier ' + 'to the surcharge. Default 0.5 means tech #2 costs half the base.', + ) + currency_id = fields.Many2one( + 'res.currency', + default=lambda self: self.env.company.currency_id, + ) + company_id = fields.Many2one( + 'res.company', + default=lambda self: self.env.company, + ) + active = fields.Boolean(default=True) + description = fields.Text( + help='Internal note - shown to CS when they pick this tier in the wizard.', + ) + + _cat_tier_unique = models.Constraint( + 'unique(category_id, tier, company_id)', + 'Only one emergency-charge row per (category, tier, company).', + ) + + @api.depends('category_id', 'tier', 'base_amount') + def _compute_name(self): + for r in self: + tier_label = dict(self._fields['tier'].selection).get(r.tier) or '?' + cat = r.category_id.name or '?' + r.name = f'{cat} - {tier_label} (${r.base_amount:.0f})' + + @api.model + def calculate(self, category, tier, techs_required=1): + """Return the surcharge for the given category + tier + tech count, + or 0.0 if no rate is configured.""" + if not category or not tier or techs_required < 1: + return 0.0 + rate = self.sudo().search([ + ('category_id', '=', category.id), + ('tier', '=', tier), + ('active', '=', True), + ('company_id', 'in', self.env.companies.ids), + ], limit=1) + if not rate: + return 0.0 + extra = max(techs_required - 1, 0) + return rate.base_amount + (rate.base_amount * rate.per_tech_multiplier * extra) 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/models/repair_labor_warranty.py b/fusion_repairs/models/repair_labor_warranty.py new file mode 100644 index 00000000..d8b2cc99 --- /dev/null +++ b/fusion_repairs/models/repair_labor_warranty.py @@ -0,0 +1,231 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +"""Store labor warranty. + +Distinct from the manufacturer warranty. This is what we extend at point +of sale: "5-year labor warranty - bring it to the store, we fix the labour +for free". Carve-outs (user negligence, gross negligence, misuse, etc.) +are tracked explicitly so the visit-report wizard can VOID the warranty +in real time when the tech encounters one. + +Important boundary - WHAT THE WARRANTY COVERS: + - In-store labour: FREE + - Home callout (tech dispatched): callout fee STILL applies (it includes + inspection / report); the hourly labour beyond 30 min is free + - Parts: NEVER free unless covered by separate manufacturer warranty + - Travel: ALWAYS charged when over the distance threshold +""" + +from dateutil.relativedelta import relativedelta + +from markupsafe import Markup + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +VOID_REASONS = [ + ('user_negligence', 'User Negligence'), + ('gross_negligence', 'Gross Negligence'), + ('misuse', 'Misuse'), + ('over_recommended_use', 'Over-Recommended Use'), + ('accidental_damage', 'Accidental Damage'), + ('not_covered_part', 'Part Not Covered'), + ('other', 'Other (see notes)'), +] + + +class FusionRepairLaborWarranty(models.Model): + _name = 'fusion.repair.labor.warranty' + _inherit = ['mail.thread'] + _description = 'Store Labor Warranty' + _order = 'end_date desc, id desc' + + name = fields.Char( + string='Reference', + 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, + tracking=True, + ) + lot_id = fields.Many2one( + 'stock.lot', + string='Serial', + tracking=True, + ) + sale_order_id = fields.Many2one( + 'sale.order', + string='Sold On', + ondelete='set null', + tracking=True, + ) + + warranty_years = fields.Integer( + string='Years', + default=5, + required=True, + tracking=True, + ) + start_date = fields.Date( + string='Start', + default=fields.Date.context_today, + required=True, + tracking=True, + ) + end_date = fields.Date( + string='Ends', + compute='_compute_end_date', + store=True, + tracking=True, + ) + + state = fields.Selection( + [ + ('active', 'Active'), + ('expired', 'Expired'), + ('void', 'Void'), + ('consumed', 'Consumed'), + ], + string='Status', + default='active', + tracking=True, + compute='_compute_state', + store=True, + ) + + # When voided + void_reason = fields.Selection( + VOID_REASONS, + string='Void Reason', + tracking=True, + ) + void_notes = fields.Text(string='Void Notes') + voided_at = fields.Datetime(string='Voided At', copy=False) + voided_by_id = fields.Many2one('res.users', string='Voided By', copy=False) + + company_id = fields.Many2one( + 'res.company', + default=lambda self: self.env.company, + ) + + _name_unique = models.Constraint( + 'unique(name)', + 'Labor-warranty references must be unique.', + ) + + # ------------------------------------------------------------------ + # CRUD + # ------------------------------------------------------------------ + @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.labor.warranty' + ) or 'LW/NEW' + return super().create(vals_list) + + # ------------------------------------------------------------------ + # COMPUTES + # ------------------------------------------------------------------ + @api.depends('start_date', 'warranty_years') + def _compute_end_date(self): + for r in self: + if r.start_date and r.warranty_years: + r.end_date = r.start_date + relativedelta(years=r.warranty_years) + else: + r.end_date = False + + @api.depends('void_reason', 'end_date') + def _compute_state(self): + today = fields.Date.context_today(self) + for r in self: + if r.state == 'consumed': + continue + if r.void_reason: + r.state = 'void' + elif r.end_date and r.end_date < today: + r.state = 'expired' + else: + r.state = 'active' + + # ------------------------------------------------------------------ + # LOOKUP + # ------------------------------------------------------------------ + @api.model + def find_active_for(self, partner, product=None, lot=None): + """Find the active labor warranty covering (partner, product/lot). + + Specificity order: + 1. exact lot match + 2. product + partner match + 3. partner-only match (last resort) + """ + if not partner: + return self.browse() + today = fields.Date.context_today(self) + base_domain = [ + ('partner_id', '=', partner.id), + ('state', '=', 'active'), + ('end_date', '>=', today), + ] + if lot: + hit = self.sudo().search( + base_domain + [('lot_id', '=', lot.id)], + order='end_date desc', limit=1, + ) + if hit: + return hit + if product: + hit = self.sudo().search( + base_domain + [('product_id', '=', product.id)], + order='end_date desc', limit=1, + ) + if hit: + return hit + return self.browse() + + # ------------------------------------------------------------------ + # VOID + # ------------------------------------------------------------------ + def action_void(self, reason='other', notes=''): + if not reason: + raise UserError(_('A void reason is required.')) + for r in self: + r.write({ + 'void_reason': reason, + 'void_notes': notes, + 'voided_at': fields.Datetime.now(), + 'voided_by_id': self.env.uid, + }) + r.message_post(body=Markup(_( + 'Warranty voided by %(user)s. Reason: %(reason)s.' + )) % { + 'user': self.env.user.name, + 'reason': dict(VOID_REASONS).get(reason, reason), + }) + + def action_reinstate(self): + for r in self: + r.write({ + 'void_reason': False, + 'void_notes': False, + 'voided_at': False, + 'voided_by_id': False, + }) + r.message_post(body=_('Warranty reinstated.')) diff --git a/fusion_repairs/models/repair_on_call_service.py b/fusion_repairs/models/repair_on_call_service.py new file mode 100644 index 00000000..6cc133e5 --- /dev/null +++ b/fusion_repairs/models/repair_on_call_service.py @@ -0,0 +1,226 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +"""On-call service - finds the next on-call manager and pages them. + +Triggered when a safety-flagged repair comes in outside business hours +(or any time, if the user wants to call us about a stuck stairlift). + +Per the design spec section "Weekend safety escalation": + 1. 911 disclaimer is shown to the client + 2. repair.order created with priority=high + Monday-followup activity + 3. Page next on-call manager (lowest x_fc_on_call_priority among + active users with x_fc_on_call=True) + 4. SMS + email sent; tokenized /repair/on-call/ack/ for ack + 5. 15-minute escalation cron pages next priority if first doesn't ack + 6. All actions logged to repair chatter + +Phase 2 ships with priority-int sorting; Phase 4 will replace with proper +shift scheduling (date ranges per on-call user). +""" + +import logging +import secrets +from datetime import timedelta + +from markupsafe import Markup + +from odoo import _, api, fields, models + +_logger = logging.getLogger(__name__) + + +class FusionRepairOnCallService(models.AbstractModel): + _name = 'fusion.repair.on.call.service' + _description = 'Repair On-Call Paging Service' + + # ------------------------------------------------------------------ + # PUBLIC API + # ------------------------------------------------------------------ + @api.model + 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() + domain = [ + ('x_fc_on_call', '=', True), + ('active', '=', True), + ('id', 'not in', exclude_user_ids), + ] + 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. + + - 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(): + _logger.info('On-call page skipped for %s - inside business hours', + repair.name) + return self.env['res.users'] + + # 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'] + + token = secrets.token_urlsafe(20) + repair.write({ + 'x_fc_on_call_token': token, + 'x_fc_on_call_paged_user_id': target.id, + 'x_fc_on_call_paged_at': fields.Datetime.now(), + }) + + 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 + def acknowledge(self, repair, user): + """Mark a repair's on-call page as acknowledged by `user`.""" + repair.ensure_one() + repair.x_fc_on_call_acknowledged_user_ids = [(4, user.id)] + repair.x_fc_on_call_acknowledged_at = fields.Datetime.now() + repair.message_post(body=Markup(_( + 'On-call page acknowledged by %s.' + )) % (user.name or user.login or '')) + + @api.model + def cron_escalate_unacknowledged(self): + """Cron: re-page the next priority for any repair whose first page + is older than 15 minutes without acknowledgement.""" + ICP = self.env['ir.config_parameter'].sudo() + try: + window_min = int(ICP.get_param( + 'fusion_repairs.on_call_escalate_minutes', '15' + )) + except (ValueError, TypeError): + window_min = 15 + cutoff = fields.Datetime.now() - timedelta(minutes=window_min) + Repair = self.env['repair.order'].sudo() + stale = Repair.search([ + ('x_fc_on_call_paged_at', '!=', False), + ('x_fc_on_call_paged_at', '<=', cutoff), + ('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: + self.page_on_call(r, force=True) + + # ------------------------------------------------------------------ + # HELPERS + # ------------------------------------------------------------------ + @api.model + def _is_business_hours(self): + """True when within the company resource_calendar's working time.""" + cal = self.env.company.resource_calendar_id + if not cal: + return False # Treat "no calendar" as always after-hours so we always page. + now = fields.Datetime.now() + try: + return bool(cal._work_intervals_batch(now, now)[False]) + except Exception: + return False + + @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 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): + repair.message_post(body=Markup(_( + 'After-hours safety paged %(name)s ' + '(priority %(p)s). Awaiting acknowledgement.' + )) % { + 'name': target.name or target.login or '', + 'p': str(target.x_fc_on_call_priority or 99), + }) + + @api.model + def _notify_office_no_oncall(self, repair): + _logger.error( + 'No on-call user configured (x_fc_on_call=True) - safety repair ' + '%s will queue for Monday with no page.', + repair.name, + ) + repair.message_post(body=Markup(_( + 'WARNING: No on-call user ' + '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 new file mode 100644 index 00000000..80595374 --- /dev/null +++ b/fusion_repairs/models/repair_order.py @@ -0,0 +1,1217 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +import logging +from datetime import date, datetime, timedelta + +from dateutil.relativedelta import relativedelta + +from markupsafe import Markup + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +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' + + # ------------------------------------------------------------------ + # CREATE - replace the picking-type default sequence with our + # date-based RO-YYYYMM-NN reference. We set vals['name'] BEFORE + # super() so Odoo's native create() (which only assigns the picking + # type sequence when name is empty or 'New') skips its own numbering. + # ------------------------------------------------------------------ + @api.model_create_multi + def create(self, vals_list): + Sequence = self.env['ir.sequence'].sudo() + for vals in vals_list: + if not vals.get('name') or vals.get('name') == 'New': + next_name = Sequence.next_by_code('fusion.repair.order.monthly') + if next_name: + vals['name'] = next_name + return super().create(vals_list) + + # ------------------------------------------------------------------ + # 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', + ) + + # 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.', + ) + + # C6: quote-only flag (set when intake submitted in quote-only mode). + x_fc_is_quote_only = fields.Boolean( + string='Quote Only', + tracking=True, + index=True, + help='True when the intake was submitted in "Quote Only" mode - the ' + 'office has not yet authorised dispatching a technician.', + ) + + # ------------------------------------------------------------------ + # ON-CALL PAGING (CL15) + # Set when a safety repair is paged to the on-call manager. Allows + # ack and the 15-minute escalation cron to roll forward to the next + # priority if not acknowledged. + # ------------------------------------------------------------------ + x_fc_on_call_token = fields.Char( + string='On-Call Ack Token', + copy=False, + index=True, + ) + x_fc_on_call_paged_user_id = fields.Many2one( + 'res.users', + string='On-Call Paged User', + copy=False, + index=True, + ) + x_fc_on_call_paged_at = fields.Datetime( + string='On-Call Paged At', + copy=False, + ) + x_fc_on_call_acknowledged_user_ids = fields.Many2many( + 'res.users', + 'fusion_repair_on_call_ack_rel', + 'repair_id', 'user_id', + string='On-Call Acknowledgements', + copy=False, + ) + x_fc_on_call_acknowledged_at = fields.Datetime( + string='Acknowledged At', + copy=False, + ) + + _on_call_token_unique = models.Constraint( + 'unique(x_fc_on_call_token)', + 'On-call acknowledgement tokens must be unique.', + ) + + # ------------------------------------------------------------------ + # X4 + M3 - NPS sent flag + loaner offered flag + done-at stamp + # (X2 day-before flag now lives on fusion.technician.task per H1) + # ------------------------------------------------------------------ + x_fc_nps_email_sent = fields.Boolean( + string='NPS Email Sent', + copy=False, + ) + x_fc_done_at = fields.Datetime( + string='Closed At', + copy=False, + readonly=True, + help='Stamped when the repair first transitions to state=done. ' + 'Drives the post-visit NPS cron (24h after close) without ' + 'getting pushed forward by every subsequent chatter message.', + ) + 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.', + ) + + # ------------------------------------------------------------------ + # Bundle 8: RUSH / EMERGENCY SERVICE + # ------------------------------------------------------------------ + x_fc_rush_requested = fields.Boolean( + string='Rush / Emergency Service', + tracking=True, + copy=False, + ) + x_fc_rush_tier = fields.Selection( + [ + ('same_day', 'Same Day'), + ('next_day', 'Next Day Priority'), + ('after_hours', 'After Hours'), + ('weekend', 'Weekend'), + ('holiday', 'Statutory Holiday'), + ], + string='Rush Tier', + tracking=True, + copy=False, + ) + x_fc_rush_techs_required = fields.Integer( + string='Technicians Required', + default=1, + copy=False, + help='Some calls need 2+ techs (e.g. heavy lifting, controller programming ' + 'plus mechanical). Surcharge scales accordingly.', + ) + x_fc_rush_surcharge = fields.Monetary( + string='Rush Surcharge', + currency_field='company_currency_id', + compute='_compute_rush_surcharge', + store=True, + tracking=True, + ) + x_fc_rush_acknowledged_at = fields.Datetime( + string='Rush Surcharge Acknowledged', + copy=False, + readonly=True, + help='Stamped when CS records that the client agreed to the rush price.', + ) + x_fc_rush_acknowledged_by_id = fields.Many2one( + 'res.users', + string='Acknowledged By', + copy=False, + readonly=True, + help='The CS rep who got the verbal OK from the client.', + ) + + # ------------------------------------------------------------------ + # Bundle 8: PARTS AWAITING (when tech can't fix on the first visit) + # ------------------------------------------------------------------ + x_fc_parts_awaiting = fields.Boolean( + string='Awaiting Parts', + tracking=True, + copy=False, + index=True, + help='Tech could not complete the repair without ordering parts. ' + 'Repair stays open; clears automatically when the last linked ' + 'fusion.repair.part.order moves to "received".', + ) + x_fc_parts_eta_date = fields.Date( + string='Parts ETA', + copy=False, + tracking=True, + ) + x_fc_part_order_ids = fields.One2many( + 'fusion.repair.part.order', + 'repair_order_id', + string='Part Orders', + copy=False, + ) + x_fc_part_order_count = fields.Integer( + compute='_compute_part_order_count', + string='# Part Orders', + ) + + # ------------------------------------------------------------------ + # Bundle 9: SERVICE CALLOUT PRICING + LABOR WARRANTY + # ------------------------------------------------------------------ + x_fc_callout_tier = fields.Selection( + [ + ('regular', 'Regular Business Hours'), + ('rush', 'Rush Service'), + ('after_hours', 'After Hours'), + ('weekend', 'Weekend'), + ('holiday', 'Statutory Holiday'), + ], + string='Callout Tier', + default='regular', + tracking=True, + help='Which rate-card tier applies. Set by CS at intake; can be changed ' + 'by dispatcher if the schedule moves into after-hours / weekend.', + ) + x_fc_callout_distance_km = fields.Float( + string='One-Way Distance (km)', + tracking=True, + help='Distance from the shop to the client. Travel beyond the rate-card ' + "threshold is billed BOTH WAYS at the rate's per-km fee.", + ) + x_fc_callout_techs = fields.Integer( + string='Technicians on Callout', + default=1, + tracking=True, + ) + x_fc_callout_labor_hours = fields.Float( + string='Billable Labor Hours', + default=0.0, + tracking=True, + help='Hours of repair work above the 30 min included in the callout fee. ' + 'Billing applies the minimum_labor_hours floor from the rate card ' + '(default 1.0) AND rounds up to the next 30-min increment - ' + '20 minutes bills 1.0 h, 75 minutes bills 1.5 h.', + ) + # Bundle 10: in-shop work uses a different (lower) hourly rate AND + # waives both the callout fee and the travel charge - client brought + # the unit to the store. + x_fc_in_shop = fields.Boolean( + string='In-Shop Repair', + tracking=True, + help='Work done in the store (no callout, no travel). Uses ' + 'in_shop_labor_rate from the rate card.', + ) + # Labor warranty link + status (resolved at visit time) + x_fc_labor_warranty_id = fields.Many2one( + 'fusion.repair.labor.warranty', + string='Store Labor Warranty', + tracking=True, + help='Auto-resolved when the visit-report wizard runs - links to the ' + 'active store labor warranty for this client + product if any.', + ) + x_fc_labor_warranty_status = fields.Selection( + [ + ('not_checked', 'Not Yet Checked'), + ('eligible', 'Covered - Labor Free'), + ('not_covered', 'No Warranty on File'), + ('expired', 'Warranty Expired'), + ('void_misuse', 'Void - Misuse / Negligence'), + ('waived', 'Manually Waived'), + ], + string='Labor Warranty Status', + default='not_checked', + tracking=True, + ) + # Manual labor-fee waiver (manager / sales rep only) + x_fc_labor_waived = fields.Boolean( + string='Labor Fee Waived', + tracking=True, + copy=False, + readonly=True, + ) + x_fc_labor_waived_by_id = fields.Many2one( + 'res.users', + string='Labor Waived By', + tracking=True, + copy=False, + readonly=True, + ) + x_fc_labor_waived_at = fields.Datetime( + string='Labor Waived At', + tracking=True, + copy=False, + readonly=True, + ) + x_fc_labor_waived_reason = fields.Char( + string='Labor Waiver Reason', + copy=False, + ) + + # Computed quote breakdown (all non-stored - depend on the rate-card) + x_fc_quote_callout_base = fields.Monetary( + string='Base Callout Fee', + currency_field='company_currency_id', + compute='_compute_callout_quote', + ) + x_fc_quote_extra_techs = fields.Monetary( + string='Extra Tech Fees', + currency_field='company_currency_id', + compute='_compute_callout_quote', + ) + x_fc_quote_labor = fields.Monetary( + string='Labor Charge', + currency_field='company_currency_id', + compute='_compute_callout_quote', + ) + x_fc_quote_travel = fields.Monetary( + string='Travel Charge', + currency_field='company_currency_id', + compute='_compute_callout_quote', + ) + x_fc_quote_waived = fields.Monetary( + string='Less: Waived', + currency_field='company_currency_id', + compute='_compute_callout_quote', + ) + x_fc_quote_total = fields.Monetary( + string='Quote Total (excl. parts)', + currency_field='company_currency_id', + compute='_compute_callout_quote', + store=True, # stored so we can show it on list views and search + ) + x_fc_quote_breakdown_text = fields.Text( + string='Quote Breakdown', + compute='_compute_callout_quote', + help='Human-readable line-by-line breakdown - used in the quote email.', + ) + + @api.depends('x_fc_callout_tier', 'x_fc_callout_distance_km', + 'x_fc_callout_techs', 'x_fc_callout_labor_hours', + 'x_fc_labor_warranty_status', 'x_fc_labor_waived', + 'x_fc_in_shop', 'x_fc_repair_category_id') + def _compute_callout_quote(self): + import math + Rate = self.env['fusion.repair.callout.rate'].sudo() + for r in self: + tier = r.x_fc_callout_tier or 'regular' + cls = (r.x_fc_repair_category_id.equipment_class + or 'standard') if r.x_fc_repair_category_id else 'standard' + rate = Rate.get_for_tier(tier, equipment_class=cls) + techs = max(r.x_fc_callout_techs or 1, 1) + hours = max(r.x_fc_callout_labor_hours or 0.0, 0.0) + distance = r.x_fc_callout_distance_km or 0.0 + in_shop = bool(r.x_fc_in_shop) + + if in_shop: + # In-shop: no callout, no extra-tech, no travel. Only labour + # at the lower in_shop_labor_rate. + base = 0.0 + extra_techs = 0.0 + travel = 0.0 + else: + base = rate.base_callout_fee if rate else 0.0 + extra_techs = 0.0 + if rate and techs >= 2 and rate.second_tech_fee: + extra_techs += rate.second_tech_fee + if rate and techs >= 3: + per_extra = rate.additional_tech_fee or rate.second_tech_fee or 0.0 + extra_techs += per_extra * (techs - 2) + # Travel - both ways, per tech, for distance over threshold. + travel = 0.0 + if rate: + over = max(distance - rate.travel_distance_threshold_km, 0.0) + travel = over * 2.0 * rate.travel_per_km_fee * techs + + # Labour: per the published rate card - + # * minimum_labor_hours floor (default 1.0) + # * beyond the floor, pro-rated in 30-min increments + # (i.e. round UP to the next 0.5 h) + # * per tech (footnote: 'If multiple technicians are required, + # rates will apply per technician') + # * in-shop uses in_shop_labor_rate + labor = 0.0 + if rate and hours > 0: + min_hours = rate.minimum_labor_hours or 1.0 + # ceil(actual * 2) / 2 -> rounds up to next 0.5 increment + rounded_up = math.ceil(hours * 2) / 2.0 + billable_h = max(rounded_up, min_hours) + hourly = (rate.in_shop_labor_rate + if in_shop else rate.hourly_labor_rate) + labor = billable_h * hourly * techs + + waived = 0.0 + if (r.x_fc_labor_warranty_status in ('eligible', 'waived') + or r.x_fc_labor_waived): + waived = labor + + total = base + extra_techs + labor + travel - waived + + r.x_fc_quote_callout_base = base + r.x_fc_quote_extra_techs = extra_techs + r.x_fc_quote_labor = labor + r.x_fc_quote_travel = travel + r.x_fc_quote_waived = waived + r.x_fc_quote_total = total + + # Human-readable breakdown for chatter / quote emails. + class_label = 'Lift & Elevating' if cls == 'lift_elevating' else 'Standard' + hourly_used = ( + (rate.in_shop_labor_rate if in_shop else rate.hourly_labor_rate) + if rate else 0.0 + ) + mode_label = 'IN-SHOP' if in_shop else f'on-site ({tier})' + lines = [] + if not in_shop: + lines.append(f'Service Call ({class_label}, {tier}, incl. 30 min): ${base:.2f}') + if extra_techs: + lines.append(f'Additional technicians ({techs - 1}): ${extra_techs:.2f}') + if labor: + lines.append( + f'Labour {mode_label}: ' + f'{hours:.2f} h actual -> billed ' + f'{max(math.ceil(hours * 2) / 2.0, rate.minimum_labor_hours if rate else 1.0):.2f} h ' + f'x {techs} tech x ${hourly_used:.2f}/h = ${labor:.2f}' + ) + if travel: + over_km = max(distance - (rate.travel_distance_threshold_km if rate else 25), 0) + lines.append( + f'Travel: {distance:.1f} km, {over_km:.1f} km over threshold, ' + f'both ways x {techs} tech x ${rate.travel_per_km_fee if rate else 0}/km = ${travel:.2f}' + ) + if waived: + lines.append(f'Less labour waived: -${waived:.2f}') + lines.append('-' * 50) + lines.append(f'TOTAL (excl. parts): ${total:.2f}') + r.x_fc_quote_breakdown_text = '\n'.join(lines) + + def action_check_labor_warranty(self): + """Look up the active store labor warranty for this repair's + partner + product. Updates x_fc_labor_warranty_id and + x_fc_labor_warranty_status. Called from the visit-report wizard + AND from the dashboard's "check warranty" button.""" + Warr = self.env['fusion.repair.labor.warranty'].sudo() + for r in self: + w = Warr.find_active_for( + r.partner_id, r.product_id, r.lot_id or False, + ) + r.x_fc_labor_warranty_id = w.id if w else False + if not w: + r.x_fc_labor_warranty_status = 'not_covered' + elif w.state == 'expired': + r.x_fc_labor_warranty_status = 'expired' + elif w.state == 'void': + r.x_fc_labor_warranty_status = 'void_misuse' + else: + r.x_fc_labor_warranty_status = 'eligible' + + def action_waive_labor_fee(self): + """Manager / sales rep only. CS rep cannot waive.""" + Group = self.env.ref + user = self.env.user + can_waive = ( + user.has_group('fusion_repairs.group_fusion_repairs_manager') + or user.has_group('fusion_repairs.group_fusion_repairs_sales_rep') + ) + if not can_waive: + raise UserError(_( + 'Only Repairs Managers and Sales Reps can waive the labor fee. ' + 'CS staff must escalate to a manager.' + )) + for r in self: + r.write({ + 'x_fc_labor_waived': True, + 'x_fc_labor_waived_by_id': user.id, + 'x_fc_labor_waived_at': fields.Datetime.now(), + 'x_fc_labor_warranty_status': 'waived', + }) + r.message_post(body=Markup(_( + 'Labor fee waived by %(user)s. (Reason: %(reason)s)' + )) % { + 'user': user.name, + 'reason': r.x_fc_labor_waived_reason or 'goodwill', + }) + + @api.depends('x_fc_rush_tier', 'x_fc_rush_techs_required', + 'x_fc_repair_category_id') + def _compute_rush_surcharge(self): + Rates = self.env['fusion.repair.emergency.charge'].sudo() + for r in self: + if not r.x_fc_rush_tier or not r.x_fc_repair_category_id: + r.x_fc_rush_surcharge = 0.0 + continue + r.x_fc_rush_surcharge = Rates.calculate( + r.x_fc_repair_category_id, + r.x_fc_rush_tier, + r.x_fc_rush_techs_required or 1, + ) + + @api.depends('x_fc_part_order_ids') + def _compute_part_order_count(self): + for r in self: + r.x_fc_part_order_count = len(r.x_fc_part_order_ids) + + def action_view_part_orders(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': _('Part Orders'), + 'res_model': 'fusion.repair.part.order', + 'view_mode': 'list,form', + 'domain': [('repair_order_id', '=', self.id)], + 'context': {'default_repair_order_id': self.id}, + } + + def action_acknowledge_rush(self): + """CS clicks this AFTER getting verbal OK from the client on the rush price.""" + for r in self: + r.x_fc_rush_acknowledged_at = fields.Datetime.now() + r.x_fc_rush_acknowledged_by_id = self.env.user + r.message_post(body=Markup(_( + 'Rush surcharge of %(amt).2f acknowledged by client ' + '(verbal OK to %(rep)s).' + )) % { + 'amt': r.x_fc_rush_surcharge, + 'rep': self.env.user.name, + }) + + def action_squeeze_into_today(self): + """Squeeze this repair into a field tech's existing route today. + + Picks the lightest-loaded skilled tech, finds the first free 1-hour + slot in their day, creates / updates the dispatch task to that slot, + and pushes a live bus.bus notification + email so the tech knows + mid-shift. + """ + from datetime import date as _date + today = _date.today() + Task = self.env['fusion.technician.task'].sudo() + for r in self: + tech_id = self._fc_find_lightest_today_tech() + if not tech_id: + raise UserError(_( + 'No field-staff users available - mark someone as Field ' + 'Staff under Settings > Users and try again.' + )) + slot_start, slot_end = self._fc_find_free_slot_today(tech_id) + if slot_start is None: + raise UserError(_( + "%s has no free hour left today. Either bump an existing " + "task or schedule for tomorrow instead." + ) % self.env['res.users'].sudo().browse(tech_id).name) + existing = r.x_fc_technician_task_ids.filtered( + lambda t: t.status not in ('completed', 'cancelled') + ) + vals = { + 'technician_id': tech_id, + 'scheduled_date': today, + 'time_start': slot_start, + 'time_end': slot_end, + } + if existing: + task = existing[0] + task.write(vals) + else: + self.env['fusion.repair.intake.service'].sudo() \ + .with_context( + force_tech_id=tech_id, + force_schedule={ + 'scheduled_date': today, + 'time_start': slot_start, + 'time_end': slot_end, + }, + ) \ + ._create_dispatch_task(r) + task = r.x_fc_technician_task_ids[:1] + self._notify_tech_of_rush(task) + r.message_post(body=Markup(_( + 'Squeezed into %(name)s\'s route today at ' + '%(start).0f:00 - %(end).0f:00; tech notified.' + )) % { + 'name': task.technician_id.name or '?', + 'start': slot_start, + 'end': slot_end, + }) + + def _fc_find_free_slot_today(self, tech_id): + """Return (start_float, end_float) for the first free 1-hour window + in this tech's day between 9 AM and 6 PM, or (None, None).""" + from datetime import date as _date + today = _date.today() + Task = self.env['fusion.technician.task'].sudo() + existing = Task.search([ + ('technician_id', '=', tech_id), + ('scheduled_date', '=', today), + ('status', 'not in', ('completed', 'cancelled')), + ]) + # Build a set of busy hours (rounded down to integer hours). + busy = set() + for t in existing: + s = int(t.time_start or 0) + e = int(t.time_end or s + 1) + for h in range(s, max(s + 1, e)): + busy.add(h) + # Scan 9 AM - 5 PM (last slot is 17:00-18:00 inclusive). + for hour in range(9, 18): + if hour not in busy: + return float(hour), float(hour + 1) + return None, None + + def _fc_find_lightest_today_tech(self): + """Return the field-staff user with the fewest scheduled tasks today. + + Honors skills filter if this repair has a category. + """ + Users = self.env['res.users'].sudo() + Task = self.env['fusion.technician.task'].sudo() + from datetime import date as _date + today = _date.today() + domain = [ + ('x_fc_is_field_staff', '=', True), + ('active', '=', True), + ] + if self.x_fc_repair_category_id: + domain.append( + ('x_fc_repair_skills', 'in', [self.x_fc_repair_category_id.id]) + ) + candidates = Users.search(domain) + if not candidates: + # Fallback: any active field staff (skills filter relaxed). + candidates = Users.search([ + ('x_fc_is_field_staff', '=', True), + ('active', '=', True), + ]) + if not candidates: + return False + # Pick the one with the fewest scheduled tasks today. + ranked = [] + for u in candidates: + count = Task.search_count([ + ('technician_id', '=', u.id), + ('scheduled_date', '=', today), + ('status', 'not in', ('completed', 'cancelled')), + ]) + ranked.append((count, u.id)) + ranked.sort() + return ranked[0][1] + + def _notify_tech_of_rush(self, task): + """Send a real-time bus push + email so the tech sees it mid-shift.""" + for r in self: + tech = task.technician_id + if not tech: + continue + # 1) bus.bus live push (shows as a sticky in-app notification). + try: + # bus.bus.sendone goes to a specific user channel; the + # web client displays it via the simple_notification service. + self.env['bus.bus']._sendone( + tech.partner_id, + 'simple_notification', + { + 'type': 'warning', + 'title': _('RUSH service added to your route'), + 'message': (_('Stairlift / urgent stop at %(client)s. ' + 'Repair %(name)s. See your tasks.') % { + 'client': r.partner_id.name or '', + 'name': r.name, + }), + 'sticky': True, + }, + ) + except Exception: + _logger.warning('bus.bus push failed for tech %s', tech.login) + # 2) email (matters if the tech is offline at the moment of squeeze). + tpl = self.env.ref( + 'fusion_repairs.email_template_rush_tech_alert', + raise_if_not_found=False, + ) + if tpl: + try: + tpl.with_context(tech_email=tech.email or tech.partner_id.email or '') \ + .send_mail(r.id, force_send=True, email_values={ + 'email_to': tech.email or tech.partner_id.email or '', + }) + except Exception: + _logger.warning('Rush-alert email failed for repair %s', r.name) + # 3) chatter on the task itself so the tech sees it inline. + task.message_post(body=Markup(_( + 'RUSH ADDED to your day: %(client)s - %(name)s. ' + 'Office squeezed it in.' + )) % { + 'client': r.partner_id.name or '?', + 'name': r.name, + }) + + # ------------------------------------------------------------------ + # M9 - Margin per repair (revenue - labour cost - parts cost) + # All non-stored computes; surfaced in the M7 analytics dashboard. + # ------------------------------------------------------------------ + x_fc_revenue = fields.Monetary( + string='Revenue', + currency_field='company_currency_id', + compute='_compute_margin', + help='Sum of posted invoice totals for the repair sale order.', + ) + x_fc_labour_cost = fields.Monetary( + string='Labour Cost', + currency_field='company_currency_id', + compute='_compute_margin', + help='Sum of (hours x technician cost rate) over all completed visits.', + ) + x_fc_parts_cost = fields.Monetary( + string='Parts Cost', + currency_field='company_currency_id', + compute='_compute_margin', + help='Sum of standard_price for parts consumed via repair operations.', + ) + x_fc_margin = fields.Monetary( + string='Margin', + currency_field='company_currency_id', + compute='_compute_margin', + help='Revenue - labour cost - parts cost.', + ) + x_fc_margin_pct = fields.Float( + string='Margin %', + compute='_compute_margin', + ) + + def _compute_margin(self): + for r in self: + revenue = 0.0 + if r.sale_order_id and hasattr(r.sale_order_id, 'invoice_ids'): + for inv in r.sale_order_id.invoice_ids.filtered( + lambda m: m.state == 'posted' and m.move_type == 'out_invoice' + ): + revenue += inv.amount_untaxed or 0.0 + labour = 0.0 + for task in r.x_fc_technician_task_ids: + if task.status != 'completed': + continue + rate = task.technician_id.x_fc_tech_cost_rate or 0.0 + labour += (task.duration_hours or 0.0) * rate + parts = 0.0 + for move in r.move_ids.filtered(lambda m: m.repair_line_type == 'add'): + parts += (move.product_id.standard_price or 0.0) * (move.product_uom_qty or 0.0) + r.x_fc_revenue = revenue + r.x_fc_labour_cost = labour + r.x_fc_parts_cost = parts + margin = revenue - labour - parts + r.x_fc_margin = margin + r.x_fc_margin_pct = (margin / revenue * 100) if revenue else 0.0 + + def write(self, vals): + # H2: stamp x_fc_done_at the first time state transitions to 'done' + # so the NPS cron has a stable timestamp (write_date moves on every + # chatter / invoice / attachment write). + if vals.get('state') == 'done': + for r in self: + if r.state != 'done' and not r.x_fc_done_at: + vals = dict(vals) + vals['x_fc_done_at'] = fields.Datetime.now() + break + return super().write(vals) + + # ------------------------------------------------------------------ + # X2 / X4 / M3 crons + # ------------------------------------------------------------------ + @api.model + def cron_send_day_before_reminders(self): + """X2: email the client the day before each scheduled tech visit. + + Per-TASK flag (not per-repair) so multi-visit repairs get a + separate reminder for each individual visit. + """ + if not self._notifications_enabled(): + return + tomorrow = date.today() + timedelta(days=1) + Task = self.env['fusion.technician.task'].sudo() + tasks = Task.search([ + ('scheduled_date', '=', tomorrow), + ('x_fc_day_before_reminder_sent', '=', False), + ('x_fc_repair_order_id', '!=', False), + ('x_fc_repair_order_id.state', 'in', ('confirmed', 'under_repair')), + ]) + tpl = self.env.ref( + 'fusion_repairs.email_template_visit_day_before', + raise_if_not_found=False, + ) + if not tpl: + _logger.warning('X2 day-before cron: email template missing') + return + for task in tasks: + repair = task.x_fc_repair_order_id + if not repair.partner_id or not repair.partner_id.email: + task.x_fc_day_before_reminder_sent = True # don't keep retrying + continue + try: + # Pass the specific task via context so the template renders + # the right scheduled date / technician (H3). + tpl.with_context(reminder_task_id=task.id) \ + .send_mail(repair.id, force_send=False) + except Exception: + _logger.exception('X2 day-before reminder failed for task %s', task.name) + # Still set the flag - the task's "tomorrow" is gone after midnight + # so retrying tomorrow would email about the wrong date. + task.x_fc_day_before_reminder_sent = True + + @api.model + def cron_send_post_visit_nps(self): + """X4: send NPS / Google review email 24h after state=done. + + Uses x_fc_done_at (H2) so chatter writes don't push the timestamp + forward. + """ + if not self._notifications_enabled(): + return + cutoff = datetime.now() - timedelta(hours=24) + repairs = self.search([ + ('state', '=', 'done'), + ('x_fc_nps_email_sent', '=', False), + ('x_fc_done_at', '!=', False), + ('x_fc_done_at', '<=', cutoff), + ]) + tpl = self.env.ref( + 'fusion_repairs.email_template_post_visit_nps', + raise_if_not_found=False, + ) + if not tpl: + _logger.warning('X4 NPS cron: email template missing') + return + for r in repairs: + if not r.partner_id or not r.partner_id.email: + r.x_fc_nps_email_sent = True # don't keep retrying + continue + try: + tpl.send_mail(r.id, force_send=False) + except Exception: + _logger.exception('X4 NPS email failed for repair %s', r.name) + r.x_fc_nps_email_sent = True + + @api.model + def cron_offer_loaner_for_long_repairs(self): + """M3: post an Offer-Loaner activity when a confirmed/in-repair + order has been waiting longer than threshold days. + + Soft-depends on fusion_loaners_management - silently no-ops when + the loaner model isn't installed. Uses schedule_date (or create_date + as fallback) so quote-only / draft repairs aren't bothered. + """ + if 'fusion.loaner.checkout' not in self.env: + 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 + cutoff = datetime.now() - timedelta(days=threshold) + activity_type = self.env.ref( + 'fusion_repairs.mail_activity_type_loaner_offer', + raise_if_not_found=False, + ) + if not activity_type: + _logger.warning('M3 loaner cron: activity type missing, skipping') + return + repairs = self.search([ + ('state', 'in', ('confirmed', 'under_repair')), + ('x_fc_is_quote_only', '=', False), + ('x_fc_loaner_offered', '=', False), + '|', + '&', ('schedule_date', '!=', False), ('schedule_date', '<=', cutoff), + '&', ('schedule_date', '=', False), ('create_date', '<=', cutoff), + ], limit=200, order='create_date desc') + for r in repairs: + try: + r.activity_schedule( + activity_type_id=activity_type.id, + summary='Offer a loaner unit', + note=( + 'This repair has been waiting 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: + _logger.exception( + 'M3 loaner cron: activity_schedule failed for repair %s', + r.name, + ) + + @api.model + def _notifications_enabled(self): + # Delegate to the shared intake-service single source of truth (M2). + Service = self.env.get('fusion.repair.intake.service') + if Service: + return Service._notifications_enabled() + return self.env['ir.config_parameter'].sudo().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() + if 'fusion.loaner.checkout' not in self.env: + 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', + 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', + ) + + # ------------------------------------------------------------------ + # 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 + # 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 + + # ------------------------------------------------------------------ + # 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, + } + + # ------------------------------------------------------------------ + # 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_part_order.py b/fusion_repairs/models/repair_part_order.py new file mode 100644 index 00000000..57ff9cb1 --- /dev/null +++ b/fusion_repairs/models/repair_part_order.py @@ -0,0 +1,252 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +"""Parts-ordering workflow. + +When the tech arrives, diagnoses, and discovers the unit needs a part we +don't stock (most common with manufacturer-specific items like Handicare +stairlift control boards), they capture the part info via the mobile +visit-report wizard in a structured way so: + +1. Office can order from the manufacturer in one click (description + OEM + part number + photos are exactly what procurement needs) +2. Client gets an immediate "we found the problem - here's the timeline" email +3. When parts arrive, office marks the order received and the system + auto-creates a follow-up dispatch task + +The grumpy-old-client never has to call us asking for status updates. +""" + +from datetime import timedelta + +from markupsafe import Markup + +from odoo import _, api, fields, models + + +class FusionRepairPartOrder(models.Model): + _name = 'fusion.repair.part.order' + _inherit = ['mail.thread'] + _description = 'Repair Part Order' + _order = 'create_date desc, id desc' + + name = fields.Char( + string='Reference', + default='New', + copy=False, + readonly=True, + tracking=True, + ) + repair_order_id = fields.Many2one( + 'repair.order', + string='Repair', + required=True, + ondelete='cascade', + index=True, + ) + partner_id = fields.Many2one( + related='repair_order_id.partner_id', + store=True, + readonly=True, + ) + + description = fields.Char( + string='Part Description', + required=True, + tracking=True, + help='Plain English - what the tech needs (e.g. "Handicare 1100 control board, ' + 'silver casing").', + ) + oem_part_number = fields.Char( + string='OEM Part Number', + tracking=True, + help='If the tech could read a part number off the broken component.', + ) + manufacturer = fields.Char( + string='Manufacturer', + tracking=True, + ) + quantity = fields.Float( + string='Quantity', + default=1.0, + required=True, + ) + notes = fields.Text( + string='Tech Notes', + help='Anything procurement needs to know (alternative SKUs, colour, ' + 'dimensions, etc.)', + ) + photo_ids = fields.Many2many( + 'ir.attachment', + 'fusion_repair_part_order_photo_rel', + 'part_order_id', 'attachment_id', + string='Photos', + help='Photos of the broken part / label / packaging. The more the better.', + ) + + state = fields.Selection( + [ + ('draft', 'Captured by Tech'), + ('ordered', 'Ordered from Manufacturer'), + ('received', 'Received in Warehouse'), + ('fitted', 'Fitted - Repair Complete'), + ('cancelled', 'Cancelled'), + ], + string='Status', + default='draft', + tracking=True, + copy=False, + ) + + ordered_date = fields.Date(string='Ordered On', tracking=True) + expected_date = fields.Date(string='Expected Arrival', tracking=True) + received_date = fields.Date(string='Received On', tracking=True, copy=False) + ordered_by_id = fields.Many2one( + 'res.users', + string='Ordered By', + tracking=True, + copy=False, + ) + + company_id = fields.Many2one( + 'res.company', + default=lambda self: self.env.company, + ) + + # ------------------------------------------------------------------ + # CRUD + # ------------------------------------------------------------------ + @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.part.order' + ) or 'PART/NEW' + records = super().create(vals_list) + for rec in records: + rec._post_creation_to_repair() + return records + + def _post_creation_to_repair(self): + for rec in self: + rec.repair_order_id.message_post(body=Markup(_( + 'Part order %(ref)s captured: %(desc)s ' + '(qty %(qty)s%(oem)s).' + )) % { + 'ref': rec.name, + 'desc': rec.description, + 'qty': rec.quantity, + 'oem': f' / OEM {rec.oem_part_number}' if rec.oem_part_number else '', + }) + + # ------------------------------------------------------------------ + # ACTIONS + # ------------------------------------------------------------------ + def action_mark_ordered(self): + """Office marks this part as ordered with the manufacturer.""" + for rec in self: + rec.state = 'ordered' + rec.ordered_date = fields.Date.context_today(rec) + rec.ordered_by_id = self.env.user + if not rec.expected_date: + rec.expected_date = fields.Date.context_today(rec) + timedelta(days=7) + rec._notify_client_parts_ordered() + + def action_mark_received(self): + """Office marks this part as received - triggers follow-up dispatch.""" + for rec in self: + rec.state = 'received' + rec.received_date = fields.Date.context_today(rec) + rec._maybe_redispatch() + rec._notify_client_parts_received() + + def action_mark_fitted(self): + for rec in self: + rec.state = 'fitted' + + def action_cancel(self): + for rec in self: + rec.state = 'cancelled' + + # ------------------------------------------------------------------ + # WORKFLOW HELPERS + # ------------------------------------------------------------------ + def _notify_client_parts_ordered(self): + for rec in self: + tpl = self.env.ref( + 'fusion_repairs.email_template_parts_ordered', + raise_if_not_found=False, + ) + if tpl and rec.partner_id and rec.partner_id.email: + try: + tpl.send_mail(rec.id, force_send=False) + except Exception: + pass + + def _notify_client_parts_received(self): + for rec in self: + tpl = self.env.ref( + 'fusion_repairs.email_template_parts_received', + raise_if_not_found=False, + ) + if tpl and rec.partner_id and rec.partner_id.email: + try: + tpl.send_mail(rec.id, force_send=False) + except Exception: + pass + + def _maybe_redispatch(self): + """When the LAST outstanding part on a repair arrives, auto-create + a follow-up tech task so the office doesn't have to remember. + + Schedules for tomorrow + first free hour slot to avoid colliding + with existing day-of tasks (the fusion_tasks model raises on + time-window conflicts). + """ + from datetime import date as _date + for rec in self: + repair = rec.repair_order_id + outstanding = repair.x_fc_part_order_ids.filtered( + lambda p: p.state in ('draft', 'ordered') + ) + if outstanding: + continue # still waiting on other parts + repair.x_fc_parts_awaiting = False + repair.x_fc_parts_eta_date = False + # Find tomorrow's first free slot for the same tech (or + # lightest-loaded skilled tech). + target_date = _date.today() + timedelta(days=1) + target_tech = ( + repair.x_fc_technician_task_ids[:1].technician_id.id + if repair.x_fc_technician_task_ids else False + ) + if not target_tech: + target_tech = self.env['repair.order'] \ + .sudo()._fc_find_lightest_today_tech.__func__(repair) + ctx = { + 'force_schedule': { + 'scheduled_date': target_date, + 'time_start': 9.0, + 'time_end': 10.0, + }, + } + if target_tech: + ctx['force_tech_id'] = target_tech + try: + self.env['fusion.repair.intake.service'].sudo() \ + .with_context(**ctx) \ + ._create_dispatch_task(repair) + repair.message_post(body=Markup(_( + 'All ordered parts received. Auto-dispatched a follow-up ' + 'visit for %(date)s 09:00 - 10:00.' + )) % {'date': target_date.isoformat()}) + except Exception as e: + # If slot 9-10 collides, just log and let the dispatcher + # pick a slot manually - we don't want to swallow the email. + repair.message_post(body=Markup(_( + 'All ordered parts received but the auto-dispatch slot ' + '%(date)s 09:00-10:00 collided. Please pick a time ' + 'manually. (%(err)s)' + )) % {'date': target_date.isoformat(), 'err': str(e)}) diff --git a/fusion_repairs/models/repair_product_category.py b/fusion_repairs/models/repair_product_category.py new file mode 100644 index 00000000..296430f1 --- /dev/null +++ b/fusion_repairs/models/repair_product_category.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 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.', + ) + + # Bundle 10: aligns Westin's printed rate card - LIFT & ELEVATING SERVICE + # has its own higher rates (stairlifts, porch lifts, lift chairs, hoyer lifts). + equipment_class = fields.Selection( + [ + ('standard', 'Standard Service'), + ('lift_elevating', 'Lift & Elevating Service'), + ], + string='Equipment Class', + default='standard', + required=True, + help='Determines which callout rate row applies. Lift & Elevating uses ' + 'higher per-card rates (e.g. $160 callout vs $95 standard).', + ) + + intake_template_id = fields.Many2one( + 'fusion.repair.intake.template', + string='Default Intake Template', + help='Default intake question set shown when this category is selected.', + ) + + _code_unique = models.Constraint( + '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/repair_self_check_rule.py b/fusion_repairs/models/repair_self_check_rule.py new file mode 100644 index 00000000..0a5bc089 --- /dev/null +++ b/fusion_repairs/models/repair_self_check_rule.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +"""Deterministic self-check rules. + +Seeded per equipment category + symptom keyword combination. Used by +fusion.repair.ai.service when: +- AI is unavailable (fusion_api not installed / OpenAI down) +- AI returns malformed / unsafe content +- The category has no AI configured + +Also rendered directly on the client portal when AI is disabled per spec. +""" + +from odoo import fields, models + + +class FusionRepairSelfCheckRule(models.Model): + _name = 'fusion.repair.self.check.rule' + _description = 'Repair Self-Check Rule (deterministic fallback)' + _order = 'category_id, sequence, id' + + name = fields.Char(string='Title', required=True, translate=True) + sequence = fields.Integer(default=10) + active = fields.Boolean(default=True) + company_id = fields.Many2one( + 'res.company', string='Company', + default=lambda self: self.env.company, + ) + + category_id = fields.Many2one( + 'fusion.repair.product.category', + string='Equipment Category', + required=True, + index=True, + ondelete='cascade', + ) + symptom_keywords = fields.Char( + string='Symptom Keywords', + help='Comma-separated, lowercase. Empty matches any symptom.', + ) + + instruction = fields.Text( + string='Instruction', + required=True, + translate=True, + help='What to ask the client to do. Plain English, <= 1 sentence.', + ) + expected_result = fields.Text( + string='Expected Result', + required=True, + translate=True, + help='What success looks like ("alarm stops", "wheel spins freely").', + ) + safety_note = fields.Text( + string='Safety Note', + translate=True, + help='Optional warning shown in red below the instruction.', + ) diff --git a/fusion_repairs/models/repair_service_plan.py b/fusion_repairs/models/repair_service_plan.py new file mode 100644 index 00000000..41cc41e0 --- /dev/null +++ b/fusion_repairs/models/repair_service_plan.py @@ -0,0 +1,270 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +r"""Pre-paid service plans (M5). + +Architecture: + + product.template + \--> x_fc_is_service_plan = True + x_fc_plan_visits_included (e.g. 4) + x_fc_plan_duration_months (e.g. 12) + + sale.order.confirm() + \--> for each line whose product is a service plan, + create a fusion.repair.service.plan.subscription + (partner + product + visits_included + start_date + end_date) + + fusion.repair.maintenance.contract.create_repair_from_booking() + visit_report_wizard.action_confirm() + \--> burns down one visit if the partner has an active matching plan + (for the same product or category) + + fusion.repair.dashboard.get_dashboard_data() + \--> exposes active_plan_count + plans_low_count for the dashboard +""" + +from dateutil.relativedelta import relativedelta + +from odoo import _, api, fields, models + + +class ProductTemplate(models.Model): + _inherit = 'product.template' + + x_fc_is_service_plan = fields.Boolean( + string='Service Plan', + help='Sell this product as a pre-paid maintenance package. ' + 'Confirming a sale order with this product creates a ' + 'visit subscription for the customer.', + ) + x_fc_plan_visits_included = fields.Integer( + string='Visits Included', + default=4, + help='Number of maintenance visits the customer is entitled to under this plan.', + ) + x_fc_plan_duration_months = fields.Integer( + string='Plan Duration (months)', + default=12, + help='Plan ends this many months after the sale-order date even if visits remain.', + ) + x_fc_plan_category_id = fields.Many2one( + 'fusion.repair.product.category', + string='Plan Category', + help='If set, plan visits only burn down for repairs on equipment of this category. ' + 'Leave blank to apply to any equipment from this customer.', + ) + + +class FusionRepairServicePlanSubscription(models.Model): + _name = 'fusion.repair.service.plan.subscription' + _inherit = ['mail.thread'] + _description = 'Pre-paid Service Plan Subscription' + _order = 'end_date desc, id desc' + + name = fields.Char( + string='Reference', 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='Plan Product', + required=True, tracking=True, + domain="[('x_fc_is_service_plan', '=', True)]", + ) + category_id = fields.Many2one( + 'fusion.repair.product.category', + string='Covers Category', + help='Computed from the plan product. Only burns visits for repairs ' + 'whose category matches.', + ) + sale_order_id = fields.Many2one( + 'sale.order', string='Sold On', + ondelete='set null', tracking=True, + ) + + visits_included = fields.Integer(string='Visits Included', required=True, default=4) + visits_used = fields.Integer(string='Visits Used', default=0, tracking=True) + visits_remaining = fields.Integer( + string='Remaining', + compute='_compute_visits_remaining', store=True, + ) + + start_date = fields.Date( + string='Start', required=True, default=fields.Date.context_today, tracking=True, + ) + end_date = fields.Date(string='Expires', required=True, tracking=True) + + state = fields.Selection( + [ + ('active', 'Active'), + ('exhausted', 'Visits Exhausted'), + ('expired', 'Expired'), + ('cancelled', 'Cancelled'), + ], + string='Status', + compute='_compute_state', store=True, tracking=True, + ) + + company_id = fields.Many2one( + 'res.company', default=lambda self: self.env.company, + ) + + burn_history_ids = fields.One2many( + 'fusion.repair.service.plan.burn', + 'subscription_id', + string='Burn History', + ) + + # ------------------------------------------------------------------ + # CRUD + # ------------------------------------------------------------------ + @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.service.plan.subscription' + ) or 'PLAN/NEW' + if vals.get('product_id') and not vals.get('end_date'): + product = self.env['product.product'].sudo().browse(vals['product_id']) + months = product.product_tmpl_id.x_fc_plan_duration_months or 12 + start = vals.get('start_date') or fields.Date.context_today(self) + vals['end_date'] = fields.Date.from_string(str(start)) + relativedelta(months=months) + if vals.get('product_id') and 'category_id' not in vals: + product = self.env['product.product'].sudo().browse(vals['product_id']) + if product.product_tmpl_id.x_fc_plan_category_id: + vals['category_id'] = product.product_tmpl_id.x_fc_plan_category_id.id + if vals.get('product_id') and 'visits_included' not in vals: + product = self.env['product.product'].sudo().browse(vals['product_id']) + vals['visits_included'] = product.product_tmpl_id.x_fc_plan_visits_included or 4 + return super().create(vals_list) + + # ------------------------------------------------------------------ + # COMPUTES + # ------------------------------------------------------------------ + @api.depends('visits_included', 'visits_used') + def _compute_visits_remaining(self): + for s in self: + s.visits_remaining = (s.visits_included or 0) - (s.visits_used or 0) + + @api.depends('visits_remaining', 'end_date') + def _compute_state(self): + today = fields.Date.context_today(self) + for s in self: + if s.state == 'cancelled': + continue + if s.end_date and s.end_date < today: + s.state = 'expired' + elif s.visits_remaining <= 0: + s.state = 'exhausted' + else: + s.state = 'active' + + # ------------------------------------------------------------------ + # BURN ENGINE + # ------------------------------------------------------------------ + @api.model + def find_for_repair(self, repair): + """Return the most-recently-started active subscription covering this + repair (partner match + category match if the plan specifies one).""" + if not repair.partner_id: + return self.browse() + domain = [ + ('partner_id', '=', repair.partner_id.id), + ('state', '=', 'active'), + ('visits_remaining', '>', 0), + ] + subs = self.search(domain, order='start_date desc') + for s in subs: + if not s.category_id or s.category_id == repair.x_fc_repair_category_id: + return s + return self.browse() + + def burn_visit(self, repair): + """Deduct one visit from this subscription and log the burn.""" + self.ensure_one() + if self.visits_remaining <= 0: + return False + self.visits_used += 1 + self.env['fusion.repair.service.plan.burn'].sudo().create({ + 'subscription_id': self.id, + 'repair_order_id': repair.id, + 'burned_on': fields.Date.context_today(self), + }) + self.message_post(body=_( + 'Visit burned for repair %s. %s of %s remaining.' + ) % (repair.name, self.visits_remaining, self.visits_included)) + return True + + def action_cancel(self): + for s in self: + s.state = 'cancelled' + s.message_post(body=_('Plan cancelled.')) + + +class FusionRepairServicePlanBurn(models.Model): + _name = 'fusion.repair.service.plan.burn' + _description = 'Service Plan Visit Burn' + _order = 'burned_on desc, id desc' + + subscription_id = fields.Many2one( + 'fusion.repair.service.plan.subscription', + string='Subscription', required=True, ondelete='cascade', + ) + repair_order_id = fields.Many2one( + 'repair.order', string='Repair', required=True, ondelete='cascade', + ) + burned_on = fields.Date(string='Burned On', required=True, + default=fields.Date.context_today) + + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + def action_confirm(self): + res = super().action_confirm() + # Spawn subscriptions for each service-plan line. + for order in self: + for line in order.order_line: + tmpl = line.product_id.product_tmpl_id + if not tmpl.x_fc_is_service_plan: + continue + # One subscription per quantity unit (so a SO line with qty=2 + # creates two distinct plans - rare but supported). + qty = int(line.product_uom_qty or 1) + for _i in range(max(qty, 1)): + self.env['fusion.repair.service.plan.subscription'].sudo().create({ + 'partner_id': order.partner_id.id, + 'product_id': line.product_id.id, + 'sale_order_id': order.id, + 'start_date': fields.Date.context_today(self), + }) + # Bundle 9: spawn store labor warranties for any product line with + # x_fc_labor_warranty_years > 0. + self._fc_spawn_labor_warranties() + return res + + def _fc_spawn_labor_warranties(self): + Warranty = self.env['fusion.repair.labor.warranty'].sudo() + for order in self: + for line in order.order_line: + tmpl = line.product_id.product_tmpl_id + years = tmpl.x_fc_labor_warranty_years or 0 + if years <= 0: + continue + # One warranty record per unit so each can be voided + # independently if a specific unit is misused. + qty = int(line.product_uom_qty or 1) + for _i in range(max(qty, 1)): + Warranty.create({ + 'partner_id': order.partner_id.id, + 'product_id': line.product_id.id, + 'sale_order_id': order.id, + 'warranty_years': years, + 'start_date': fields.Date.context_today(self), + }) diff --git a/fusion_repairs/models/repair_warranty.py b/fusion_repairs/models/repair_warranty.py new file mode 100644 index 00000000..518406f0 --- /dev/null +++ b/fusion_repairs/models/repair_warranty.py @@ -0,0 +1,131 @@ +# -*- 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, + ) + # 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', + ) + + 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. + + 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), + ('expiry_date', '>=', today), + ] + 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/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/sale_order.py b/fusion_repairs/models/sale_order.py new file mode 100644 index 00000000..023544fe --- /dev/null +++ b/fusion_repairs/models/sale_order.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +"""sale.order extensions: smart buttons that link an original purchase SO +to its downstream repairs, maintenance contracts, and repair invoices. + +Mirrors the count + action_view_* pattern from +fusion_claims/views/sale_order_views.xml line ~1176. +""" + +from odoo import _, api, fields, models + + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + x_fc_repair_order_ids = fields.One2many( + 'repair.order', + 'x_fc_original_sale_order_id', + string='Repairs', + ) + x_fc_repair_order_count = fields.Integer( + compute='_compute_x_fc_repair_order_count', + ) + + x_fc_maintenance_contract_ids = fields.One2many( + 'fusion.repair.maintenance.contract', + 'original_sale_order_id', + string='Maintenance Contracts', + ) + x_fc_maintenance_contract_count = fields.Integer( + compute='_compute_x_fc_maintenance_contract_count', + ) + + @api.depends('x_fc_repair_order_ids') + def _compute_x_fc_repair_order_count(self): + for so in self: + so.x_fc_repair_order_count = len(so.x_fc_repair_order_ids) + + @api.depends('x_fc_maintenance_contract_ids') + def _compute_x_fc_maintenance_contract_count(self): + for so in self: + so.x_fc_maintenance_contract_count = len(so.x_fc_maintenance_contract_ids) + + def action_view_repair_orders(self): + self.ensure_one() + if len(self.x_fc_repair_order_ids) == 1: + return { + 'type': 'ir.actions.act_window', + 'name': self.x_fc_repair_order_ids.name, + 'res_model': 'repair.order', + 'view_mode': 'form', + 'res_id': self.x_fc_repair_order_ids.id, + } + return { + 'type': 'ir.actions.act_window', + 'name': _('Repairs from %(name)s', name=self.name), + 'res_model': 'repair.order', + 'view_mode': 'list,form', + 'domain': [('x_fc_original_sale_order_id', '=', self.id)], + } + + def action_view_maintenance_contracts(self): + self.ensure_one() + if len(self.x_fc_maintenance_contract_ids) == 1: + return { + 'type': 'ir.actions.act_window', + 'name': self.x_fc_maintenance_contract_ids.name, + 'res_model': 'fusion.repair.maintenance.contract', + 'view_mode': 'form', + 'res_id': self.x_fc_maintenance_contract_ids.id, + } + return { + 'type': 'ir.actions.act_window', + 'name': _('Maintenance Contracts from %(name)s', name=self.name), + 'res_model': 'fusion.repair.maintenance.contract', + 'view_mode': 'list,form', + 'domain': [('original_sale_order_id', '=', self.id)], + } diff --git a/fusion_repairs/models/service_catalog.py b/fusion_repairs/models/service_catalog.py new file mode 100644 index 00000000..973a1717 --- /dev/null +++ b/fusion_repairs/models/service_catalog.py @@ -0,0 +1,151 @@ +# -*- 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. + + 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 + """ + 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() + best = None + best_score = 0 + for c in candidates: + kws = [k.strip().lower() for k in (c.symptom_keywords or '').split(',') if k.strip()] + # 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 + # No keywords matched -> return empty rather than the lowest-sequence guess. + if best and best_score > 0: + return best + return self.browse() diff --git a/fusion_repairs/models/technician_task.py b/fusion_repairs/models/technician_task.py new file mode 100644 index 00000000..36792c33 --- /dev/null +++ b/fusion_repairs/models/technician_task.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from urllib.parse import quote_plus + +from markupsafe import Markup + +from odoo import _, fields, models +from odoo.exceptions import UserError + + +class FusionTechnicianTaskRepairs(models.Model): + """Adds the back-link from fusion.technician.task to repair.order so + repairs and tasks share one timeline. Also hooks task completion to + roll a linked maintenance contract to its next cycle. + """ + + _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, + ) + + # X2: per-task day-before reminder flag. Per-task (not per-repair) so + # a repair with multiple visits gets a separate reminder for each one. + x_fc_day_before_reminder_sent = fields.Boolean( + string='Day-Before Reminder Sent', + copy=False, + ) + + # ------------------------------------------------------------------ + # T3 - Labour timer. The tech taps Start when they begin work and + # Stop when done; the accumulated minutes feeds the visit-report + # actual hours field. Multiple start/stop cycles are accumulated. + # ------------------------------------------------------------------ + x_fc_timer_running_since = fields.Datetime( + string='Timer Running Since', + copy=False, + ) + x_fc_timer_accumulated_minutes = fields.Float( + string='Accumulated Minutes', + default=0.0, + copy=False, + help='Total labour minutes captured by the tech timer. ' + 'Divide by 60 for the hours that prefill the visit report.', + ) + + def action_timer_start(self): + for t in self: + if t.x_fc_timer_running_since: + continue # already running + t.x_fc_timer_running_since = fields.Datetime.now() + t.message_post(body=Markup(_('Labour timer started.'))) + + def action_timer_stop(self): + for t in self: + if not t.x_fc_timer_running_since: + continue + from datetime import datetime + elapsed_minutes = ( + datetime.now() - t.x_fc_timer_running_since + ).total_seconds() / 60.0 + t.x_fc_timer_accumulated_minutes = ( + t.x_fc_timer_accumulated_minutes or 0.0 + ) + elapsed_minutes + t.x_fc_timer_running_since = False + t.message_post(body=Markup(_( + 'Labour timer stopped. Added %(mins).1f min, total %(tot).1f min.' + )) % { + 'mins': elapsed_minutes, + 'tot': t.x_fc_timer_accumulated_minutes or 0.0, + }) + + 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=Markup( + 'Rolled forward after maintenance task ' + '%s completed. Next due %s.' + ) % (task.name or '', str(contract.next_due_date or ''))) + 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: + 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, + } + + # ------------------------------------------------------------------ + # 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() + # Prefer fusion_tasks.address_display because in real data address_street + # often contains the full Google-Places-formatted address; concatenating + # the other address_* fields would duplicate city/zip. + addr = (getattr(self, 'address_display', '') or '').strip() + if not addr and self.partner_id: + p = self.partner_id + parts = [ + p.street, p.street2, p.city, + p.state_id.name if p.state_id else False, + p.zip, + p.country_id.name if p.country_id else False, + ] + addr = ', '.join(str(x) for x in parts if x) + if not addr: + raise UserError(_('No address on this task or its client.')) + return { + 'type': 'ir.actions.act_url', + 'url': f'https://www.google.com/maps?q={quote_plus(addr)}', + 'target': 'new', + } diff --git a/fusion_repairs/report/inspection_certificate_report.xml b/fusion_repairs/report/inspection_certificate_report.xml new file mode 100644 index 00000000..0e8fe1ca --- /dev/null +++ b/fusion_repairs/report/inspection_certificate_report.xml @@ -0,0 +1,167 @@ + + + + + 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/report/qr_sticker_report.xml b/fusion_repairs/report/qr_sticker_report.xml new file mode 100644 index 00000000..7f7a3528 --- /dev/null +++ b/fusion_repairs/report/qr_sticker_report.xml @@ -0,0 +1,86 @@ + + + + + + QR Stickers + fusion.repair.qr.sticker.wizard + qweb-pdf + fusion_repairs.report_qr_stickers + fusion_repairs.report_qr_stickers + 'QR Stickers - %s' % (object.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..71d0806d --- /dev/null +++ b/fusion_repairs/security/ir.model.access.csv @@ -0,0 +1,53 @@ +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 +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 +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 +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 Read-Only,model_fusion_repair_inspection_certificate,fusion_tasks.group_field_technician,1,0,0,0 +access_service_plan_sub_user,Service Plan Sub User Read,model_fusion_repair_service_plan_subscription,group_fusion_repairs_user,1,0,0,0 +access_service_plan_sub_dispatcher,Service Plan Sub Dispatcher,model_fusion_repair_service_plan_subscription,group_fusion_repairs_dispatcher,1,1,1,0 +access_service_plan_sub_manager,Service Plan Sub Manager Full,model_fusion_repair_service_plan_subscription,group_fusion_repairs_manager,1,1,1,1 +access_service_plan_burn_user,Service Plan Burn User Read,model_fusion_repair_service_plan_burn,group_fusion_repairs_user,1,0,0,0 +access_service_plan_burn_manager,Service Plan Burn Manager Full,model_fusion_repair_service_plan_burn,group_fusion_repairs_manager,1,1,1,1 +access_emergency_charge_user,Emergency Charge User Read,model_fusion_repair_emergency_charge,group_fusion_repairs_user,1,0,0,0 +access_emergency_charge_manager,Emergency Charge Manager Full,model_fusion_repair_emergency_charge,group_fusion_repairs_manager,1,1,1,1 +access_part_order_user,Part Order User Read,model_fusion_repair_part_order,group_fusion_repairs_user,1,0,0,0 +access_part_order_dispatcher,Part Order Dispatcher,model_fusion_repair_part_order,group_fusion_repairs_dispatcher,1,1,1,0 +access_part_order_manager,Part Order Manager Full,model_fusion_repair_part_order,group_fusion_repairs_manager,1,1,1,1 +access_part_order_technician,Part Order Field Tech Create,model_fusion_repair_part_order,fusion_tasks.group_field_technician,1,1,1,0 +access_visit_report_partline_user,Visit Report Part Line User Full,model_fusion_repair_visit_report_wizard_partline,group_fusion_repairs_user,1,1,1,1 +access_visit_report_partline_tech,Visit Report Part Line Field Tech Full,model_fusion_repair_visit_report_wizard_partline,fusion_tasks.group_field_technician,1,1,1,1 +access_callout_rate_user,Callout Rate User Read,model_fusion_repair_callout_rate,group_fusion_repairs_user,1,0,0,0 +access_callout_rate_manager,Callout Rate Manager Full,model_fusion_repair_callout_rate,group_fusion_repairs_manager,1,1,1,1 +access_delivery_charge_user,Delivery Charge User Read,model_fusion_repair_delivery_charge,group_fusion_repairs_user,1,0,0,0 +access_delivery_charge_manager,Delivery Charge Manager Full,model_fusion_repair_delivery_charge,group_fusion_repairs_manager,1,1,1,1 +access_labor_warranty_user,Labor Warranty User Read,model_fusion_repair_labor_warranty,group_fusion_repairs_user,1,0,0,0 +access_labor_warranty_sales_rep,Labor Warranty Sales Rep Write,model_fusion_repair_labor_warranty,group_fusion_repairs_sales_rep,1,1,0,0 +access_labor_warranty_manager,Labor Warranty Manager Full,model_fusion_repair_labor_warranty,group_fusion_repairs_manager,1,1,1,1 +access_labor_warranty_technician,Labor Warranty Field Tech Read,model_fusion_repair_labor_warranty,fusion_tasks.group_field_technician,1,1,0,0 diff --git a/fusion_repairs/security/security.xml b/fusion_repairs/security/security.xml new file mode 100644 index 00000000..0511d0fb --- /dev/null +++ b/fusion_repairs/security/security.xml @@ -0,0 +1,176 @@ + + + + + + + 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: Sales Rep + + + Sales reps who can waive labor fees on their accounts (CS cannot waive). + + + + Repairs: Manager + + + Configures intake templates, pricing, maintenance contracts, on-call rotation, variance overrides. Implies all lower groups including sales rep. + + + + + + + + + + + + + + Repair Order: Multi-Company + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + + + + + Repair Order: Technician sees own repairs + + ['|', ('x_fc_technician_task_ids.technician_id', '=', user.id), ('x_fc_technician_task_ids.additional_technician_ids', 'in', [user.id])] + + + + + + + + + + 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 + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + + + + + Inspection Certificate: Read-only for non-managers + + [(1, '=', 1)] + + + + + + + + + + Repair Order: Sales Rep Portal - Own Repairs + + [('x_fc_intake_user_id', '=', user.id)] + + + + + + + + diff --git a/fusion_repairs/static/description/icon.png b/fusion_repairs/static/description/icon.png new file mode 100644 index 00000000..5738185d Binary files /dev/null and b/fusion_repairs/static/description/icon.png differ diff --git a/fusion_repairs/static/src/components/dashboard/dashboard.js b/fusion_repairs/static/src/components/dashboard/dashboard.js new file mode 100644 index 00000000..d0ef352b --- /dev/null +++ b/fusion_repairs/static/src/components/dashboard/dashboard.js @@ -0,0 +1,149 @@ +/** @odoo-module **/ +// Fusion Repairs dashboard - OWL client action. +// Uses standalone rpc() from @web/core/network/rpc per project rule #3 +// and useService("action") to navigate to backend act_window actions. + +import { Component, useState, onWillStart } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { rpc } from "@web/core/network/rpc"; +import { useService } from "@web/core/utils/hooks"; +import { _t } from "@web/core/l10n/translation"; + +export class FusionRepairsDashboard extends Component { + static template = "fusion_repairs.Dashboard"; + static props = ["*"]; + + setup() { + this.action = useService("action"); + this.notification = useService("notification"); + this.state = useState({ + loading: true, + stats: {}, + urgency_breakdown: [], + source_breakdown: [], + recent: [], + upcoming: [], + portals: {}, + failures_by_product: [], + failures_by_symptom: [], + margin_summary: {}, + }); + + onWillStart(async () => { + await this._loadData(); + }); + } + + async _loadData() { + try { + const data = await rpc("/web/dataset/call_kw", { + model: "fusion.repair.dashboard", + method: "get_dashboard_data", + args: [], + kwargs: {}, + }); + this.state.stats = data.stats || {}; + this.state.urgency_breakdown = data.urgency_breakdown || []; + this.state.source_breakdown = data.source_breakdown || []; + this.state.recent = data.recent || []; + this.state.upcoming = data.upcoming || []; + this.state.portals = data.portals || {}; + this.state.failures_by_product = data.failures_by_product || []; + this.state.failures_by_symptom = data.failures_by_symptom || []; + this.state.margin_summary = data.margin_summary || {}; + } catch (e) { + this.notification.add(_t("Could not load dashboard data."), { + type: "danger", + }); + } finally { + this.state.loading = false; + } + } + + async refresh() { + this.state.loading = true; + await this._loadData(); + } + + openAction(xmlId, extraContext) { + return this.action.doAction(xmlId, { + additionalContext: extraContext || {}, + }); + } + + openWizard() { + return this.action.doAction("fusion_repairs.action_open_repair_intake_wizard"); + } + + openRepair(repairId) { + return this.action.doAction({ + type: "ir.actions.act_window", + res_model: "repair.order", + res_id: repairId, + views: [[false, "form"]], + target: "current", + }); + } + + openContract(contractId) { + return this.action.doAction({ + type: "ir.actions.act_window", + res_model: "fusion.repair.maintenance.contract", + res_id: contractId, + views: [[false, "form"]], + target: "current", + }); + } + + openUrl(url) { + if (url) { + window.open(url, "_blank", "noopener"); + } + } + + async copyUrl(url) { + if (!url) return; + try { + await navigator.clipboard.writeText(url); + this.notification.add(_t("Copied to clipboard."), { type: "success" }); + } catch (e) { + this.notification.add(_t("Could not copy URL. Select and copy manually."), { + type: "warning", + }); + } + } + + formatDate(value) { + if (!value) return ""; + return value.slice(0, 10); + } + + formatMoney(value) { + const v = Number(value || 0); + return v.toLocaleString("en-CA", { + style: "currency", + currency: "CAD", + maximumFractionDigits: 0, + }); + } + + formatPercent(value) { + const v = Number(value || 0); + return `${v.toFixed(1)}%`; + } + + urgencyPillClass(urgency) { + if (urgency === "safety") return "fr-pill fr-pill-safety"; + if (urgency === "urgent") return "fr-pill fr-pill-urgent"; + return "fr-pill fr-pill-normal"; + } + + urgencyLabel(urgency) { + const map = { safety: "Safety", urgent: "Urgent", normal: "Normal" }; + return map[urgency] || "Normal"; + } +} + +registry + .category("actions") + .add("fusion_repairs.dashboard", FusionRepairsDashboard); diff --git a/fusion_repairs/static/src/components/dashboard/dashboard.xml b/fusion_repairs/static/src/components/dashboard/dashboard.xml new file mode 100644 index 00000000..d2407682 --- /dev/null +++ b/fusion_repairs/static/src/components/dashboard/dashboard.xml @@ -0,0 +1,341 @@ + + + + +
+ + +
+ +
Loading dashboard...
+
+ + + + + +
+

Fusion Repairs

+

Service calls, technician dispatch, maintenance and self-service in one place.

+
+ + +
Quick Actions
+
+ + + + + + + +
+ + +
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 + + + + + · + +
+ + + +
+
+
+
+ + +
Last 90 Days
+
+
+

Margin Summary

+ +
No data yet for the last 90 days
+
+ +
+
+ Revenue + Posted invoices on repair SOs +
+ + + +
+
+
+ Labour Cost + Hours x tech cost rate +
+ + - + +
+
+
+ Parts Cost + Standard price of consumed parts +
+ + - + +
+
+
+ Margin + + + on repairs + +
+ + + +
+
+
+ +
+

Failure Rate by Product

+ +
No repairs in the last 90 days
+
+ +
+
+ +
+ + + +
+
+
+
+ +
+
+

Failure Rate by Symptom

+ +
No symptoms tagged in the last 90 days
+
+ +
+
+ +
+ + + +
+
+
+
+ + +
Configuration
+
+ + + +
+ +
+ +
+ + +
+ + + diff --git a/fusion_repairs/static/src/js/portal_client_repair.js b/fusion_repairs/static/src/js/portal_client_repair.js new file mode 100644 index 00000000..d56829ff --- /dev/null +++ b/fusion_repairs/static/src/js/portal_client_repair.js @@ -0,0 +1,173 @@ +/** @odoo-module **/ +/* + * Public client repair portal - frontend interactions. + * + * B3 phone lookup -> POST /repair/lookup_phone (jsonrpc); pre-fills the form + * B2 AI self-check -> POST /repair/self_check (jsonrpc); renders 1-3 steps + * + * Uses Odoo 19's Interaction class. All DOM building uses createElement + + * textContent (never innerHTML) so untrusted server output cannot inject markup. + */ +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; +import { rpc } from "@web/core/network/rpc"; + +function el(tag, className, text) { + const e = document.createElement(tag); + if (className) e.className = className; + if (text != null) e.textContent = text; + return e; +} + +export class FusionRepairsClientForm extends Interaction { + static selector = "form[data-fr-client-form='1']"; + + dynamicContent = { + "#fr_lookup_btn": { "t-on-click.prevent": this.onLookup.bind(this) }, + "#fr_selfcheck_btn": { "t-on-click.prevent": this.onSelfCheck.bind(this) }, + }; + + setup() { + this.lookupResult = this.el.querySelector("#fr_lookup_result"); + this.selfCheckResult = this.el.querySelector("#fr_selfcheck_result"); + } + + // ------------------------------------------------------------------ + // B3: phone lookup - pre-fill the form for returning clients + // ------------------------------------------------------------------ + async onLookup() { + const phoneEl = this.el.querySelector("#fr_lookup_phone"); + const phone = (phoneEl?.value || "").trim(); + if (!phone) { + this.renderLookupMsg("alert-warning", "Enter a phone number first."); + return; + } + this.renderLookupMsg("alert-info", "Looking you up..."); + let result; + try { + result = await rpc("/repair/lookup_phone", { phone }); + } catch (err) { + this.renderLookupMsg("alert-warning", + "Lookup failed. Please fill the form below as usual."); + return; + } + if (result && result.error === "rate_limited") { + this.renderLookupMsg("alert-warning", + "Too many lookups from your location - please fill the form below."); + return; + } + const partners = (result && result.partners) || []; + if (partners.length === 0) { + this.renderLookupMsg("alert-secondary", + "We don't have a match yet. Please fill in the form below."); + return; + } + const p = partners[0]; + this.el.querySelector("#fr_client_name").value = p.name || ""; + this.el.querySelector("#fr_client_phone").value = phone; + if (p.email) this.el.querySelector("#fr_client_email").value = p.email; + if (p.street) this.el.querySelector("#fr_client_street").value = p.street; + if (p.city) this.el.querySelector("#fr_client_city").value = p.city; + this.el.querySelector("#fr_known_partner_id").value = p.id; + this.renderLookupMsg("alert-success", + `Welcome back! We've pre-filled your contact details. (Account: ${p.name})`); + } + + renderLookupMsg(cls, text) { + if (!this.lookupResult) return; + this.lookupResult.replaceChildren(el("div", `alert ${cls} mb-0 mt-2`, text)); + } + + // ------------------------------------------------------------------ + // B2: AI self-check + // ------------------------------------------------------------------ + async onSelfCheck() { + const categoryId = parseInt(this.el.querySelector("#fr_category_id")?.value, 10); + const symptoms = (this.el.querySelector("#fr_issue_summary")?.value || "").trim(); + if (!categoryId) { + this.renderSelfCheckMsg("alert-warning", "Pick the equipment category first."); + return; + } + if (!symptoms) { + this.renderSelfCheckMsg("alert-warning", + "Please describe what's wrong first (Step 3)."); + return; + } + this.renderSelfCheckMsg("alert-info", "Looking up safe self-check steps..."); + let result; + try { + result = await rpc("/repair/self_check", { + category_id: categoryId, + symptoms: [symptoms], + urgency: this.el.querySelector("[name='urgency']")?.value || "normal", + }); + } catch (err) { + this.renderSelfCheckMsg("alert-warning", + "Couldn't check right now. Please go ahead and submit the form."); + return; + } + if (result && result.error === "rate_limited") { + this.renderSelfCheckMsg("alert-warning", + "Too many requests from your location. Please submit the form."); + return; + } + this.renderSelfCheckResult(result); + } + + renderSelfCheckMsg(cls, text) { + if (!this.selfCheckResult) return; + this.selfCheckResult.replaceChildren(el("div", `alert ${cls}`, text)); + } + + renderSelfCheckResult(result) { + if (!this.selfCheckResult) return; + const children = []; + if (!result) { + this.selfCheckResult.replaceChildren(); + return; + } + const card = el("div", "card border-info"); + const body = el("div", "card-body"); + + if (result.escalate_immediately) { + const alert = el("div", "alert alert-warning mb-2"); + const strong = el("strong", null, + "Please submit the form below. "); + const tail = document.createTextNode( + "Based on what you described, this isn't something to try fixing yourself. " + + "Our technician will help you."); + alert.append(strong, tail); + body.appendChild(alert); + } else { + body.appendChild(el("p", "text-muted small mb-3", + "Here are a few safe things you can try in under 2 minutes. " + + "If they don't help, submit the form below and we'll come to you.")); + (result.steps || []).forEach((step, idx) => { + const stepWrap = el("div", "mb-3 p-2 border-start border-3 border-info"); + stepWrap.appendChild(el("div", "fw-bold", + `${idx + 1}. ${step.instruction}`)); + if (step.expected_result) { + stepWrap.appendChild(el("div", "small text-muted", + `Expected result: ${step.expected_result}`)); + } + if (step.safety_note) { + stepWrap.appendChild(el("div", "small text-danger mt-1", + `Safety: ${step.safety_note}`)); + } + body.appendChild(stepWrap); + }); + } + if (result.disclaimer) { + body.appendChild(el("div", "small text-muted fst-italic mt-2", + result.disclaimer)); + } + card.appendChild(body); + children.push(card); + this.selfCheckResult.replaceChildren(...children); + } +} + +registry.category("public.interactions").add( + "fusion_repairs.client_form", + FusionRepairsClientForm, +); 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/_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..2f537f8c --- /dev/null +++ b/fusion_repairs/static/src/scss/dashboard.scss @@ -0,0 +1,323 @@ +// 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; + // 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%); + 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/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/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/maintenance_contract_views.xml b/fusion_repairs/views/maintenance_contract_views.xml new file mode 100644 index 00000000..cef34375 --- /dev/null +++ b/fusion_repairs/views/maintenance_contract_views.xml @@ -0,0 +1,67 @@ + + + + + fusion.repair.maintenance.contract.list + fusion.repair.maintenance.contract + + + + + + + + + + + + + + + + fusion.repair.maintenance.contract.form + fusion.repair.maintenance.contract + +
+
+ +
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + +
+ + +
+
+ + + Maintenance Contracts + fusion.repair.maintenance.contract + list,form + + +
diff --git a/fusion_repairs/views/menus.xml b/fusion_repairs/views/menus.xml new file mode 100644 index 00000000..949b7d08 --- /dev/null +++ b/fusion_repairs/views/menus.xml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..15812838 --- /dev/null +++ b/fusion_repairs/views/portal_client_repair_templates.xml @@ -0,0 +1,287 @@ + + + + + + + + + + + + + + + + + + + diff --git a/fusion_repairs/views/portal_maintenance_templates.xml b/fusion_repairs/views/portal_maintenance_templates.xml new file mode 100644 index 00000000..3b174e12 --- /dev/null +++ b/fusion_repairs/views/portal_maintenance_templates.xml @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + + + + + + + + + + diff --git a/fusion_repairs/views/repair_callout_rate_views.xml b/fusion_repairs/views/repair_callout_rate_views.xml new file mode 100644 index 00000000..b4eea7fe --- /dev/null +++ b/fusion_repairs/views/repair_callout_rate_views.xml @@ -0,0 +1,30 @@ + + + + + fusion.repair.callout.rate.list + fusion.repair.callout.rate + + + + + + + + + + + + + + + + + + + Callout Rate Card + fusion.repair.callout.rate + list + + + diff --git a/fusion_repairs/views/repair_dashboard_views.xml b/fusion_repairs/views/repair_dashboard_views.xml new file mode 100644 index 00000000..1bf31d10 --- /dev/null +++ b/fusion_repairs/views/repair_dashboard_views.xml @@ -0,0 +1,10 @@ + + + + + + Fusion Repairs + fusion_repairs.dashboard + + + diff --git a/fusion_repairs/views/repair_delivery_charge_views.xml b/fusion_repairs/views/repair_delivery_charge_views.xml new file mode 100644 index 00000000..9cd5605e --- /dev/null +++ b/fusion_repairs/views/repair_delivery_charge_views.xml @@ -0,0 +1,27 @@ + + + + + fusion.repair.delivery.charge.list + fusion.repair.delivery.charge + + + + + + + + + + + + + + + + Delivery / Pickup Charges + fusion.repair.delivery.charge + list + + + diff --git a/fusion_repairs/views/repair_emergency_charge_views.xml b/fusion_repairs/views/repair_emergency_charge_views.xml new file mode 100644 index 00000000..3dec8a4d --- /dev/null +++ b/fusion_repairs/views/repair_emergency_charge_views.xml @@ -0,0 +1,26 @@ + + + + + fusion.repair.emergency.charge.list + fusion.repair.emergency.charge + + + + + + + + + + + + + + + Emergency Surcharges + fusion.repair.emergency.charge + list + + + diff --git a/fusion_repairs/views/repair_inspection_views.xml b/fusion_repairs/views/repair_inspection_views.xml new file mode 100644 index 00000000..db63348e --- /dev/null +++ b/fusion_repairs/views/repair_inspection_views.xml @@ -0,0 +1,79 @@ + + + + + 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/views/repair_labor_warranty_views.xml b/fusion_repairs/views/repair_labor_warranty_views.xml new file mode 100644 index 00000000..2507c422 --- /dev/null +++ b/fusion_repairs/views/repair_labor_warranty_views.xml @@ -0,0 +1,77 @@ + + + + + fusion.repair.labor.warranty.list + fusion.repair.labor.warranty + + + + + + + + + + + + + + + + + + fusion.repair.labor.warranty.form + fusion.repair.labor.warranty + +
+
+
+ +
+

+
+ + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + Labor Warranties + fusion.repair.labor.warranty + list,form + + +
diff --git a/fusion_repairs/views/repair_order_views.xml b/fusion_repairs/views/repair_order_views.xml new file mode 100644 index 00000000..2c29d432 --- /dev/null +++ b/fusion_repairs/views/repair_order_views.xml @@ -0,0 +1,419 @@ + + + + + + + + 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 + + + + + + + + + + repair.order.dashboard.fusion_repairs + repair.order + + + + + + + + + + + + + + +
+ + + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + 3rd-party +
+
+
+
+
+
+ + + repair.order.search.fusion_repairs + repair.order + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Service Calls + repair.order + kanban,list,form + + {'search_default_open': 1} + +

No service calls yet

+

+ Click New in the top-left to open the guided + intake wizard. The form will walk you through caller info, + equipment selection, the issue, urgency and photos. +

+
+
+ + + + kanban + + + + +
diff --git a/fusion_repairs/views/repair_part_order_views.xml b/fusion_repairs/views/repair_part_order_views.xml new file mode 100644 index 00000000..138f6f2b --- /dev/null +++ b/fusion_repairs/views/repair_part_order_views.xml @@ -0,0 +1,91 @@ + + + + + fusion.repair.part.order.list + fusion.repair.part.order + + + + + + + + + + + + + + + + + + + fusion.repair.part.order.form + fusion.repair.part.order + +
+
+
+ +
+

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + Parts to Order + fusion.repair.part.order + list,form + + +
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/repair_service_plan_views.xml b/fusion_repairs/views/repair_service_plan_views.xml new file mode 100644 index 00000000..272a03a5 --- /dev/null +++ b/fusion_repairs/views/repair_service_plan_views.xml @@ -0,0 +1,108 @@ + + + + + + product.template.form.service.plan.fusion_repairs + product.template + + + + + + + + + + + + + + + + + + fusion.repair.service.plan.subscription.list + fusion.repair.service.plan.subscription + + + + + + + + + + + + + + + + + + + fusion.repair.service.plan.subscription.form + fusion.repair.service.plan.subscription + +
+
+
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + Service Plans + fusion.repair.service.plan.subscription + list,form + + +
diff --git a/fusion_repairs/views/repair_warranty_views.xml b/fusion_repairs/views/repair_warranty_views.xml new file mode 100644 index 00000000..202f7c42 --- /dev/null +++ b/fusion_repairs/views/repair_warranty_views.xml @@ -0,0 +1,59 @@ + + + + + fusion.repair.warranty.coverage.list + fusion.repair.warranty.coverage + + + + + + + + + + + + + + + + fusion.repair.warranty.coverage.form + fusion.repair.warranty.coverage + +
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + +
+
+
+
+ + + Repair Warranties + fusion.repair.warranty.coverage + 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..d42732ea --- /dev/null +++ b/fusion_repairs/views/res_config_settings_views.xml @@ -0,0 +1,61 @@ + + + + + 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/views/sale_order_views.xml b/fusion_repairs/views/sale_order_views.xml new file mode 100644 index 00000000..38d4ccb9 --- /dev/null +++ b/fusion_repairs/views/sale_order_views.xml @@ -0,0 +1,31 @@ + + + + + sale.order.form.inherit.fusion_repairs + sale.order + + + + + + + + + + diff --git a/fusion_repairs/views/service_catalog_views.xml b/fusion_repairs/views/service_catalog_views.xml new file mode 100644 index 00000000..d63ce00a --- /dev/null +++ b/fusion_repairs/views/service_catalog_views.xml @@ -0,0 +1,74 @@ + + + + + fusion.repair.service.catalog.list + fusion.repair.service.catalog + + + + + + + + + + + + + + + + + fusion.repair.service.catalog.form + fusion.repair.service.catalog + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + Service Catalogue + fusion.repair.service.catalog + list,form + + +
diff --git a/fusion_repairs/views/technician_task_views.xml b/fusion_repairs/views/technician_task_views.xml new file mode 100644 index 00000000..2d78de05 --- /dev/null +++ b/fusion_repairs/views/technician_task_views.xml @@ -0,0 +1,49 @@ + + + + + + fusion.technician.task.form.inherit.fusion_repairs + fusion.technician.task + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ + + diff --git a/fusion_repairs/wizard/repair_visit_report_wizard.py b/fusion_repairs/wizard/repair_visit_report_wizard.py new file mode 100644 index 00000000..4f57b553 --- /dev/null +++ b/fusion_repairs/wizard/repair_visit_report_wizard.py @@ -0,0 +1,654 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +"""Technician visit report wizard. + +Opened from a completed (or in-progress) repair.order. Captures: +- labour hours +- parts/consumables used +- recommended upsell products +- optional client signature + +On confirm: +- writes labour + parts as repair.order lines (Odoo native operations) +- updates x_fc_actual_cost on the repair +- triggers variance reconciliation (sets x_fc_requires_requote if over threshold) +- if not requote: confirms the repair (state='under_repair' -> 'done' via Odoo native flow) +- offers an action_collect_payment shortcut to fire Poynt on the resulting invoice +""" + +import logging +from datetime import timedelta + +from markupsafe import Markup + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class RepairVisitReportWizard(models.TransientModel): + _name = 'fusion.repair.visit.report.wizard' + _description = 'Repair Visit Report Wizard' + + repair_id = fields.Many2one( + 'repair.order', + string='Repair Order', + required=True, + readonly=True, + ) + technician_id = fields.Many2one( + 'res.users', + string='Technician', + default=lambda self: self.env.user, + domain="[('x_fc_is_field_staff', '=', True)]", + ) + + # Labour + labour_hours = fields.Float( + string='Labour Hours', + required=True, + default=1.0, + ) + + # Parts used (simple line model below) + parts_line_ids = fields.One2many( + 'fusion.repair.visit.report.wizard.line', + 'wizard_id', + string='Parts Used', + ) + + # Outcome + notes = fields.Html(string='Technician Notes') + found_another_issue = fields.Boolean( + string='Found Another Issue', + 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, + ) + + # ----- T4 client signature ----- + client_signature = fields.Binary( + string='Client Signature', + attachment=True, + help='Captured via signature widget on tech mobile - proves the ' + 'client accepted the work.', + ) + client_signature_name = fields.Char( + string='Signed By', + help='Type the client name as they signed (for the audit log).', + ) + + # ----- T7 no-show photo proof ----- + no_show = fields.Boolean( + string='Client No-Show', + help='Tick if the client was not present. Forces a no-show photo.', + ) + no_show_photo = fields.Binary( + string='No-Show Photo', + attachment=True, + help='Photo of the door / driveway proving the technician attended.', + ) + + # ----- T6 parts replaced - serial capture ----- + parts_serial_capture = fields.Text( + string='Replaced Parts - Serials', + help='One serial per line. Used for OEM warranty claims.', + ) + + # ----- Bundle 8: Cannot Fix Today - Needs Parts ----- + outcome = fields.Selection( + [ + ('completed', 'Repair Complete - Close It'), + ('parts_needed', "Can't Fix Today - Need to Order Parts"), + ('rescheduled', 'Could Not Reach / Rescheduled'), + ], + string='Visit Outcome', + default='completed', + required=True, + help='Drives what happens after you submit: completed -> closes the ' + "repair; parts_needed -> captures the part info, emails the client, " + "schedules follow-up; rescheduled -> repair stays open.", + ) + needs_parts_line_ids = fields.One2many( + 'fusion.repair.visit.report.wizard.partline', + 'wizard_id', + string='Parts To Order', + help='ONE line per distinct part. Description + OEM number + photos go to ' + 'procurement so they can place the manufacturer order from your input ' + 'alone.', + ) + + # ----- Bundle 9: callout pricing + warranty ----- + callout_distance_km = fields.Float( + related='repair_id.x_fc_callout_distance_km', + string='One-Way Distance (km)', + readonly=False, + help='Distance from shop to client. Beyond the rate-card threshold, ' + 'EVERY km is billed BOTH WAYS, per tech.', + ) + callout_techs = fields.Integer( + related='repair_id.x_fc_callout_techs', + string='Technicians on Callout', + readonly=False, + ) + callout_tier = fields.Selection( + related='repair_id.x_fc_callout_tier', + string='Callout Tier', + readonly=False, + ) + callout_in_shop = fields.Boolean( + related='repair_id.x_fc_in_shop', + string='In-Shop Repair', + readonly=False, + ) + callout_labor_hours_used = fields.Float( + string='Repair Hours (after 30 min inspection)', + default=1.0, + help='Total hours of REPAIR WORK after the 30 minutes the callout fee covers. ' + 'Minimum 1 hour is billed even if the actual fix took less.', + ) + quote_total_preview = fields.Monetary( + related='repair_id.x_fc_quote_total', + currency_field='company_currency_id', + readonly=True, + ) + quote_breakdown_preview = fields.Text( + related='repair_id.x_fc_quote_breakdown_text', + readonly=True, + ) + labor_warranty_status_preview = fields.Selection( + related='repair_id.x_fc_labor_warranty_status', + readonly=True, + ) + labor_warranty_id_preview = fields.Many2one( + related='repair_id.x_fc_labor_warranty_id', + readonly=True, + ) + # Void path: tech finds misuse / negligence -> warranty is void + warranty_void_reason = fields.Selection( + [ + ('user_negligence', 'User Negligence'), + ('gross_negligence', 'Gross Negligence'), + ('misuse', 'Misuse'), + ('over_recommended_use', 'Over-Recommended Use'), + ('accidental_damage', 'Accidental Damage'), + ], + string='Void Warranty Reason', + help='If you find evidence the unit was misused, pick the reason. The ' + 'matching labor warranty record (if any) is voided permanently ' + 'and the client is billed full labor.', + ) + warranty_void_notes = fields.Text(string='Void Notes') + + # Variance display + estimated_cost = fields.Monetary( + related='repair_id.x_fc_estimated_cost', + currency_field='company_currency_id', + readonly=True, + ) + actual_cost = fields.Monetary( + string='Actual Cost', + compute='_compute_actual_cost', + currency_field='company_currency_id', + ) + variance_pct = fields.Float( + string='Variance %', + compute='_compute_actual_cost', + ) + requires_requote = fields.Boolean( + compute='_compute_actual_cost', + ) + + company_currency_id = fields.Many2one( + 'res.currency', + related='repair_id.company_currency_id', + readonly=True, + ) + + @api.depends('labour_hours', 'parts_line_ids.subtotal', 'repair_id.x_fc_estimated_cost') + def _compute_actual_cost(self): + ICP = self.env['ir.config_parameter'].sudo() + try: + threshold_pct = float(ICP.get_param('fusion_repairs.variance_threshold_pct', '20')) + except (ValueError, TypeError): + threshold_pct = 20.0 + try: + threshold_amt = float(ICP.get_param('fusion_repairs.variance_threshold_amount', '100')) + except (ValueError, TypeError): + threshold_amt = 100.0 + + for w in self: + catalog = w.repair_id.x_fc_service_catalog_id + labour_rate = 0.0 + if catalog and catalog.service_product_id: + labour_rate = catalog.service_product_id.list_price + parts_total = sum(w.parts_line_ids.mapped('subtotal')) + w.actual_cost = (w.labour_hours * labour_rate) + parts_total + est = w.estimated_cost or 0.0 + variance_pct = ((w.actual_cost - est) / est * 100) if est else 0.0 + w.variance_pct = variance_pct + # One-sided: only OVER-cost triggers re-quote. Coming in under + # estimate is good news and must not block invoicing. + over_pct = variance_pct + over_amt = w.actual_cost - est + w.requires_requote = est > 0 and ( + over_pct >= threshold_pct or over_amt >= threshold_amt + ) + + # ------------------------------------------------------------------ + # ACTION + # ------------------------------------------------------------------ + def action_confirm(self): + self.ensure_one() + repair = self.repair_id + if not repair: + raise UserError(_('No repair selected.')) + + # Create native repair operations (stock moves) for the parts used. + # 'add' type moves consume parts from the parts source location and + # flow through to the invoice when action_create_sale_order() is run. + self._create_repair_part_moves(repair) + + # Persist actual cost + requote flag on the repair. + repair.write({ + 'x_fc_actual_cost': self.actual_cost, + 'x_fc_requires_requote': self.requires_requote, + # Bundle 9 - persist hours the tech actually worked + resolve warranty + 'x_fc_callout_labor_hours': self.callout_labor_hours_used, + }) + + # Bundle 9: resolve labor warranty + apply void reason if the tech + # found misuse during the visit. + repair.action_check_labor_warranty() + if self.warranty_void_reason and repair.x_fc_labor_warranty_id: + repair.x_fc_labor_warranty_id.action_void( + reason=self.warranty_void_reason, + notes=self.warranty_void_notes or '', + ) + repair.x_fc_labor_warranty_status = 'void_misuse' + repair.message_post(body=Markup(_( + 'Warranty VOIDED on this visit. Reason: %(r)s. ' + 'Full labor charged.' + )) % {'r': dict(self._fields['warranty_void_reason'].selection).get( + self.warranty_void_reason)}) + + # Append technician notes to chatter. + if self.notes: + repair.message_post(body=self.notes) + + # Spawn a follow-up repair if the tech found another issue. + stub = False + if self.found_another_issue: + stub = repair.copy({ + 'state': 'draft', + 'internal_notes': _( + '

Spawned from visit report on %(ref)s. Add details for the new issue.

', + ref=repair.name, + ), + 'x_fc_intake_source': 'manual', + 'x_fc_intake_session_id': repair.x_fc_intake_session_id, + 'x_fc_estimated_cost': 0.0, + 'x_fc_actual_cost': 0.0, + 'x_fc_requires_requote': False, + 'x_fc_intake_template_id': False, + 'x_fc_service_catalog_id': False, + 'x_fc_maintenance_contract_id': False, + }) + repair.message_post( + body=Markup(_( + 'Spawned follow-up repair %(name)s for "found another issue".' + )) % {'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) + + # T4 / T6 / T7: persist captured artefacts as ir.attachment on the + # repair so they survive the wizard close. + self._persist_mobile_artefacts(repair) + + # M5: burn a pre-paid service plan visit if the client has one and + # the repair is a maintenance visit. The wizard intentionally does NOT + # zero out the client's invoice line - the office still posts the + # invoice; the burn is informational + the office reconciles credits + # in their accounting flow. + if not repair.x_fc_is_quote_only: + self._burn_service_plan_visit(repair) + + # Bundle 8: parts-needed branch - capture the parts, flag the repair, + # email the client, leave the repair OPEN with awaiting_parts substate. + if self.outcome == 'parts_needed': + self._handle_parts_needed(repair) + elif self.outcome == 'rescheduled': + repair.message_post(body=Markup(_( + 'Visit reported as rescheduled. Repair kept open.' + ))) + # BUG-B1 fix: actually close the repair so the whole downstream chain + # (NPS cron, dashboard "done this month" stats, customer survey) fires. + # Leave open if requote needed - the office will re-quote and the tech + # will revisit. No-show / parts-needed / rescheduled / quote-only also + # stay open. + elif (self.outcome == 'completed' + and not self.requires_requote + and not self.no_show + and not repair.x_fc_is_quote_only + and not stub): + self._close_repair(repair) + elif self.no_show: + repair.message_post(body=Markup(_( + 'Repair kept open due to no-show. Office to reschedule.' + ))) + elif self.requires_requote: + repair.message_post(body=Markup(_( + 'Repair kept open pending re-quote (variance flag).' + ))) + + # If a stub was spawned, open it directly so the tech can fill in details. + # 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': repair.name, + 'res_model': 'repair.order', + 'view_mode': 'form', + 'res_id': repair.id, + } + + def _persist_mobile_artefacts(self, repair): + """T4/T6/T7: attach signature image, no-show photo, and serial list + to the repair so they survive after the transient wizard closes.""" + Attachment = self.env['ir.attachment'].sudo() + if self.client_signature: + Attachment.create({ + 'name': f'signature-{repair.name}.png', + 'datas': self.client_signature, + 'res_model': 'repair.order', + 'res_id': repair.id, + 'mimetype': 'image/png', + }) + who = self.client_signature_name or repair.partner_id.name or '' + repair.message_post(body=Markup(_( + 'Client signature captured (%s).' + )) % who) + if self.no_show: + if self.no_show_photo: + Attachment.create({ + 'name': f'no-show-{repair.name}.jpg', + 'datas': self.no_show_photo, + 'res_model': 'repair.order', + 'res_id': repair.id, + 'mimetype': 'image/jpeg', + }) + repair.message_post(body=Markup(_( + 'Visit recorded as client no-show%s.' + )) % (' (photo attached)' if self.no_show_photo else '')) + if self.parts_serial_capture and self.parts_serial_capture.strip(): + repair.message_post(body=Markup(_( + 'Replaced part serials captured:
%s
' + )) % self.parts_serial_capture.strip()) + + def _handle_parts_needed(self, repair): + """Capture each part line as a fusion.repair.part.order record, + flag the repair as Awaiting Parts, and email the client a + "we found the problem - here's the timeline" note.""" + if not self.needs_parts_line_ids: + raise UserError(_( + 'Tick "Can\'t Fix Today - Need to Order Parts" but no parts ' + 'are captured. Add at least one part line so procurement can ' + 'place the order.' + )) + PartOrder = self.env['fusion.repair.part.order'].sudo() + Attachment = self.env['ir.attachment'].sudo() + max_lead = 0 + for line in self.needs_parts_line_ids: + # Copy any uploaded photos onto attachments owned by the part order. + photo_ids = [] + for att in line.photo_ids: + copied = Attachment.create({ + 'name': att.name, + 'datas': att.datas, + 'mimetype': att.mimetype, + }) + photo_ids.append(copied.id) + part = PartOrder.create({ + 'repair_order_id': repair.id, + 'description': line.description, + 'oem_part_number': line.oem_part_number, + 'manufacturer': line.manufacturer, + 'quantity': line.quantity or 1.0, + 'notes': line.notes, + 'photo_ids': [(6, 0, photo_ids)] if photo_ids else False, + 'expected_date': line.expected_lead_days and ( + fields.Date.context_today(self) + + timedelta(days=line.expected_lead_days) + ) or False, + }) + max_lead = max(max_lead, int(line.expected_lead_days or 0)) + repair.write({ + 'x_fc_parts_awaiting': True, + 'x_fc_parts_eta_date': ( + fields.Date.context_today(self) + timedelta(days=max_lead + 2) + if max_lead else False + ), + }) + # Office activity - "place these orders today". + repair.activity_schedule( + summary='Order parts from manufacturer(s)', + note=_('Tech captured %d part(s) - place the order(s) today.' + ) % len(self.needs_parts_line_ids), + user_id=repair.user_id.id or self.env.uid, + ) + # Client comms. + tpl = self.env.ref( + 'fusion_repairs.email_template_repair_awaiting_parts', + raise_if_not_found=False, + ) + if tpl and repair.partner_id and repair.partner_id.email: + try: + tpl.send_mail(repair.id, force_send=False) + except Exception: + _logger.exception('Awaiting-parts email failed for %s', repair.name) + repair.message_post(body=Markup(_( + 'Visit reported as parts needed. Captured %(n)d part order(s); ' + 'repair flagged "Awaiting Parts". Client notified.' + )) % {'n': len(self.needs_parts_line_ids)}) + + def _close_repair(self, repair): + """Drive the Odoo native state machine from draft -> done. + + Odoo 19 sequence: draft -> action_validate (confirmed/under_repair) + -> action_repair_start (under_repair) -> action_repair_end (done). + Calls are guarded - silently re-runs only the missing steps. + """ + try: + if repair.state == 'draft': + # action_validate is the standard entry path; if the product is + # storable it expects reservations etc., so fall back to the + # simpler _action_repair_confirm() helper if validate refuses. + try: + repair.action_validate() + except Exception as e: + _logger.info( + 'action_validate skipped for %s: %s; using internal confirm.', + repair.name, e, + ) + repair._action_repair_confirm() + if repair.state == 'confirmed': + repair.action_repair_start() + if repair.state == 'under_repair': + repair.action_repair_end() + repair.message_post(body=Markup(_( + 'Visit report submitted - repair closed by %s.' + )) % (self.technician_id.name or self.env.user.name)) + except Exception as e: + _logger.exception( + 'Visit report could not close repair %s automatically: %s', + repair.name, e, + ) + repair.message_post(body=Markup(_( + 'Could not auto-close repair: %s. Office must close manually.' + )) % str(e)) + + def _burn_service_plan_visit(self, repair): + """M5: deduct one visit from the most-recently-active service plan + covering this repair. Quietly no-ops if the client has no plan.""" + Plan = self.env['fusion.repair.service.plan.subscription'].sudo() + sub = Plan.find_for_repair(repair) + if sub: + sub.burn_visit(repair) + + 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'). + + Locations follow the repair order's configured source / parts locations; + Odoo natively links these moves to the SO line generated by + action_create_sale_order() so they invoice correctly. + """ + Move = self.env['stock.move'].sudo() + for line in self.parts_line_ids: + if not line.product_id or line.quantity <= 0: + continue + vals = { + 'name': line.product_id.display_name, + 'product_id': line.product_id.id, + 'product_uom_qty': line.quantity, + 'product_uom': line.product_id.uom_id.id, + 'repair_id': repair.id, + 'repair_line_type': 'add', + 'location_id': repair.location_id.id, + 'location_dest_id': repair.parts_location_id.id or repair.location_id.id, + 'company_id': repair.company_id.id, + } + try: + Move.create(vals) + except Exception as e: + _logger.warning( + 'Could not create repair part move on %s for %s: %s', + repair.name, line.product_id.display_name, e, + ) + + +class RepairVisitReportWizardLine(models.TransientModel): + _name = 'fusion.repair.visit.report.wizard.line' + _description = 'Repair Visit Report Wizard - Part Line' + + wizard_id = fields.Many2one( + 'fusion.repair.visit.report.wizard', + required=True, + ondelete='cascade', + ) + product_id = fields.Many2one( + 'product.product', + string='Part', + required=True, + ) + quantity = fields.Float(default=1.0, required=True) + unit_price = fields.Float(string='Unit Price') + subtotal = fields.Float(compute='_compute_subtotal', store=True) + + @api.onchange('product_id') + def _onchange_product_id(self): + if self.product_id: + self.unit_price = self.product_id.list_price + + @api.depends('quantity', 'unit_price') + def _compute_subtotal(self): + for line in self: + line.subtotal = line.quantity * line.unit_price + + +class RepairVisitReportWizardPartLine(models.TransientModel): + """Bundle 8: parts the tech needs the office to ORDER from the manufacturer. + + Captured during the visit report when outcome='parts_needed'; one record per + distinct part. On wizard confirm, each line creates a + fusion.repair.part.order which is the procurement-facing record. + """ + _name = 'fusion.repair.visit.report.wizard.partline' + _description = 'Visit Report - Part to Order' + + wizard_id = fields.Many2one( + 'fusion.repair.visit.report.wizard', + required=True, + ondelete='cascade', + ) + description = fields.Char( + string='Description', + required=True, + help='Plain English (e.g. "Handicare 1100 right armrest").', + ) + oem_part_number = fields.Char(string='OEM #') + manufacturer = fields.Char(string='Manufacturer') + quantity = fields.Float(default=1.0, required=True) + expected_lead_days = fields.Integer( + string='Lead Time (days)', + default=7, + help='Tech estimate. Office uses this to set client ETA expectations.', + ) + notes = fields.Text(string='Notes for Procurement') + photo_ids = fields.Many2many( + 'ir.attachment', + 'fusion_repair_visit_partline_photo_rel', + 'partline_id', 'attachment_id', + string='Photos', + ) diff --git a/fusion_repairs/wizard/repair_visit_report_wizard_views.xml b/fusion_repairs/wizard/repair_visit_report_wizard_views.xml new file mode 100644 index 00000000..bc1703c0 --- /dev/null +++ b/fusion_repairs/wizard/repair_visit_report_wizard_views.xml @@ -0,0 +1,138 @@ + + + + + fusion.repair.visit.report.wizard.form + fusion.repair.visit.report.wizard + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
diff --git a/fusion_service_charges/__init__.py b/fusion_service_charges/__init__.py new file mode 100644 index 00000000..ba959657 --- /dev/null +++ b/fusion_service_charges/__init__.py @@ -0,0 +1,196 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +"""Fusion — Service Charges. + +Seeds the service-billing product catalog (Service Call, Labour, +Delivery, Stairlift set-up, etc.) on FIRST install only. + +Architecture decision: no data XML in the manifest. All products are +created imperatively in a post_init_hook. This guarantees: + + - Users can edit prices / names / accounting tags freely after + install — upgrades won't overwrite them. + - Users can delete products that don't apply to their shop — + upgrades won't resurrect them (Odoo's "noupdate=1" doesn't + actually prevent re-creation when the ir.model.data row is + missing, only updates; see fusion_plating 19.0.20.5.0 hook for + the same pattern + investigation). + - Re-installing the module after uninstall DOES re-seed (the + ir.model.data sentinels are dropped on uninstall, so the next + install's hook treats it as a fresh seed). + +Per-shop pricing — currently identical on Westin Healthcare and +Mobility Specialties; if they diverge we can add a setting per +shop or a pricelist override. +""" +import logging + +_logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Rate schedule — single source of truth. +# +# Each row creates one product.template via the post_init_hook. +# Tuple structure: (xmlid_suffix, name, category, uom_xmlid, list_price, +# description) +# +# Per-km surcharges (Rush / Outside Local / After-Hours) are noted in +# the description so the dispatcher knows to add a km line manually. +# A formula-based pricelist would automate this but is out of scope — +# matching today's manual workflow on both shops. +# --------------------------------------------------------------------------- +_SERVICE_CHARGES = [ + # (xmlid_suffix, name, default_code, uom_xmlid, price, description) + + # ---- Standard Service ---- + ('standard_service_call', + 'Standard Service Call', + 'SVC-STD-CALL', 'uom.product_uom_unit', 95.00, + 'Service Call — appointment outside a Westin Healthcare location. ' + 'Billed once per service request. Includes the first 30 minutes ' + 'of labour; additional time billed at the Standard Labour Rate. ' + 'Excludes parts (covered by manufacturer warranty when applicable).'), + ('standard_labour', + 'Standard Labour (Hourly)', + 'SVC-STD-LABOUR', 'uom.product_uom_hour', 85.00, + 'Standard hourly labour rate. Pro-rated in 30-minute increments. ' + 'Starts after the 30 minutes included in the Service Call. ' + 'Applies per technician when multiple are on the job.'), + ('in_shop_labour', + 'In-Shop Labour (Hourly)', + 'SVC-INSHOP-LABOUR', 'uom.product_uom_hour', 75.00, + 'Hourly labour rate when the work is done at the Westin Healthcare ' + 'shop instead of on-site. Pro-rated in 30-minute increments.'), + ('rush_service_call', + 'Rush Service Call', + 'SVC-RUSH-CALL', 'uom.product_uom_unit', 120.00, + 'Rush dispatch — same-day / priority response. Adds $0.70 per ' + 'km (2-way) on top of this flat fee; add the mileage as a ' + 'separate line.'), + ('after_hours_service_call', + 'After-Hours Service Call', + 'SVC-AH-CALL', 'uom.product_uom_unit', 140.00, + 'Service call outside standard business hours. Adds $0.70 per ' + 'km (2-way) on top of this flat fee; add the mileage as a ' + 'separate line.'), + + # ---- Lift & Elevating Service ---- + ('lift_service_call', + 'Lift & Elevating Service Call', + 'SVC-LIFT-CALL', 'uom.product_uom_unit', 160.00, + 'Service Call for stairlift / lift / elevating equipment. ' + 'Includes the first 30 minutes of labour. Excludes parts ' + 'unless covered by manufacturer warranty.'), + ('lift_labour', + 'Lift & Elevating Labour (Hourly)', + 'SVC-LIFT-LABOUR', 'uom.product_uom_hour', 110.00, + 'Hourly labour rate for stairlift / lift / elevating equipment. ' + 'Pro-rated in 30-minute increments. Per-technician.'), + + # ---- Delivery / Pickup ---- + ('delivery_local', + 'Local Delivery / Pickup', + 'DEL-LOCAL', 'uom.product_uom_unit', 35.00, + 'Drop-off or pick-up within Brampton.'), + ('delivery_outside_local', + 'Outside Local Delivery / Pickup', + 'DEL-OUT', 'uom.product_uom_unit', 60.00, + 'Drop-off or pick-up outside Brampton.'), + ('delivery_rush', + 'Rush Delivery / Pickup', + 'DEL-RUSH', 'uom.product_uom_unit', 60.00, + 'Same-day delivery or pickup. Adds $0.70 per km (2-way) on top ' + 'of this flat fee; add the mileage as a separate line.'), + ('delivery_lift_chair', + 'Lift Chair Delivery + Set-up', + 'DEL-LIFT-CHAIR', 'uom.product_uom_unit', 120.00, + 'Delivery and in-home set-up of a lift chair.'), + ('delivery_hospital_bed', + 'Hospital Bed Delivery + Set-up', + 'DEL-HOSP-BED', 'uom.product_uom_unit', 120.00, + 'Delivery and in-home set-up of a hospital bed.'), + ('delivery_stairlift', + 'Stairlift Delivery + Set-up', + 'DEL-STAIRLIFT', 'uom.product_uom_unit', 300.00, + 'Delivery and installation of a stairlift.'), + ('removal_stairlift', + 'Stairlift Removal', + 'SVC-STAIRLIFT-RM', 'uom.product_uom_unit', 300.00, + 'On-site removal of an existing stairlift.'), +] + + +def post_init_hook(env): + _seed_service_charges_once(env) + + +def _seed_service_charges_once(env): + """Create product.template rows for the service catalog. + + Idempotent — each row guarded by an ir.model.data xmlid check. + If the xmlid already resolves to a record, that product is left + alone (its price / name / accounting tags may have been edited + by the shop). New rows are created with an ir.model.data sentinel + so a future run sees them as already-seeded. + + Re-running the hook by hand: + env['ir.module.module'].search([('name', '=', 'fusion_service_charges')]).button_upgrade() + # (post_init_hook only fires on first install in Odoo 19; for + # a re-seed you'd uninstall+reinstall, which is fine because + # ir.model.data is dropped on uninstall) + """ + Product = env['product.template'].sudo() + IMD = env['ir.model.data'].sudo() + module_name = 'fusion_service_charges' + + created = [] + skipped = [] + for (xmlid_suffix, name, default_code, uom_xmlid, price, + description) in _SERVICE_CHARGES: + existing = IMD.search([ + ('module', '=', module_name), + ('name', '=', xmlid_suffix), + ], limit=1) + if existing: + skipped.append(default_code) + continue + uom = env.ref(uom_xmlid, raise_if_not_found=False) + if not uom: + _logger.warning( + 'fusion_service_charges: UoM %s not found, ' + 'falling back to product_uom_unit', uom_xmlid, + ) + uom = env.ref('uom.product_uom_unit') + # Odoo 19 retired uom_po_id on product.template — uom_id is the + # single source of truth for sale + purchase. + product = Product.create({ + 'name': name, + 'type': 'service', + 'default_code': default_code, + 'list_price': price, + 'sale_ok': True, + 'purchase_ok': False, + 'uom_id': uom.id, + 'description_sale': description, + }) + IMD.create({ + 'module': module_name, + 'name': xmlid_suffix, + 'model': 'product.template', + 'res_id': product.id, + 'noupdate': True, + }) + created.append(default_code) + + if created: + _logger.info( + 'fusion_service_charges: seeded %d product(s) — %s', + len(created), ', '.join(created), + ) + if skipped: + _logger.info( + 'fusion_service_charges: skipped %d existing product(s) — %s', + len(skipped), ', '.join(skipped), + ) diff --git a/fusion_service_charges/__manifest__.py b/fusion_service_charges/__manifest__.py new file mode 100644 index 00000000..e6daaa6b --- /dev/null +++ b/fusion_service_charges/__manifest__.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +{ + 'name': 'Fusion — Service Charges', + 'version': '19.0.1.0.0', + 'category': 'Sales', + 'summary': ( + 'Standard service-call, labour, delivery, and installation ' + 'products for Westin Healthcare and Mobility Specialties.' + ), + 'description': """ +Fusion — Service Charges +========================== + +Seeds the service-billing product catalog used by Westin Healthcare +and Mobility Specialties: + +* Standard Service: Service Call, Labour (hourly), In-Shop Labour, + Rush Service Call, After-Hours Service Call +* Lift & Elevating Service: Service Call, Labour (hourly) +* Delivery / Pickup: Local, Outside Local Area, Rush, Lift Chair + set-up, Hospital Bed set-up, Stairlift set-up, Stairlift Removal + +Loading pattern (deliberate): + +* Products are created via post_init_hook on FIRST install only. +* No data XML is registered in the manifest, so ``-u`` upgrades + never touch the records. Edits and deletions made by sales/ops + survive every upgrade. +* The hook is idempotent — sentinel xmlid check skips already- + seeded products. Re-running the hook by hand is safe. +* Re-installing the module after uninstall re-creates the products + (the ir.model.data sentinels go away on uninstall, so the next + install treats it as fresh). + +Per-km surcharges (Rush, Outside Local) ARE captured on the +product as a hint in the product description; actual km billing +is left as a manual SO-line tweak by the dispatcher (matches +current shop workflow — formula-based pricing would need a +sale.order.line.onchange to compute, out of scope here). +""", + 'author': 'Nexa Systems Inc.', + 'website': 'https://www.nexasystems.ca', + 'license': 'OPL-1', + 'depends': [ + 'product', + 'uom', + ], + # Empty on purpose — no data XML. See the docstring on + # _seed_service_charges_once() for why every product is created + # imperatively via the post_init_hook. + 'data': [], + 'post_init_hook': 'post_init_hook', + 'installable': True, + 'application': False, + 'auto_install': False, +}