From 429084e0bf97d109207a3f6692d382f491a5b93f Mon Sep 17 00:00:00 2001
From: gsinghpal
Date: Wed, 20 May 2026 21:35:52 -0400
Subject: [PATCH] feat(fusion_repairs): Phase 1 MVP - backend intake wizard +
core models
Scaffolds the fusion_repairs module that extends Odoo 19 repair.order with
a guided medical-equipment intake workflow.
Models
- fusion.repair.product.category (8 medical equipment categories seeded)
- fusion.repair.intake.template / .question / .answer (7 templates,
32 questions seeded across hospital bed, stairlift, porch lift,
wheelchair, walker/rollator, mattress)
- fusion.repair.intake.service (AbstractModel) - single entry point used
by backend wizard, sales rep portal, and public client portal so all
three surfaces produce identical outcomes
- repair.order extensions (x_fc_intake_*, x_fc_third_party_equipment,
x_fc_photo_ids, x_fc_urgency, x_fc_estimated/actual_cost, AI summary)
- fusion.technician.task back-link (x_fc_repair_order_id)
- res.partner service preferences (preferred tech, time window, access notes)
- res.users repair extensions (skills, cost rate, on-call rotation fields)
- res.config.settings for variance thresholds, portal URL, rate limit
UI
- Backend intake wizard with multi-equipment loop, third-party flag, photos
- repair.order form: Intake tab, Photos, Pricing tab, AI tab, smart buttons
(technician tasks, intake answers, original SO)
- Kanban + list view urgency badges
- Fusion Repairs app menu (New Service Call, Repair Orders, Config)
Activities & Email
- 4 follow-up activity types (CS callback, tech dispatch, visit follow-up,
manager review) with urgency-tiered deadlines
- 2 mail templates (client confirmation + office notification) with the
same dark/light-safe styling as fusion_claims ADP templates
Security
- New res.groups.privilege + 3 groups (User, Dispatcher, Manager)
- Reuses fusion_tasks.group_field_technician (do NOT recreate)
- Reuses fusion_authorizer_portal.group_sales_rep_portal
- Multi-company global rule + technician scoping rule on repair.order
Verified end-to-end on local westin-v19 dev DB via odoo-shell - creates
multiple repairs in one session, auto-creates dispatch task for urgent,
attaches 4 activity types correctly per urgency tier and third-party flag.
Co-authored-by: Cursor
---
fusion_repairs/__init__.py | 6 +
fusion_repairs/__manifest__.py | 100 +++++
fusion_repairs/data/intake_template_data.xml | 378 ++++++++++++++++++
.../data/ir_config_parameter_data.xml | 60 +++
fusion_repairs/data/ir_sequence_data.xml | 16 +
.../data/mail_activity_type_data.xml | 54 +++
fusion_repairs/data/mail_template_data.xml | 103 +++++
.../data/repair_product_category_data.xml | 81 ++++
fusion_repairs/models/__init__.py | 15 +
fusion_repairs/models/intake_answer.py | 88 ++++
fusion_repairs/models/intake_question.py | 84 ++++
fusion_repairs/models/intake_service.py | 347 ++++++++++++++++
fusion_repairs/models/intake_template.py | 74 ++++
fusion_repairs/models/product_template.py | 34 ++
fusion_repairs/models/repair_order.py | 281 +++++++++++++
.../models/repair_product_category.py | 49 +++
fusion_repairs/models/res_config_settings.py | 64 +++
fusion_repairs/models/res_partner.py | 64 +++
fusion_repairs/models/res_users.py | 57 +++
fusion_repairs/models/technician_task.py | 42 ++
fusion_repairs/security/ir.model.access.csv | 12 +
fusion_repairs/security/security.xml | 76 ++++
.../views/intake_template_views.xml | 95 +++++
fusion_repairs/views/menus.xml | 48 +++
fusion_repairs/views/repair_order_views.xml | 143 +++++++
.../views/repair_product_category_views.xml | 55 +++
.../views/res_config_settings_views.xml | 62 +++
fusion_repairs/views/res_partner_views.xml | 29 ++
fusion_repairs/views/res_users_views.xml | 33 ++
fusion_repairs/wizard/__init__.py | 5 +
fusion_repairs/wizard/repair_intake_wizard.py | 199 +++++++++
.../wizard/repair_intake_wizard_views.xml | 69 ++++
32 files changed, 2823 insertions(+)
create mode 100644 fusion_repairs/__init__.py
create mode 100644 fusion_repairs/__manifest__.py
create mode 100644 fusion_repairs/data/intake_template_data.xml
create mode 100644 fusion_repairs/data/ir_config_parameter_data.xml
create mode 100644 fusion_repairs/data/ir_sequence_data.xml
create mode 100644 fusion_repairs/data/mail_activity_type_data.xml
create mode 100644 fusion_repairs/data/mail_template_data.xml
create mode 100644 fusion_repairs/data/repair_product_category_data.xml
create mode 100644 fusion_repairs/models/__init__.py
create mode 100644 fusion_repairs/models/intake_answer.py
create mode 100644 fusion_repairs/models/intake_question.py
create mode 100644 fusion_repairs/models/intake_service.py
create mode 100644 fusion_repairs/models/intake_template.py
create mode 100644 fusion_repairs/models/product_template.py
create mode 100644 fusion_repairs/models/repair_order.py
create mode 100644 fusion_repairs/models/repair_product_category.py
create mode 100644 fusion_repairs/models/res_config_settings.py
create mode 100644 fusion_repairs/models/res_partner.py
create mode 100644 fusion_repairs/models/res_users.py
create mode 100644 fusion_repairs/models/technician_task.py
create mode 100644 fusion_repairs/security/ir.model.access.csv
create mode 100644 fusion_repairs/security/security.xml
create mode 100644 fusion_repairs/views/intake_template_views.xml
create mode 100644 fusion_repairs/views/menus.xml
create mode 100644 fusion_repairs/views/repair_order_views.xml
create mode 100644 fusion_repairs/views/repair_product_category_views.xml
create mode 100644 fusion_repairs/views/res_config_settings_views.xml
create mode 100644 fusion_repairs/views/res_partner_views.xml
create mode 100644 fusion_repairs/views/res_users_views.xml
create mode 100644 fusion_repairs/wizard/__init__.py
create mode 100644 fusion_repairs/wizard/repair_intake_wizard.py
create mode 100644 fusion_repairs/wizard/repair_intake_wizard_views.xml
diff --git a/fusion_repairs/__init__.py b/fusion_repairs/__init__.py
new file mode 100644
index 00000000..866992e6
--- /dev/null
+++ b/fusion_repairs/__init__.py
@@ -0,0 +1,6 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from . import models
+from . import wizard
diff --git a/fusion_repairs/__manifest__.py b/fusion_repairs/__manifest__.py
new file mode 100644
index 00000000..bb20e0a0
--- /dev/null
+++ b/fusion_repairs/__manifest__.py
@@ -0,0 +1,100 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+{
+ 'name': 'Fusion Repairs',
+ 'version': '19.0.1.0.0',
+ 'category': 'Inventory/Repairs',
+ 'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal',
+ 'description': """
+Fusion Repairs
+==============
+
+Comprehensive repairs and maintenance management for medical equipment retailers
+and service providers (hospital beds, wheelchairs, stairlifts, porch lifts,
+walkers, mattresses, rollators).
+
+Phase 1 - MVP
+-------------
+- Three intake surfaces sharing one service layer:
+ * Backend wizard for CS reps on the phone
+ * Sales rep portal (/my/repair/new) for reps on the road
+ * Public client self-service portal (/repair) - voicemail ready
+- Guided question templates per medical equipment category
+- Phone-first partner lookup with duplicate-call detection
+- Multi-equipment per call (one repair.order per unit)
+- Photo / video capture during intake
+- Third-party equipment support (equipment we didn't sell)
+- Auto warranty detection from original sale order
+- Office notification recipients + 4 follow-up activities
+- repair.order extensions linked to fusion.technician.task
+
+Phase 2-4 (roadmap)
+-------------------
+- AI self-check engine with strict medical safety guardrails
+- Upsell engine and direct-buy parts/plans
+- Repair warranty tracking (free re-do window)
+- Visit report wizard with Poynt terminal payment
+- Maintenance contracts with client self-booking
+- Weekend safety on-call paging
+- SMS notifications, compliance certificates, analytics
+
+Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
+ """,
+ 'author': 'Nexa Systems Inc.',
+ 'website': 'https://www.nexasystems.ca',
+ 'maintainer': 'Nexa Systems Inc.',
+ 'support': 'support@nexasystems.ca',
+ 'license': 'OPL-1',
+ 'price': 0.00,
+ 'currency': 'CAD',
+ 'depends': [
+ 'base',
+ 'mail',
+ 'portal',
+ 'website',
+ 'sale_management',
+ 'stock',
+ 'repair',
+ 'maintenance',
+ 'fusion_tasks',
+ 'fusion_poynt',
+ 'fusion_authorizer_portal',
+ ],
+ 'data': [
+ # Security
+ 'security/security.xml',
+ 'security/ir.model.access.csv',
+ # Data (must load before views that reference records)
+ 'data/ir_sequence_data.xml',
+ 'data/ir_config_parameter_data.xml',
+ 'data/mail_activity_type_data.xml',
+ 'data/mail_template_data.xml',
+ 'data/repair_product_category_data.xml',
+ 'data/intake_template_data.xml',
+ # Views
+ 'views/repair_product_category_views.xml',
+ 'views/intake_template_views.xml',
+ 'views/repair_order_views.xml',
+ 'views/res_partner_views.xml',
+ 'views/res_users_views.xml',
+ 'views/res_config_settings_views.xml',
+ # Wizard
+ 'wizard/repair_intake_wizard_views.xml',
+ # Menus (last, after all referenced actions exist)
+ 'views/menus.xml',
+ ],
+ 'assets': {
+ 'web.assets_backend': [
+ # Phase 2+: history_sidebar.js, signature_pad.js, etc.
+ ],
+ 'web.assets_frontend': [
+ # Phase 1+: portal_client_repair.js etc.
+ ],
+ },
+ 'images': ['static/description/icon.png'],
+ 'installable': True,
+ 'application': True,
+ 'auto_install': False,
+}
diff --git a/fusion_repairs/data/intake_template_data.xml b/fusion_repairs/data/intake_template_data.xml
new file mode 100644
index 00000000..c71f3182
--- /dev/null
+++ b/fusion_repairs/data/intake_template_data.xml
@@ -0,0 +1,378 @@
+
+
+
+
+
+
+
+
+
+ Default - General Intake
+ default
+ 1
+
+ Generic question set used when no equipment-specific template is configured.
]]>
+
+
+
+
+ 10
+ Who is calling? (self / family / caregiver / other)
+ caller_relationship
+ char
+
+
+
+
+ 20
+ Is the service address the same as the contact address on file?
+ address_match
+ boolean
+
+
+
+
+ 30
+ Was this equipment purchased from us?
+ purchased_from_us
+ boolean
+
+
+
+ 40
+ Approximate purchase date (if known)
+ purchase_date
+ date
+
+
+
+ 50
+ Describe the issue in your own words
+ issue_summary
+ text
+
+
+
+
+ 60
+ Does this issue affect anyone's safety right now?
+ safety_concern
+ boolean
+
+
+
+
+ 70
+ Anything the technician should know about access? (stairs, parking, gate code, pet)
+ access_notes
+ text
+ e.g. "dog in front yard, use side gate"
+
+
+
+
+
+
+ Hospital Bed - Intake
+ hospital_bed
+ 10
+
+
+
+
+
+ 10
+ Is the bed plugged in and does it power on?
+ powered
+ selection
+ Yes - powers on normally
+No - no lights/sound at all
+Powers on but won't move
+
+
+
+
+ 20
+ Does the remote control respond when buttons are pressed?
+ remote_works
+ boolean
+
+
+
+ 30
+ Which motor seems affected? (head, foot, height, all)
+ motor_side
+ char
+ motor
+
+
+
+ 40
+ Are the side rails functioning normally?
+ rails_ok
+ boolean
+ rail,side
+
+
+
+ 50
+ Is the mattress included in this issue?
+ mattress_involved
+ boolean
+
+
+
+
+
+
+ Stairlift - Intake
+ stairlift
+ 20
+
+
+
+
+
+ 10
+ Does the stairlift power on? (any lights, beeps)
+ powered
+ boolean
+
+
+
+
+ 20
+ Is there an error code displayed? (note the number/letter shown)
+ error_code
+ char
+ error code
+
+
+
+ 30
+ Is anyone currently stuck on the stairlift?
+ person_stuck
+ boolean
+
+ If yes, this is a safety issue - escalate immediately.
+
+
+
+ 40
+ Does it stop partway up or down the track?
+ stops_midway
+ boolean
+ stops midway
+
+
+
+ 50
+ Any burning smell, smoke, or unusual noise?
+ burning_smell
+ boolean
+
+ burning smell,smoke
+
+
+
+
+
+
+ Porch Lift - Intake
+ porch_lift
+ 30
+
+
+
+
+
+ 10
+ Does the lift respond when you press the call/send button?
+ powered
+ boolean
+
+
+
+
+ 20
+ Are all gate and door safety switches fully closed?
+ gate_switches
+ boolean
+
+
+
+ 30
+ Is anyone currently stuck on the lift?
+ person_stuck
+ boolean
+
+
+
+
+ 40
+ Is the lift outdoors exposed to weather?
+ outdoor
+ boolean
+
+
+
+
+
+
+ Wheelchair - Intake
+ wheelchair
+ 40
+
+
+
+
+
+ 10
+ Do the brakes engage and hold the wheelchair?
+ brakes_ok
+ boolean
+
+ brake
+
+
+
+ 20
+ Are both tires inflated and undamaged?
+ tires_ok
+ boolean
+
+
+
+ 30
+ Is there any visible damage to the frame or footrests?
+ frame_damage
+ boolean
+
+
+
+ 40
+ For power chairs: does the battery hold a charge?
+ battery_holds_charge
+ boolean
+ battery,charge
+
+
+
+ 50
+ For power chairs: any error code shown on the joystick display?
+ joystick_error
+ char
+
+
+
+
+
+
+ Walker / Rollator - Intake
+ walker_rollator
+ 50
+
+
+
+
+
+ 10
+ Do all wheels roll freely?
+ wheels_roll
+ boolean
+
+
+
+ 20
+ Do the brakes lock when engaged? (rollator only)
+ brakes_lock
+ boolean
+
+
+
+ 30
+ Is the frame stable, with no wobble or loose parts?
+ frame_stable
+ boolean
+
+
+
+
+
+
+ Medical Mattress - Intake
+ mattress
+ 60
+
+
+
+
+
+ 10
+ Is the pump plugged in and showing any indicator lights?
+ pump_powered
+ boolean
+
+
+
+
+ 20
+ Is the mattress leaking or losing air?
+ leak
+ boolean
+ leak,deflate
+
+
+
+ 30
+ Is the pump showing an error code or alarm?
+ alarm
+ char
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fusion_repairs/data/ir_config_parameter_data.xml b/fusion_repairs/data/ir_config_parameter_data.xml
new file mode 100644
index 00000000..a3d04c17
--- /dev/null
+++ b/fusion_repairs/data/ir_config_parameter_data.xml
@@ -0,0 +1,60 @@
+
+
+
+
+
+ fusion_repairs.enable_email_notifications
+ True
+
+
+
+
+ fusion_repairs.outstanding_balance_threshold
+ 100.00
+
+
+
+
+ fusion_repairs.duplicate_call_window_days
+ 14
+
+
+
+
+ fusion_repairs.variance_threshold_pct
+ 20
+
+
+ fusion_repairs.variance_threshold_amount
+ 100.00
+
+
+
+
+ fusion_repairs.followup_maintenance_enabled
+ True
+
+
+ fusion_repairs.followup_repair_no_tech_enabled
+ True
+
+
+ fusion_repairs.followup_overdue_visit_enabled
+ True
+
+
+ fusion_repairs.followup_unpaid_invoice_enabled
+ True
+
+
+
+
+ fusion_repairs.client_portal_url
+ /repair
+
+
+ fusion_repairs.client_portal_rate_limit_per_hour
+ 10
+
+
+
diff --git a/fusion_repairs/data/ir_sequence_data.xml b/fusion_repairs/data/ir_sequence_data.xml
new file mode 100644
index 00000000..37c8f7d7
--- /dev/null
+++ b/fusion_repairs/data/ir_sequence_data.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+ Repair Intake Session
+ fusion.repair.intake.session
+ RIS
+ 6
+ 1
+ 1
+
+
+
+
diff --git a/fusion_repairs/data/mail_activity_type_data.xml b/fusion_repairs/data/mail_activity_type_data.xml
new file mode 100644
index 00000000..323594f4
--- /dev/null
+++ b/fusion_repairs/data/mail_activity_type_data.xml
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+ Repair: CS Callback
+ Call client back if any intake info was missing
+ 1
+ days
+ previous_activity
+ repair.order
+ fa-phone
+ 10
+
+
+
+
+ Repair: Assign Technician
+ Assign a technician to this repair
+ 2
+ days
+ previous_activity
+ repair.order
+ fa-wrench
+ 20
+
+
+
+
+ Repair: Visit Follow-Up
+ Confirm visit outcome and complete repair
+ 1
+ days
+ previous_activity
+ repair.order
+ fa-check-square-o
+ 30
+
+
+
+
+ Repair: Manager Review
+ Third-party equipment - manager awareness
+ 1
+ days
+ previous_activity
+ repair.order
+ fa-flag
+ 40
+
+
+
+
diff --git a/fusion_repairs/data/mail_template_data.xml b/fusion_repairs/data/mail_template_data.xml
new file mode 100644
index 00000000..06571eeb
--- /dev/null
+++ b/fusion_repairs/data/mail_template_data.xml
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+
+
+
+ Repair: Intake Received (Client)
+
+ {{ object.company_id.name }} - Service Call {{ object.name or 'received' }}
+ {{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}
+ {{ object.partner_id.id }}
+
+
+
+
+
+
+
+
We received your service request
+
+ Hello , thank you for letting us know about your equipment.
+ Your service call reference is .
+
+
+ | Service Call Details |
+ | Reference | |
+
+ | Equipment | |
+
+
+ | Scheduled | |
+
+ | Status | |
+
+
+
+ A team member will be in touch shortly to confirm the next steps.
+ If you need to reach us before then, please contact our office directly.
+
+
+
+ --
+
+
+
+
+ {{ object.partner_id.lang }}
+
+
+
+
+
+
+
+ Repair: Intake Received (Office)
+
+ [New Service Call] {{ object.partner_id.name or 'Walk-in' }} - {{ object.name or 'n/a' }}
+ {{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}
+
+
+
+
+
+ Internal: New Service Call
+
+
A new repair has been submitted
+
+ Submitted by
+ via the .
+
+
+ | Details |
+ | Reference | |
+ | Client | |
+
+ | Phone | |
+
+
+ | Equipment | |
+
+ | Urgency | |
+
+ | Third-party | Yes - equipment not sold by us |
+
+
+ | Warranty | Under warranty |
+
+
+
+
+
+
+
+
+
+
diff --git a/fusion_repairs/data/repair_product_category_data.xml b/fusion_repairs/data/repair_product_category_data.xml
new file mode 100644
index 00000000..52d36e35
--- /dev/null
+++ b/fusion_repairs/data/repair_product_category_data.xml
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+ Hospital Bed
+ hospital_bed
+ 10
+ fa-bed
+ Electric and manual hospital beds, semi-electric beds, low beds.
+
+
+
+ Wheelchair (Manual)
+ wheelchair_manual
+ 20
+ fa-wheelchair
+ Standard, transport, and tilt-in-space manual wheelchairs.
+
+
+
+ Wheelchair (Power)
+ wheelchair_power
+ 30
+ fa-wheelchair
+ Power wheelchairs, scooters, and powered mobility devices.
+
+
+
+ Stairlift
+ stairlift
+ 40
+ fa-arrows-v
+ Straight and curved indoor stairlifts. Annual safety inspection required in many jurisdictions.
+
+
+
+
+ Porch Lift
+ porch_lift
+ 50
+ fa-arrow-up
+ Vertical platform lifts for porches, decks, and accessible building entrances.
+
+
+
+
+ Walker
+ walker
+ 60
+ fa-male
+ Standard walkers, hemi-walkers, and folding walkers.
+
+
+
+ Rollator
+ rollator
+ 70
+ fa-male
+ Wheeled walkers with seats and brakes.
+
+
+
+ Medical Mattress
+ mattress
+ 80
+ fa-bed
+ Air mattresses, alternating pressure, low air loss, and pressure relief mattresses.
+
+
+
+ Other Equipment
+ other
+ 100
+ fa-question-circle
+ Any other medical equipment not in the standard categories.
+
+
+
+
diff --git a/fusion_repairs/models/__init__.py b/fusion_repairs/models/__init__.py
new file mode 100644
index 00000000..5b36b893
--- /dev/null
+++ b/fusion_repairs/models/__init__.py
@@ -0,0 +1,15 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from . import repair_product_category
+from . import intake_template
+from . import intake_question
+from . import intake_answer
+from . import product_template
+from . import res_partner
+from . import res_users
+from . import res_config_settings
+from . import technician_task
+from . import repair_order
+from . import intake_service
diff --git a/fusion_repairs/models/intake_answer.py b/fusion_repairs/models/intake_answer.py
new file mode 100644
index 00000000..0db6b64f
--- /dev/null
+++ b/fusion_repairs/models/intake_answer.py
@@ -0,0 +1,88 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from odoo import api, fields, models
+
+
+class FusionRepairIntakeAnswer(models.Model):
+ """An answer to a single intake question on a specific repair order.
+
+ Persists raw answer values for audit + reporting + AI / catalogue matching.
+ """
+
+ _name = 'fusion.repair.intake.answer'
+ _description = 'Repair Intake Answer'
+ _order = 'repair_id, sequence, id'
+
+ repair_id = fields.Many2one(
+ 'repair.order',
+ string='Repair Order',
+ required=True,
+ ondelete='cascade',
+ index=True,
+ )
+ question_id = fields.Many2one(
+ 'fusion.repair.intake.question',
+ string='Question',
+ required=True,
+ ondelete='restrict',
+ )
+ question_name = fields.Char(
+ related='question_id.name',
+ string='Question',
+ store=True,
+ )
+ question_type = fields.Selection(
+ related='question_id.question_type',
+ store=True,
+ )
+ sequence = fields.Integer(
+ related='question_id.sequence',
+ store=True,
+ )
+
+ # Typed value fields - one per supported type, plus a display string.
+ value_char = fields.Char(string='Text Answer')
+ value_text = fields.Text(string='Long Text Answer')
+ value_selection = fields.Char(string='Choice Answer')
+ value_boolean = fields.Boolean(string='Yes/No Answer')
+ value_integer = fields.Integer(string='Number Answer')
+ value_date = fields.Date(string='Date Answer')
+
+ value_display = fields.Char(
+ string='Answer',
+ compute='_compute_value_display',
+ store=True,
+ )
+
+ company_id = fields.Many2one(
+ 'res.company',
+ related='repair_id.company_id',
+ store=True,
+ index=True,
+ )
+
+ @api.depends(
+ 'question_type',
+ 'value_char', 'value_text', 'value_selection',
+ 'value_boolean', 'value_integer', 'value_date',
+ )
+ def _compute_value_display(self):
+ for answer in self:
+ if answer.question_type == 'char':
+ answer.value_display = answer.value_char or ''
+ elif answer.question_type == 'text':
+ answer.value_display = (answer.value_text or '')[:200]
+ elif answer.question_type == 'selection':
+ answer.value_display = answer.value_selection or ''
+ elif answer.question_type == 'boolean':
+ answer.value_display = 'Yes' if answer.value_boolean else 'No'
+ elif answer.question_type == 'integer':
+ answer.value_display = str(answer.value_integer or 0)
+ elif answer.question_type == 'date':
+ answer.value_display = (
+ fields.Date.to_string(answer.value_date) if answer.value_date else ''
+ )
+ else:
+ answer.value_display = ''
diff --git a/fusion_repairs/models/intake_question.py b/fusion_repairs/models/intake_question.py
new file mode 100644
index 00000000..48188f71
--- /dev/null
+++ b/fusion_repairs/models/intake_question.py
@@ -0,0 +1,84 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from odoo import fields, models
+
+
+QUESTION_TYPES = [
+ ('char', 'Short Text'),
+ ('text', 'Long Text'),
+ ('selection', 'Single Choice'),
+ ('boolean', 'Yes / No'),
+ ('integer', 'Number'),
+ ('date', 'Date'),
+]
+
+
+class FusionRepairIntakeQuestion(models.Model):
+ """A single question on an intake template.
+
+ Supports basic conditional display: a question is only shown when the
+ parent question's answer matches `parent_answer_value`. The wizard and
+ portal forms render based on these rules.
+ """
+
+ _name = 'fusion.repair.intake.question'
+ _description = 'Repair Intake Question'
+ _order = 'sequence, id'
+
+ template_id = fields.Many2one(
+ 'fusion.repair.intake.template',
+ string='Template',
+ required=True,
+ ondelete='cascade',
+ index=True,
+ )
+ sequence = fields.Integer(string='Sequence', default=10)
+ name = fields.Char(
+ string='Question',
+ required=True,
+ translate=True,
+ help='Text shown to the user.',
+ )
+ code = fields.Char(
+ string='Code',
+ help='Stable identifier for this question (used by automation rules and reporting).',
+ )
+ help_text = fields.Char(
+ string='Help Text',
+ translate=True,
+ help='Optional shorter hint shown beneath the question (e.g. "e.g. SN-12345").',
+ )
+ question_type = fields.Selection(
+ QUESTION_TYPES,
+ string='Type',
+ required=True,
+ default='char',
+ )
+ required = fields.Boolean(default=False)
+
+ selection_options = fields.Text(
+ string='Choices',
+ help='One option per line, only used when type is "Single Choice".',
+ )
+
+ # Conditional display
+ parent_question_id = fields.Many2one(
+ 'fusion.repair.intake.question',
+ string='Show Only If Question',
+ domain="[('template_id', '=', template_id), ('id', '!=', id)]",
+ ondelete='set null',
+ help='Show this question only when the parent question matches the value below.',
+ )
+ parent_answer_value = fields.Char(
+ string='Parent Answer Equals',
+ help='Value the parent answer must equal for this question to be displayed.',
+ )
+
+ # Symptom keyword classification - feeds the service catalogue matcher and AI prompt
+ symptom_keywords = fields.Char(
+ string='Symptom Keywords',
+ help='Comma-separated keywords that, when present in the answer, tag the repair '
+ 'for catalogue matching (e.g. "battery,charge").',
+ )
diff --git a/fusion_repairs/models/intake_service.py b/fusion_repairs/models/intake_service.py
new file mode 100644
index 00000000..bbe8a708
--- /dev/null
+++ b/fusion_repairs/models/intake_service.py
@@ -0,0 +1,347 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+"""Shared intake service.
+
+This AbstractModel is the SINGLE entry point for creating repair orders from
+any intake surface: the backend wizard (Phase 1), the sales rep portal
+(Phase 1+), and the public client self-service portal (Phase 1+).
+
+All three surfaces call `create_repair_orders(payload, source='...')` so that
+business logic - activities, emails, warranty determination, AI summary,
+catalogue match, third-party flag, dispatch task creation - lives in one
+place and the surfaces never drift apart.
+"""
+
+import logging
+from datetime import timedelta
+
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError
+
+_logger = logging.getLogger(__name__)
+
+
+class FusionRepairIntakeService(models.AbstractModel):
+ _name = 'fusion.repair.intake.service'
+ _description = 'Repair Intake Service (shared by backend / sales rep / client)'
+
+ # ------------------------------------------------------------------
+ # PUBLIC API
+ # ------------------------------------------------------------------
+ @api.model
+ def create_repair_orders(self, payload, source='backend_wizard'):
+ """Create one repair.order per equipment item in the payload.
+
+ :param payload: dict with keys:
+ - partner_id: int (required) or partner_vals: dict to create new partner
+ - intake_user_id: int (optional, defaults to env.user)
+ - equipment_items: list of dicts, each with:
+ - product_id: int (optional)
+ - lot_id: int (optional)
+ - repair_category_id: int (optional)
+ - intake_template_id: int (optional)
+ - third_party: bool (optional)
+ - urgency: str (optional, default 'normal')
+ - issue_summary: str (optional)
+ - internal_notes: str (optional)
+ - photo_attachment_ids: list[int] (optional)
+ - answers: list of dicts with keys
+ (question_id, value_char|value_text|value_selection|
+ value_boolean|value_integer|value_date)
+ :param source: str, one of repair_order.INTAKE_SOURCES values.
+ :return: recordset of repair.order records created.
+ """
+ partner_id = self._resolve_partner(payload)
+ if not partner_id:
+ raise UserError(_('A client is required to create a repair request.'))
+
+ intake_user = self.env['res.users'].browse(
+ payload.get('intake_user_id') or self.env.uid
+ )
+ session_ref = (
+ self.env['ir.sequence'].next_by_code('fusion.repair.intake.session')
+ or 'RIS/NEW'
+ )
+
+ equipment = payload.get('equipment_items') or [{}]
+ repairs = self.env['repair.order']
+ for item in equipment:
+ repair = self._create_single_repair(
+ partner_id=partner_id,
+ intake_user=intake_user,
+ session_ref=session_ref,
+ source=source,
+ item=item,
+ )
+ repairs |= repair
+
+ return repairs
+
+ # ------------------------------------------------------------------
+ # PARTNER RESOLUTION
+ # ------------------------------------------------------------------
+ @api.model
+ def _resolve_partner(self, payload):
+ partner_id = payload.get('partner_id')
+ if partner_id:
+ return partner_id
+ partner_vals = payload.get('partner_vals')
+ if not partner_vals:
+ return False
+ partner = self.env['res.partner'].sudo().create(partner_vals)
+ return partner.id
+
+ # ------------------------------------------------------------------
+ # CORE CREATION
+ # ------------------------------------------------------------------
+ @api.model
+ def _create_single_repair(self, partner_id, intake_user, session_ref, source, item):
+ Repair = self.env['repair.order']
+ product_id = item.get('product_id')
+
+ vals = {
+ 'partner_id': partner_id,
+ 'user_id': intake_user.id,
+ 'x_fc_intake_user_id': intake_user.id,
+ 'x_fc_intake_session_id': session_ref,
+ 'x_fc_intake_source': source,
+ 'x_fc_repair_category_id': item.get('repair_category_id') or False,
+ 'x_fc_intake_template_id': item.get('intake_template_id') or False,
+ 'x_fc_third_party_equipment': bool(item.get('third_party')),
+ 'x_fc_urgency': item.get('urgency') or 'normal',
+ 'x_fc_issue_category': item.get('issue_category') or False,
+ 'internal_notes': self._wrap_internal_notes(item),
+ }
+ if product_id:
+ vals['product_id'] = product_id
+ if item.get('lot_id'):
+ vals['lot_id'] = item['lot_id']
+ if item.get('schedule_date'):
+ vals['schedule_date'] = item['schedule_date']
+
+ repair = Repair.create(vals)
+
+ # Determine warranty AFTER creation (needs product on record).
+ if not repair.x_fc_third_party_equipment:
+ self._auto_link_original_sale_order(repair)
+ if repair._fc_compute_warranty_status():
+ repair.under_warranty = True
+
+ # Persist intake answers.
+ self._create_answers(repair, item.get('answers') or [])
+
+ # Attach photos.
+ photo_ids = item.get('photo_attachment_ids') or []
+ if photo_ids:
+ attachments = self.env['ir.attachment'].sudo().browse(photo_ids).exists()
+ attachments.write({
+ 'res_model': 'repair.order',
+ 'res_id': repair.id,
+ })
+ repair.write({'x_fc_photo_ids': [(6, 0, attachments.ids)]})
+
+ # Activities.
+ self._schedule_activities(repair)
+
+ # Optional dispatch draft task (urgent / safety).
+ if repair.x_fc_urgency in ('urgent', 'safety'):
+ self._create_dispatch_task(repair)
+
+ # Emails (client + office).
+ self._send_intake_emails(repair)
+
+ # Audit message in chatter.
+ repair.message_post(
+ body=_(
+ 'Service call submitted via %(source)s by %(user)s. '
+ 'Session reference: %(ref)s.',
+ source=dict(repair._fields['x_fc_intake_source'].selection).get(source),
+ user=intake_user.name,
+ ref=session_ref,
+ ),
+ )
+
+ return repair
+
+ @api.model
+ def _wrap_internal_notes(self, item):
+ notes = item.get('internal_notes') or ''
+ summary = item.get('issue_summary') or ''
+ if not (notes or summary):
+ return False
+ parts = []
+ if summary:
+ parts.append('Issue summary: %s
' % summary)
+ if notes:
+ parts.append('Notes: %s
' % notes)
+ return ''.join(parts)
+
+ # ------------------------------------------------------------------
+ # ORIGINAL SO AUTO-LINK
+ # ------------------------------------------------------------------
+ @api.model
+ def _auto_link_original_sale_order(self, repair):
+ if not repair.partner_id or not repair.product_id:
+ return
+ SaleOrder = self.env['sale.order'].sudo()
+ domain = [
+ ('partner_id', '=', repair.partner_id.id),
+ ('state', 'in', ('sale', 'done')),
+ ('order_line.product_id', '=', repair.product_id.id),
+ ]
+ if repair.lot_id:
+ domain.append(('order_line.lot_ids', 'in', repair.lot_id.id))
+ candidate = SaleOrder.search(domain, order='date_order desc', limit=1)
+ if candidate:
+ repair.x_fc_original_sale_order_id = candidate
+
+ # ------------------------------------------------------------------
+ # ANSWERS
+ # ------------------------------------------------------------------
+ @api.model
+ def _create_answers(self, repair, answers):
+ if not answers:
+ return
+ Answer = self.env['fusion.repair.intake.answer']
+ for ans in answers:
+ qid = ans.get('question_id')
+ if not qid:
+ continue
+ Answer.create({
+ 'repair_id': repair.id,
+ 'question_id': qid,
+ 'value_char': ans.get('value_char'),
+ 'value_text': ans.get('value_text'),
+ 'value_selection': ans.get('value_selection'),
+ 'value_boolean': bool(ans.get('value_boolean')),
+ 'value_integer': int(ans.get('value_integer') or 0),
+ 'value_date': ans.get('value_date') or False,
+ })
+
+ # ------------------------------------------------------------------
+ # ACTIVITIES
+ # ------------------------------------------------------------------
+ @api.model
+ def _schedule_activities(self, repair):
+ """Create the 4 intake activities described in the spec."""
+ try:
+ cs_callback_type = self.env.ref('fusion_repairs.mail_activity_type_cs_callback')
+ tech_dispatch_type = self.env.ref('fusion_repairs.mail_activity_type_tech_dispatch')
+ manager_review_type = self.env.ref('fusion_repairs.mail_activity_type_manager_review')
+ except ValueError:
+ _logger.warning('Repair activity types missing - skipping')
+ return
+
+ # CS callback - always, intake user
+ repair.activity_schedule(
+ activity_type_id=cs_callback_type.id,
+ summary=_('Call client back if any intake info was missing'),
+ user_id=repair.x_fc_intake_user_id.id or self.env.uid,
+ )
+
+ # Tech dispatch - assigned to responsible user, urgency-adjusted deadline
+ deadline_days = {'safety': 0, 'urgent': 1, 'normal': 2}.get(repair.x_fc_urgency, 2)
+ repair.activity_schedule(
+ activity_type_id=tech_dispatch_type.id,
+ summary=_('Assign a technician (urgency: %s)', repair.x_fc_urgency),
+ user_id=repair.user_id.id or self.env.uid,
+ date_deadline=fields.Date.context_today(self) + timedelta(days=deadline_days),
+ )
+
+ # Manager review - only for third-party equipment
+ if repair.x_fc_third_party_equipment:
+ manager_group = self.env.ref(
+ 'fusion_repairs.group_fusion_repairs_manager',
+ raise_if_not_found=False,
+ )
+ manager_user = self.env.user
+ if manager_group:
+ # res.groups has no .users field in Odoo 19;
+ # query via res.users.all_group_ids (Odoo 19 renamed groups_id).
+ candidate = self.env['res.users'].sudo().search(
+ [('all_group_ids', 'in', manager_group.ids), ('active', '=', True)],
+ limit=1,
+ )
+ if candidate:
+ manager_user = candidate
+ repair.activity_schedule(
+ activity_type_id=manager_review_type.id,
+ summary=_('Third-party equipment - manager awareness'),
+ user_id=manager_user.id,
+ )
+
+ # ------------------------------------------------------------------
+ # DISPATCH TASK
+ # ------------------------------------------------------------------
+ @api.model
+ def _create_dispatch_task(self, repair):
+ """Create a draft fusion.technician.task for urgent / safety repairs.
+
+ Phase 1 simple approach: no date/technician assigned, dispatcher confirms.
+ """
+ Task = self.env['fusion.technician.task'].sudo()
+ try:
+ vals = {
+ 'partner_id': repair.partner_id.id,
+ 'task_type': 'repair',
+ 'status': 'pending',
+ 'scheduled_date': fields.Date.context_today(self),
+ 'duration_hours': repair.x_fc_estimated_duration or 1.0,
+ 'x_fc_repair_order_id': repair.id,
+ 'description': repair.internal_notes or repair.name,
+ }
+ # technician_id is required on fusion.technician.task; we fall back to
+ # the intake user. Dispatcher will reassign.
+ vals['technician_id'] = (
+ repair.user_id.id if repair.user_id and repair.user_id.x_fc_is_field_staff
+ else self.env.uid
+ )
+ Task.create(vals)
+ except Exception as e:
+ _logger.warning('Failed to auto-create dispatch task for repair %s: %s',
+ repair.name, e)
+
+ # ------------------------------------------------------------------
+ # EMAILS
+ # ------------------------------------------------------------------
+ @api.model
+ def _send_intake_emails(self, repair):
+ if not self._notifications_enabled():
+ return
+ # Client confirmation
+ if repair.partner_id and repair.partner_id.email:
+ try:
+ self.env.ref('fusion_repairs.email_template_intake_received_client') \
+ .send_mail(repair.id, force_send=False)
+ except Exception as e:
+ _logger.warning('Failed to send client intake email for %s: %s',
+ repair.name, e)
+
+ # Office notification
+ office_emails = self._office_emails(repair.company_id)
+ if office_emails:
+ try:
+ tpl = self.env.ref('fusion_repairs.email_template_intake_received_office')
+ tpl.with_context(default_email_to=','.join(office_emails)) \
+ .send_mail(repair.id, force_send=False, email_values={
+ 'email_to': ','.join(office_emails),
+ })
+ except Exception as e:
+ _logger.warning('Failed to send office intake email for %s: %s',
+ repair.name, e)
+
+ @api.model
+ def _notifications_enabled(self):
+ ICP = self.env['ir.config_parameter'].sudo()
+ return ICP.get_param('fusion_repairs.enable_email_notifications', 'True') == 'True'
+
+ @api.model
+ def _office_emails(self, company):
+ # Reuse the office notification recipients defined by fusion_claims.
+ partners = company.sudo()
+ recipients = getattr(partners, 'x_fc_office_notification_ids', False)
+ if recipients:
+ return [p.email for p in recipients if p.email]
+ return []
diff --git a/fusion_repairs/models/intake_template.py b/fusion_repairs/models/intake_template.py
new file mode 100644
index 00000000..ba6a0416
--- /dev/null
+++ b/fusion_repairs/models/intake_template.py
@@ -0,0 +1,74 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from odoo import api, fields, models
+
+
+class FusionRepairIntakeTemplate(models.Model):
+ """A reusable set of intake questions per medical equipment category.
+
+ Each template contains an ordered list of questions; the intake wizard
+ (and sales-rep / client portals) render these dynamically with
+ conditional show/hide based on prior answers.
+ """
+
+ _name = 'fusion.repair.intake.template'
+ _description = 'Repair Intake Question Template'
+ _order = 'sequence, name'
+
+ name = fields.Char(string='Template Name', required=True, translate=True)
+ code = fields.Char(
+ string='Code',
+ help='Optional stable identifier for referencing this template from code/data.',
+ )
+ sequence = fields.Integer(string='Sequence', default=10)
+ active = fields.Boolean(default=True)
+ is_default = fields.Boolean(
+ string='Default Fallback',
+ help='Used when no template is explicitly configured for the selected category. '
+ 'Exactly one template should be flagged as default per company.',
+ )
+ description = fields.Html(string='Description', translate=True)
+
+ product_category_ids = fields.Many2many(
+ 'fusion.repair.product.category',
+ 'fusion_repair_intake_template_category_rel',
+ 'template_id',
+ 'category_id',
+ string='Applies to Categories',
+ help='Categories that automatically select this template during intake.',
+ )
+
+ question_ids = fields.One2many(
+ 'fusion.repair.intake.question',
+ 'template_id',
+ string='Questions',
+ copy=True,
+ )
+ question_count = fields.Integer(
+ compute='_compute_question_count',
+ string='Question Count',
+ )
+
+ company_id = fields.Many2one(
+ 'res.company',
+ string='Company',
+ default=lambda self: self.env.company,
+ )
+
+ @api.depends('question_ids')
+ def _compute_question_count(self):
+ for tpl in self:
+ tpl.question_count = len(tpl.question_ids)
+
+ def action_view_questions(self):
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': self.name,
+ 'res_model': 'fusion.repair.intake.question',
+ 'view_mode': 'list,form',
+ 'domain': [('template_id', '=', self.id)],
+ 'context': {'default_template_id': self.id},
+ }
diff --git a/fusion_repairs/models/product_template.py b/fusion_repairs/models/product_template.py
new file mode 100644
index 00000000..ea6224b0
--- /dev/null
+++ b/fusion_repairs/models/product_template.py
@@ -0,0 +1,34 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from odoo import fields, models
+
+
+class ProductTemplate(models.Model):
+ _inherit = 'product.template'
+
+ x_fc_repair_category_id = fields.Many2one(
+ 'fusion.repair.product.category',
+ string='Repair Category',
+ help='Medical equipment category - drives intake template selection and '
+ 'technician skills filter for repairs of this product.',
+ )
+ x_fc_warranty_months = fields.Integer(
+ string='Warranty (Months)',
+ default=12,
+ help='Default warranty period for new units of this product. Used to auto-detect '
+ 'warranty status on repair intake (delivery date + warranty months >= today).',
+ )
+ x_fc_maintenance_interval_months = fields.Integer(
+ string='Maintenance Interval (Months)',
+ default=0,
+ help='If > 0, delivering a unit of this product auto-creates a maintenance contract '
+ 'with this recurring interval. Phase 3 feature.',
+ )
+ x_fc_intake_template_id = fields.Many2one(
+ 'fusion.repair.intake.template',
+ string='Intake Template Override',
+ help='Optional override of the intake template normally chosen from the '
+ 'repair category. Leave empty to use category default.',
+ )
diff --git a/fusion_repairs/models/repair_order.py b/fusion_repairs/models/repair_order.py
new file mode 100644
index 00000000..91de2022
--- /dev/null
+++ b/fusion_repairs/models/repair_order.py
@@ -0,0 +1,281 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from datetime import timedelta
+
+from odoo import api, fields, models, _
+
+
+INTAKE_SOURCES = [
+ ('backend_wizard', 'Backend Wizard (CS)'),
+ ('sales_rep_portal', 'Sales Rep Portal'),
+ ('client_portal', 'Client Self-Service'),
+ ('manual', 'Manual / Other'),
+]
+
+URGENCY_LEVELS = [
+ ('normal', 'Normal'),
+ ('urgent', 'Urgent'),
+ ('safety', 'Safety Issue'),
+]
+
+
+class RepairOrder(models.Model):
+ """Extend Odoo Repairs with intake context, dispatch link, warranty
+ determination, and pricing variance tracking for Fusion Repairs."""
+
+ _inherit = 'repair.order'
+
+ # ------------------------------------------------------------------
+ # INTAKE METADATA
+ # ------------------------------------------------------------------
+ x_fc_intake_source = fields.Selection(
+ INTAKE_SOURCES,
+ string='Intake Source',
+ default='manual',
+ tracking=True,
+ help='Which intake surface created this repair (backend CS wizard, '
+ 'sales rep portal, public client portal, or manual entry).',
+ )
+ x_fc_intake_user_id = fields.Many2one(
+ 'res.users',
+ string='Intake By',
+ tracking=True,
+ index=True,
+ help='User who took the call / submitted the intake. For client portal, '
+ 'this is the OdooBot or admin user.',
+ )
+ x_fc_intake_session_id = fields.Char(
+ string='Intake Session',
+ index=True,
+ copy=False,
+ help='Reference shared by multiple repair orders created during the same call.',
+ )
+ x_fc_intake_template_id = fields.Many2one(
+ 'fusion.repair.intake.template',
+ string='Intake Template',
+ help='Question template used during intake.',
+ )
+ x_fc_intake_answer_ids = fields.One2many(
+ 'fusion.repair.intake.answer',
+ 'repair_id',
+ string='Intake Answers',
+ )
+ x_fc_intake_answer_count = fields.Integer(
+ compute='_compute_intake_answer_count',
+ )
+
+ # ------------------------------------------------------------------
+ # EQUIPMENT / WARRANTY
+ # ------------------------------------------------------------------
+ x_fc_repair_category_id = fields.Many2one(
+ 'fusion.repair.product.category',
+ string='Equipment Category',
+ tracking=True,
+ index=True,
+ help='Medical equipment category - drives intake template and tech skills filter.',
+ )
+ x_fc_third_party_equipment = fields.Boolean(
+ string='Third-Party Equipment',
+ tracking=True,
+ help='True if the equipment was not sold by us. Forces under_warranty=False '
+ 'and typically triggers a service call-out fee.',
+ )
+ x_fc_original_sale_order_id = fields.Many2one(
+ 'sale.order',
+ string='Original Purchase SO',
+ tracking=True,
+ index=True,
+ help='Sale order through which the customer originally purchased this unit. '
+ 'Auto-matched on intake by partner + lot/serial.',
+ )
+ x_fc_warranty_override_reason = fields.Char(
+ string='Warranty Override Reason',
+ help='Required when CS overrides the auto-detected warranty status.',
+ )
+
+ # ------------------------------------------------------------------
+ # TRIAGE / URGENCY
+ # ------------------------------------------------------------------
+ x_fc_urgency = fields.Selection(
+ URGENCY_LEVELS,
+ string='Urgency',
+ default='normal',
+ tracking=True,
+ index=True,
+ )
+ x_fc_issue_category = fields.Char(
+ string='Issue Category',
+ help='Symptom classification (e.g. "battery", "motor", "remote"). Used by '
+ 'service catalogue matcher and AI prompt context.',
+ )
+
+ # ------------------------------------------------------------------
+ # PHOTOS
+ # ------------------------------------------------------------------
+ x_fc_photo_ids = fields.Many2many(
+ 'ir.attachment',
+ 'fusion_repair_order_photo_rel',
+ 'repair_id',
+ 'attachment_id',
+ string='Intake Photos / Videos',
+ help='Photos and videos uploaded during intake.',
+ )
+ x_fc_photo_count = fields.Integer(
+ compute='_compute_photo_count',
+ )
+
+ # ------------------------------------------------------------------
+ # PRICING (estimate vs actual - Phase 2 reconciliation)
+ # ------------------------------------------------------------------
+ x_fc_estimated_duration = fields.Float(
+ string='Estimated Duration (h)',
+ help='Estimated visit duration from service catalogue, used to size technician slot.',
+ )
+ x_fc_estimated_cost = fields.Monetary(
+ string='Estimated Cost',
+ currency_field='company_currency_id',
+ help='Estimated total from catalogue match at intake (pre-visit).',
+ )
+ x_fc_actual_cost = fields.Monetary(
+ string='Actual Cost',
+ currency_field='company_currency_id',
+ help='Actual total recorded from the visit report (post-visit).',
+ )
+ x_fc_cost_variance_pct = fields.Float(
+ string='Cost Variance %',
+ compute='_compute_cost_variance',
+ store=True,
+ help='(actual - estimated) / estimated * 100',
+ )
+ x_fc_requires_requote = fields.Boolean(
+ string='Requires Re-Quote',
+ help='Set when actual cost exceeds estimate beyond the configured threshold; '
+ 'blocks automatic invoicing until manager approves or client re-confirms.',
+ )
+
+ company_currency_id = fields.Many2one(
+ 'res.currency',
+ related='company_id.currency_id',
+ readonly=True,
+ )
+
+ # ------------------------------------------------------------------
+ # FIELD SERVICE LINK
+ # ------------------------------------------------------------------
+ x_fc_technician_task_ids = fields.One2many(
+ 'fusion.technician.task',
+ 'x_fc_repair_order_id',
+ string='Technician Tasks',
+ )
+ x_fc_technician_task_count = fields.Integer(
+ compute='_compute_technician_task_count',
+ )
+
+ # ------------------------------------------------------------------
+ # AI SUMMARY (Phase 2)
+ # ------------------------------------------------------------------
+ x_fc_ai_summary = fields.Text(
+ string='AI Pre-Visit Brief',
+ help='AI-generated short brief for the technician based on intake answers. '
+ 'Optional - never blocks intake submit.',
+ )
+
+ # ------------------------------------------------------------------
+ # COMPUTES
+ # ------------------------------------------------------------------
+ @api.depends('x_fc_intake_answer_ids')
+ def _compute_intake_answer_count(self):
+ for repair in self:
+ repair.x_fc_intake_answer_count = len(repair.x_fc_intake_answer_ids)
+
+ @api.depends('x_fc_photo_ids')
+ def _compute_photo_count(self):
+ for repair in self:
+ repair.x_fc_photo_count = len(repair.x_fc_photo_ids)
+
+ @api.depends('x_fc_technician_task_ids')
+ def _compute_technician_task_count(self):
+ for repair in self:
+ repair.x_fc_technician_task_count = len(repair.x_fc_technician_task_ids)
+
+ @api.depends('x_fc_estimated_cost', 'x_fc_actual_cost')
+ def _compute_cost_variance(self):
+ for repair in self:
+ if repair.x_fc_estimated_cost:
+ repair.x_fc_cost_variance_pct = (
+ (repair.x_fc_actual_cost - repair.x_fc_estimated_cost)
+ / repair.x_fc_estimated_cost * 100
+ )
+ else:
+ repair.x_fc_cost_variance_pct = 0.0
+
+ # ------------------------------------------------------------------
+ # WARRANTY DETERMINATION
+ # ------------------------------------------------------------------
+ def _fc_compute_warranty_status(self):
+ """Auto-detect warranty: not third-party AND within warranty window."""
+ self.ensure_one()
+ if self.x_fc_third_party_equipment:
+ return False
+ if not self.x_fc_original_sale_order_id:
+ return False
+ original = self.x_fc_original_sale_order_id
+ delivery_date = original.commitment_date or original.date_order
+ if not delivery_date:
+ return False
+ warranty_months = (
+ self.product_id.product_tmpl_id.x_fc_warranty_months
+ if self.product_id else 0
+ )
+ if not warranty_months:
+ return False
+ # Datetime + months: use simple 30-day approximation per month for now.
+ cutoff = fields.Datetime.from_string(str(delivery_date)) + timedelta(days=warranty_months * 30)
+ return fields.Datetime.now() <= cutoff
+
+ # ------------------------------------------------------------------
+ # SMART BUTTONS
+ # ------------------------------------------------------------------
+ def action_view_intake_answers(self):
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Intake Answers'),
+ 'res_model': 'fusion.repair.intake.answer',
+ 'view_mode': 'list,form',
+ 'domain': [('repair_id', '=', self.id)],
+ 'context': {'default_repair_id': self.id},
+ }
+
+ def action_view_technician_tasks(self):
+ self.ensure_one()
+ if len(self.x_fc_technician_task_ids) == 1:
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': self.x_fc_technician_task_ids.name,
+ 'res_model': 'fusion.technician.task',
+ 'view_mode': 'form',
+ 'res_id': self.x_fc_technician_task_ids.id,
+ }
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Technician Tasks'),
+ 'res_model': 'fusion.technician.task',
+ 'view_mode': 'list,form',
+ 'domain': [('x_fc_repair_order_id', '=', self.id)],
+ 'context': {'default_x_fc_repair_order_id': self.id},
+ }
+
+ def action_view_original_sale_order(self):
+ self.ensure_one()
+ if not self.x_fc_original_sale_order_id:
+ return False
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': self.x_fc_original_sale_order_id.name,
+ 'res_model': 'sale.order',
+ 'view_mode': 'form',
+ 'res_id': self.x_fc_original_sale_order_id.id,
+ }
diff --git a/fusion_repairs/models/repair_product_category.py b/fusion_repairs/models/repair_product_category.py
new file mode 100644
index 00000000..d360f4d4
--- /dev/null
+++ b/fusion_repairs/models/repair_product_category.py
@@ -0,0 +1,49 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from odoo import api, fields, models
+
+
+class FusionRepairProductCategory(models.Model):
+ """Medical equipment categories used to route repair intake and match skills."""
+
+ _name = 'fusion.repair.product.category'
+ _description = 'Repair Product Category'
+ _order = 'sequence, name'
+
+ name = fields.Char(string='Name', required=True, translate=True)
+ code = fields.Char(
+ string='Code',
+ required=True,
+ help='Stable identifier used by code (e.g. "stairlift"). Lowercase, no spaces.',
+ )
+ sequence = fields.Integer(string='Sequence', default=10)
+ icon = fields.Char(
+ string='Icon',
+ default='fa-wrench',
+ help='Font Awesome icon class shown next to the category in pickers.',
+ )
+ description = fields.Text(string='Description', translate=True)
+ active = fields.Boolean(default=True)
+ safety_critical = fields.Boolean(
+ string='Safety-Critical',
+ help='Categories where motor / mechanical issues warrant immediate escalation '
+ '(stairlifts, porch lifts). Used by the AI self-check engine to skip '
+ 'self-help and force escalation when safety symptoms appear.',
+ )
+
+ intake_template_id = fields.Many2one(
+ 'fusion.repair.intake.template',
+ string='Default Intake Template',
+ help='Default intake question set shown when this category is selected.',
+ )
+
+ _sql_constraints = [
+ ('code_unique', 'unique(code)', 'Category code must be unique.'),
+ ]
+
+ @api.depends('name', 'code')
+ def _compute_display_name(self):
+ for cat in self:
+ cat.display_name = cat.name or cat.code or ''
diff --git a/fusion_repairs/models/res_config_settings.py b/fusion_repairs/models/res_config_settings.py
new file mode 100644
index 00000000..7bbf4f14
--- /dev/null
+++ b/fusion_repairs/models/res_config_settings.py
@@ -0,0 +1,64 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from odoo import fields, models
+
+
+class ResConfigSettings(models.TransientModel):
+ _inherit = 'res.config.settings'
+
+ # NOTE: res.config.settings only supports boolean/integer/float/char/
+ # selection/many2one/datetime types per project Odoo 19 conventions.
+
+ fc_repairs_enable_email_notifications = fields.Boolean(
+ string='Enable Repair Email Notifications',
+ config_parameter='fusion_repairs.enable_email_notifications',
+ default=True,
+ help='Master toggle for automated repair-related emails to clients and office.',
+ )
+
+ fc_repairs_outstanding_balance_threshold = fields.Float(
+ string='Outstanding Balance Warning ($)',
+ config_parameter='fusion_repairs.outstanding_balance_threshold',
+ default=100.0,
+ help='Show a warning banner during intake if the client has open invoices '
+ 'totalling more than this amount.',
+ )
+
+ fc_repairs_duplicate_call_window_days = fields.Integer(
+ string='Duplicate Call Window (Days)',
+ config_parameter='fusion_repairs.duplicate_call_window_days',
+ default=14,
+ help='When the intake wizard finds an open repair from this many days back on '
+ 'the same phone number, it offers "add note to existing repair instead".',
+ )
+
+ fc_repairs_variance_threshold_pct = fields.Integer(
+ string='Pricing Variance Threshold (%)',
+ config_parameter='fusion_repairs.variance_threshold_pct',
+ default=20,
+ help='If actual cost exceeds estimated cost by more than this percentage, '
+ 'invoicing is blocked until a manager reviews / a re-quote email is sent.',
+ )
+
+ fc_repairs_variance_threshold_amount = fields.Float(
+ string='Pricing Variance Threshold ($)',
+ config_parameter='fusion_repairs.variance_threshold_amount',
+ default=100.0,
+ help='Absolute variance amount that also triggers re-quote (whichever hits first).',
+ )
+
+ fc_repairs_client_portal_url = fields.Char(
+ string='Public Client Portal URL Path',
+ config_parameter='fusion_repairs.client_portal_url',
+ default='/repair',
+ help='URL path mentioned in voicemail greetings and printed on QR stickers. '
+ 'Phase 1 ships with the form at this path.',
+ )
+
+ fc_repairs_client_portal_rate_limit_per_hour = fields.Integer(
+ string='Client Portal Rate Limit (per hour, per IP)',
+ config_parameter='fusion_repairs.client_portal_rate_limit_per_hour',
+ default=10,
+ )
diff --git a/fusion_repairs/models/res_partner.py b/fusion_repairs/models/res_partner.py
new file mode 100644
index 00000000..83d1a4d6
--- /dev/null
+++ b/fusion_repairs/models/res_partner.py
@@ -0,0 +1,64 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from odoo import fields, models
+
+
+PREFERRED_WINDOW = [
+ ('morning', 'Morning (9 AM - 12 PM)'),
+ ('afternoon', 'Afternoon (12 PM - 5 PM)'),
+ ('evening', 'Evening (after 5 PM)'),
+ ('any', 'Any Time'),
+]
+
+
+class ResPartner(models.Model):
+ _inherit = 'res.partner'
+
+ # ------------------------------------------------------------------
+ # SERVICE PREFERENCES (P1 - shown in client history sidebar)
+ # ------------------------------------------------------------------
+ x_fc_preferred_tech_id = fields.Many2one(
+ 'res.users',
+ string='Preferred Technician',
+ domain="[('x_fc_is_field_staff', '=', True)]",
+ help='If set, this technician is suggested first on dispatch.',
+ )
+ x_fc_preferred_window = fields.Selection(
+ PREFERRED_WINDOW,
+ string='Preferred Visit Window',
+ default='any',
+ )
+ x_fc_access_notes = fields.Text(
+ string='Access Notes',
+ help='Free-form notes for technicians arriving at this address: '
+ 'gate code, dog warning, where to park, side door entry, etc.',
+ )
+
+ # ------------------------------------------------------------------
+ # CLIENT HISTORY SIDEBAR (C2 - pulled lazily on demand)
+ # ------------------------------------------------------------------
+ x_fc_repair_count = fields.Integer(
+ compute='_compute_x_fc_repair_count',
+ string='Repairs Count',
+ compute_sudo=True,
+ help='Lightweight count of repair orders for this partner. Heavier history '
+ 'data is fetched lazily by the wizard / portal sidebar via RPC.',
+ )
+
+ def _compute_x_fc_repair_count(self):
+ # Non-stored compute - safe to omit @api.depends.
+ if not self.ids:
+ for partner in self:
+ partner.x_fc_repair_count = 0
+ return
+ Repair = self.env['repair.order'].sudo()
+ data = Repair._read_group(
+ [('partner_id', 'in', self.ids)],
+ ['partner_id'],
+ ['__count'],
+ )
+ counts = {row[0].id: row[1] for row in data}
+ for partner in self:
+ partner.x_fc_repair_count = counts.get(partner.id, 0)
diff --git a/fusion_repairs/models/res_users.py b/fusion_repairs/models/res_users.py
new file mode 100644
index 00000000..59297420
--- /dev/null
+++ b/fusion_repairs/models/res_users.py
@@ -0,0 +1,57 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from odoo import fields, models
+
+
+class ResUsers(models.Model):
+ """Extends res.users with fusion_repairs specific fields.
+
+ Reuses the existing x_fc_is_field_staff Boolean from fusion_tasks
+ as the technician flag - do NOT recreate that field here.
+
+ All technician selectors in fusion_repairs use the same domain
+ [('x_fc_is_field_staff', '=', True)] for consistency with fusion_tasks.
+ """
+
+ _inherit = 'res.users'
+
+ x_fc_repair_skills = fields.Many2many(
+ 'fusion.repair.product.category',
+ 'fusion_repair_user_skill_rel',
+ 'user_id',
+ 'category_id',
+ string='Repair Skills',
+ help='Medical equipment categories this user is qualified to service. '
+ 'Used by dispatcher to filter candidate technicians for a repair.',
+ )
+
+ x_fc_tech_cost_rate = fields.Monetary(
+ string='Tech Cost Rate (/h)',
+ currency_field='company_currency_id',
+ help='Internal cost per hour - used for repair margin calculation (Phase 4).',
+ )
+
+ # On-call rotation - Phase 2 (simple priority-int approach).
+ x_fc_on_call = fields.Boolean(
+ string='On-Call Eligible',
+ help='Tick if this user is eligible for the weekend / after-hours on-call rotation.',
+ )
+ x_fc_on_call_priority = fields.Integer(
+ string='On-Call Priority',
+ default=99,
+ help='Lower number = paged first. The escalation cron picks the lowest priority '
+ 'available user when a safety repair is submitted after hours.',
+ )
+ x_fc_on_call_phone = fields.Char(
+ string='On-Call Phone Override',
+ help='Phone number to use for on-call SMS / calls. If empty, falls back to '
+ 'the user partner phone.',
+ )
+
+ company_currency_id = fields.Many2one(
+ 'res.currency',
+ related='company_id.currency_id',
+ readonly=True,
+ )
diff --git a/fusion_repairs/models/technician_task.py b/fusion_repairs/models/technician_task.py
new file mode 100644
index 00000000..f8963c83
--- /dev/null
+++ b/fusion_repairs/models/technician_task.py
@@ -0,0 +1,42 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from odoo import fields, models
+
+
+class FusionTechnicianTaskRepairs(models.Model):
+ """Adds the back-link from fusion.technician.task to repair.order so
+ repairs and tasks share one timeline.
+ """
+
+ _inherit = 'fusion.technician.task'
+
+ x_fc_repair_order_id = fields.Many2one(
+ 'repair.order',
+ string='Repair Order',
+ ondelete='set null',
+ index=True,
+ tracking=True,
+ help='Repair order this task fulfils. Set automatically when the intake '
+ 'wizard auto-creates a draft task for urgent / safety calls.',
+ )
+
+ x_fc_repair_intake_session_id = fields.Char(
+ related='x_fc_repair_order_id.x_fc_intake_session_id',
+ string='Intake Session',
+ store=True,
+ index=True,
+ )
+
+ def action_view_repair_order(self):
+ self.ensure_one()
+ if not self.x_fc_repair_order_id:
+ return False
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': self.x_fc_repair_order_id.name,
+ 'res_model': 'repair.order',
+ 'view_mode': 'form',
+ 'res_id': self.x_fc_repair_order_id.id,
+ }
diff --git a/fusion_repairs/security/ir.model.access.csv b/fusion_repairs/security/ir.model.access.csv
new file mode 100644
index 00000000..1574ea4d
--- /dev/null
+++ b/fusion_repairs/security/ir.model.access.csv
@@ -0,0 +1,12 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_repair_product_category_user,Repair Category User Read,model_fusion_repair_product_category,group_fusion_repairs_user,1,0,0,0
+access_repair_product_category_manager,Repair Category Manager Full,model_fusion_repair_product_category,group_fusion_repairs_manager,1,1,1,1
+access_repair_intake_template_user,Intake Template User Read,model_fusion_repair_intake_template,group_fusion_repairs_user,1,0,0,0
+access_repair_intake_template_manager,Intake Template Manager Full,model_fusion_repair_intake_template,group_fusion_repairs_manager,1,1,1,1
+access_repair_intake_question_user,Intake Question User Read,model_fusion_repair_intake_question,group_fusion_repairs_user,1,0,0,0
+access_repair_intake_question_manager,Intake Question Manager Full,model_fusion_repair_intake_question,group_fusion_repairs_manager,1,1,1,1
+access_repair_intake_answer_user,Intake Answer User Full,model_fusion_repair_intake_answer,group_fusion_repairs_user,1,1,1,0
+access_repair_intake_answer_manager,Intake Answer Manager Full,model_fusion_repair_intake_answer,group_fusion_repairs_manager,1,1,1,1
+access_repair_intake_answer_tech_portal,Intake Answer Technician Read,model_fusion_repair_intake_answer,fusion_tasks.group_field_technician,1,0,0,0
+access_repair_intake_wizard_user,Intake Wizard User Full,model_fusion_repair_intake_wizard,group_fusion_repairs_user,1,1,1,1
+access_repair_intake_wizard_equipment_user,Intake Wizard Equipment User Full,model_fusion_repair_intake_wizard_equipment,group_fusion_repairs_user,1,1,1,1
diff --git a/fusion_repairs/security/security.xml b/fusion_repairs/security/security.xml
new file mode 100644
index 00000000..79401dba
--- /dev/null
+++ b/fusion_repairs/security/security.xml
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+ Fusion Repairs
+ 47
+
+
+
+
+
+
+ Fusion Repairs
+ 47
+
+
+
+
+
+
+
+ Repairs: User (CS Intake)
+
+
+ CS / front-office staff who take repair intake calls and view repairs.
+
+
+
+ Repairs: Dispatcher
+
+
+ Assigns technicians to repairs, reschedules visits, manages parts pre-pull picklists.
+
+
+
+ Repairs: Manager
+
+
+ Configures intake templates, pricing, maintenance contracts, on-call rotation, variance overrides.
+
+
+
+
+
+
+
+
+ Repair Order: Multi-Company
+
+ ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]
+
+
+
+
+
+ Repair Order: Technician sees own repairs
+
+ [('x_fc_technician_task_ids.all_technician_ids', 'in', [user.id])]
+
+
+
+
+
+
+
+
+
+ Repair Intake Answer: Multi-Company
+
+ ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]
+
+
+
+
diff --git a/fusion_repairs/views/intake_template_views.xml b/fusion_repairs/views/intake_template_views.xml
new file mode 100644
index 00000000..e7e57340
--- /dev/null
+++ b/fusion_repairs/views/intake_template_views.xml
@@ -0,0 +1,95 @@
+
+
+
+
+
+ fusion.repair.intake.template.list
+ fusion.repair.intake.template
+
+
+
+
+
+
+
+
+
+
+
+
+
+ fusion.repair.intake.template.form
+ fusion.repair.intake.template
+
+
+
+
+
+
+ Intake Templates
+ fusion.repair.intake.template
+ list,form
+
+
+
diff --git a/fusion_repairs/views/menus.xml b/fusion_repairs/views/menus.xml
new file mode 100644
index 00000000..8820056a
--- /dev/null
+++ b/fusion_repairs/views/menus.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fusion_repairs/views/repair_order_views.xml b/fusion_repairs/views/repair_order_views.xml
new file mode 100644
index 00000000..9544f9e0
--- /dev/null
+++ b/fusion_repairs/views/repair_order_views.xml
@@ -0,0 +1,143 @@
+
+
+
+
+
+
+
+ repair.order.form.inherit.fusion_repairs
+ repair.order
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ repair.order.kanban.inherit.fusion_repairs
+ repair.order
+
+
+
+
+
+
+
+
+
+
+
+
+
+ repair.order.list.inherit.fusion_repairs
+ repair.order
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ New Service Call
+ fusion.repair.intake.wizard
+ form
+ new
+
+
+
diff --git a/fusion_repairs/views/repair_product_category_views.xml b/fusion_repairs/views/repair_product_category_views.xml
new file mode 100644
index 00000000..0ca1a2e7
--- /dev/null
+++ b/fusion_repairs/views/repair_product_category_views.xml
@@ -0,0 +1,55 @@
+
+
+
+
+ fusion.repair.product.category.list
+ fusion.repair.product.category
+
+
+
+
+
+
+
+
+
+
+
+
+
+ fusion.repair.product.category.form
+ fusion.repair.product.category
+
+
+
+
+
+
+ Equipment Categories
+ fusion.repair.product.category
+ list,form
+
+
+
diff --git a/fusion_repairs/views/res_config_settings_views.xml b/fusion_repairs/views/res_config_settings_views.xml
new file mode 100644
index 00000000..c86345e4
--- /dev/null
+++ b/fusion_repairs/views/res_config_settings_views.xml
@@ -0,0 +1,62 @@
+
+
+
+
+ res.config.settings.view.form.inherit.fusion_repairs
+ res.config.settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fusion_repairs/views/res_partner_views.xml b/fusion_repairs/views/res_partner_views.xml
new file mode 100644
index 00000000..5c160dee
--- /dev/null
+++ b/fusion_repairs/views/res_partner_views.xml
@@ -0,0 +1,29 @@
+
+
+
+
+ res.partner.form.inherit.fusion_repairs
+ res.partner
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fusion_repairs/views/res_users_views.xml b/fusion_repairs/views/res_users_views.xml
new file mode 100644
index 00000000..78758d91
--- /dev/null
+++ b/fusion_repairs/views/res_users_views.xml
@@ -0,0 +1,33 @@
+
+
+
+
+ res.users.form.inherit.fusion_repairs
+ res.users
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fusion_repairs/wizard/__init__.py b/fusion_repairs/wizard/__init__.py
new file mode 100644
index 00000000..3fe33326
--- /dev/null
+++ b/fusion_repairs/wizard/__init__.py
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from . import repair_intake_wizard
diff --git a/fusion_repairs/wizard/repair_intake_wizard.py b/fusion_repairs/wizard/repair_intake_wizard.py
new file mode 100644
index 00000000..f6769403
--- /dev/null
+++ b/fusion_repairs/wizard/repair_intake_wizard.py
@@ -0,0 +1,199 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+"""Backend intake wizard.
+
+A simple Phase 1 transient model that captures one-or-many equipment items
+per call, then delegates to fusion.repair.intake.service to create the
+repair.order(s). The shared service guarantees identical behaviour to the
+sales rep portal and the public client portal added in later phases.
+
+Multi-equipment per call is supported via the equipment_ids One2many.
+"""
+
+import logging
+
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError
+
+_logger = logging.getLogger(__name__)
+
+
+class RepairIntakeWizard(models.TransientModel):
+ _name = 'fusion.repair.intake.wizard'
+ _description = 'Repair Intake Wizard'
+
+ # ------------------------------------------------------------------
+ # CALLER / CLIENT
+ # ------------------------------------------------------------------
+ intake_user_id = fields.Many2one(
+ 'res.users',
+ string='Taken By',
+ default=lambda self: self.env.user,
+ required=True,
+ )
+ partner_id = fields.Many2one(
+ 'res.partner',
+ string='Client',
+ required=True,
+ help='Existing client. Use the create-and-edit dialog to add a new contact.',
+ )
+ partner_phone = fields.Char(
+ related='partner_id.phone',
+ string='Phone',
+ readonly=True,
+ )
+
+ # ------------------------------------------------------------------
+ # EQUIPMENT (one-or-many)
+ # ------------------------------------------------------------------
+ equipment_ids = fields.One2many(
+ 'fusion.repair.intake.wizard.equipment',
+ 'wizard_id',
+ string='Equipment Items',
+ required=True,
+ )
+
+ # ------------------------------------------------------------------
+ # SUBMIT
+ # ------------------------------------------------------------------
+ def action_submit(self):
+ self.ensure_one()
+ if not self.equipment_ids:
+ raise UserError(_('Please add at least one piece of equipment.'))
+
+ payload = {
+ 'partner_id': self.partner_id.id,
+ 'intake_user_id': self.intake_user_id.id,
+ 'equipment_items': [self._equipment_payload(eq) for eq in self.equipment_ids],
+ }
+
+ repairs = self.env['fusion.repair.intake.service'].create_repair_orders(
+ payload, source='backend_wizard',
+ )
+
+ if len(repairs) == 1:
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': repairs.name,
+ 'res_model': 'repair.order',
+ 'view_mode': 'form',
+ 'res_id': repairs.id,
+ }
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Service Calls Created (%(count)s)', count=len(repairs)),
+ 'res_model': 'repair.order',
+ 'view_mode': 'list,form',
+ 'domain': [('id', 'in', repairs.ids)],
+ }
+
+ def _equipment_payload(self, eq):
+ """Render an equipment record as a dict the intake service expects."""
+ return {
+ 'product_id': eq.product_id.id or False,
+ 'lot_id': eq.lot_id.id or False,
+ 'repair_category_id': eq.repair_category_id.id or False,
+ 'intake_template_id': eq.intake_template_id.id or False,
+ 'third_party': eq.third_party,
+ 'urgency': eq.urgency,
+ 'issue_summary': eq.issue_summary or '',
+ 'issue_category': eq.issue_category or '',
+ 'internal_notes': eq.internal_notes or '',
+ 'schedule_date': eq.scheduled_date or False,
+ 'photo_attachment_ids': eq.photo_ids.ids if eq.photo_ids else [],
+ 'answers': [], # Phase 1 wizard doesn't expose per-question answer rows yet
+ }
+
+
+class RepairIntakeWizardEquipment(models.TransientModel):
+ """A single piece of equipment captured in the wizard.
+
+ Multiple lines = multi-equipment intake (one repair.order per line).
+ """
+
+ _name = 'fusion.repair.intake.wizard.equipment'
+ _description = 'Repair Intake Wizard - Equipment Line'
+ _order = 'sequence, id'
+
+ wizard_id = fields.Many2one(
+ 'fusion.repair.intake.wizard',
+ string='Wizard',
+ required=True,
+ ondelete='cascade',
+ )
+ sequence = fields.Integer(default=10)
+
+ # Equipment identification
+ repair_category_id = fields.Many2one(
+ 'fusion.repair.product.category',
+ string='Category',
+ required=True,
+ )
+ product_id = fields.Many2one(
+ 'product.product',
+ string='Product',
+ help='Specific product if known. Leave blank for generic equipment.',
+ )
+ lot_id = fields.Many2one(
+ 'stock.lot',
+ string='Serial Number',
+ domain="[('product_id', '=', product_id)]",
+ help='Lot or serial number if known.',
+ )
+ third_party = fields.Boolean(
+ string='Not Purchased From Us',
+ help='Tick if this equipment was bought elsewhere - we still service it but '
+ 'warranty is not honoured and a service call-out fee applies.',
+ )
+
+ # Intake context
+ intake_template_id = fields.Many2one(
+ 'fusion.repair.intake.template',
+ string='Question Template',
+ help='Defaults to the template configured on the category if left blank.',
+ )
+
+ # Triage
+ urgency = fields.Selection(
+ [('normal', 'Normal'), ('urgent', 'Urgent'), ('safety', 'Safety Issue')],
+ string='Urgency',
+ default='normal',
+ required=True,
+ )
+ scheduled_date = fields.Datetime(
+ string='Preferred Date',
+ default=fields.Datetime.now,
+ )
+ issue_summary = fields.Char(
+ string='Issue Summary',
+ help='One-line summary of what is wrong (e.g. "stairlift stops halfway up").',
+ )
+ issue_category = fields.Char(
+ string='Symptom Category',
+ help='Optional symptom tag for catalogue matching (e.g. "battery", "motor").',
+ )
+ internal_notes = fields.Text(string='Internal Notes')
+
+ photo_ids = fields.Many2many(
+ 'ir.attachment',
+ 'fusion_repair_intake_wizard_eq_photo_rel',
+ 'eq_id',
+ 'attachment_id',
+ string='Photos / Videos',
+ )
+
+ @api.onchange('repair_category_id')
+ def _onchange_repair_category_id(self):
+ """Pre-fill the intake template from the category default."""
+ if self.repair_category_id and not self.intake_template_id:
+ self.intake_template_id = self.repair_category_id.intake_template_id
+
+ @api.onchange('product_id')
+ def _onchange_product_id(self):
+ """Pre-fill the category from the product if defined."""
+ if self.product_id and not self.repair_category_id:
+ cat = self.product_id.product_tmpl_id.x_fc_repair_category_id
+ if cat:
+ self.repair_category_id = cat
diff --git a/fusion_repairs/wizard/repair_intake_wizard_views.xml b/fusion_repairs/wizard/repair_intake_wizard_views.xml
new file mode 100644
index 00000000..65a21502
--- /dev/null
+++ b/fusion_repairs/wizard/repair_intake_wizard_views.xml
@@ -0,0 +1,69 @@
+
+
+
+
+ fusion.repair.intake.wizard.form
+ fusion.repair.intake.wizard
+
+
+
+
+
+