feat(fusion_repairs): Phase 1 MVP - backend intake wizard + core models

Scaffolds the fusion_repairs module that extends Odoo 19 repair.order with
a guided medical-equipment intake workflow.

Models
- fusion.repair.product.category (8 medical equipment categories seeded)
- fusion.repair.intake.template / .question / .answer (7 templates,
  32 questions seeded across hospital bed, stairlift, porch lift,
  wheelchair, walker/rollator, mattress)
- fusion.repair.intake.service (AbstractModel) - single entry point used
  by backend wizard, sales rep portal, and public client portal so all
  three surfaces produce identical outcomes
- repair.order extensions (x_fc_intake_*, x_fc_third_party_equipment,
  x_fc_photo_ids, x_fc_urgency, x_fc_estimated/actual_cost, AI summary)
- fusion.technician.task back-link (x_fc_repair_order_id)
- res.partner service preferences (preferred tech, time window, access notes)
- res.users repair extensions (skills, cost rate, on-call rotation fields)
- res.config.settings for variance thresholds, portal URL, rate limit

UI
- Backend intake wizard with multi-equipment loop, third-party flag, photos
- repair.order form: Intake tab, Photos, Pricing tab, AI tab, smart buttons
  (technician tasks, intake answers, original SO)
- Kanban + list view urgency badges
- Fusion Repairs app menu (New Service Call, Repair Orders, Config)

Activities & Email
- 4 follow-up activity types (CS callback, tech dispatch, visit follow-up,
  manager review) with urgency-tiered deadlines
- 2 mail templates (client confirmation + office notification) with the
  same dark/light-safe styling as fusion_claims ADP templates

Security
- New res.groups.privilege + 3 groups (User, Dispatcher, Manager)
- Reuses fusion_tasks.group_field_technician (do NOT recreate)
- Reuses fusion_authorizer_portal.group_sales_rep_portal
- Multi-company global rule + technician scoping rule on repair.order

Verified end-to-end on local westin-v19 dev DB via odoo-shell - creates
multiple repairs in one session, auto-creates dispatch task for urgent,
attaches 4 activity types correctly per urgency tier and third-party flag.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
gsinghpal
2026-05-20 21:35:52 -04:00
parent 79fbfec61f
commit 429084e0bf
32 changed files with 2823 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import models
from . import wizard

View File

@@ -0,0 +1,100 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Repairs',
'version': '19.0.1.0.0',
'category': 'Inventory/Repairs',
'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal',
'description': """
Fusion Repairs
==============
Comprehensive repairs and maintenance management for medical equipment retailers
and service providers (hospital beds, wheelchairs, stairlifts, porch lifts,
walkers, mattresses, rollators).
Phase 1 - MVP
-------------
- Three intake surfaces sharing one service layer:
* Backend wizard for CS reps on the phone
* Sales rep portal (/my/repair/new) for reps on the road
* Public client self-service portal (/repair) - voicemail ready
- Guided question templates per medical equipment category
- Phone-first partner lookup with duplicate-call detection
- Multi-equipment per call (one repair.order per unit)
- Photo / video capture during intake
- Third-party equipment support (equipment we didn't sell)
- Auto warranty detection from original sale order
- Office notification recipients + 4 follow-up activities
- repair.order extensions linked to fusion.technician.task
Phase 2-4 (roadmap)
-------------------
- AI self-check engine with strict medical safety guardrails
- Upsell engine and direct-buy parts/plans
- Repair warranty tracking (free re-do window)
- Visit report wizard with Poynt terminal payment
- Maintenance contracts with client self-booking
- Weekend safety on-call paging
- SMS notifications, compliance certificates, analytics
Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
""",
'author': 'Nexa Systems Inc.',
'website': 'https://www.nexasystems.ca',
'maintainer': 'Nexa Systems Inc.',
'support': 'support@nexasystems.ca',
'license': 'OPL-1',
'price': 0.00,
'currency': 'CAD',
'depends': [
'base',
'mail',
'portal',
'website',
'sale_management',
'stock',
'repair',
'maintenance',
'fusion_tasks',
'fusion_poynt',
'fusion_authorizer_portal',
],
'data': [
# Security
'security/security.xml',
'security/ir.model.access.csv',
# Data (must load before views that reference records)
'data/ir_sequence_data.xml',
'data/ir_config_parameter_data.xml',
'data/mail_activity_type_data.xml',
'data/mail_template_data.xml',
'data/repair_product_category_data.xml',
'data/intake_template_data.xml',
# Views
'views/repair_product_category_views.xml',
'views/intake_template_views.xml',
'views/repair_order_views.xml',
'views/res_partner_views.xml',
'views/res_users_views.xml',
'views/res_config_settings_views.xml',
# Wizard
'wizard/repair_intake_wizard_views.xml',
# Menus (last, after all referenced actions exist)
'views/menus.xml',
],
'assets': {
'web.assets_backend': [
# Phase 2+: history_sidebar.js, signature_pad.js, etc.
],
'web.assets_frontend': [
# Phase 1+: portal_client_repair.js etc.
],
},
'images': ['static/description/icon.png'],
'installable': True,
'application': True,
'auto_install': False,
}

View File

