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-partyYes - equipment not sold by us
WarrantyUnder warranty
+
+
+
+ +
+ +
+
diff --git a/fusion_repairs/data/repair_product_category_data.xml b/fusion_repairs/data/repair_product_category_data.xml new file mode 100644 index 00000000..52d36e35 --- /dev/null +++ b/fusion_repairs/data/repair_product_category_data.xml @@ -0,0 +1,81 @@ + + + + + + + Hospital Bed + hospital_bed + 10 + fa-bed + Electric and manual hospital beds, semi-electric beds, low beds. + + + + Wheelchair (Manual) + wheelchair_manual + 20 + fa-wheelchair + Standard, transport, and tilt-in-space manual wheelchairs. + + + + Wheelchair (Power) + wheelchair_power + 30 + fa-wheelchair + Power wheelchairs, scooters, and powered mobility devices. + + + + Stairlift + stairlift + 40 + fa-arrows-v + Straight and curved indoor stairlifts. Annual safety inspection required in many jurisdictions. + + + + + Porch Lift + porch_lift + 50 + fa-arrow-up + Vertical platform lifts for porches, decks, and accessible building entrances. + + + + + Walker + walker + 60 + fa-male + Standard walkers, hemi-walkers, and folding walkers. + + + + Rollator + rollator + 70 + fa-male + Wheeled walkers with seats and brakes. + + + + Medical Mattress + mattress + 80 + fa-bed + Air mattresses, alternating pressure, low air loss, and pressure relief mattresses. + + + + Other Equipment + other + 100 + fa-question-circle + Any other medical equipment not in the standard categories. + + + + diff --git a/fusion_repairs/models/__init__.py b/fusion_repairs/models/__init__.py new file mode 100644 index 00000000..5b36b893 --- /dev/null +++ b/fusion_repairs/models/__init__.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from . import repair_product_category +from . import intake_template +from . import intake_question +from . import intake_answer +from . import product_template +from . import res_partner +from . import res_users +from . import res_config_settings +from . import technician_task +from . import repair_order +from . import intake_service diff --git a/fusion_repairs/models/intake_answer.py b/fusion_repairs/models/intake_answer.py new file mode 100644 index 00000000..0db6b64f --- /dev/null +++ b/fusion_repairs/models/intake_answer.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from odoo import api, fields, models + + +class FusionRepairIntakeAnswer(models.Model): + """An answer to a single intake question on a specific repair order. + + Persists raw answer values for audit + reporting + AI / catalogue matching. + """ + + _name = 'fusion.repair.intake.answer' + _description = 'Repair Intake Answer' + _order = 'repair_id, sequence, id' + + repair_id = fields.Many2one( + 'repair.order', + string='Repair Order', + required=True, + ondelete='cascade', + index=True, + ) + question_id = fields.Many2one( + 'fusion.repair.intake.question', + string='Question', + required=True, + ondelete='restrict', + ) + question_name = fields.Char( + related='question_id.name', + string='Question', + store=True, + ) + question_type = fields.Selection( + related='question_id.question_type', + store=True, + ) + sequence = fields.Integer( + related='question_id.sequence', + store=True, + ) + + # Typed value fields - one per supported type, plus a display string. + value_char = fields.Char(string='Text Answer') + value_text = fields.Text(string='Long Text Answer') + value_selection = fields.Char(string='Choice Answer') + value_boolean = fields.Boolean(string='Yes/No Answer') + value_integer = fields.Integer(string='Number Answer') + value_date = fields.Date(string='Date Answer') + + value_display = fields.Char( + string='Answer', + compute='_compute_value_display', + store=True, + ) + + company_id = fields.Many2one( + 'res.company', + related='repair_id.company_id', + store=True, + index=True, + ) + + @api.depends( + 'question_type', + 'value_char', 'value_text', 'value_selection', + 'value_boolean', 'value_integer', 'value_date', + ) + def _compute_value_display(self): + for answer in self: + if answer.question_type == 'char': + answer.value_display = answer.value_char or '' + elif answer.question_type == 'text': + answer.value_display = (answer.value_text or '')[:200] + elif answer.question_type == 'selection': + answer.value_display = answer.value_selection or '' + elif answer.question_type == 'boolean': + answer.value_display = 'Yes' if answer.value_boolean else 'No' + elif answer.question_type == 'integer': + answer.value_display = str(answer.value_integer or 0) + elif answer.question_type == 'date': + answer.value_display = ( + fields.Date.to_string(answer.value_date) if answer.value_date else '' + ) + else: + answer.value_display = '' diff --git a/fusion_repairs/models/intake_question.py b/fusion_repairs/models/intake_question.py new file mode 100644 index 00000000..48188f71 --- /dev/null +++ b/fusion_repairs/models/intake_question.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from odoo import fields, models + + +QUESTION_TYPES = [ + ('char', 'Short Text'), + ('text', 'Long Text'), + ('selection', 'Single Choice'), + ('boolean', 'Yes / No'), + ('integer', 'Number'), + ('date', 'Date'), +] + + +class FusionRepairIntakeQuestion(models.Model): + """A single question on an intake template. + + Supports basic conditional display: a question is only shown when the + parent question's answer matches `parent_answer_value`. The wizard and + portal forms render based on these rules. + """ + + _name = 'fusion.repair.intake.question' + _description = 'Repair Intake Question' + _order = 'sequence, id' + + template_id = fields.Many2one( + 'fusion.repair.intake.template', + string='Template', + required=True, + ondelete='cascade', + index=True, + ) + sequence = fields.Integer(string='Sequence', default=10) + name = fields.Char( + string='Question', + required=True, + translate=True, + help='Text shown to the user.', + ) + code = fields.Char( + string='Code', + help='Stable identifier for this question (used by automation rules and reporting).', + ) + help_text = fields.Char( + string='Help Text', + translate=True, + help='Optional shorter hint shown beneath the question (e.g. "e.g. SN-12345").', + ) + question_type = fields.Selection( + QUESTION_TYPES, + string='Type', + required=True, + default='char', + ) + required = fields.Boolean(default=False) + + selection_options = fields.Text( + string='Choices', + help='One option per line, only used when type is "Single Choice".', + ) + + # Conditional display + parent_question_id = fields.Many2one( + 'fusion.repair.intake.question', + string='Show Only If Question', + domain="[('template_id', '=', template_id), ('id', '!=', id)]", + ondelete='set null', + help='Show this question only when the parent question matches the value below.', + ) + parent_answer_value = fields.Char( + string='Parent Answer Equals', + help='Value the parent answer must equal for this question to be displayed.', + ) + + # Symptom keyword classification - feeds the service catalogue matcher and AI prompt + symptom_keywords = fields.Char( + string='Symptom Keywords', + help='Comma-separated keywords that, when present in the answer, tag the repair ' + 'for catalogue matching (e.g. "battery,charge").', + ) diff --git a/fusion_repairs/models/intake_service.py b/fusion_repairs/models/intake_service.py new file mode 100644 index 00000000..bbe8a708 --- /dev/null +++ b/fusion_repairs/models/intake_service.py @@ -0,0 +1,347 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +"""Shared intake service. + +This AbstractModel is the SINGLE entry point for creating repair orders from +any intake surface: the backend wizard (Phase 1), the sales rep portal +(Phase 1+), and the public client self-service portal (Phase 1+). + +All three surfaces call `create_repair_orders(payload, source='...')` so that +business logic - activities, emails, warranty determination, AI summary, +catalogue match, third-party flag, dispatch task creation - lives in one +place and the surfaces never drift apart. +""" + +import logging +from datetime import timedelta + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class FusionRepairIntakeService(models.AbstractModel): + _name = 'fusion.repair.intake.service' + _description = 'Repair Intake Service (shared by backend / sales rep / client)' + + # ------------------------------------------------------------------ + # PUBLIC API + # ------------------------------------------------------------------ + @api.model + def create_repair_orders(self, payload, source='backend_wizard'): + """Create one repair.order per equipment item in the payload. + + :param payload: dict with keys: + - partner_id: int (required) or partner_vals: dict to create new partner + - intake_user_id: int (optional, defaults to env.user) + - equipment_items: list of dicts, each with: + - product_id: int (optional) + - lot_id: int (optional) + - repair_category_id: int (optional) + - intake_template_id: int (optional) + - third_party: bool (optional) + - urgency: str (optional, default 'normal') + - issue_summary: str (optional) + - internal_notes: str (optional) + - photo_attachment_ids: list[int] (optional) + - answers: list of dicts with keys + (question_id, value_char|value_text|value_selection| + value_boolean|value_integer|value_date) + :param source: str, one of repair_order.INTAKE_SOURCES values. + :return: recordset of repair.order records created. + """ + partner_id = self._resolve_partner(payload) + if not partner_id: + raise UserError(_('A client is required to create a repair request.')) + + intake_user = self.env['res.users'].browse( + payload.get('intake_user_id') or self.env.uid + ) + session_ref = ( + self.env['ir.sequence'].next_by_code('fusion.repair.intake.session') + or 'RIS/NEW' + ) + + equipment = payload.get('equipment_items') or [{}] + repairs = self.env['repair.order'] + for item in equipment: + repair = self._create_single_repair( + partner_id=partner_id, + intake_user=intake_user, + session_ref=session_ref, + source=source, + item=item, + ) + repairs |= repair + + return repairs + + # ------------------------------------------------------------------ + # PARTNER RESOLUTION + # ------------------------------------------------------------------ + @api.model + def _resolve_partner(self, payload): + partner_id = payload.get('partner_id') + if partner_id: + return partner_id + partner_vals = payload.get('partner_vals') + if not partner_vals: + return False + partner = self.env['res.partner'].sudo().create(partner_vals) + return partner.id + + # ------------------------------------------------------------------ + # CORE CREATION + # ------------------------------------------------------------------ + @api.model + def _create_single_repair(self, partner_id, intake_user, session_ref, source, item): + Repair = self.env['repair.order'] + product_id = item.get('product_id') + + vals = { + 'partner_id': partner_id, + 'user_id': intake_user.id, + 'x_fc_intake_user_id': intake_user.id, + 'x_fc_intake_session_id': session_ref, + 'x_fc_intake_source': source, + 'x_fc_repair_category_id': item.get('repair_category_id') or False, + 'x_fc_intake_template_id': item.get('intake_template_id') or False, + 'x_fc_third_party_equipment': bool(item.get('third_party')), + 'x_fc_urgency': item.get('urgency') or 'normal', + 'x_fc_issue_category': item.get('issue_category') or False, + 'internal_notes': self._wrap_internal_notes(item), + } + if product_id: + vals['product_id'] = product_id + if item.get('lot_id'): + vals['lot_id'] = item['lot_id'] + if item.get('schedule_date'): + vals['schedule_date'] = item['schedule_date'] + + repair = Repair.create(vals) + + # Determine warranty AFTER creation (needs product on record). + if not repair.x_fc_third_party_equipment: + self._auto_link_original_sale_order(repair) + if repair._fc_compute_warranty_status(): + repair.under_warranty = True + + # Persist intake answers. + self._create_answers(repair, item.get('answers') or []) + + # Attach photos. + photo_ids = item.get('photo_attachment_ids') or [] + if photo_ids: + attachments = self.env['ir.attachment'].sudo().browse(photo_ids).exists() + attachments.write({ + 'res_model': 'repair.order', + 'res_id': repair.id, + }) + repair.write({'x_fc_photo_ids': [(6, 0, attachments.ids)]}) + + # Activities. + self._schedule_activities(repair) + + # Optional dispatch draft task (urgent / safety). + if repair.x_fc_urgency in ('urgent', 'safety'): + self._create_dispatch_task(repair) + + # Emails (client + office). + self._send_intake_emails(repair) + + # Audit message in chatter. + repair.message_post( + body=_( + 'Service call submitted via %(source)s by %(user)s. ' + 'Session reference: %(ref)s.', + source=dict(repair._fields['x_fc_intake_source'].selection).get(source), + user=intake_user.name, + ref=session_ref, + ), + ) + + return repair + + @api.model + def _wrap_internal_notes(self, item): + notes = item.get('internal_notes') or '' + summary = item.get('issue_summary') or '' + if not (notes or summary): + return False + parts = [] + if summary: + parts.append('

