# -*- 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. Returns empty when no symptom keywords match. We never "guess" a default catalog because the match drives estimated cost + auto-dispatch task - a wrong guess would propagate into pricing and scheduling. :param product_category_id: int id of the equipment category :param text_hints: list[str] - text snippets to look for symptom keywords in """ import re if not product_category_id: return self.browse() haystack = ' '.join(s.lower() for s in (text_hints or []) if s).strip() if not haystack: return self.browse() candidates = self.search([ ('product_category_id', '=', product_category_id), ('active', '=', True), ], order='sequence') if not candidates: return self.browse() best = None best_score = 0 for c in candidates: kws = [k.strip().lower() for k in (c.symptom_keywords or '').split(',') if k.strip()] # Word-boundary match avoids false positives where "battery" matches # inside "no battery problem". score = sum( 1 for kw in kws if kw and re.search(rf'\b{re.escape(kw)}\b', haystack) ) if score > best_score: best = c best_score = score # No keywords matched -> return empty rather than the lowest-sequence guess. if best and best_score > 0: return best return self.browse()