@@ -0,0 +1,378 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Seed intake templates - one per major medical equipment category.
Question banks based on the design spec Section "Configurable intake".
All templates noupdate=1 so customers can customise without losing data on upgrade.
-->
<odoo>
<data noupdate="1">
<!-- ============================================================== -->
<!-- DEFAULT (fallback) - applies to any category without override -->
<!-- ============================================================== -->
<record id="intake_template_default" model="fusion.repair.intake.template">
<field name="name">Default - General Intake</field>
<field name="code">default</field>
<field name="sequence">1</field>
<field name="is_default" eval="True"/>
<field name="description"><![CDATA[<p>Generic question set used when no equipment-specific template is configured.</p>]]></field>
</record>
<record id="q_default_caller_relationship" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_default"/>
<field name="sequence">10</field>
<field name="name">Who is calling? (self / family / caregiver / other)</field>
<field name="code">caller_relationship</field>
<field name="question_type">char</field>
<field name="required" eval="True"/>
</record>
<record id="q_default_address_match" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_default"/>
<field name="sequence">20</field>
<field name="name">Is the service address the same as the contact address on file?</field>
<field name="code">address_match</field>
<field name="question_type">boolean</field>
<field name="required" eval="True"/>
</record>
<record id="q_default_purchased_from_us" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_default"/>
<field name="sequence">30</field>
<field name="name">Was this equipment purchased from us?</field>
<field name="code">purchased_from_us</field>
<field name="question_type">boolean</field>
</record>
<record id="q_default_purchase_date" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_default"/>
<field name="sequence">40</field>
<field name="name">Approximate purchase date (if known)</field>
<field name="code">purchase_date</field>
<field name="question_type">date</field>
</record>
<record id="q_default_issue_summary" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_default"/>
<field name="sequence">50</field>
<field name="name">Describe the issue in your own words</field>
<field name="code">issue_summary</field>
<field name="question_type">text</field>
<field name="required" eval="True"/>
</record>
<record id="q_default_safety_concern" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_default"/>
<field name="sequence">60</field>
<field name="name">Does this issue affect anyone's safety right now?</field>
<field name="code">safety_concern</field>
<field name="question_type">boolean</field>
<field name="required" eval="True"/>
</record>
<record id="q_default_access_notes" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_default"/>
<field name="sequence">70</field>
<field name="name">Anything the technician should know about access? (stairs, parking, gate code, pet)</field>
<field name="code">access_notes</field>
<field name="question_type">text</field>
<field name="help_text">e.g. "dog in front yard, use side gate"</field>
</record>
<!-- ============================================================== -->
<!-- HOSPITAL BED -->
<!-- ============================================================== -->
<record id="intake_template_hospital_bed" model="fusion.repair.intake.template">
<field name="name">Hospital Bed - Intake</field>
<field name="code">hospital_bed</field>
<field name="sequence">10</field>
<field name="product_category_ids" eval="[(6, 0, [ref('category_hospital_bed')])]"/>
</record>
<record id="q_bed_powered" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_hospital_bed"/>
<field name="sequence">10</field>
<field name="name">Is the bed plugged in and does it power on?</field>
<field name="code">powered</field>
<field name="question_type">selection</field>
<field name="selection_options">Yes - powers on normally
No - no lights/sound at all
Powers on but won't move</field>
<field name="required" eval="True"/>
</record>
<record id="q_bed_remote_works" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_hospital_bed"/>
<field name="sequence">20</field>
<field name="name">Does the remote control respond when buttons are pressed?</field>
<field name="code">remote_works</field>
<field name="question_type">boolean</field>
</record>
<record id="q_bed_motor_side" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_hospital_bed"/>
<field name="sequence">30</field>
<field name="name">Which motor seems affected? (head, foot, height, all)</field>
<field name="code">motor_side</field>
<field name="question_type">char</field>
<field name="symptom_keywords">motor</field>
</record>
<record id="q_bed_rails" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_hospital_bed"/>
<field name="sequence">40</field>
<field name="name">Are the side rails functioning normally?</field>
<field name="code">rails_ok</field>
<field name="question_type">boolean</field>
<field name="symptom_keywords">rail,side</field>
</record>
<record id="q_bed_mattress" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_hospital_bed"/>
<field name="sequence">50</field>
<field name="name">Is the mattress included in this issue?</field>
<field name="code">mattress_involved</field>
<field name="question_type">boolean</field>
</record>
<!-- ============================================================== -->
<!-- STAIRLIFT -->
<!-- ============================================================== -->
<record id="intake_template_stairlift" model="fusion.repair.intake.template">
<field name="name">Stairlift - Intake</field>
<field name="code">stairlift</field>
<field name="sequence">20</field>
<field name="product_category_ids" eval="[(6, 0, [ref('category_stairlift')])]"/>
</record>
<record id="q_stairlift_powered" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_stairlift"/>
<field name="sequence">10</field>
<field name="name">Does the stairlift power on? (any lights, beeps)</field>
<field name="code">powered</field>
<field name="question_type">boolean</field>
<field name="required" eval="True"/>
</record>
<record id="q_stairlift_error_code" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_stairlift"/>
<field name="sequence">20</field>
<field name="name">Is there an error code displayed? (note the number/letter shown)</field>
<field name="code">error_code</field>
<field name="question_type">char</field>
<field name="symptom_keywords">error code</field>
</record>
<record id="q_stairlift_stuck_position" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_stairlift"/>
<field name="sequence">30</field>
<field name="name">Is anyone currently stuck on the stairlift?</field>
<field name="code">person_stuck</field>
<field name="question_type">boolean</field>
<field name="required" eval="True"/>
<field name="help_text">If yes, this is a safety issue - escalate immediately.</field>
</record>
<record id="q_stairlift_stops_midway" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_stairlift"/>
<field name="sequence">40</field>
<field name="name">Does it stop partway up or down the track?</field>
<field name="code">stops_midway</field>
<field name="question_type">boolean</field>
<field name="symptom_keywords">stops midway</field>
</record>
<record id="q_stairlift_burning" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_stairlift"/>
<field name="sequence">50</field>
<field name="name">Any burning smell, smoke, or unusual noise?</field>
<field name="code">burning_smell</field>
<field name="question_type">boolean</field>
<field name="required" eval="True"/>
<field name="symptom_keywords">burning smell,smoke</field>
</record>
<!-- ============================================================== -->
<!-- PORCH LIFT -->
<!-- ============================================================== -->
<record id="intake_template_porch_lift" model="fusion.repair.intake.template">
<field name="name">Porch Lift - Intake</field>
<field name="code">porch_lift</field>
<field name="sequence">30</field>
<field name="product_category_ids" eval="[(6, 0, [ref('category_porch_lift')])]"/>
</record>
<record id="q_porch_powered" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_porch_lift"/>
<field name="sequence">10</field>
<field name="name">Does the lift respond when you press the call/send button?</field>
<field name="code">powered</field>
<field name="question_type">boolean</field>
<field name="required" eval="True"/>
</record>
<record id="q_porch_gate_switches" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_porch_lift"/>
<field name="sequence">20</field>
<field name="name">Are all gate and door safety switches fully closed?</field>
<field name="code">gate_switches</field>
<field name="question_type">boolean</field>
</record>
<record id="q_porch_stuck" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_porch_lift"/>
<field name="sequence">30</field>
<field name="name">Is anyone currently stuck on the lift?</field>
<field name="code">person_stuck</field>
<field name="question_type">boolean</field>
<field name="required" eval="True"/>
</record>
<record id="q_porch_outdoor" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_porch_lift"/>
<field name="sequence">40</field>
<field name="name">Is the lift outdoors exposed to weather?</field>
<field name="code">outdoor</field>
<field name="question_type">boolean</field>
</record>
<!-- ============================================================== -->
<!-- WHEELCHAIR (MANUAL + POWER - shared template) -->
<!-- ============================================================== -->
<record id="intake_template_wheelchair" model="fusion.repair.intake.template">
<field name="name">Wheelchair - Intake</field>
<field name="code">wheelchair</field>
<field name="sequence">40</field>
<field name="product_category_ids" eval="[(6, 0, [ref('category_wheelchair_manual'), ref('category_wheelchair_power')])]"/>
</record>
<record id="q_wc_brakes" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_wheelchair"/>
<field name="sequence">10</field>
<field name="name">Do the brakes engage and hold the wheelchair?</field>
<field name="code">brakes_ok</field>
<field name="question_type">boolean</field>
<field name="required" eval="True"/>
<field name="symptom_keywords">brake</field>
</record>
<record id="q_wc_tires" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_wheelchair"/>
<field name="sequence">20</field>
<field name="name">Are both tires inflated and undamaged?</field>
<field name="code">tires_ok</field>
<field name="question_type">boolean</field>
</record>
<record id="q_wc_frame" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_wheelchair"/>
<field name="sequence">30</field>
<field name="name">Is there any visible damage to the frame or footrests?</field>
<field name="code">frame_damage</field>
<field name="question_type">boolean</field>
</record>
<record id="q_wc_battery" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_wheelchair"/>
<field name="sequence">40</field>
<field name="name">For power chairs: does the battery hold a charge?</field>
<field name="code">battery_holds_charge</field>
<field name="question_type">boolean</field>
<field name="symptom_keywords">battery,charge</field>
</record>
<record id="q_wc_joystick" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_wheelchair"/>
<field name="sequence">50</field>
<field name="name">For power chairs: any error code shown on the joystick display?</field>
<field name="code">joystick_error</field>
<field name="question_type">char</field>
</record>
<!-- ============================================================== -->
<!-- WALKER / ROLLATOR (shared) -->
<!-- ============================================================== -->
<record id="intake_template_walker_rollator" model="fusion.repair.intake.template">
<field name="name">Walker / Rollator - Intake</field>
<field name="code">walker_rollator</field>
<field name="sequence">50</field>
<field name="product_category_ids" eval="[(6, 0, [ref('category_walker'), ref('category_rollator')])]"/>
</record>
<record id="q_walker_wheels" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_walker_rollator"/>
<field name="sequence">10</field>
<field name="name">Do all wheels roll freely?</field>
<field name="code">wheels_roll</field>
<field name="question_type">boolean</field>
</record>
<record id="q_walker_brakes" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_walker_rollator"/>
<field name="sequence">20</field>
<field name="name">Do the brakes lock when engaged? (rollator only)</field>
<field name="code">brakes_lock</field>
<field name="question_type">boolean</field>
</record>
<record id="q_walker_frame" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_walker_rollator"/>
<field name="sequence">30</field>
<field name="name">Is the frame stable, with no wobble or loose parts?</field>
<field name="code">frame_stable</field>
<field name="question_type">boolean</field>
</record>
<!-- ============================================================== -->
<!-- MEDICAL MATTRESS -->
<!-- ============================================================== -->
<record id="intake_template_mattress" model="fusion.repair.intake.template">
<field name="name">Medical Mattress - Intake</field>
<field name="code">mattress</field>
<field name="sequence">60</field>
<field name="product_category_ids" eval="[(6, 0, [ref('category_mattress')])]"/>
</record>
<record id="q_mattress_pump_powered" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_mattress"/>
<field name="sequence">10</field>
<field name="name">Is the pump plugged in and showing any indicator lights?</field>
<field name="code">pump_powered</field>
<field name="question_type">boolean</field>
<field name="required" eval="True"/>
</record>
<record id="q_mattress_leak" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_mattress"/>
<field name="sequence">20</field>
<field name="name">Is the mattress leaking or losing air?</field>
<field name="code">leak</field>
<field name="question_type">boolean</field>
<field name="symptom_keywords">leak,deflate</field>
</record>
<record id="q_mattress_alarm" model="fusion.repair.intake.question">
<field name="template_id" ref="intake_template_mattress"/>
<field name="sequence">30</field>
<field name="name">Is the pump showing an error code or alarm?</field>
<field name="code">alarm</field>
<field name="question_type">char</field>
</record>
<!-- ============================================================== -->
<!-- Backfill category defaults -->
<!-- ============================================================== -->
<function model="fusion.repair.product.category" name="write">
<value model="fusion.repair.product.category" eval="[ref('category_hospital_bed')]"/>
<value eval="{'intake_template_id': ref('intake_template_hospital_bed')}"/>
</function>
<function model="fusion.repair.product.category" name="write">
<value model="fusion.repair.product.category" eval="[ref('category_stairlift')]"/>
<value eval="{'intake_template_id': ref('intake_template_stairlift')}"/>
</function>
<function model="fusion.repair.product.category" name="write">
<value model="fusion.repair.product.category" eval="[ref('category_porch_lift')]"/>
<value eval="{'intake_template_id': ref('intake_template_porch_lift')}"/>
</function>
<function model="fusion.repair.product.category" name="write">
<value model="fusion.repair.product.category" eval="[ref('category_wheelchair_manual')]"/>
<value eval="{'intake_template_id': ref('intake_template_wheelchair')}"/>
</function>
<function model="fusion.repair.product.category" name="write">
<value model="fusion.repair.product.category" eval="[ref('category_wheelchair_power')]"/>
<value eval="{'intake_template_id': ref('intake_template_wheelchair')}"/>
</function>
<function model="fusion.repair.product.category" name="write">
<value model="fusion.repair.product.category" eval="[ref('category_walker')]"/>
<value eval="{'intake_template_id': ref('intake_template_walker_rollator')}"/>
</function>
<function model="fusion.repair.product.category" name="write">
<value model="fusion.repair.product.category" eval="[ref('category_rollator')]"/>
<value eval="{'intake_template_id': ref('intake_template_walker_rollator')}"/>
</function>
<function model="fusion.repair.product.category" name="write">
<value model="fusion.repair.product.category" eval="[ref('category_mattress')]"/>
<value eval="{'intake_template_id': ref('intake_template_default')}"/>
</function>
<function model="fusion.repair.product.category" name="write">
<value model="fusion.repair.product.category" eval="[ref('category_other')]"/>
<value eval="{'intake_template_id': ref('intake_template_default')}"/>
</function>
</data>
</odoo>

View File

