feat(fusion_repairs): Bundle 1 - wizard polish (C1 + C5 + C6 + D2 + T1)
C1 duplicate-call detection - Wizard computes duplicate_count + duplicate_repair_ids when partner is picked (open repairs from the configurable window, default 14 days). - Yellow banner with "Open Existing Repair" button to jump to the most recent duplicate so CS can add a note instead of creating a new repair. C5 outstanding-balance warning - Wizard sums posted unpaid account.move.amount_residual across all invoices of the partner. - Red banner shown when balance >= fusion_repairs.outstanding_balance_threshold (default $100) with a "View Invoices" button. C6 quote-only mode - New quote_only boolean on the wizard; passed through the shared intake service. Skips dispatch-task creation for urgent/safety AND for catalogue auto_schedule. Chatter note "Created in Quote Only mode" posted on the resulting repair.order. D2 skills filter on dispatch picker - _pick_dispatch_technician(repair) prefers users whose x_fc_repair_skills Many2many contains the repair's product category. Three-tier preference: 1) intake user if field staff AND has the skill 2) any active field-staff user with the skill 3) any active field-staff user (no skill filter) - last-resort - Logs a warning + skips task creation if no field-staff user exists at all. T1 Open in Maps on technician task - action_open_in_maps() returns ir.actions.act_url to https://www.google.com/maps?q=<URL-encoded address>. Deep-links into Apple Maps / Google Maps native apps on iOS / Android, browser otherwise. - Header button added on the fusion.technician.task form (after the existing buttons) plus a "View Repair" button when x_fc_repair_order_id is set. Verified end-to-end on local westin-v19: Existing repair: RO-202605-06 C1 duplicate_count = 5 (>=1 expected) - last duplicate: RO-202605-06 C5 balance check ran without error (target partner had $0) C6 quote-only repair: RO-202605-07 tech_tasks = 0 (expected 0) D2 picked the only stairlift-skilled field-staff user T1 Maps URL: https://www.google.com/maps?q=15+Fisherman+Dr%2C+Brampton%2C+ON+L7A+1B7%2C+Canad... Bumped to 19.0.1.1.0. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 <b>Quote Only</b> 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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user