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 @@
+
+
+
+
+ Hello
| 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. +
++ Internal: New Service Call +
+
+ Submitted by
| Details | |
| Reference | |
| Client | |
| Phone | |
| Equipment | |
| Urgency | |
| Third-party | Yes - equipment not sold by us |
| Warranty | Under warranty |
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 @@ + +