diff --git a/fusion_repairs/__manifest__.py b/fusion_repairs/__manifest__.py index b0de4ed2..5c9be84d 100644 --- a/fusion_repairs/__manifest__.py +++ b/fusion_repairs/__manifest__.py @@ -4,7 +4,7 @@ { 'name': 'Fusion Repairs', - 'version': '19.0.1.0.7', + 'version': '19.0.1.1.0', 'category': 'Inventory/Repairs', 'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal', 'description': """ @@ -83,6 +83,7 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved. 'views/repair_dashboard_views.xml', 'views/repair_order_views.xml', 'views/sale_order_views.xml', + 'views/technician_task_views.xml', 'views/res_partner_views.xml', 'views/res_users_views.xml', 'views/res_config_settings_views.xml', diff --git a/fusion_repairs/models/intake_service.py b/fusion_repairs/models/intake_service.py index ef44a55a..b1ab387c 100644 --- a/fusion_repairs/models/intake_service.py +++ b/fusion_repairs/models/intake_service.py @@ -39,6 +39,7 @@ class FusionRepairIntakeService(models.AbstractModel): :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) + - quote_only: bool (optional, C6 - skips dispatch task creation) - equipment_items: list of dicts, each with: - product_id: int (optional) - lot_id: int (optional) @@ -68,6 +69,7 @@ class FusionRepairIntakeService(models.AbstractModel): ) equipment = payload.get('equipment_items') or [{}] + quote_only = bool(payload.get('quote_only')) repairs = self.env['repair.order'] for item in equipment: repair = self._create_single_repair( @@ -76,6 +78,7 @@ class FusionRepairIntakeService(models.AbstractModel): session_ref=session_ref, source=source, item=item, + quote_only=quote_only, ) repairs |= repair @@ -103,7 +106,8 @@ class FusionRepairIntakeService(models.AbstractModel): # CORE CREATION # ------------------------------------------------------------------ @api.model - def _create_single_repair(self, partner_id, intake_user, session_ref, source, item): + def _create_single_repair(self, partner_id, intake_user, session_ref, + source, item, quote_only=False): Repair = self.env['repair.order'] product_id = item.get('product_id') @@ -139,7 +143,7 @@ class FusionRepairIntakeService(models.AbstractModel): self._create_answers(repair, item.get('answers') or []) # Service catalogue auto-match. - self._match_service_catalog(repair, item) + self._match_service_catalog(repair, item, quote_only=quote_only) # Check our own repair-warranty (30/90 day re-do free). self._check_repair_warranty(repair) @@ -162,11 +166,17 @@ class FusionRepairIntakeService(models.AbstractModel): # Optional dispatch draft task (urgent / safety). # Skip if the catalogue match already auto-created one. + # Skip entirely if intake is quote-only (C6). if ( - repair.x_fc_urgency in ('urgent', 'safety') + not quote_only + and repair.x_fc_urgency in ('urgent', 'safety') and not repair.x_fc_technician_task_ids ): self._create_dispatch_task(repair) + elif quote_only: + repair.message_post(body=Markup(_( + 'Created in Quote Only mode - no technician dispatched.' + ))) # Emails (client + office). self._send_intake_emails(repair) @@ -202,7 +212,7 @@ class FusionRepairIntakeService(models.AbstractModel): # SERVICE CATALOGUE MATCH # ------------------------------------------------------------------ @api.model - def _match_service_catalog(self, repair, item): + def _match_service_catalog(self, repair, item, quote_only=False): category = repair.x_fc_repair_category_id if not category: return @@ -222,7 +232,12 @@ class FusionRepairIntakeService(models.AbstractModel): '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: + # Quote-only intakes skip this too. + if ( + catalog.auto_schedule + and repair.x_fc_technician_task_count == 0 + and not quote_only + ): self._create_dispatch_task(repair) # ------------------------------------------------------------------ @@ -410,29 +425,61 @@ class FusionRepairIntakeService(models.AbstractModel): 'description': repair.internal_notes or repair.name, } # technician_id is required AND constrained to x_fc_is_field_staff. - # Use the intake user if they qualify, otherwise the lowest-id active - # field-staff user as a placeholder for the dispatcher to reassign. - if repair.user_id and repair.user_id.x_fc_is_field_staff: - vals['technician_id'] = repair.user_id.id - else: - fallback = self.env['res.users'].sudo().search([ - ('x_fc_is_field_staff', '=', True), - ('active', '=', True), - ], order='id', limit=1) - if not fallback: - _logger.warning( - 'No field-staff user available - skipping auto-dispatch ' - 'task for repair %s (mark a user as Field Staff under ' - 'Settings > Users).', - repair.name, - ) - return - vals['technician_id'] = fallback.id + # D2: prefer a tech whose x_fc_repair_skills covers this repair's + # category. Falls back to ANY active field-staff user if no skilled + # tech exists, then to the lowest-id field-staff user as a placeholder. + tech_id = self._pick_dispatch_technician(repair) + if not tech_id: + _logger.warning( + 'No field-staff user available - skipping auto-dispatch ' + 'task for repair %s (mark a user as Field Staff under ' + 'Settings > Users).', + repair.name, + ) + return + vals['technician_id'] = tech_id Task.create(vals) except Exception as e: _logger.warning('Failed to auto-create dispatch task for repair %s: %s', repair.name, e) + @api.model + def _pick_dispatch_technician(self, repair): + """D2: pick the best technician for the initial dispatch task. + + Preference order: + 1. The intake user IF they are field staff AND have the skill + 2. Any active field-staff user with x_fc_repair_skills covering + the repair's product category + 3. Any active field-staff user (no skills filter) + + Returns the chosen user id, or False if none found. + """ + Users = self.env['res.users'].sudo() + category = repair.x_fc_repair_category_id + + # Try intake user first if they qualify. + if repair.user_id and repair.user_id.x_fc_is_field_staff: + if not category or category in repair.user_id.x_fc_repair_skills: + return repair.user_id.id + + # Skills-filtered candidates. + if category: + skilled = Users.search([ + ('x_fc_is_field_staff', '=', True), + ('active', '=', True), + ('x_fc_repair_skills', 'in', category.id), + ], order='id', limit=1) + if skilled: + return skilled.id + + # Any active field staff. + fallback = Users.search([ + ('x_fc_is_field_staff', '=', True), + ('active', '=', True), + ], order='id', limit=1) + return fallback.id if fallback else False + # ------------------------------------------------------------------ # EMAILS # ------------------------------------------------------------------ diff --git a/fusion_repairs/models/technician_task.py b/fusion_repairs/models/technician_task.py index 40a59996..d4241a9a 100644 --- a/fusion_repairs/models/technician_task.py +++ b/fusion_repairs/models/technician_task.py @@ -4,7 +4,7 @@ from markupsafe import Markup -from odoo import fields, models +from odoo import _, fields, models class FusionTechnicianTaskRepairs(models.Model): @@ -69,3 +69,34 @@ class FusionTechnicianTaskRepairs(models.Model): 'view_mode': 'form', 'res_id': self.x_fc_repair_order_id.id, } + + # ------------------------------------------------------------------ + # T1: Open in Maps - returns an act_url action that opens the device's + # default maps app (Apple Maps on iOS, Google Maps on Android, browser + # otherwise). Address is built from the task's address fields with the + # partner address as a fallback. + # ------------------------------------------------------------------ + def action_open_in_maps(self): + self.ensure_one() + from urllib.parse import quote_plus + parts = [] + for f in ('address_street', 'address_city', 'address_zip'): + v = getattr(self, f, None) + if v: + parts.append(str(v)) + if not parts and self.partner_id: + for f in ('street', 'street2', 'city', 'state_id', 'zip'): + v = getattr(self.partner_id, f, None) + if v: + parts.append(v.name if hasattr(v, 'name') else str(v)) + if not parts: + from odoo.exceptions import UserError + raise UserError(_('No address on this task or its client.')) + query = quote_plus(', '.join(parts)) + # https://www.google.com/maps?q=ADDR works on every platform and + # automatically deep-links into the native app where supported. + return { + 'type': 'ir.actions.act_url', + 'url': f'https://www.google.com/maps?q={query}', + 'target': 'new', + } diff --git a/fusion_repairs/views/technician_task_views.xml b/fusion_repairs/views/technician_task_views.xml new file mode 100644 index 00000000..ec1c8fe4 --- /dev/null +++ b/fusion_repairs/views/technician_task_views.xml @@ -0,0 +1,32 @@ + + + + + + fusion.technician.task.form.inherit.fusion_repairs + fusion.technician.task + + + +