@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Feature toggles -->
<record id="param_enable_email_notifications" model="ir.config_parameter">
<field name="key">fusion_repairs.enable_email_notifications</field>
<field name="value">True</field>
</record>
<!-- Outstanding balance warning threshold (CAD) - C5 -->
<record id="param_outstanding_balance_threshold" model="ir.config_parameter">
<field name="key">fusion_repairs.outstanding_balance_threshold</field>
<field name="value">100.00</field>
</record>
<!-- Duplicate-call detection window (days) - C1 -->
<record id="param_duplicate_call_window_days" model="ir.config_parameter">
<field name="key">fusion_repairs.duplicate_call_window_days</field>
<field name="value">14</field>
</record>
<!-- Pricing variance reconciliation - Phase 2 -->
<record id="param_variance_threshold_pct" model="ir.config_parameter">
<field name="key">fusion_repairs.variance_threshold_pct</field>
<field name="value">20</field>
</record>
<record id="param_variance_threshold_amount" model="ir.config_parameter">
<field name="key">fusion_repairs.variance_threshold_amount</field>
<field name="value">100.00</field>
</record>
<!-- Office follow-up cron toggles - Phase 3 -->
<record id="param_followup_maintenance_enabled" model="ir.config_parameter">
<field name="key">fusion_repairs.followup_maintenance_enabled</field>
<field name="value">True</field>
</record>
<record id="param_followup_repair_no_tech_enabled" model="ir.config_parameter">
<field name="key">fusion_repairs.followup_repair_no_tech_enabled</field>
<field name="value">True</field>
</record>
<record id="param_followup_overdue_visit_enabled" model="ir.config_parameter">
<field name="key">fusion_repairs.followup_overdue_visit_enabled</field>
<field name="value">True</field>
</record>
<record id="param_followup_unpaid_invoice_enabled" model="ir.config_parameter">
<field name="key">fusion_repairs.followup_unpaid_invoice_enabled</field>
<field name="value">True</field>
</record>
<!-- Public client portal - Phase 1+ -->
<record id="param_client_portal_url" model="ir.config_parameter">
<field name="key">fusion_repairs.client_portal_url</field>
<field name="value">/repair</field>
</record>
<record id="param_client_portal_rate_limit_per_hour" model="ir.config_parameter">
<field name="key">fusion_repairs.client_portal_rate_limit_per_hour</field>
<field name="value">10</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Intake session reference. -->
<!-- Groups multiple repair.order records created from the same call. -->
<record id="seq_repair_intake_session" model="ir.sequence">
<field name="name">Repair Intake Session</field>
<field name="code">fusion.repair.intake.session</field>
<field name="prefix">RIS</field>
<field name="padding">6</field>
<field name="number_next">1</field>
<field name="number_increment">1</field>
<field name="company_id" eval="False"/>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- CS callback after intake - confirms call back if anything was missing -->
<record id="mail_activity_type_cs_callback" model="mail.activity.type">
<field name="name">Repair: CS Callback</field>
<field name="summary">Call client back if any intake info was missing</field>
<field name="delay_count">1</field>
<field name="delay_unit">days</field>
<field name="delay_from">previous_activity</field>
<field name="res_model">repair.order</field>
<field name="icon">fa-phone</field>
<field name="sequence">10</field>
</record>
<!-- Tech dispatch needed - office must assign a technician -->
<record id="mail_activity_type_tech_dispatch" model="mail.activity.type">
<field name="name">Repair: Assign Technician</field>
<field name="summary">Assign a technician to this repair</field>
<field name="delay_count">2</field>
<field name="delay_unit">days</field>
<field name="delay_from">previous_activity</field>
<field name="res_model">repair.order</field>
<field name="icon">fa-wrench</field>
<field name="sequence">20</field>
</record>
<!-- Visit follow-up - tech must report visit outcome -->
<record id="mail_activity_type_visit_followup" model="mail.activity.type">
<field name="name">Repair: Visit Follow-Up</field>
<field name="summary">Confirm visit outcome and complete repair</field>
<field name="delay_count">1</field>
<field name="delay_unit">days</field>
<field name="delay_from">previous_activity</field>
<field name="res_model">repair.order</field>
<field name="icon">fa-check-square-o</field>
<field name="sequence">30</field>
</record>
<!-- Manager review - third-party equipment -->
<record id="mail_activity_type_manager_review" model="mail.activity.type">
<field name="name">Repair: Manager Review</field>
<field name="summary">Third-party equipment - manager awareness</field>
<field name="delay_count">1</field>
<field name="delay_unit">days</field>
<field name="delay_from">previous_activity</field>
<field name="res_model">repair.order</field>
<field name="icon">fa-flag</field>
<field name="sequence">40</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Fusion Repairs Mail Templates.
Styling: 4px accent bar, 600px max-width, dark/light safe.
Mirrors fusion_claims/data/mail_template_data.xml ADP templates for consistency.
-->
<odoo>
<data noupdate="1">
<!-- ============================================================== -->
<!-- Repair Intake Received - Client Confirmation -->
<!-- ============================================================== -->
<record id="email_template_intake_received_client" model="mail.template">
<field name="name">Repair: Intake Received (Client)</field>
<field name="model_id" ref="repair.model_repair_order"/>
<field name="subject">{{ object.company_id.name }} - Service Call {{ object.name or 'received' }}</field>
<field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;">
<div style="height:4px;background-color:#2B6CB0;"></div>
<div style="padding:32px 28px;">
<p style="color:#2B6CB0;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
<t t-out="object.company_id.name"/>
</p>
<h2 style="font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">We received your service request</h2>
<p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
Hello <t t-out="object.partner_id.name or 'there'"/>, thank you for letting us know about your equipment.
Your service call reference is <strong><t t-out="object.name"/></strong>.
</p>
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;opacity:0.55;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid rgba(128,128,128,0.25);">Service Call Details</td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);width:35%;">Reference</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.name"/></td></tr>
<t t-if="object.product_id">
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Equipment</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.product_id.display_name"/></td></tr>
</t>
<t t-if="object.schedule_date">
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Scheduled</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.schedule_date" t-options="{'widget': 'datetime'}"/></td></tr>
</t>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;">Status</td><td style="padding:10px 14px;font-size:14px;"><t t-out="dict(object._fields['state'].selection).get(object.state)"/></td></tr>
</table>
<div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;">
<p style="margin:0;font-size:14px;line-height:1.5;">
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.
</p>
</div>
<t t-if="not is_html_empty(object.user_id.signature)" data-o-mail-quote-container="1">
<div data-o-mail-quote="1">--<br data-o-mail-quote="1"/><t t-out="object.user_id.signature or ''" data-o-mail-quote="1"/></div>
</t>
</div>
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
<!-- ============================================================== -->
<!-- Repair Intake Received - Office Notification -->
<!-- ============================================================== -->
<record id="email_template_intake_received_office" model="mail.template">
<field name="name">Repair: Intake Received (Office)</field>
<field name="model_id" ref="repair.model_repair_order"/>
<field name="subject">[New Service Call] {{ object.partner_id.name or 'Walk-in' }} - {{ object.name or 'n/a' }}</field>
<field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;">
<div style="height:4px;background-color:#d69e2e;"></div>
<div style="padding:32px 28px;">
<p style="color:#d69e2e;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
Internal: New Service Call
</p>
<h2 style="font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">A new repair has been submitted</h2>
<p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
Submitted by <strong><t t-out="object.x_fc_intake_user_id.name or object.user_id.name or 'system'"/></strong>
via the <strong><t t-out="dict(object._fields['x_fc_intake_source'].selection).get(object.x_fc_intake_source) or 'intake'"/></strong>.
</p>
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;opacity:0.55;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid rgba(128,128,128,0.25);">Details</td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);width:35%;">Reference</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.name"/></td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Client</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.partner_id.name or 'Walk-in / unknown'"/></td></tr>
<t t-if="object.partner_id.phone">
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Phone</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.partner_id.phone"/></td></tr>
</t>
<t t-if="object.product_id">
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Equipment</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.product_id.display_name"/></td></tr>
</t>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Urgency</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="dict(object._fields['x_fc_urgency'].selection).get(object.x_fc_urgency) or 'normal'"/></td></tr>
<t t-if="object.x_fc_third_party_equipment">
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Third-party</td><td style="padding:10px 14px;color:#d69e2e;font-size:14px;font-weight:600;border-bottom:1px solid rgba(128,128,128,0.15);">Yes - equipment not sold by us</td></tr>
</t>
<t t-if="object.under_warranty">
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Warranty</td><td style="padding:10px 14px;color:#38a169;font-size:14px;font-weight:600;border-bottom:1px solid rgba(128,128,128,0.15);">Under warranty</td></tr>
</t>
</table>
</div>
</div>
</field>
<field name="auto_delete" eval="True"/>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Medical equipment categories used for repair intake routing and skills matching. -->
<record id="category_hospital_bed" model="fusion.repair.product.category">
<field name="name">Hospital Bed</field>
<field name="code">hospital_bed</field>
<field name="sequence">10</field>
<field name="icon">fa-bed</field>
<field name="description">Electric and manual hospital beds, semi-electric beds, low beds.</field>
</record>
<record id="category_wheelchair_manual" model="fusion.repair.product.category">
<field name="name">Wheelchair (Manual)</field>
<field name="code">wheelchair_manual</field>
<field name="sequence">20</field>
<field name="icon">fa-wheelchair</field>
<field name="description">Standard, transport, and tilt-in-space manual wheelchairs.</field>
</record>
<record id="category_wheelchair_power" model="fusion.repair.product.category">
<field name="name">Wheelchair (Power)</field>
<field name="code">wheelchair_power</field>
<field name="sequence">30</field>
<field name="icon">fa-wheelchair</field>
<field name="description">Power wheelchairs, scooters, and powered mobility devices.</field>
</record>
<record id="category_stairlift" model="fusion.repair.product.category">
<field name="name">Stairlift</field>
<field name="code">stairlift</field>
<field name="sequence">40</field>
<field name="icon">fa-arrows-v</field>
<field name="description">Straight and curved indoor stairlifts. Annual safety inspection required in many jurisdictions.</field>
<field name="safety_critical" eval="True"/>
</record>
<record id="category_porch_lift" model="fusion.repair.product.category">
<field name="name">Porch Lift</field>
<field name="code">porch_lift</field>
<field name="sequence">50</field>
<field name="icon">fa-arrow-up</field>
<field name="description">Vertical platform lifts for porches, decks, and accessible building entrances.</field>
<field name="safety_critical" eval="True"/>
</record>
<record id="category_walker" model="fusion.repair.product.category">
<field name="name">Walker</field>
<field name="code">walker</field>
<field name="sequence">60</field>
<field name="icon">fa-male</field>
<field name="description">Standard walkers, hemi-walkers, and folding walkers.</field>
</record>
<record id="category_rollator" model="fusion.repair.product.category">
<field name="name">Rollator</field>
<field name="code">rollator</field>
<field name="sequence">70</field>
<field name="icon">fa-male</field>
<field name="description">Wheeled walkers with seats and brakes.</field>
</record>
<record id="category_mattress" model="fusion.repair.product.category">
<field name="name">Medical Mattress</field>
<field name="code">mattress</field>
<field name="sequence">80</field>
<field name="icon">fa-bed</field>
<field name="description">Air mattresses, alternating pressure, low air loss, and pressure relief mattresses.</field>
</record>
<record id="category_other" model="fusion.repair.product.category">
<field name="name">Other Equipment</field>
<field name="code">other</field>
<field name="sequence">100</field>
<field name="icon">fa-question-circle</field>
<field name="description">Any other medical equipment not in the standard categories.</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import repair_product_category
from . import intake_template
from . import intake_question
from . import intake_answer
from . import product_template
from . import res_partner
from . import res_users
from . import res_config_settings
from . import technician_task
from . import repair_order
from . import intake_service

View File

@@ -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 = ''

View File

@@ -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").',
)

View File

@@ -0,0 +1,347 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Shared intake service.
This AbstractModel is the SINGLE entry point for creating repair orders from
any intake surface: the backend wizard (Phase 1), the sales rep portal
(Phase 1+), and the public client self-service portal (Phase 1+).
All three surfaces call `create_repair_orders(payload, source='...')` so that
business logic - activities, emails, warranty determination, AI summary,
catalogue match, third-party flag, dispatch task creation - lives in one
place and the surfaces never drift apart.
"""
import logging
from datetime import timedelta
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class FusionRepairIntakeService(models.AbstractModel):
_name = 'fusion.repair.intake.service'
_description = 'Repair Intake Service (shared by backend / sales rep / client)'
# ------------------------------------------------------------------
# PUBLIC API
# ------------------------------------------------------------------
@api.model
def create_repair_orders(self, payload, source='backend_wizard'):
"""Create one repair.order per equipment item in the payload.
:param payload: dict with keys:
- partner_id: int (required) or partner_vals: dict to create new partner
- intake_user_id: int (optional, defaults to env.user)
- equipment_items: list of dicts, each with:
- product_id: int (optional)
- lot_id: int (optional)
- repair_category_id: int (optional)
- intake_template_id: int (optional)
- third_party: bool (optional)
- urgency: str (optional, default 'normal')
- issue_summary: str (optional)
- internal_notes: str (optional)
- photo_attachment_ids: list[int] (optional)
- answers: list of dicts with keys
(question_id, value_char|value_text|value_selection|
value_boolean|value_integer|value_date)
:param source: str, one of repair_order.INTAKE_SOURCES values.
:return: recordset of repair.order records created.
"""
partner_id = self._resolve_partner(payload)
if not partner_id:
raise UserError(_('A client is required to create a repair request.'))
intake_user = self.env['res.users'].browse(
payload.get('intake_user_id') or self.env.uid
)
session_ref = (
self.env['ir.sequence'].next_by_code('fusion.repair.intake.session')
or 'RIS/NEW'
)
equipment = payload.get('equipment_items') or [{}]
repairs = self.env['repair.order']
for item in equipment:
repair = self._create_single_repair(
partner_id=partner_id,
intake_user=intake_user,
session_ref=session_ref,
source=source,
item=item,
)
repairs |= repair
return repairs
# ------------------------------------------------------------------
# PARTNER RESOLUTION
# ------------------------------------------------------------------
@api.model
def _resolve_partner(self, payload):
partner_id = payload.get('partner_id')
if partner_id:
return partner_id
partner_vals = payload.get('partner_vals')
if not partner_vals:
return False
partner = self.env['res.partner'].sudo().create(partner_vals)
return partner.id
# ------------------------------------------------------------------
# CORE CREATION
# ------------------------------------------------------------------
@api.model
def _create_single_repair(self, partner_id, intake_user, session_ref, source, item):
Repair = self.env['repair.order']
product_id = item.get('product_id')
vals = {
'partner_id': partner_id,
'user_id': intake_user.id,
'x_fc_intake_user_id': intake_user.id,
'x_fc_intake_session_id': session_ref,
'x_fc_intake_source': source,
'x_fc_repair_category_id': item.get('repair_category_id') or False,
'x_fc_intake_template_id': item.get('intake_template_id') or False,
'x_fc_third_party_equipment': bool(item.get('third_party')),
'x_fc_urgency': item.get('urgency') or 'normal',
'x_fc_issue_category': item.get('issue_category') or False,
'internal_notes': self._wrap_internal_notes(item),
}
if product_id:
vals['product_id'] = product_id
if item.get('lot_id'):
vals['lot_id'] = item['lot_id']
if item.get('schedule_date'):
vals['schedule_date'] = item['schedule_date']
repair = Repair.create(vals)
# Determine warranty AFTER creation (needs product on record).
if not repair.x_fc_third_party_equipment:
self._auto_link_original_sale_order(repair)
if repair._fc_compute_warranty_status():
repair.under_warranty = True
# Persist intake answers.
self._create_answers(repair, item.get('answers') or [])
# Attach photos.
photo_ids = item.get('photo_attachment_ids') or []
if photo_ids:
attachments = self.env['ir.attachment'].sudo().browse(photo_ids).exists()
attachments.write({
'res_model': 'repair.order',
'res_id': repair.id,
})
repair.write({'x_fc_photo_ids': [(6, 0, attachments.ids)]})
# Activities.
self._schedule_activities(repair)
# Optional dispatch draft task (urgent / safety).
if repair.x_fc_urgency in ('urgent', 'safety'):
self._create_dispatch_task(repair)
# Emails (client + office).
self._send_intake_emails(repair)
# Audit message in chatter.
repair.message_post(
body=_(
'Service call submitted via <b>%(source)s</b> by %(user)s. '
'Session reference: %(ref)s.',
source=dict(repair._fields['x_fc_intake_source'].selection).get(source),
user=intake_user.name,
ref=session_ref,
),
)
return repair
@api.model
def _wrap_internal_notes(self, item):
notes = item.get('internal_notes') or ''
summary = item.get('issue_summary') or ''
if not (notes or summary):
return False
parts = []
if summary:
parts.append('<p><strong>Issue summary:</strong> %s</p>' % summary)
if notes:
parts.append('<p><strong>Notes:</strong> %s</p>' % notes)
return ''.join(parts)
# ------------------------------------------------------------------
# ORIGINAL SO AUTO-LINK
# ------------------------------------------------------------------
@api.model
def _auto_link_original_sale_order(self, repair):
if not repair.partner_id or not repair.product_id:
return
SaleOrder = self.env['sale.order'].sudo()
domain = [
('partner_id', '=', repair.partner_id.id),
('state', 'in', ('sale', 'done')),
('order_line.product_id', '=', repair.product_id.id),
]
if repair.lot_id:
domain.append(('order_line.lot_ids', 'in', repair.lot_id.id))
candidate = SaleOrder.search(domain, order='date_order desc', limit=1)
if candidate:
repair.x_fc_original_sale_order_id = candidate
# ------------------------------------------------------------------
# ANSWERS
# ------------------------------------------------------------------
@api.model
def _create_answers(self, repair, answers):
if not answers:
return
Answer = self.env['fusion.repair.intake.answer']
for ans in answers:
qid = ans.get('question_id')
if not qid:
continue
Answer.create({
'repair_id': repair.id,
'question_id': qid,
'value_char': ans.get('value_char'),
'value_text': ans.get('value_text'),
'value_selection': ans.get('value_selection'),
'value_boolean': bool(ans.get('value_boolean')),
'value_integer': int(ans.get('value_integer') or 0),
'value_date': ans.get('value_date') or False,
})
# ------------------------------------------------------------------
# ACTIVITIES
# ------------------------------------------------------------------
@api.model
def _schedule_activities(self, repair):
"""Create the 4 intake activities described in the spec."""
try:
cs_callback_type = self.env.ref('fusion_repairs.mail_activity_type_cs_callback')
tech_dispatch_type = self.env.ref('fusion_repairs.mail_activity_type_tech_dispatch')
manager_review_type = self.env.ref('fusion_repairs.mail_activity_type_manager_review')
except ValueError:
_logger.warning('Repair activity types missing - skipping')
return
# CS callback - always, intake user
repair.activity_schedule(
activity_type_id=cs_callback_type.id,
summary=_('Call client back if any intake info was missing'),
user_id=repair.x_fc_intake_user_id.id or self.env.uid,
)
# Tech dispatch - assigned to responsible user, urgency-adjusted deadline
deadline_days = {'safety': 0, 'urgent': 1, 'normal': 2}.get(repair.x_fc_urgency, 2)
repair.activity_schedule(
activity_type_id=tech_dispatch_type.id,
summary=_('Assign a technician (urgency: %s)', repair.x_fc_urgency),
user_id=repair.user_id.id or self.env.uid,
date_deadline=fields.Date.context_today(self) + timedelta(days=deadline_days),
)
# Manager review - only for third-party equipment
if repair.x_fc_third_party_equipment:
manager_group = self.env.ref(
'fusion_repairs.group_fusion_repairs_manager',
raise_if_not_found=False,
)
manager_user = self.env.user
if manager_group:
# res.groups has no .users field in Odoo 19;
# query via res.users.all_group_ids (Odoo 19 renamed groups_id).
candidate = self.env['res.users'].sudo().search(
[('all_group_ids', 'in', manager_group.ids), ('active', '=', True)],
limit=1,
)
if candidate:
manager_user = candidate
repair.activity_schedule(
activity_type_id=manager_review_type.id,
summary=_('Third-party equipment - manager awareness'),
user_id=manager_user.id,
)
# ------------------------------------------------------------------
# DISPATCH TASK
# ------------------------------------------------------------------
@api.model
def _create_dispatch_task(self, repair):
"""Create a draft fusion.technician.task for urgent / safety repairs.
Phase 1 simple approach: no date/technician assigned, dispatcher confirms.
"""
Task = self.env['fusion.technician.task'].sudo()
try:
vals = {
'partner_id': repair.partner_id.id,
'task_type': 'repair',
'status': 'pending',
'scheduled_date': fields.Date.context_today(self),
'duration_hours': repair.x_fc_estimated_duration or 1.0,
'x_fc_repair_order_id': repair.id,
'description': repair.internal_notes or repair.name,
}
# technician_id is required on fusion.technician.task; we fall back to
# the intake user. Dispatcher will reassign.
vals['technician_id'] = (
repair.user_id.id if repair.user_id and repair.user_id.x_fc_is_field_staff
else self.env.uid
)
Task.create(vals)
except Exception as e:
_logger.warning('Failed to auto-create dispatch task for repair %s: %s',
repair.name, e)
# ------------------------------------------------------------------
# EMAILS
# ------------------------------------------------------------------
@api.model
def _send_intake_emails(self, repair):
if not self._notifications_enabled():
return
# Client confirmation
if repair.partner_id and repair.partner_id.email:
try:
self.env.ref('fusion_repairs.email_template_intake_received_client') \
.send_mail(repair.id, force_send=False)
except Exception as e:
_logger.warning('Failed to send client intake email for %s: %s',
repair.name, e)
# Office notification
office_emails = self._office_emails(repair.company_id)
if office_emails:
try:
tpl = self.env.ref('fusion_repairs.email_template_intake_received_office')
tpl.with_context(default_email_to=','.join(office_emails)) \
.send_mail(repair.id, force_send=False, email_values={
'email_to': ','.join(office_emails),
})
except Exception as e:
_logger.warning('Failed to send office intake email for %s: %s',
repair.name, e)
@api.model
def _notifications_enabled(self):
ICP = self.env['ir.config_parameter'].sudo()
return ICP.get_param('fusion_repairs.enable_email_notifications', 'True') == 'True'
@api.model
def _office_emails(self, company):
# Reuse the office notification recipients defined by fusion_claims.
partners = company.sudo()
recipients = getattr(partners, 'x_fc_office_notification_ids', False)
if recipients:
return [p.email for p in recipients if p.email]
return []

View File

@@ -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},
}

View File

@@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import fields, models
class ProductTemplate(models.Model):
_inherit = 'product.template'
x_fc_repair_category_id = fields.Many2one(
'fusion.repair.product.category',
string='Repair Category',
help='Medical equipment category - drives intake template selection and '
'technician skills filter for repairs of this product.',
)
x_fc_warranty_months = fields.Integer(
string='Warranty (Months)',
default=12,
help='Default warranty period for new units of this product. Used to auto-detect '
'warranty status on repair intake (delivery date + warranty months >= today).',
)
x_fc_maintenance_interval_months = fields.Integer(
string='Maintenance Interval (Months)',
default=0,
help='If > 0, delivering a unit of this product auto-creates a maintenance contract '
'with this recurring interval. Phase 3 feature.',
)
x_fc_intake_template_id = fields.Many2one(
'fusion.repair.intake.template',
string='Intake Template Override',
help='Optional override of the intake template normally chosen from the '
'repair category. Leave empty to use category default.',
)

View File

@@ -0,0 +1,281 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from datetime import timedelta
from odoo import api, fields, models, _
INTAKE_SOURCES = [
('backend_wizard', 'Backend Wizard (CS)'),
('sales_rep_portal', 'Sales Rep Portal'),
('client_portal', 'Client Self-Service'),
('manual', 'Manual / Other'),
]
URGENCY_LEVELS = [
('normal', 'Normal'),
('urgent', 'Urgent'),
('safety', 'Safety Issue'),
]
class RepairOrder(models.Model):
"""Extend Odoo Repairs with intake context, dispatch link, warranty
determination, and pricing variance tracking for Fusion Repairs."""
_inherit = 'repair.order'
# ------------------------------------------------------------------
# INTAKE METADATA
# ------------------------------------------------------------------
x_fc_intake_source = fields.Selection(
INTAKE_SOURCES,
string='Intake Source',
default='manual',
tracking=True,
help='Which intake surface created this repair (backend CS wizard, '
'sales rep portal, public client portal, or manual entry).',
)
x_fc_intake_user_id = fields.Many2one(
'res.users',
string='Intake By',
tracking=True,
index=True,
help='User who took the call / submitted the intake. For client portal, '
'this is the OdooBot or admin user.',
)
x_fc_intake_session_id = fields.Char(
string='Intake Session',
index=True,
copy=False,
help='Reference shared by multiple repair orders created during the same call.',
)
x_fc_intake_template_id = fields.Many2one(
'fusion.repair.intake.template',
string='Intake Template',
help='Question template used during intake.',
)
x_fc_intake_answer_ids = fields.One2many(
'fusion.repair.intake.answer',
'repair_id',
string='Intake Answers',
)
x_fc_intake_answer_count = fields.Integer(
compute='_compute_intake_answer_count',
)
# ------------------------------------------------------------------
# EQUIPMENT / WARRANTY
# ------------------------------------------------------------------
x_fc_repair_category_id = fields.Many2one(
'fusion.repair.product.category',
string='Equipment Category',
tracking=True,
index=True,
help='Medical equipment category - drives intake template and tech skills filter.',
)
x_fc_third_party_equipment = fields.Boolean(
string='Third-Party Equipment',
tracking=True,
help='True if the equipment was not sold by us. Forces under_warranty=False '
'and typically triggers a service call-out fee.',
)
x_fc_original_sale_order_id = fields.Many2one(
'sale.order',
string='Original Purchase SO',
tracking=True,
index=True,
help='Sale order through which the customer originally purchased this unit. '
'Auto-matched on intake by partner + lot/serial.',
)
x_fc_warranty_override_reason = fields.Char(
string='Warranty Override Reason',
help='Required when CS overrides the auto-detected warranty status.',
)
# ------------------------------------------------------------------
# TRIAGE / URGENCY
# ------------------------------------------------------------------
x_fc_urgency = fields.Selection(
URGENCY_LEVELS,
string='Urgency',
default='normal',
tracking=True,
index=True,
)
x_fc_issue_category = fields.Char(
string='Issue Category',
help='Symptom classification (e.g. "battery", "motor", "remote"). Used by '
'service catalogue matcher and AI prompt context.',
)
# ------------------------------------------------------------------
# PHOTOS
# ------------------------------------------------------------------
x_fc_photo_ids = fields.Many2many(
'ir.attachment',
'fusion_repair_order_photo_rel',
'repair_id',
'attachment_id',
string='Intake Photos / Videos',
help='Photos and videos uploaded during intake.',
)
x_fc_photo_count = fields.Integer(
compute='_compute_photo_count',
)
# ------------------------------------------------------------------
# PRICING (estimate vs actual - Phase 2 reconciliation)
# ------------------------------------------------------------------
x_fc_estimated_duration = fields.Float(
string='Estimated Duration (h)',
help='Estimated visit duration from service catalogue, used to size technician slot.',
)
x_fc_estimated_cost = fields.Monetary(
string='Estimated Cost',
currency_field='company_currency_id',
help='Estimated total from catalogue match at intake (pre-visit).',
)
x_fc_actual_cost = fields.Monetary(
string='Actual Cost',
currency_field='company_currency_id',
help='Actual total recorded from the visit report (post-visit).',
)
x_fc_cost_variance_pct = fields.Float(
string='Cost Variance %',
compute='_compute_cost_variance',
store=True,
help='(actual - estimated) / estimated * 100',
)
x_fc_requires_requote = fields.Boolean(
string='Requires Re-Quote',
help='Set when actual cost exceeds estimate beyond the configured threshold; '
'blocks automatic invoicing until manager approves or client re-confirms.',
)
company_currency_id = fields.Many2one(
'res.currency',
related='company_id.currency_id',
readonly=True,
)
# ------------------------------------------------------------------
# FIELD SERVICE LINK
# ------------------------------------------------------------------
x_fc_technician_task_ids = fields.One2many(
'fusion.technician.task',
'x_fc_repair_order_id',
string='Technician Tasks',
)
x_fc_technician_task_count = fields.Integer(
compute='_compute_technician_task_count',
)
# ------------------------------------------------------------------
# AI SUMMARY (Phase 2)
# ------------------------------------------------------------------
x_fc_ai_summary = fields.Text(
string='AI Pre-Visit Brief',
help='AI-generated short brief for the technician based on intake answers. '
'Optional - never blocks intake submit.',
)
# ------------------------------------------------------------------
# COMPUTES
# ------------------------------------------------------------------
@api.depends('x_fc_intake_answer_ids')
def _compute_intake_answer_count(self):
for repair in self:
repair.x_fc_intake_answer_count = len(repair.x_fc_intake_answer_ids)
@api.depends('x_fc_photo_ids')
def _compute_photo_count(self):
for repair in self:
repair.x_fc_photo_count = len(repair.x_fc_photo_ids)
@api.depends('x_fc_technician_task_ids')
def _compute_technician_task_count(self):
for repair in self:
repair.x_fc_technician_task_count = len(repair.x_fc_technician_task_ids)
@api.depends('x_fc_estimated_cost', 'x_fc_actual_cost')
def _compute_cost_variance(self):
for repair in self:
if repair.x_fc_estimated_cost:
repair.x_fc_cost_variance_pct = (
(repair.x_fc_actual_cost - repair.x_fc_estimated_cost)
/ repair.x_fc_estimated_cost * 100
)
else:
repair.x_fc_cost_variance_pct = 0.0
# ------------------------------------------------------------------
# WARRANTY DETERMINATION
# ------------------------------------------------------------------
def _fc_compute_warranty_status(self):
"""Auto-detect warranty: not third-party AND within warranty window."""
self.ensure_one()
if self.x_fc_third_party_equipment:
return False
if not self.x_fc_original_sale_order_id:
return False
original = self.x_fc_original_sale_order_id
delivery_date = original.commitment_date or original.date_order
if not delivery_date:
return False
warranty_months = (
self.product_id.product_tmpl_id.x_fc_warranty_months
if self.product_id else 0
)
if not warranty_months:
return False
# Datetime + months: use simple 30-day approximation per month for now.
cutoff = fields.Datetime.from_string(str(delivery_date)) + timedelta(days=warranty_months * 30)
return fields.Datetime.now() <= cutoff
# ------------------------------------------------------------------
# SMART BUTTONS
# ------------------------------------------------------------------
def action_view_intake_answers(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Intake Answers'),
'res_model': 'fusion.repair.intake.answer',
'view_mode': 'list,form',
'domain': [('repair_id', '=', self.id)],
'context': {'default_repair_id': self.id},
}
def action_view_technician_tasks(self):
self.ensure_one()
if len(self.x_fc_technician_task_ids) == 1:
return {
'type': 'ir.actions.act_window',
'name': self.x_fc_technician_task_ids.name,
'res_model': 'fusion.technician.task',
'view_mode': 'form',
'res_id': self.x_fc_technician_task_ids.id,
}
return {
'type': 'ir.actions.act_window',
'name': _('Technician Tasks'),
'res_model': 'fusion.technician.task',
'view_mode': 'list,form',
'domain': [('x_fc_repair_order_id', '=', self.id)],
'context': {'default_x_fc_repair_order_id': self.id},
}
def action_view_original_sale_order(self):
self.ensure_one()
if not self.x_fc_original_sale_order_id:
return False
return {
'type': 'ir.actions.act_window',
'name': self.x_fc_original_sale_order_id.name,
'res_model': 'sale.order',
'view_mode': 'form',
'res_id': self.x_fc_original_sale_order_id.id,
}

View File

@@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
class FusionRepairProductCategory(models.Model):
"""Medical equipment categories used to route repair intake and match skills."""
_name = 'fusion.repair.product.category'
_description = 'Repair Product Category'
_order = 'sequence, name'
name = fields.Char(string='Name', required=True, translate=True)
code = fields.Char(
string='Code',
required=True,
help='Stable identifier used by code (e.g. "stairlift"). Lowercase, no spaces.',
)
sequence = fields.Integer(string='Sequence', default=10)
icon = fields.Char(
string='Icon',
default='fa-wrench',
help='Font Awesome icon class shown next to the category in pickers.',
)
description = fields.Text(string='Description', translate=True)
active = fields.Boolean(default=True)
safety_critical = fields.Boolean(
string='Safety-Critical',
help='Categories where motor / mechanical issues warrant immediate escalation '
'(stairlifts, porch lifts). Used by the AI self-check engine to skip '
'self-help and force escalation when safety symptoms appear.',
)
intake_template_id = fields.Many2one(
'fusion.repair.intake.template',
string='Default Intake Template',
help='Default intake question set shown when this category is selected.',
)
_sql_constraints = [
('code_unique', 'unique(code)', 'Category code must be unique.'),
]
@api.depends('name', 'code')
def _compute_display_name(self):
for cat in self:
cat.display_name = cat.name or cat.code or ''

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import fields, models
class FusionTechnicianTaskRepairs(models.Model):
"""Adds the back-link from fusion.technician.task to repair.order so
repairs and tasks share one timeline.
"""
_inherit = 'fusion.technician.task'
x_fc_repair_order_id = fields.Many2one(
'repair.order',
string='Repair Order',
ondelete='set null',
index=True,
tracking=True,
help='Repair order this task fulfils. Set automatically when the intake '
'wizard auto-creates a draft task for urgent / safety calls.',
)
x_fc_repair_intake_session_id = fields.Char(
related='x_fc_repair_order_id.x_fc_intake_session_id',
string='Intake Session',
store=True,
index=True,
)
def action_view_repair_order(self):
self.ensure_one()
if not self.x_fc_repair_order_id:
return False
return {
'type': 'ir.actions.act_window',
'name': self.x_fc_repair_order_id.name,
'res_model': 'repair.order',
'view_mode': 'form',
'res_id': self.x_fc_repair_order_id.id,
}

View File

@@ -0,0 +1,12 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_repair_product_category_user,Repair Category User Read,model_fusion_repair_product_category,group_fusion_repairs_user,1,0,0,0
access_repair_product_category_manager,Repair Category Manager Full,model_fusion_repair_product_category,group_fusion_repairs_manager,1,1,1,1
access_repair_intake_template_user,Intake Template User Read,model_fusion_repair_intake_template,group_fusion_repairs_user,1,0,0,0
access_repair_intake_template_manager,Intake Template Manager Full,model_fusion_repair_intake_template,group_fusion_repairs_manager,1,1,1,1
access_repair_intake_question_user,Intake Question User Read,model_fusion_repair_intake_question,group_fusion_repairs_user,1,0,0,0
access_repair_intake_question_manager,Intake Question Manager Full,model_fusion_repair_intake_question,group_fusion_repairs_manager,1,1,1,1
access_repair_intake_answer_user,Intake Answer User Full,model_fusion_repair_intake_answer,group_fusion_repairs_user,1,1,1,0
access_repair_intake_answer_manager,Intake Answer Manager Full,model_fusion_repair_intake_answer,group_fusion_repairs_manager,1,1,1,1
access_repair_intake_answer_tech_portal,Intake Answer Technician Read,model_fusion_repair_intake_answer,fusion_tasks.group_field_technician,1,0,0,0
access_repair_intake_wizard_user,Intake Wizard User Full,model_fusion_repair_intake_wizard,group_fusion_repairs_user,1,1,1,1
access_repair_intake_wizard_equipment_user,Intake Wizard Equipment User Full,model_fusion_repair_intake_wizard_equipment,group_fusion_repairs_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_repair_product_category_user Repair Category User Read model_fusion_repair_product_category group_fusion_repairs_user 1 0 0 0
3 access_repair_product_category_manager Repair Category Manager Full model_fusion_repair_product_category group_fusion_repairs_manager 1 1 1 1
4 access_repair_intake_template_user Intake Template User Read model_fusion_repair_intake_template group_fusion_repairs_user 1 0 0 0
5 access_repair_intake_template_manager Intake Template Manager Full model_fusion_repair_intake_template group_fusion_repairs_manager 1 1 1 1
6 access_repair_intake_question_user Intake Question User Read model_fusion_repair_intake_question group_fusion_repairs_user 1 0 0 0
7 access_repair_intake_question_manager Intake Question Manager Full model_fusion_repair_intake_question group_fusion_repairs_manager 1 1 1 1
8 access_repair_intake_answer_user Intake Answer User Full model_fusion_repair_intake_answer group_fusion_repairs_user 1 1 1 0
9 access_repair_intake_answer_manager Intake Answer Manager Full model_fusion_repair_intake_answer group_fusion_repairs_manager 1 1 1 1
10 access_repair_intake_answer_tech_portal Intake Answer Technician Read model_fusion_repair_intake_answer fusion_tasks.group_field_technician 1 0 0 0
11 access_repair_intake_wizard_user Intake Wizard User Full model_fusion_repair_intake_wizard group_fusion_repairs_user 1 1 1 1
12 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

View File

@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ==================================================================== -->
<!-- MODULE CATEGORY -->
<!-- ==================================================================== -->
<record id="module_category_fusion_repairs" model="ir.module.category">
<field name="name">Fusion Repairs</field>
<field name="sequence">47</field>
</record>
<!-- ==================================================================== -->
<!-- FUSION REPAIRS PRIVILEGE (Odoo 19 res.groups.privilege pattern) -->
<!-- ==================================================================== -->
<record id="res_groups_privilege_fusion_repairs" model="res.groups.privilege">
<field name="name">Fusion Repairs</field>
<field name="sequence">47</field>
<field name="category_id" ref="module_category_fusion_repairs"/>
</record>
<!-- ==================================================================== -->
<!-- GROUPS -->
<!-- ==================================================================== -->
<record id="group_fusion_repairs_user" model="res.groups">
<field name="name">Repairs: User (CS Intake)</field>
<field name="privilege_id" ref="res_groups_privilege_fusion_repairs"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
<field name="comment">CS / front-office staff who take repair intake calls and view repairs.</field>
</record>
<record id="group_fusion_repairs_dispatcher" model="res.groups">
<field name="name">Repairs: Dispatcher</field>
<field name="privilege_id" ref="res_groups_privilege_fusion_repairs"/>
<field name="implied_ids" eval="[(4, ref('group_fusion_repairs_user'))]"/>
<field name="comment">Assigns technicians to repairs, reschedules visits, manages parts pre-pull picklists.</field>
</record>
<record id="group_fusion_repairs_manager" model="res.groups">
<field name="name">Repairs: Manager</field>
<field name="privilege_id" ref="res_groups_privilege_fusion_repairs"/>
<field name="implied_ids" eval="[(4, ref('group_fusion_repairs_dispatcher'))]"/>
<field name="comment">Configures intake templates, pricing, maintenance contracts, on-call rotation, variance overrides.</field>
</record>
<!-- ==================================================================== -->
<!-- RECORD RULES -->
<!-- ==================================================================== -->
<!-- Multi-company isolation on repair.order -->
<record id="rule_repair_order_company" model="ir.rule">
<field name="name">Repair Order: Multi-Company</field>
<field name="model_id" ref="repair.model_repair_order"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
<field name="global" eval="True"/>
</record>
<!-- Field technicians (from fusion_tasks) see only repairs they're assigned to as technician on a linked task -->
<record id="rule_repair_order_technician_own" model="ir.rule">
<field name="name">Repair Order: Technician sees own repairs</field>
<field name="model_id" ref="repair.model_repair_order"/>
<field name="domain_force">[('x_fc_technician_task_ids.all_technician_ids', 'in', [user.id])]</field>
<field name="groups" eval="[(4, ref('fusion_tasks.group_field_technician'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Intake answer access scoped to repair access -->
<record id="rule_repair_intake_answer_company" model="ir.rule">
<field name="name">Repair Intake Answer: Multi-Company</field>
<field name="model_id" ref="model_fusion_repair_intake_answer"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
<field name="global" eval="True"/>
</record>
</odoo>

View File

@@ -0,0 +1,95 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Intake Template -->
<record id="view_repair_intake_template_list" model="ir.ui.view">
<field name="name">fusion.repair.intake.template.list</field>
<field name="model">fusion.repair.intake.template</field>
<field name="arch" type="xml">
<list string="Intake Templates">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="code"/>
<field name="question_count"/>
<field name="is_default"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="view_repair_intake_template_form" model="ir.ui.view">
<field name="name">fusion.repair.intake.template.form</field>
<field name="model">fusion.repair.intake.template</field>
<field name="arch" type="xml">
<form string="Intake Template">
<sheet>
<div class="oe_title">
<label for="name"/>
<h1>
<field name="name" placeholder="e.g. Stairlift - Standard Intake"/>
</h1>
</div>
<group>
<group>
<field name="code"/>
<field name="sequence"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
<group>
<field name="is_default"/>
<field name="active"/>
</group>
</group>
<field name="product_category_ids" widget="many2many_tags"/>
<notebook>
<page string="Questions" name="questions">
<field name="question_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="code" optional="hide"/>
<field name="question_type"/>
<field name="required"/>
<field name="help_text" optional="hide"/>
<field name="selection_options" optional="hide"/>
<field name="symptom_keywords" optional="hide"/>
</list>
<form>
<group>
<group>
<field name="name"/>
<field name="code"/>
<field name="question_type"/>
<field name="required"/>
</group>
<group>
<field name="sequence"/>
<field name="parent_question_id"/>
<field name="parent_answer_value"
invisible="not parent_question_id"/>
</group>
</group>
<field name="help_text" placeholder="Optional hint shown beneath the question"/>
<field name="selection_options"
invisible="question_type != 'selection'"
placeholder="One option per line"/>
<field name="symptom_keywords" placeholder="e.g. battery,charge,won't turn on"/>
</form>
</field>
</page>
<page string="Description" name="description">
<field name="description"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="action_repair_intake_template" model="ir.actions.act_window">
<field name="name">Intake Templates</field>
<field name="res_model">fusion.repair.intake.template</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Top-level app menu -->
<menuitem id="menu_fusion_repairs_root"
name="Fusion Repairs"
sequence="48"
web_icon="fusion_repairs,static/description/icon.png"
groups="fusion_repairs.group_fusion_repairs_user"/>
<!-- Operations -->
<menuitem id="menu_fusion_repairs_operations"
name="Operations"
parent="menu_fusion_repairs_root"
sequence="10"/>
<menuitem id="menu_fusion_repairs_new_call"
name="New Service Call"
parent="menu_fusion_repairs_operations"
action="action_open_repair_intake_wizard"
sequence="10"/>
<menuitem id="menu_fusion_repairs_orders"
name="Repair Orders"
parent="menu_fusion_repairs_operations"
action="repair.action_repair_order_tree"
sequence="20"/>
<!-- Configuration -->
<menuitem id="menu_fusion_repairs_configuration"
name="Configuration"
parent="menu_fusion_repairs_root"
sequence="90"
groups="fusion_repairs.group_fusion_repairs_manager"/>
<menuitem id="menu_fusion_repairs_categories"
name="Equipment Categories"
parent="menu_fusion_repairs_configuration"
action="action_repair_product_category"
sequence="10"/>
<menuitem id="menu_fusion_repairs_intake_templates"
name="Intake Templates"
parent="menu_fusion_repairs_configuration"
action="action_repair_intake_template"
sequence="20"/>
</odoo>

View File

@@ -0,0 +1,143 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================== -->
<!-- Form view extensions -->
<!-- ============================================================== -->
<record id="view_repair_order_form_inherit_fusion_repairs" model="ir.ui.view">
<field name="name">repair.order.form.inherit.fusion_repairs</field>
<field name="model">repair.order</field>
<field name="inherit_id" ref="repair.view_repair_order_form"/>
<field name="arch" type="xml">
<!-- Smart buttons: Technician Tasks + Intake Answers + Original SO. -->
<xpath expr="//div[hasclass('oe_button_box')]" position="inside">
<button name="action_view_technician_tasks"
type="object"
class="oe_stat_button"
icon="fa-wrench"
invisible="x_fc_technician_task_count == 0">
<field name="x_fc_technician_task_count" widget="statinfo" string="Tech Tasks"/>
</button>
<button name="action_view_intake_answers"
type="object"
class="oe_stat_button"
icon="fa-list-alt"
invisible="x_fc_intake_answer_count == 0">
<field name="x_fc_intake_answer_count" widget="statinfo" string="Answers"/>
</button>
<button name="action_view_original_sale_order"
type="object"
class="oe_stat_button"
icon="fa-dollar"
invisible="not x_fc_original_sale_order_id">
<field name="x_fc_original_sale_order_id" widget="statinfo" string="Original SO"/>
</button>
</xpath>
<!-- Add intake metadata under partner_id -->
<xpath expr="//field[@name='partner_id']" position="after">
<field name="x_fc_repair_category_id" options="{'no_create': True}"/>
<field name="x_fc_urgency" widget="badge"
decoration-success="x_fc_urgency == 'normal'"
decoration-warning="x_fc_urgency == 'urgent'"
decoration-danger="x_fc_urgency == 'safety'"/>
<field name="x_fc_third_party_equipment"/>
<field name="x_fc_intake_source" readonly="1"/>
<field name="x_fc_intake_user_id" readonly="1" invisible="not x_fc_intake_user_id"/>
<field name="x_fc_intake_session_id" readonly="1" invisible="not x_fc_intake_session_id"/>
</xpath>
<!-- Add a Fusion Repairs notebook tab with intake + photos. -->
<xpath expr="//notebook" position="inside">
<page string="Intake" name="fusion_intake">
<group>
<group>
<field name="x_fc_intake_template_id" readonly="1"/>
<field name="x_fc_issue_category"/>
</group>
<group>
<field name="x_fc_warranty_override_reason"
placeholder="Reason if warranty status was overridden"/>
<field name="x_fc_estimated_duration" widget="float_time"/>
</group>
</group>
<separator string="Answers"/>
<field name="x_fc_intake_answer_ids" readonly="1">
<list>
<field name="sequence" column_invisible="True"/>
<field name="question_name"/>
<field name="value_display"/>
<field name="question_type" optional="hide"/>
</list>
</field>
<separator string="Photos &amp; Videos"/>
<field name="x_fc_photo_ids" widget="many2many_binary"/>
</page>
<page string="Pricing" name="fusion_pricing" invisible="not x_fc_estimated_cost and not x_fc_actual_cost">
<group>
<group>
<field name="x_fc_estimated_cost" widget="monetary"/>
<field name="x_fc_actual_cost" widget="monetary"/>
</group>
<group>
<field name="x_fc_cost_variance_pct" widget="float" digits="[16,2]"/>
<field name="x_fc_requires_requote"/>
<field name="company_currency_id" invisible="1"/>
</group>
</group>
</page>
<page string="AI Brief" name="fusion_ai" invisible="not x_fc_ai_summary">
<field name="x_fc_ai_summary" readonly="1"/>
</page>
</xpath>
</field>
</record>
<!-- ============================================================== -->
<!-- Kanban: add urgency badge + intake source -->
<!-- ============================================================== -->
<record id="view_repair_order_kanban_inherit_fusion_repairs" model="ir.ui.view">
<field name="name">repair.order.kanban.inherit.fusion_repairs</field>
<field name="model">repair.order</field>
<field name="inherit_id" ref="repair.view_repair_kanban"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='partner_id']" position="after">
<field name="x_fc_urgency"/>
<field name="x_fc_third_party_equipment"/>
</xpath>
</field>
</record>
<!-- ============================================================== -->
<!-- List: add urgency + source columns -->
<!-- ============================================================== -->
<record id="view_repair_order_list_inherit_fusion_repairs" model="ir.ui.view">
<field name="name">repair.order.list.inherit.fusion_repairs</field>
<field name="model">repair.order</field>
<field name="inherit_id" ref="repair.view_repair_order_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='partner_id']" position="after">
<field name="x_fc_urgency" widget="badge"
decoration-success="x_fc_urgency == 'normal'"
decoration-warning="x_fc_urgency == 'urgent'"
decoration-danger="x_fc_urgency == 'safety'"
optional="show"/>
<field name="x_fc_intake_source" optional="hide"/>
<field name="x_fc_third_party_equipment" optional="hide"/>
</xpath>
</field>
</record>
<!-- ============================================================== -->
<!-- New Service Call action - opens the wizard -->
<!-- ============================================================== -->
<record id="action_open_repair_intake_wizard" model="ir.actions.act_window">
<field name="name">New Service Call</field>
<field name="res_model">fusion.repair.intake.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_repair_product_category_list" model="ir.ui.view">
<field name="name">fusion.repair.product.category.list</field>
<field name="model">fusion.repair.product.category</field>
<field name="arch" type="xml">
<list string="Repair Categories">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="code"/>
<field name="safety_critical"/>
<field name="intake_template_id"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="view_repair_product_category_form" model="ir.ui.view">
<field name="name">fusion.repair.product.category.form</field>
<field name="model">fusion.repair.product.category</field>
<field name="arch" type="xml">
<form string="Repair Category">
<sheet>
<div class="oe_title">
<label for="name"/>
<h1>
<field name="name" placeholder="e.g. Stairlift"/>
</h1>
</div>
<group>
<group>
<field name="code" placeholder="e.g. stairlift"/>
<field name="sequence"/>
<field name="icon"/>
</group>
<group>
<field name="safety_critical"/>
<field name="intake_template_id"/>
<field name="active"/>
</group>
</group>
<field name="description" placeholder="Describe what equipment falls into this category..."/>
</sheet>
</form>
</field>
</record>
<record id="action_repair_product_category" model="ir.actions.act_window">
<field name="name">Equipment Categories</field>
<field name="res_model">fusion.repair.product.category</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form_inherit_fusion_repairs" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.fusion_repairs</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//form" position="inside">
<app data-string="Fusion Repairs"
string="Fusion Repairs"
name="fusion_repairs"
groups="fusion_repairs.group_fusion_repairs_manager">
<block title="Notifications" name="fc_repairs_notifications">
<setting id="fc_repairs_enable_email_notifications"
string="Enable Email Notifications"
help="Master toggle for all automated repair-related emails (intake confirmations, dispatch alerts, office digests).">
<field name="fc_repairs_enable_email_notifications"/>
</setting>
</block>
<block title="Intake Behaviour" name="fc_repairs_intake">
<setting id="fc_repairs_duplicate_call_window_days"
string="Duplicate Call Window (days)"
help="When an intake matches an open repair from this many days back on the same phone, the wizard offers 'add note instead'.">
<field name="fc_repairs_duplicate_call_window_days"/>
</setting>
<setting id="fc_repairs_outstanding_balance_threshold"
string="Outstanding Balance Warning ($)"
help="Show a warning if the client's open invoice total exceeds this amount.">
<field name="fc_repairs_outstanding_balance_threshold"/>
</setting>
</block>
<block title="Pricing Variance" name="fc_repairs_pricing">
<setting id="fc_repairs_variance_threshold_pct"
string="Variance Threshold (%)"
help="If the actual repair cost exceeds the estimate by more than this percentage, invoicing is blocked until manager review.">
<field name="fc_repairs_variance_threshold_pct"/>
</setting>
<setting id="fc_repairs_variance_threshold_amount"
string="Variance Threshold ($)"
help="Absolute variance amount that also triggers re-quote (whichever hits first).">
<field name="fc_repairs_variance_threshold_amount"/>
</setting>
</block>
<block title="Public Client Portal" name="fc_repairs_client_portal">
<setting id="fc_repairs_client_portal_url"
string="Portal URL Path"
help="URL path mentioned in voicemail greetings and printed on QR stickers. Phase 1 ships the form at this path.">
<field name="fc_repairs_client_portal_url"/>
</setting>
<setting id="fc_repairs_client_portal_rate_limit_per_hour"
string="Rate Limit (per hour per IP)"
help="Anti-spam limit for the public form.">
<field name="fc_repairs_client_portal_rate_limit_per_hour"/>
</setting>
</block>
</app>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_res_partner_form_inherit_fusion_repairs" model="ir.ui.view">
<field name="name">res.partner.form.inherit.fusion_repairs</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page string="Service Preferences" name="fusion_repairs_prefs"
groups="fusion_repairs.group_fusion_repairs_user">
<group>
<group>
<field name="x_fc_preferred_tech_id" options="{'no_create': True}"/>
<field name="x_fc_preferred_window"/>
</group>
<group>
<field name="x_fc_repair_count" readonly="1"/>
</group>
</group>
<separator string="Access Notes for Technicians"/>
<field name="x_fc_access_notes"
placeholder="e.g. Dog in front yard, use side door, gate code 1234"/>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_res_users_form_inherit_fusion_repairs" model="ir.ui.view">
<field name="name">res.users.form.inherit.fusion_repairs</field>
<field name="model">res.users</field>
<field name="inherit_id" ref="base.view_users_form"/>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page string="Repairs" name="fusion_repairs_user"
groups="fusion_repairs.group_fusion_repairs_manager">
<group>
<group string="Skills &amp; Costing">
<field name="x_fc_repair_skills" widget="many2many_tags"
options="{'no_create': True}"/>
<field name="x_fc_tech_cost_rate" widget="monetary"/>
<field name="company_currency_id" invisible="1"/>
</group>
<group string="On-Call Rotation">
<field name="x_fc_on_call"/>
<field name="x_fc_on_call_priority"
invisible="not x_fc_on_call"/>
<field name="x_fc_on_call_phone"
invisible="not x_fc_on_call"
placeholder="Leave blank to use partner phone"/>
</group>
</group>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import repair_intake_wizard

View File

@@ -0,0 +1,199 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Backend intake wizard.
A simple Phase 1 transient model that captures one-or-many equipment items
per call, then delegates to fusion.repair.intake.service to create the
repair.order(s). The shared service guarantees identical behaviour to the
sales rep portal and the public client portal added in later phases.
Multi-equipment per call is supported via the equipment_ids One2many.
"""
import logging
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class RepairIntakeWizard(models.TransientModel):
_name = 'fusion.repair.intake.wizard'
_description = 'Repair Intake Wizard'
# ------------------------------------------------------------------
# CALLER / CLIENT
# ------------------------------------------------------------------
intake_user_id = fields.Many2one(
'res.users',
string='Taken By',
default=lambda self: self.env.user,
required=True,
)
partner_id = fields.Many2one(
'res.partner',
string='Client',
required=True,
help='Existing client. Use the create-and-edit dialog to add a new contact.',
)
partner_phone = fields.Char(
related='partner_id.phone',
string='Phone',
readonly=True,
)
# ------------------------------------------------------------------
# EQUIPMENT (one-or-many)
# ------------------------------------------------------------------
equipment_ids = fields.One2many(
'fusion.repair.intake.wizard.equipment',
'wizard_id',
string='Equipment Items',
required=True,
)
# ------------------------------------------------------------------
# SUBMIT
# ------------------------------------------------------------------
def action_submit(self):
self.ensure_one()
if not self.equipment_ids:
raise UserError(_('Please add at least one piece of equipment.'))
payload = {
'partner_id': self.partner_id.id,
'intake_user_id': self.intake_user_id.id,
'equipment_items': [self._equipment_payload(eq) for eq in self.equipment_ids],
}
repairs = self.env['fusion.repair.intake.service'].create_repair_orders(
payload, source='backend_wizard',
)
if len(repairs) == 1:
return {
'type': 'ir.actions.act_window',
'name': repairs.name,
'res_model': 'repair.order',
'view_mode': 'form',
'res_id': repairs.id,
}
return {
'type': 'ir.actions.act_window',
'name': _('Service Calls Created (%(count)s)', count=len(repairs)),
'res_model': 'repair.order',
'view_mode': 'list,form',
'domain': [('id', 'in', repairs.ids)],
}
def _equipment_payload(self, eq):
"""Render an equipment record as a dict the intake service expects."""
return {
'product_id': eq.product_id.id or False,
'lot_id': eq.lot_id.id or False,
'repair_category_id': eq.repair_category_id.id or False,
'intake_template_id': eq.intake_template_id.id or False,
'third_party': eq.third_party,
'urgency': eq.urgency,
'issue_summary': eq.issue_summary or '',
'issue_category': eq.issue_category or '',
'internal_notes': eq.internal_notes or '',
'schedule_date': eq.scheduled_date or False,
'photo_attachment_ids': eq.photo_ids.ids if eq.photo_ids else [],
'answers': [], # Phase 1 wizard doesn't expose per-question answer rows yet
}
class RepairIntakeWizardEquipment(models.TransientModel):
"""A single piece of equipment captured in the wizard.
Multiple lines = multi-equipment intake (one repair.order per line).
"""
_name = 'fusion.repair.intake.wizard.equipment'
_description = 'Repair Intake Wizard - Equipment Line'
_order = 'sequence, id'
wizard_id = fields.Many2one(
'fusion.repair.intake.wizard',
string='Wizard',
required=True,
ondelete='cascade',
)
sequence = fields.Integer(default=10)
# Equipment identification
repair_category_id = fields.Many2one(
'fusion.repair.product.category',
string='Category',
required=True,
)
product_id = fields.Many2one(
'product.product',
string='Product',
help='Specific product if known. Leave blank for generic equipment.',
)
lot_id = fields.Many2one(
'stock.lot',
string='Serial Number',
domain="[('product_id', '=', product_id)]",
help='Lot or serial number if known.',
)
third_party = fields.Boolean(
string='Not Purchased From Us',
help='Tick if this equipment was bought elsewhere - we still service it but '
'warranty is not honoured and a service call-out fee applies.',
)
# Intake context
intake_template_id = fields.Many2one(
'fusion.repair.intake.template',
string='Question Template',
help='Defaults to the template configured on the category if left blank.',
)
# Triage
urgency = fields.Selection(
[('normal', 'Normal'), ('urgent', 'Urgent'), ('safety', 'Safety Issue')],
string='Urgency',
default='normal',
required=True,
)
scheduled_date = fields.Datetime(
string='Preferred Date',
default=fields.Datetime.now,
)
issue_summary = fields.Char(
string='Issue Summary',
help='One-line summary of what is wrong (e.g. "stairlift stops halfway up").',
)
issue_category = fields.Char(
string='Symptom Category',
help='Optional symptom tag for catalogue matching (e.g. "battery", "motor").',
)
internal_notes = fields.Text(string='Internal Notes')
photo_ids = fields.Many2many(
'ir.attachment',
'fusion_repair_intake_wizard_eq_photo_rel',
'eq_id',
'attachment_id',
string='Photos / Videos',
)
@api.onchange('repair_category_id')
def _onchange_repair_category_id(self):
"""Pre-fill the intake template from the category default."""
if self.repair_category_id and not self.intake_template_id:
self.intake_template_id = self.repair_category_id.intake_template_id
@api.onchange('product_id')
def _onchange_product_id(self):
"""Pre-fill the category from the product if defined."""
if self.product_id and not self.repair_category_id:
cat = self.product_id.product_tmpl_id.x_fc_repair_category_id
if cat:
self.repair_category_id = cat