Issue summary: %s

' % summary) + if notes: + parts.append('

Notes: %s

' % notes) + return ''.join(parts) + + # ------------------------------------------------------------------ + # ORIGINAL SO AUTO-LINK + # ------------------------------------------------------------------ + @api.model + def _auto_link_original_sale_order(self, repair): + if not repair.partner_id or not repair.product_id: + return + SaleOrder = self.env['sale.order'].sudo() + domain = [ + ('partner_id', '=', repair.partner_id.id), + ('state', 'in', ('sale', 'done')), + ('order_line.product_id', '=', repair.product_id.id), + ] + if repair.lot_id: + domain.append(('order_line.lot_ids', 'in', repair.lot_id.id)) + candidate = SaleOrder.search(domain, order='date_order desc', limit=1) + if candidate: + repair.x_fc_original_sale_order_id = candidate + + # ------------------------------------------------------------------ + # ANSWERS + # ------------------------------------------------------------------ + @api.model + def _create_answers(self, repair, answers): + if not answers: + return + Answer = self.env['fusion.repair.intake.answer'] + for ans in answers: + qid = ans.get('question_id') + if not qid: + continue + Answer.create({ + 'repair_id': repair.id, + 'question_id': qid, + 'value_char': ans.get('value_char'), + 'value_text': ans.get('value_text'), + 'value_selection': ans.get('value_selection'), + 'value_boolean': bool(ans.get('value_boolean')), + 'value_integer': int(ans.get('value_integer') or 0), + 'value_date': ans.get('value_date') or False, + }) + + # ------------------------------------------------------------------ + # ACTIVITIES + # ------------------------------------------------------------------ + @api.model + def _schedule_activities(self, repair): + """Create the 4 intake activities described in the spec.""" + try: + cs_callback_type = self.env.ref('fusion_repairs.mail_activity_type_cs_callback') + tech_dispatch_type = self.env.ref('fusion_repairs.mail_activity_type_tech_dispatch') + manager_review_type = self.env.ref('fusion_repairs.mail_activity_type_manager_review') + except ValueError: + _logger.warning('Repair activity types missing - skipping') + return + + # CS callback - always, intake user + repair.activity_schedule( + activity_type_id=cs_callback_type.id, + summary=_('Call client back if any intake info was missing'), + user_id=repair.x_fc_intake_user_id.id or self.env.uid, + ) + + # Tech dispatch - assigned to responsible user, urgency-adjusted deadline + deadline_days = {'safety': 0, 'urgent': 1, 'normal': 2}.get(repair.x_fc_urgency, 2) + repair.activity_schedule( + activity_type_id=tech_dispatch_type.id, + summary=_('Assign a technician (urgency: %s)', repair.x_fc_urgency), + user_id=repair.user_id.id or self.env.uid, + date_deadline=fields.Date.context_today(self) + timedelta(days=deadline_days), + ) + + # Manager review - only for third-party equipment + if repair.x_fc_third_party_equipment: + manager_group = self.env.ref( + 'fusion_repairs.group_fusion_repairs_manager', + raise_if_not_found=False, + ) + manager_user = self.env.user + if manager_group: + # res.groups has no .users field in Odoo 19; + # query via res.users.all_group_ids (Odoo 19 renamed groups_id). + candidate = self.env['res.users'].sudo().search( + [('all_group_ids', 'in', manager_group.ids), ('active', '=', True)], + limit=1, + ) + if candidate: + manager_user = candidate + repair.activity_schedule( + activity_type_id=manager_review_type.id, + summary=_('Third-party equipment - manager awareness'), + user_id=manager_user.id, + ) + + # ------------------------------------------------------------------ + # DISPATCH TASK + # ------------------------------------------------------------------ + @api.model + def _create_dispatch_task(self, repair): + """Create a draft fusion.technician.task for urgent / safety repairs. + + Phase 1 simple approach: no date/technician assigned, dispatcher confirms. + """ + Task = self.env['fusion.technician.task'].sudo() + try: + vals = { + 'partner_id': repair.partner_id.id, + 'task_type': 'repair', + 'status': 'pending', + 'scheduled_date': fields.Date.context_today(self), + 'duration_hours': repair.x_fc_estimated_duration or 1.0, + 'x_fc_repair_order_id': repair.id, + 'description': repair.internal_notes or repair.name, + } + # technician_id is required on fusion.technician.task; we fall back to + # the intake user. Dispatcher will reassign. + vals['technician_id'] = ( + repair.user_id.id if repair.user_id and repair.user_id.x_fc_is_field_staff + else self.env.uid + ) + Task.create(vals) + except Exception as e: + _logger.warning('Failed to auto-create dispatch task for repair %s: %s', + repair.name, e) + + # ------------------------------------------------------------------ + # EMAILS + # ------------------------------------------------------------------ + @api.model + def _send_intake_emails(self, repair): + if not self._notifications_enabled(): + return + # Client confirmation + if repair.partner_id and repair.partner_id.email: + try: + self.env.ref('fusion_repairs.email_template_intake_received_client') \ + .send_mail(repair.id, force_send=False) + except Exception as e: + _logger.warning('Failed to send client intake email for %s: %s', + repair.name, e) + + # Office notification + office_emails = self._office_emails(repair.company_id) + if office_emails: + try: + tpl = self.env.ref('fusion_repairs.email_template_intake_received_office') + tpl.with_context(default_email_to=','.join(office_emails)) \ + .send_mail(repair.id, force_send=False, email_values={ + 'email_to': ','.join(office_emails), + }) + except Exception as e: + _logger.warning('Failed to send office intake email for %s: %s', + repair.name, e) + + @api.model + def _notifications_enabled(self): + ICP = self.env['ir.config_parameter'].sudo() + return ICP.get_param('fusion_repairs.enable_email_notifications', 'True') == 'True' + + @api.model + def _office_emails(self, company): + # Reuse the office notification recipients defined by fusion_claims. + partners = company.sudo() + recipients = getattr(partners, 'x_fc_office_notification_ids', False) + if recipients: + return [p.email for p in recipients if p.email] + return [] diff --git a/fusion_repairs/models/intake_template.py b/fusion_repairs/models/intake_template.py new file mode 100644 index 00000000..ba6a0416 --- /dev/null +++ b/fusion_repairs/models/intake_template.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from odoo import api, fields, models + + +class FusionRepairIntakeTemplate(models.Model): + """A reusable set of intake questions per medical equipment category. + + Each template contains an ordered list of questions; the intake wizard + (and sales-rep / client portals) render these dynamically with + conditional show/hide based on prior answers. + """ + + _name = 'fusion.repair.intake.template' + _description = 'Repair Intake Question Template' + _order = 'sequence, name' + + name = fields.Char(string='Template Name', required=True, translate=True) + code = fields.Char( + string='Code', + help='Optional stable identifier for referencing this template from code/data.', + ) + sequence = fields.Integer(string='Sequence', default=10) + active = fields.Boolean(default=True) + is_default = fields.Boolean( + string='Default Fallback', + help='Used when no template is explicitly configured for the selected category. ' + 'Exactly one template should be flagged as default per company.', + ) + description = fields.Html(string='Description', translate=True) + + product_category_ids = fields.Many2many( + 'fusion.repair.product.category', + 'fusion_repair_intake_template_category_rel', + 'template_id', + 'category_id', + string='Applies to Categories', + help='Categories that automatically select this template during intake.', + ) + + question_ids = fields.One2many( + 'fusion.repair.intake.question', + 'template_id', + string='Questions', + copy=True, + ) + question_count = fields.Integer( + compute='_compute_question_count', + string='Question Count', + ) + + company_id = fields.Many2one( + 'res.company', + string='Company', + default=lambda self: self.env.company, + ) + + @api.depends('question_ids') + def _compute_question_count(self): + for tpl in self: + tpl.question_count = len(tpl.question_ids) + + def action_view_questions(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': self.name, + 'res_model': 'fusion.repair.intake.question', + 'view_mode': 'list,form', + 'domain': [('template_id', '=', self.id)], + 'context': {'default_template_id': self.id}, + } diff --git a/fusion_repairs/models/product_template.py b/fusion_repairs/models/product_template.py new file mode 100644 index 00000000..ea6224b0 --- /dev/null +++ b/fusion_repairs/models/product_template.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from odoo import fields, models + + +class ProductTemplate(models.Model): + _inherit = 'product.template' + + x_fc_repair_category_id = fields.Many2one( + 'fusion.repair.product.category', + string='Repair Category', + help='Medical equipment category - drives intake template selection and ' + 'technician skills filter for repairs of this product.', + ) + x_fc_warranty_months = fields.Integer( + string='Warranty (Months)', + default=12, + help='Default warranty period for new units of this product. Used to auto-detect ' + 'warranty status on repair intake (delivery date + warranty months >= today).', + ) + x_fc_maintenance_interval_months = fields.Integer( + string='Maintenance Interval (Months)', + default=0, + help='If > 0, delivering a unit of this product auto-creates a maintenance contract ' + 'with this recurring interval. Phase 3 feature.', + ) + x_fc_intake_template_id = fields.Many2one( + 'fusion.repair.intake.template', + string='Intake Template Override', + help='Optional override of the intake template normally chosen from the ' + 'repair category. Leave empty to use category default.', + ) diff --git a/fusion_repairs/models/repair_order.py b/fusion_repairs/models/repair_order.py new file mode 100644 index 00000000..91de2022 --- /dev/null +++ b/fusion_repairs/models/repair_order.py @@ -0,0 +1,281 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from datetime import timedelta + +from odoo import api, fields, models, _ + + +INTAKE_SOURCES = [ + ('backend_wizard', 'Backend Wizard (CS)'), + ('sales_rep_portal', 'Sales Rep Portal'), + ('client_portal', 'Client Self-Service'), + ('manual', 'Manual / Other'), +] + +URGENCY_LEVELS = [ + ('normal', 'Normal'), + ('urgent', 'Urgent'), + ('safety', 'Safety Issue'), +] + + +class RepairOrder(models.Model): + """Extend Odoo Repairs with intake context, dispatch link, warranty + determination, and pricing variance tracking for Fusion Repairs.""" + + _inherit = 'repair.order' + + # ------------------------------------------------------------------ + # INTAKE METADATA + # ------------------------------------------------------------------ + x_fc_intake_source = fields.Selection( + INTAKE_SOURCES, + string='Intake Source', + default='manual', + tracking=True, + help='Which intake surface created this repair (backend CS wizard, ' + 'sales rep portal, public client portal, or manual entry).', + ) + x_fc_intake_user_id = fields.Many2one( + 'res.users', + string='Intake By', + tracking=True, + index=True, + help='User who took the call / submitted the intake. For client portal, ' + 'this is the OdooBot or admin user.', + ) + x_fc_intake_session_id = fields.Char( + string='Intake Session', + index=True, + copy=False, + help='Reference shared by multiple repair orders created during the same call.', + ) + x_fc_intake_template_id = fields.Many2one( + 'fusion.repair.intake.template', + string='Intake Template', + help='Question template used during intake.', + ) + x_fc_intake_answer_ids = fields.One2many( + 'fusion.repair.intake.answer', + 'repair_id', + string='Intake Answers', + ) + x_fc_intake_answer_count = fields.Integer( + compute='_compute_intake_answer_count', + ) + + # ------------------------------------------------------------------ + # EQUIPMENT / WARRANTY + # ------------------------------------------------------------------ + x_fc_repair_category_id = fields.Many2one( + 'fusion.repair.product.category', + string='Equipment Category', + tracking=True, + index=True, + help='Medical equipment category - drives intake template and tech skills filter.', + ) + x_fc_third_party_equipment = fields.Boolean( + string='Third-Party Equipment', + tracking=True, + help='True if the equipment was not sold by us. Forces under_warranty=False ' + 'and typically triggers a service call-out fee.', + ) + x_fc_original_sale_order_id = fields.Many2one( + 'sale.order', + string='Original Purchase SO', + tracking=True, + index=True, + help='Sale order through which the customer originally purchased this unit. ' + 'Auto-matched on intake by partner + lot/serial.', + ) + x_fc_warranty_override_reason = fields.Char( + string='Warranty Override Reason', + help='Required when CS overrides the auto-detected warranty status.', + ) + + # ------------------------------------------------------------------ + # TRIAGE / URGENCY + # ------------------------------------------------------------------ + x_fc_urgency = fields.Selection( + URGENCY_LEVELS, + string='Urgency', + default='normal', + tracking=True, + index=True, + ) + x_fc_issue_category = fields.Char( + string='Issue Category', + help='Symptom classification (e.g. "battery", "motor", "remote"). Used by ' + 'service catalogue matcher and AI prompt context.', + ) + + # ------------------------------------------------------------------ + # PHOTOS + # ------------------------------------------------------------------ + x_fc_photo_ids = fields.Many2many( + 'ir.attachment', + 'fusion_repair_order_photo_rel', + 'repair_id', + 'attachment_id', + string='Intake Photos / Videos', + help='Photos and videos uploaded during intake.', + ) + x_fc_photo_count = fields.Integer( + compute='_compute_photo_count', + ) + + # ------------------------------------------------------------------ + # PRICING (estimate vs actual - Phase 2 reconciliation) + # ------------------------------------------------------------------ + x_fc_estimated_duration = fields.Float( + string='Estimated Duration (h)', + help='Estimated visit duration from service catalogue, used to size technician slot.', + ) + x_fc_estimated_cost = fields.Monetary( + string='Estimated Cost', + currency_field='company_currency_id', + help='Estimated total from catalogue match at intake (pre-visit).', + ) + x_fc_actual_cost = fields.Monetary( + string='Actual Cost', + currency_field='company_currency_id', + help='Actual total recorded from the visit report (post-visit).', + ) + x_fc_cost_variance_pct = fields.Float( + string='Cost Variance %', + compute='_compute_cost_variance', + store=True, + help='(actual - estimated) / estimated * 100', + ) + x_fc_requires_requote = fields.Boolean( + string='Requires Re-Quote', + help='Set when actual cost exceeds estimate beyond the configured threshold; ' + 'blocks automatic invoicing until manager approves or client re-confirms.', + ) + + company_currency_id = fields.Many2one( + 'res.currency', + related='company_id.currency_id', + readonly=True, + ) + + # ------------------------------------------------------------------ + # FIELD SERVICE LINK + # ------------------------------------------------------------------ + x_fc_technician_task_ids = fields.One2many( + 'fusion.technician.task', + 'x_fc_repair_order_id', + string='Technician Tasks', + ) + x_fc_technician_task_count = fields.Integer( + compute='_compute_technician_task_count', + ) + + # ------------------------------------------------------------------ + # AI SUMMARY (Phase 2) + # ------------------------------------------------------------------ + x_fc_ai_summary = fields.Text( + string='AI Pre-Visit Brief', + help='AI-generated short brief for the technician based on intake answers. ' + 'Optional - never blocks intake submit.', + ) + + # ------------------------------------------------------------------ + # COMPUTES + # ------------------------------------------------------------------ + @api.depends('x_fc_intake_answer_ids') + def _compute_intake_answer_count(self): + for repair in self: + repair.x_fc_intake_answer_count = len(repair.x_fc_intake_answer_ids) + + @api.depends('x_fc_photo_ids') + def _compute_photo_count(self): + for repair in self: + repair.x_fc_photo_count = len(repair.x_fc_photo_ids) + + @api.depends('x_fc_technician_task_ids') + def _compute_technician_task_count(self): + for repair in self: + repair.x_fc_technician_task_count = len(repair.x_fc_technician_task_ids) + + @api.depends('x_fc_estimated_cost', 'x_fc_actual_cost') + def _compute_cost_variance(self): + for repair in self: + if repair.x_fc_estimated_cost: + repair.x_fc_cost_variance_pct = ( + (repair.x_fc_actual_cost - repair.x_fc_estimated_cost) + / repair.x_fc_estimated_cost * 100 + ) + else: + repair.x_fc_cost_variance_pct = 0.0 + + # ------------------------------------------------------------------ + # WARRANTY DETERMINATION + # ------------------------------------------------------------------ + def _fc_compute_warranty_status(self): + """Auto-detect warranty: not third-party AND within warranty window.""" + self.ensure_one() + if self.x_fc_third_party_equipment: + return False + if not self.x_fc_original_sale_order_id: + return False + original = self.x_fc_original_sale_order_id + delivery_date = original.commitment_date or original.date_order + if not delivery_date: + return False + warranty_months = ( + self.product_id.product_tmpl_id.x_fc_warranty_months + if self.product_id else 0 + ) + if not warranty_months: + return False + # Datetime + months: use simple 30-day approximation per month for now. + cutoff = fields.Datetime.from_string(str(delivery_date)) + timedelta(days=warranty_months * 30) + return fields.Datetime.now() <= cutoff + + # ------------------------------------------------------------------ + # SMART BUTTONS + # ------------------------------------------------------------------ + def action_view_intake_answers(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': _('Intake Answers'), + 'res_model': 'fusion.repair.intake.answer', + 'view_mode': 'list,form', + 'domain': [('repair_id', '=', self.id)], + 'context': {'default_repair_id': self.id}, + } + + def action_view_technician_tasks(self): + self.ensure_one() + if len(self.x_fc_technician_task_ids) == 1: + return { + 'type': 'ir.actions.act_window', + 'name': self.x_fc_technician_task_ids.name, + 'res_model': 'fusion.technician.task', + 'view_mode': 'form', + 'res_id': self.x_fc_technician_task_ids.id, + } + return { + 'type': 'ir.actions.act_window', + 'name': _('Technician Tasks'), + 'res_model': 'fusion.technician.task', + 'view_mode': 'list,form', + 'domain': [('x_fc_repair_order_id', '=', self.id)], + 'context': {'default_x_fc_repair_order_id': self.id}, + } + + def action_view_original_sale_order(self): + self.ensure_one() + if not self.x_fc_original_sale_order_id: + return False + return { + 'type': 'ir.actions.act_window', + 'name': self.x_fc_original_sale_order_id.name, + 'res_model': 'sale.order', + 'view_mode': 'form', + 'res_id': self.x_fc_original_sale_order_id.id, + } diff --git a/fusion_repairs/models/repair_product_category.py b/fusion_repairs/models/repair_product_category.py new file mode 100644 index 00000000..d360f4d4 --- /dev/null +++ b/fusion_repairs/models/repair_product_category.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from odoo import api, fields, models + + +class FusionRepairProductCategory(models.Model): + """Medical equipment categories used to route repair intake and match skills.""" + + _name = 'fusion.repair.product.category' + _description = 'Repair Product Category' + _order = 'sequence, name' + + name = fields.Char(string='Name', required=True, translate=True) + code = fields.Char( + string='Code', + required=True, + help='Stable identifier used by code (e.g. "stairlift"). Lowercase, no spaces.', + ) + sequence = fields.Integer(string='Sequence', default=10) + icon = fields.Char( + string='Icon', + default='fa-wrench', + help='Font Awesome icon class shown next to the category in pickers.', + ) + description = fields.Text(string='Description', translate=True) + active = fields.Boolean(default=True) + safety_critical = fields.Boolean( + string='Safety-Critical', + help='Categories where motor / mechanical issues warrant immediate escalation ' + '(stairlifts, porch lifts). Used by the AI self-check engine to skip ' + 'self-help and force escalation when safety symptoms appear.', + ) + + intake_template_id = fields.Many2one( + 'fusion.repair.intake.template', + string='Default Intake Template', + help='Default intake question set shown when this category is selected.', + ) + + _sql_constraints = [ + ('code_unique', 'unique(code)', 'Category code must be unique.'), + ] + + @api.depends('name', 'code') + def _compute_display_name(self): + for cat in self: + cat.display_name = cat.name or cat.code or '' diff --git a/fusion_repairs/models/res_config_settings.py b/fusion_repairs/models/res_config_settings.py new file mode 100644 index 00000000..7bbf4f14 --- /dev/null +++ b/fusion_repairs/models/res_config_settings.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + # NOTE: res.config.settings only supports boolean/integer/float/char/ + # selection/many2one/datetime types per project Odoo 19 conventions. + + fc_repairs_enable_email_notifications = fields.Boolean( + string='Enable Repair Email Notifications', + config_parameter='fusion_repairs.enable_email_notifications', + default=True, + help='Master toggle for automated repair-related emails to clients and office.', + ) + + fc_repairs_outstanding_balance_threshold = fields.Float( + string='Outstanding Balance Warning ($)', + config_parameter='fusion_repairs.outstanding_balance_threshold', + default=100.0, + help='Show a warning banner during intake if the client has open invoices ' + 'totalling more than this amount.', + ) + + fc_repairs_duplicate_call_window_days = fields.Integer( + string='Duplicate Call Window (Days)', + config_parameter='fusion_repairs.duplicate_call_window_days', + default=14, + help='When the intake wizard finds an open repair from this many days back on ' + 'the same phone number, it offers "add note to existing repair instead".', + ) + + fc_repairs_variance_threshold_pct = fields.Integer( + string='Pricing Variance Threshold (%)', + config_parameter='fusion_repairs.variance_threshold_pct', + default=20, + help='If actual cost exceeds estimated cost by more than this percentage, ' + 'invoicing is blocked until a manager reviews / a re-quote email is sent.', + ) + + fc_repairs_variance_threshold_amount = fields.Float( + string='Pricing Variance Threshold ($)', + config_parameter='fusion_repairs.variance_threshold_amount', + default=100.0, + help='Absolute variance amount that also triggers re-quote (whichever hits first).', + ) + + fc_repairs_client_portal_url = fields.Char( + string='Public Client Portal URL Path', + config_parameter='fusion_repairs.client_portal_url', + default='/repair', + help='URL path mentioned in voicemail greetings and printed on QR stickers. ' + 'Phase 1 ships with the form at this path.', + ) + + fc_repairs_client_portal_rate_limit_per_hour = fields.Integer( + string='Client Portal Rate Limit (per hour, per IP)', + config_parameter='fusion_repairs.client_portal_rate_limit_per_hour', + default=10, + ) diff --git a/fusion_repairs/models/res_partner.py b/fusion_repairs/models/res_partner.py new file mode 100644 index 00000000..83d1a4d6 --- /dev/null +++ b/fusion_repairs/models/res_partner.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from odoo import fields, models + + +PREFERRED_WINDOW = [ + ('morning', 'Morning (9 AM - 12 PM)'), + ('afternoon', 'Afternoon (12 PM - 5 PM)'), + ('evening', 'Evening (after 5 PM)'), + ('any', 'Any Time'), +] + + +class ResPartner(models.Model): + _inherit = 'res.partner' + + # ------------------------------------------------------------------ + # SERVICE PREFERENCES (P1 - shown in client history sidebar) + # ------------------------------------------------------------------ + x_fc_preferred_tech_id = fields.Many2one( + 'res.users', + string='Preferred Technician', + domain="[('x_fc_is_field_staff', '=', True)]", + help='If set, this technician is suggested first on dispatch.', + ) + x_fc_preferred_window = fields.Selection( + PREFERRED_WINDOW, + string='Preferred Visit Window', + default='any', + ) + x_fc_access_notes = fields.Text( + string='Access Notes', + help='Free-form notes for technicians arriving at this address: ' + 'gate code, dog warning, where to park, side door entry, etc.', + ) + + # ------------------------------------------------------------------ + # CLIENT HISTORY SIDEBAR (C2 - pulled lazily on demand) + # ------------------------------------------------------------------ + x_fc_repair_count = fields.Integer( + compute='_compute_x_fc_repair_count', + string='Repairs Count', + compute_sudo=True, + help='Lightweight count of repair orders for this partner. Heavier history ' + 'data is fetched lazily by the wizard / portal sidebar via RPC.', + ) + + def _compute_x_fc_repair_count(self): + # Non-stored compute - safe to omit @api.depends. + if not self.ids: + for partner in self: + partner.x_fc_repair_count = 0 + return + Repair = self.env['repair.order'].sudo() + data = Repair._read_group( + [('partner_id', 'in', self.ids)], + ['partner_id'], + ['__count'], + ) + counts = {row[0].id: row[1] for row in data} + for partner in self: + partner.x_fc_repair_count = counts.get(partner.id, 0) diff --git a/fusion_repairs/models/res_users.py b/fusion_repairs/models/res_users.py new file mode 100644 index 00000000..59297420 --- /dev/null +++ b/fusion_repairs/models/res_users.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from odoo import fields, models + + +class ResUsers(models.Model): + """Extends res.users with fusion_repairs specific fields. + + Reuses the existing x_fc_is_field_staff Boolean from fusion_tasks + as the technician flag - do NOT recreate that field here. + + All technician selectors in fusion_repairs use the same domain + [('x_fc_is_field_staff', '=', True)] for consistency with fusion_tasks. + """ + + _inherit = 'res.users' + + x_fc_repair_skills = fields.Many2many( + 'fusion.repair.product.category', + 'fusion_repair_user_skill_rel', + 'user_id', + 'category_id', + string='Repair Skills', + help='Medical equipment categories this user is qualified to service. ' + 'Used by dispatcher to filter candidate technicians for a repair.', + ) + + x_fc_tech_cost_rate = fields.Monetary( + string='Tech Cost Rate (/h)', + currency_field='company_currency_id', + help='Internal cost per hour - used for repair margin calculation (Phase 4).', + ) + + # On-call rotation - Phase 2 (simple priority-int approach). + x_fc_on_call = fields.Boolean( + string='On-Call Eligible', + help='Tick if this user is eligible for the weekend / after-hours on-call rotation.', + ) + x_fc_on_call_priority = fields.Integer( + string='On-Call Priority', + default=99, + help='Lower number = paged first. The escalation cron picks the lowest priority ' + 'available user when a safety repair is submitted after hours.', + ) + x_fc_on_call_phone = fields.Char( + string='On-Call Phone Override', + help='Phone number to use for on-call SMS / calls. If empty, falls back to ' + 'the user partner phone.', + ) + + company_currency_id = fields.Many2one( + 'res.currency', + related='company_id.currency_id', + readonly=True, + ) diff --git a/fusion_repairs/models/technician_task.py b/fusion_repairs/models/technician_task.py new file mode 100644 index 00000000..f8963c83 --- /dev/null +++ b/fusion_repairs/models/technician_task.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from odoo import fields, models + + +class FusionTechnicianTaskRepairs(models.Model): + """Adds the back-link from fusion.technician.task to repair.order so + repairs and tasks share one timeline. + """ + + _inherit = 'fusion.technician.task' + + x_fc_repair_order_id = fields.Many2one( + 'repair.order', + string='Repair Order', + ondelete='set null', + index=True, + tracking=True, + help='Repair order this task fulfils. Set automatically when the intake ' + 'wizard auto-creates a draft task for urgent / safety calls.', + ) + + x_fc_repair_intake_session_id = fields.Char( + related='x_fc_repair_order_id.x_fc_intake_session_id', + string='Intake Session', + store=True, + index=True, + ) + + def action_view_repair_order(self): + self.ensure_one() + if not self.x_fc_repair_order_id: + return False + return { + 'type': 'ir.actions.act_window', + 'name': self.x_fc_repair_order_id.name, + 'res_model': 'repair.order', + 'view_mode': 'form', + 'res_id': self.x_fc_repair_order_id.id, + } diff --git a/fusion_repairs/security/ir.model.access.csv b/fusion_repairs/security/ir.model.access.csv new file mode 100644 index 00000000..1574ea4d --- /dev/null +++ b/fusion_repairs/security/ir.model.access.csv @@ -0,0 +1,12 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_repair_product_category_user,Repair Category User Read,model_fusion_repair_product_category,group_fusion_repairs_user,1,0,0,0 +access_repair_product_category_manager,Repair Category Manager Full,model_fusion_repair_product_category,group_fusion_repairs_manager,1,1,1,1 +access_repair_intake_template_user,Intake Template User Read,model_fusion_repair_intake_template,group_fusion_repairs_user,1,0,0,0 +access_repair_intake_template_manager,Intake Template Manager Full,model_fusion_repair_intake_template,group_fusion_repairs_manager,1,1,1,1 +access_repair_intake_question_user,Intake Question User Read,model_fusion_repair_intake_question,group_fusion_repairs_user,1,0,0,0 +access_repair_intake_question_manager,Intake Question Manager Full,model_fusion_repair_intake_question,group_fusion_repairs_manager,1,1,1,1 +access_repair_intake_answer_user,Intake Answer User Full,model_fusion_repair_intake_answer,group_fusion_repairs_user,1,1,1,0 +access_repair_intake_answer_manager,Intake Answer Manager Full,model_fusion_repair_intake_answer,group_fusion_repairs_manager,1,1,1,1 +access_repair_intake_answer_tech_portal,Intake Answer Technician Read,model_fusion_repair_intake_answer,fusion_tasks.group_field_technician,1,0,0,0 +access_repair_intake_wizard_user,Intake Wizard User Full,model_fusion_repair_intake_wizard,group_fusion_repairs_user,1,1,1,1 +access_repair_intake_wizard_equipment_user,Intake Wizard Equipment User Full,model_fusion_repair_intake_wizard_equipment,group_fusion_repairs_user,1,1,1,1 diff --git a/fusion_repairs/security/security.xml b/fusion_repairs/security/security.xml new file mode 100644 index 00000000..79401dba --- /dev/null +++ b/fusion_repairs/security/security.xml @@ -0,0 +1,76 @@ + + + + + + + Fusion Repairs + 47 + + + + + + + Fusion Repairs + 47 + + + + + + + + Repairs: User (CS Intake) + + + CS / front-office staff who take repair intake calls and view repairs. + + + + Repairs: Dispatcher + + + Assigns technicians to repairs, reschedules visits, manages parts pre-pull picklists. + + + + Repairs: Manager + + + Configures intake templates, pricing, maintenance contracts, on-call rotation, variance overrides. + + + + + + + + + Repair Order: Multi-Company + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + + + + + Repair Order: Technician sees own repairs + + [('x_fc_technician_task_ids.all_technician_ids', 'in', [user.id])] + + + + + + + + + + Repair Intake Answer: Multi-Company + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + + + diff --git a/fusion_repairs/views/intake_template_views.xml b/fusion_repairs/views/intake_template_views.xml new file mode 100644 index 00000000..e7e57340 --- /dev/null +++ b/fusion_repairs/views/intake_template_views.xml @@ -0,0 +1,95 @@ + + + + + + fusion.repair.intake.template.list + fusion.repair.intake.template + + + + + + + + + + + + + + fusion.repair.intake.template.form + fusion.repair.intake.template + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + Intake Templates + fusion.repair.intake.template + list,form + + +
diff --git a/fusion_repairs/views/menus.xml b/fusion_repairs/views/menus.xml new file mode 100644 index 00000000..8820056a --- /dev/null +++ b/fusion_repairs/views/menus.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/fusion_repairs/views/repair_order_views.xml b/fusion_repairs/views/repair_order_views.xml new file mode 100644 index 00000000..9544f9e0 --- /dev/null +++ b/fusion_repairs/views/repair_order_views.xml @@ -0,0 +1,143 @@ + + + + + + + + repair.order.form.inherit.fusion_repairs + repair.order + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + repair.order.kanban.inherit.fusion_repairs + repair.order + + + + + + + + + + + + + + repair.order.list.inherit.fusion_repairs + repair.order + + + + + + + + + + + + + + + New Service Call + fusion.repair.intake.wizard + form + new + + + diff --git a/fusion_repairs/views/repair_product_category_views.xml b/fusion_repairs/views/repair_product_category_views.xml new file mode 100644 index 00000000..0ca1a2e7 --- /dev/null +++ b/fusion_repairs/views/repair_product_category_views.xml @@ -0,0 +1,55 @@ + + + + + fusion.repair.product.category.list + fusion.repair.product.category + + + + + + + + + + + + + + fusion.repair.product.category.form + fusion.repair.product.category + +
+ +
+
+ + + + + + + + + + + + + +
+
+
+
+ + + Equipment Categories + fusion.repair.product.category + list,form + + +
diff --git a/fusion_repairs/views/res_config_settings_views.xml b/fusion_repairs/views/res_config_settings_views.xml new file mode 100644 index 00000000..c86345e4 --- /dev/null +++ b/fusion_repairs/views/res_config_settings_views.xml @@ -0,0 +1,62 @@ + + + + + res.config.settings.view.form.inherit.fusion_repairs + res.config.settings + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fusion_repairs/views/res_partner_views.xml b/fusion_repairs/views/res_partner_views.xml new file mode 100644 index 00000000..5c160dee --- /dev/null +++ b/fusion_repairs/views/res_partner_views.xml @@ -0,0 +1,29 @@ + + + + + res.partner.form.inherit.fusion_repairs + res.partner + + + + + + + + + + + + + + + + + + + + + diff --git a/fusion_repairs/views/res_users_views.xml b/fusion_repairs/views/res_users_views.xml new file mode 100644 index 00000000..78758d91 --- /dev/null +++ b/fusion_repairs/views/res_users_views.xml @@ -0,0 +1,33 @@ + + + + + res.users.form.inherit.fusion_repairs + res.users + + + + + + + + + + + + + + + + + + + + + + diff --git a/fusion_repairs/wizard/__init__.py b/fusion_repairs/wizard/__init__.py new file mode 100644 index 00000000..3fe33326 --- /dev/null +++ b/fusion_repairs/wizard/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from . import repair_intake_wizard diff --git a/fusion_repairs/wizard/repair_intake_wizard.py b/fusion_repairs/wizard/repair_intake_wizard.py new file mode 100644 index 00000000..f6769403 --- /dev/null +++ b/fusion_repairs/wizard/repair_intake_wizard.py @@ -0,0 +1,199 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +"""Backend intake wizard. + +A simple Phase 1 transient model that captures one-or-many equipment items +per call, then delegates to fusion.repair.intake.service to create the +repair.order(s). The shared service guarantees identical behaviour to the +sales rep portal and the public client portal added in later phases. + +Multi-equipment per call is supported via the equipment_ids One2many. +""" + +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class RepairIntakeWizard(models.TransientModel): + _name = 'fusion.repair.intake.wizard' + _description = 'Repair Intake Wizard' + + # ------------------------------------------------------------------ + # CALLER / CLIENT + # ------------------------------------------------------------------ + intake_user_id = fields.Many2one( + 'res.users', + string='Taken By', + default=lambda self: self.env.user, + required=True, + ) + partner_id = fields.Many2one( + 'res.partner', + string='Client', + required=True, + help='Existing client. Use the create-and-edit dialog to add a new contact.', + ) + partner_phone = fields.Char( + related='partner_id.phone', + string='Phone', + readonly=True, + ) + + # ------------------------------------------------------------------ + # EQUIPMENT (one-or-many) + # ------------------------------------------------------------------ + equipment_ids = fields.One2many( + 'fusion.repair.intake.wizard.equipment', + 'wizard_id', + string='Equipment Items', + required=True, + ) + + # ------------------------------------------------------------------ + # SUBMIT + # ------------------------------------------------------------------ + def action_submit(self): + self.ensure_one() + if not self.equipment_ids: + raise UserError(_('Please add at least one piece of equipment.')) + + payload = { + 'partner_id': self.partner_id.id, + 'intake_user_id': self.intake_user_id.id, + 'equipment_items': [self._equipment_payload(eq) for eq in self.equipment_ids], + } + + repairs = self.env['fusion.repair.intake.service'].create_repair_orders( + payload, source='backend_wizard', + ) + + if len(repairs) == 1: + return { + 'type': 'ir.actions.act_window', + 'name': repairs.name, + 'res_model': 'repair.order', + 'view_mode': 'form', + 'res_id': repairs.id, + } + return { + 'type': 'ir.actions.act_window', + 'name': _('Service Calls Created (%(count)s)', count=len(repairs)), + 'res_model': 'repair.order', + 'view_mode': 'list,form', + 'domain': [('id', 'in', repairs.ids)], + } + + def _equipment_payload(self, eq): + """Render an equipment record as a dict the intake service expects.""" + return { + 'product_id': eq.product_id.id or False, + 'lot_id': eq.lot_id.id or False, + 'repair_category_id': eq.repair_category_id.id or False, + 'intake_template_id': eq.intake_template_id.id or False, + 'third_party': eq.third_party, + 'urgency': eq.urgency, + 'issue_summary': eq.issue_summary or '', + 'issue_category': eq.issue_category or '', + 'internal_notes': eq.internal_notes or '', + 'schedule_date': eq.scheduled_date or False, + 'photo_attachment_ids': eq.photo_ids.ids if eq.photo_ids else [], + 'answers': [], # Phase 1 wizard doesn't expose per-question answer rows yet + } + + +class RepairIntakeWizardEquipment(models.TransientModel): + """A single piece of equipment captured in the wizard. + + Multiple lines = multi-equipment intake (one repair.order per line). + """ + + _name = 'fusion.repair.intake.wizard.equipment' + _description = 'Repair Intake Wizard - Equipment Line' + _order = 'sequence, id' + + wizard_id = fields.Many2one( + 'fusion.repair.intake.wizard', + string='Wizard', + required=True, + ondelete='cascade', + ) + sequence = fields.Integer(default=10) + + # Equipment identification + repair_category_id = fields.Many2one( + 'fusion.repair.product.category', + string='Category', + required=True, + ) + product_id = fields.Many2one( + 'product.product', + string='Product', + help='Specific product if known. Leave blank for generic equipment.', + ) + lot_id = fields.Many2one( + 'stock.lot', + string='Serial Number', + domain="[('product_id', '=', product_id)]", + help='Lot or serial number if known.', + ) + third_party = fields.Boolean( + string='Not Purchased From Us', + help='Tick if this equipment was bought elsewhere - we still service it but ' + 'warranty is not honoured and a service call-out fee applies.', + ) + + # Intake context + intake_template_id = fields.Many2one( + 'fusion.repair.intake.template', + string='Question Template', + help='Defaults to the template configured on the category if left blank.', + ) + + # Triage + urgency = fields.Selection( + [('normal', 'Normal'), ('urgent', 'Urgent'), ('safety', 'Safety Issue')], + string='Urgency', + default='normal', + required=True, + ) + scheduled_date = fields.Datetime( + string='Preferred Date', + default=fields.Datetime.now, + ) + issue_summary = fields.Char( + string='Issue Summary', + help='One-line summary of what is wrong (e.g. "stairlift stops halfway up").', + ) + issue_category = fields.Char( + string='Symptom Category', + help='Optional symptom tag for catalogue matching (e.g. "battery", "motor").', + ) + internal_notes = fields.Text(string='Internal Notes') + + photo_ids = fields.Many2many( + 'ir.attachment', + 'fusion_repair_intake_wizard_eq_photo_rel', + 'eq_id', + 'attachment_id', + string='Photos / Videos', + ) + + @api.onchange('repair_category_id') + def _onchange_repair_category_id(self): + """Pre-fill the intake template from the category default.""" + if self.repair_category_id and not self.intake_template_id: + self.intake_template_id = self.repair_category_id.intake_template_id + + @api.onchange('product_id') + def _onchange_product_id(self): + """Pre-fill the category from the product if defined.""" + if self.product_id and not self.repair_category_id: + cat = self.product_id.product_tmpl_id.x_fc_repair_category_id + if cat: + self.repair_category_id = cat diff --git a/fusion_repairs/wizard/repair_intake_wizard_views.xml b/fusion_repairs/wizard/repair_intake_wizard_views.xml new file mode 100644 index 00000000..65a21502 --- /dev/null +++ b/fusion_repairs/wizard/repair_intake_wizard_views.xml @@ -0,0 +1,69 @@ + + + + + fusion.repair.intake.wizard.form + fusion.repair.intake.wizard + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ +