diff --git a/fusion_repairs/__manifest__.py b/fusion_repairs/__manifest__.py index c6450219..38d00ee4 100644 --- a/fusion_repairs/__manifest__.py +++ b/fusion_repairs/__manifest__.py @@ -76,6 +76,8 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved. # Views 'views/repair_product_category_views.xml', 'views/intake_template_views.xml', + 'views/service_catalog_views.xml', + 'views/repair_warranty_views.xml', 'views/repair_order_views.xml', 'views/res_partner_views.xml', 'views/res_users_views.xml', @@ -83,8 +85,9 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved. # Portal templates 'views/portal_sales_rep_templates.xml', 'views/portal_client_repair_templates.xml', - # Wizard + # Wizards 'wizard/repair_intake_wizard_views.xml', + 'wizard/repair_visit_report_wizard_views.xml', # Menus (last, after all referenced actions exist) 'views/menus.xml', ], diff --git a/fusion_repairs/models/__init__.py b/fusion_repairs/models/__init__.py index 5b36b893..0f4d53fe 100644 --- a/fusion_repairs/models/__init__.py +++ b/fusion_repairs/models/__init__.py @@ -6,6 +6,8 @@ from . import repair_product_category from . import intake_template from . import intake_question from . import intake_answer +from . import service_catalog +from . import repair_warranty from . import product_template from . import res_partner from . import res_users diff --git a/fusion_repairs/models/intake_service.py b/fusion_repairs/models/intake_service.py index bbe8a708..16479f97 100644 --- a/fusion_repairs/models/intake_service.py +++ b/fusion_repairs/models/intake_service.py @@ -132,6 +132,15 @@ class FusionRepairIntakeService(models.AbstractModel): # Persist intake answers. self._create_answers(repair, item.get('answers') or []) + # Service catalogue auto-match. + self._match_service_catalog(repair, item) + + # Check our own repair-warranty (30/90 day re-do free). + self._check_repair_warranty(repair) + + # Optional AI brief generation - never blocks intake. + self._generate_ai_summary(repair, item) + # Attach photos. photo_ids = item.get('photo_attachment_ids') or [] if photo_ids: @@ -146,7 +155,11 @@ class FusionRepairIntakeService(models.AbstractModel): self._schedule_activities(repair) # Optional dispatch draft task (urgent / safety). - if repair.x_fc_urgency in ('urgent', 'safety'): + # Skip if the catalogue match already auto-created one. + if ( + repair.x_fc_urgency in ('urgent', 'safety') + and not repair.x_fc_technician_task_ids + ): self._create_dispatch_task(repair) # Emails (client + office). @@ -178,6 +191,102 @@ class FusionRepairIntakeService(models.AbstractModel): parts.append('
Notes: %s
' % notes) return ''.join(parts) + # ------------------------------------------------------------------ + # SERVICE CATALOGUE MATCH + # ------------------------------------------------------------------ + @api.model + def _match_service_catalog(self, repair, item): + category = repair.x_fc_repair_category_id + if not category: + return + text_hints = [ + (item.get('issue_summary') or ''), + (item.get('issue_category') or ''), + (item.get('internal_notes') or ''), + ] + catalog = self.env['fusion.repair.service.catalog'].sudo().find_best_match( + category.id, text_hints, + ) + if not catalog: + return + repair.write({ + 'x_fc_service_catalog_id': catalog.id, + 'x_fc_estimated_duration': catalog.estimated_hours, + 'x_fc_estimated_cost': catalog.estimated_cost, + }) + # Auto-create dispatch task if catalogue says so (in addition to urgency rule). + if catalog.auto_schedule and repair.x_fc_technician_task_count == 0: + self._create_dispatch_task(repair) + + # ------------------------------------------------------------------ + # REPAIR WARRANTY (our 30/90-day re-do free) + # ------------------------------------------------------------------ + @api.model + def _check_repair_warranty(self, repair): + if not repair.partner_id: + return + warranty = self.env['fusion.repair.warranty.coverage'].sudo() \ + .find_active_for(repair.partner_id.id, repair.product_id.id or None, + repair.lot_id.id or None) + if not warranty: + return + repair.message_post( + body=_( + 'This repair MAY be covered by our active warranty %(ref)s ' + '(expires %(exp)s). Manager review recommended before invoicing.', + ref=warranty.name, + exp=warranty.expiry_date, + ), + message_type='comment', + ) + + # ------------------------------------------------------------------ + # AI SUMMARY (try/fallback per fusion-api-integration rule) + # ------------------------------------------------------------------ + @api.model + def _generate_ai_summary(self, repair, item): + try: + ApiService = self.env.get('fusion.api.service') + if not ApiService: + return + issue = (item.get('issue_summary') or '').strip() + if not issue: + return + category = repair.x_fc_repair_category_id.name or 'medical equipment' + urgency = repair.x_fc_urgency or 'normal' + messages = [ + { + 'role': 'system', + 'content': ( + 'You are an assistant for a medical equipment repair service. ' + 'Given an intake note, output ONE short paragraph (under 80 words) ' + 'briefing the technician about: likely cause, what to bring, and ' + 'any safety considerations. NEVER provide medical advice. NEVER ' + 'recommend stopping equipment use. NEVER claim a definitive cause. ' + 'Plain English, no jargon.' + ), + }, + { + 'role': 'user', + 'content': ( + f'Equipment category: {category}\n' + f'Urgency: {urgency}\n' + f'Issue: {issue}\n' + f'Notes: {(item.get("internal_notes") or "").strip()}' + ), + }, + ] + summary = ApiService.call_openai( + consumer='fusion_repairs', + feature='intake_triage', + messages=messages, + max_tokens=200, + ) + if summary: + repair.x_fc_ai_summary = summary.strip() + except Exception as e: + _logger.info('AI intake summary skipped: %s', e) + # ------------------------------------------------------------------ # ORIGINAL SO AUTO-LINK # ------------------------------------------------------------------ diff --git a/fusion_repairs/models/repair_order.py b/fusion_repairs/models/repair_order.py index 91de2022..06854124 100644 --- a/fusion_repairs/models/repair_order.py +++ b/fusion_repairs/models/repair_order.py @@ -5,6 +5,7 @@ from datetime import timedelta from odoo import api, fields, models, _ +from odoo.exceptions import UserError INTAKE_SOURCES = [ @@ -62,6 +63,14 @@ class RepairOrder(models.Model): 'repair_id', string='Intake Answers', ) + + # Catalogue match (Phase 2) + x_fc_service_catalog_id = fields.Many2one( + 'fusion.repair.service.catalog', + string='Service Catalogue Match', + index=True, + help='Auto-matched catalogue entry that pre-fills estimated cost and duration.', + ) x_fc_intake_answer_count = fields.Integer( compute='_compute_intake_answer_count', ) @@ -279,3 +288,35 @@ class RepairOrder(models.Model): 'view_mode': 'form', 'res_id': self.x_fc_original_sale_order_id.id, } + + # ------------------------------------------------------------------ + # WIZARDS / PAYMENT + # ------------------------------------------------------------------ + def action_open_visit_report(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': _('Visit Report'), + 'res_model': 'fusion.repair.visit.report.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': { + 'default_repair_id': self.id, + 'default_labour_hours': self.x_fc_estimated_duration or 1.0, + }, + } + + def action_collect_payment(self): + """Open the Poynt payment wizard for the linked posted invoice.""" + self.ensure_one() + # Resolve the linked invoice via the standard repair -> SO -> invoice chain. + if not self.sale_order_id: + raise UserError(_('Confirm a sale order from this repair first.')) + invoice = self.sale_order_id.invoice_ids.filtered( + lambda m: m.state == 'posted' and m.payment_state in ('not_paid', 'partial') + )[:1] + if not invoice: + raise UserError(_('No posted, unpaid invoice was found for this repair.')) + if hasattr(invoice, 'action_open_poynt_payment_wizard'): + return invoice.action_open_poynt_payment_wizard() + raise UserError(_('Poynt payment is not available - install or configure fusion_poynt.')) diff --git a/fusion_repairs/models/repair_warranty.py b/fusion_repairs/models/repair_warranty.py new file mode 100644 index 00000000..29c0756e --- /dev/null +++ b/fusion_repairs/models/repair_warranty.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +"""Repair warranty coverage. + +Tracks the 30/90-day warranty we offer on completed repair work. +When a new repair is created on the same equipment within the +coverage window, the intake wizard / portal shows a banner: +"This repair may be covered by our warranty - no charge". + +Phase 2 ships the model + manual creation from a completed repair. +Phase 4 will add automatic creation when a repair moves to 'done'. +""" + +from datetime import timedelta + +from odoo import api, fields, models + + +class FusionRepairWarrantyCoverage(models.Model): + _name = 'fusion.repair.warranty.coverage' + _description = 'Repair Warranty Coverage' + _order = 'expiry_date desc, id desc' + + name = fields.Char(string='Reference', compute='_compute_name', store=True) + repair_id = fields.Many2one( + 'repair.order', + string='Original Repair', + required=True, + ondelete='cascade', + index=True, + ) + partner_id = fields.Many2one( + 'res.partner', + string='Client', + related='repair_id.partner_id', + store=True, + index=True, + ) + product_id = fields.Many2one( + 'product.product', + string='Equipment', + related='repair_id.product_id', + store=True, + index=True, + ) + lot_id = fields.Many2one( + 'stock.lot', + string='Serial Number', + related='repair_id.lot_id', + store=True, + ) + + start_date = fields.Date( + string='Start Date', + required=True, + default=fields.Date.context_today, + ) + coverage_days = fields.Integer( + string='Coverage Window (days)', + default=30, + required=True, + ) + expiry_date = fields.Date( + string='Expires', + compute='_compute_expiry_date', + store=True, + ) + is_active = fields.Boolean( + string='Active', + compute='_compute_is_active', + store=True, + ) + + notes = fields.Text() + company_id = fields.Many2one( + 'res.company', + string='Company', + related='repair_id.company_id', + store=True, + ) + + @api.depends('repair_id.name', 'expiry_date') + def _compute_name(self): + for w in self: + w.name = ( + f"Warranty {w.repair_id.name or '?'} (until {w.expiry_date or '?'})" + ) + + @api.depends('start_date', 'coverage_days') + def _compute_expiry_date(self): + for w in self: + if w.start_date and w.coverage_days: + w.expiry_date = w.start_date + timedelta(days=w.coverage_days) + else: + w.expiry_date = False + + @api.depends('expiry_date') + def _compute_is_active(self): + today = fields.Date.context_today(self) + for w in self: + w.is_active = bool(w.expiry_date and w.expiry_date >= today) + + # ------------------------------------------------------------------ + # LOOKUP + # ------------------------------------------------------------------ + @api.model + def find_active_for(self, partner_id, product_id=None, lot_id=None): + """Return active warranty coverage matching the partner + equipment, if any.""" + if not partner_id: + return self.browse() + domain = [ + ('partner_id', '=', partner_id), + ('is_active', '=', True), + ] + if lot_id: + domain.append(('lot_id', '=', lot_id)) + elif product_id: + domain.append(('product_id', '=', product_id)) + return self.search(domain, order='expiry_date desc', limit=1) diff --git a/fusion_repairs/models/service_catalog.py b/fusion_repairs/models/service_catalog.py new file mode 100644 index 00000000..fcc08488 --- /dev/null +++ b/fusion_repairs/models/service_catalog.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +"""Service catalogue. + +Each fusion.repair.service.catalog record is a named repair / maintenance +service (e.g. "Stairlift motor replacement", "Bed remote troubleshoot") +with estimated duration, estimated cost, default parts, and symptom +keywords used to auto-match an intake to the right catalogue entry. + +The catalogue feeds: +- intake auto-match -> sets x_fc_service_catalog_id + + x_fc_estimated_duration + x_fc_estimated_cost on the repair +- visit report -> default labour line + parts pre-fill +- pricing variance -> compares estimate vs actual +""" + +from odoo import api, fields, models + + +class FusionRepairServiceCatalog(models.Model): + _name = 'fusion.repair.service.catalog' + _description = 'Repair Service Catalogue Entry' + _order = 'sequence, name' + + name = fields.Char(string='Service Name', required=True, translate=True) + code = fields.Char(string='Code', help='Stable identifier (lowercase, no spaces).') + sequence = fields.Integer(default=10) + active = fields.Boolean(default=True) + company_id = fields.Many2one( + 'res.company', string='Company', + default=lambda self: self.env.company, + ) + + # Routing & matching + product_category_id = fields.Many2one( + 'fusion.repair.product.category', + string='Equipment Category', + required=True, + index=True, + ) + symptom_keywords = fields.Char( + string='Symptom Keywords', + help='Comma-separated keywords used to auto-match an intake to this catalogue entry. ' + 'Matched against the issue summary, issue category, and intake answer text.', + ) + + # Service product (what actually gets invoiced) + service_product_id = fields.Many2one( + 'product.product', + string='Service Product', + domain=[('type', '=', 'service')], + help='Product line added to the repair sale order for the labour portion.', + ) + default_parts_product_ids = fields.Many2many( + 'product.product', + 'fusion_repair_catalog_parts_rel', + 'catalog_id', 'product_id', + string='Default Parts', + help='Parts typically used. Pre-loaded onto the visit report wizard for the tech to confirm.', + ) + pricelist_id = fields.Many2one( + 'product.pricelist', + string='Pricelist Override', + help='Optional pricelist applied to repair SOs from this catalogue entry. ' + 'Leave blank to use the partner default pricelist.', + ) + + # Estimates + estimated_hours = fields.Float( + string='Estimated Labour (h)', + default=1.0, + help='Used to size the technician task and the visit report labour default.', + ) + estimated_cost = fields.Monetary( + string='Estimated Cost', + currency_field='company_currency_id', + help='Headline estimate shown to the client/CS during intake. Phase 1 is a flat number; ' + 'Phase 2+ may compute from labour + parts.', + ) + + # Automation hints + auto_schedule = fields.Boolean( + string='Auto-Create Tech Task', + help='When True, the intake service creates a draft technician task immediately for any ' + 'repair matched to this catalogue entry (even at normal urgency).', + ) + task_type = fields.Selection( + [('delivery', 'Delivery'), ('repair', 'Repair'), ('pickup', 'Pickup'), + ('troubleshoot', 'Troubleshoot'), ('assessment', 'Assessment'), + ('installation', 'Installation'), ('maintenance', 'Maintenance'), + ('other', 'Other')], + string='Default Task Type', + default='repair', + ) + + company_currency_id = fields.Many2one( + 'res.currency', + related='company_id.currency_id', + readonly=True, + ) + + @api.depends('name', 'code') + def _compute_display_name(self): + for c in self: + c.display_name = c.name or c.code or '' + + # ------------------------------------------------------------------ + # MATCHING + # ------------------------------------------------------------------ + @api.model + def find_best_match(self, product_category_id, text_hints): + """Return the best-matching catalogue entry, or empty recordset. + + :param product_category_id: int id of the equipment category + :param text_hints: list[str] - text snippets to look for symptom keywords in + (typically: issue_summary, issue_category, recent intake answer values) + """ + if not product_category_id: + return self.browse() + haystack = ' '.join(s.lower() for s in (text_hints or []) if s).strip() + candidates = self.search([ + ('product_category_id', '=', product_category_id), + ('active', '=', True), + ], order='sequence') + if not candidates: + return self.browse() + if not haystack: + return candidates[:1] + best = None + best_score = 0 + for c in candidates: + kws = [k.strip().lower() for k in (c.symptom_keywords or '').split(',') if k.strip()] + score = sum(1 for kw in kws if kw and kw in haystack) + if score > best_score: + best = c + best_score = score + if best: + return best + return candidates[:1] diff --git a/fusion_repairs/security/ir.model.access.csv b/fusion_repairs/security/ir.model.access.csv index 1574ea4d..4d58809f 100644 --- a/fusion_repairs/security/ir.model.access.csv +++ b/fusion_repairs/security/ir.model.access.csv @@ -10,3 +10,9 @@ access_repair_intake_answer_manager,Intake Answer Manager Full,model_fusion_repa access_repair_intake_answer_tech_portal,Intake Answer Technician Read,model_fusion_repair_intake_answer,fusion_tasks.group_field_technician,1,0,0,0 access_repair_intake_wizard_user,Intake Wizard User Full,model_fusion_repair_intake_wizard,group_fusion_repairs_user,1,1,1,1 access_repair_intake_wizard_equipment_user,Intake Wizard Equipment User Full,model_fusion_repair_intake_wizard_equipment,group_fusion_repairs_user,1,1,1,1 +access_repair_service_catalog_user,Catalogue User Read,model_fusion_repair_service_catalog,group_fusion_repairs_user,1,0,0,0 +access_repair_service_catalog_manager,Catalogue Manager Full,model_fusion_repair_service_catalog,group_fusion_repairs_manager,1,1,1,1 +access_repair_warranty_user,Warranty User Read,model_fusion_repair_warranty_coverage,group_fusion_repairs_user,1,0,0,0 +access_repair_warranty_manager,Warranty Manager Full,model_fusion_repair_warranty_coverage,group_fusion_repairs_manager,1,1,1,1 +access_repair_visit_report_wizard_user,Visit Report Wizard User,model_fusion_repair_visit_report_wizard,group_fusion_repairs_user,1,1,1,1 +access_repair_visit_report_wizard_line_user,Visit Report Line User,model_fusion_repair_visit_report_wizard_line,group_fusion_repairs_user,1,1,1,1 diff --git a/fusion_repairs/views/menus.xml b/fusion_repairs/views/menus.xml index 8820056a..4a30c436 100644 --- a/fusion_repairs/views/menus.xml +++ b/fusion_repairs/views/menus.xml @@ -45,4 +45,16 @@ action="action_repair_intake_template" sequence="20"/> + + + + diff --git a/fusion_repairs/views/repair_order_views.xml b/fusion_repairs/views/repair_order_views.xml index 9544f9e0..b49549ff 100644 --- a/fusion_repairs/views/repair_order_views.xml +++ b/fusion_repairs/views/repair_order_views.xml @@ -10,6 +10,22 @@