View File

@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_repair_intake_wizard_form" model="ir.ui.view">
<field name="name">fusion.repair.intake.wizard.form</field>
<field name="model">fusion.repair.intake.wizard</field>
<field name="arch" type="xml">
<form string="New Service Call">
<sheet>
<group>
<group string="Caller / Client">
<field name="intake_user_id" options="{'no_create': True}"/>
<field name="partner_id"
options="{'no_create_edit': False, 'no_quick_create': False}"/>
<field name="partner_phone" readonly="1" invisible="not partner_id"/>
</group>
</group>
<separator string="Equipment Items (one repair per item)"/>
<field name="equipment_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="repair_category_id" options="{'no_create': True}"/>
<field name="product_id" optional="show"/>
<field name="lot_id" optional="hide"/>
<field name="third_party" optional="show"/>
<field name="urgency" widget="badge"
decoration-success="urgency == 'normal'"
decoration-warning="urgency == 'urgent'"
decoration-danger="urgency == 'safety'"/>
<field name="issue_summary"/>
<field name="scheduled_date" optional="hide"/>
</list>
<form>
<group>
<group>
<field name="repair_category_id" options="{'no_create': True}"/>
<field name="product_id"/>
<field name="lot_id"/>
<field name="third_party"/>
</group>
<group>
<field name="urgency"/>
<field name="scheduled_date"/>
<field name="intake_template_id" options="{'no_create': True}"/>
</group>
</group>
<field name="issue_summary"
placeholder="One-line summary (e.g. 'stairlift stops halfway up')"/>
<field name="issue_category"
placeholder="Symptom tag (e.g. battery, motor, remote)"/>
<field name="internal_notes" placeholder="Internal notes"/>
<separator string="Photos / Videos"/>
<field name="photo_ids" widget="many2many_binary"/>
</form>
</field>
</sheet>
<footer>
<button string="Submit"
name="action_submit"
type="object"
class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>