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:
gsinghpal
2026-05-20 23:27:43 -04:00
parent d15d9e4303
commit 194850e3cf
6 changed files with 303 additions and 25 deletions

View File

@@ -4,7 +4,7 @@
{ {
'name': 'Fusion Repairs', 'name': 'Fusion Repairs',
'version': '19.0.1.0.7', 'version': '19.0.1.1.0',
'category': 'Inventory/Repairs', 'category': 'Inventory/Repairs',
'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal', 'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal',
'description': """ 'description': """
@@ -83,6 +83,7 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
'views/repair_dashboard_views.xml', 'views/repair_dashboard_views.xml',
'views/repair_order_views.xml', 'views/repair_order_views.xml',
'views/sale_order_views.xml', 'views/sale_order_views.xml',
'views/technician_task_views.xml',
'views/res_partner_views.xml', 'views/res_partner_views.xml',
'views/res_users_views.xml', 'views/res_users_views.xml',
'views/res_config_settings_views.xml', 'views/res_config_settings_views.xml',

View File

@@ -39,6 +39,7 @@ class FusionRepairIntakeService(models.AbstractModel):
:param payload: dict with keys: :param payload: dict with keys:
- partner_id: int (required) or partner_vals: dict to create new partner - partner_id: int (required) or partner_vals: dict to create new partner
- intake_user_id: int (optional, defaults to env.user) - 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: - equipment_items: list of dicts, each with:
- product_id: int (optional) - product_id: int (optional)
- lot_id: int (optional) - lot_id: int (optional)
@@ -68,6 +69,7 @@ class FusionRepairIntakeService(models.AbstractModel):
) )
equipment = payload.get('equipment_items') or [{}] equipment = payload.get('equipment_items') or [{}]
quote_only = bool(payload.get('quote_only'))
repairs = self.env['repair.order'] repairs = self.env['repair.order']
for item in equipment: for item in equipment:
repair = self._create_single_repair( repair = self._create_single_repair(
@@ -76,6 +78,7 @@ class FusionRepairIntakeService(models.AbstractModel):
session_ref=session_ref, session_ref=session_ref,
source=source, source=source,
item=item, item=item,
quote_only=quote_only,
) )
repairs |= repair repairs |= repair
@@ -103,7 +106,8 @@ class FusionRepairIntakeService(models.AbstractModel):
# CORE CREATION # CORE CREATION
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@api.model @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'] Repair = self.env['repair.order']
product_id = item.get('product_id') product_id = item.get('product_id')
@@ -139,7 +143,7 @@ class FusionRepairIntakeService(models.AbstractModel):
self._create_answers(repair, item.get('answers') or []) self._create_answers(repair, item.get('answers') or [])
# Service catalogue auto-match. # 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). # Check our own repair-warranty (30/90 day re-do free).
self._check_repair_warranty(repair) self._check_repair_warranty(repair)
@@ -162,11 +166,17 @@ class FusionRepairIntakeService(models.AbstractModel):
# Optional dispatch draft task (urgent / safety). # Optional dispatch draft task (urgent / safety).
# Skip if the catalogue match already auto-created one. # Skip if the catalogue match already auto-created one.
# Skip entirely if intake is quote-only (C6).
if ( 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 and not repair.x_fc_technician_task_ids
): ):
self._create_dispatch_task(repair) 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). # Emails (client + office).
self._send_intake_emails(repair) self._send_intake_emails(repair)
@@ -202,7 +212,7 @@ class FusionRepairIntakeService(models.AbstractModel):
# SERVICE CATALOGUE MATCH # SERVICE CATALOGUE MATCH
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@api.model @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 category = repair.x_fc_repair_category_id
if not category: if not category:
return return
@@ -222,7 +232,12 @@ class FusionRepairIntakeService(models.AbstractModel):
'x_fc_estimated_cost': catalog.estimated_cost, 'x_fc_estimated_cost': catalog.estimated_cost,
}) })
# Auto-create dispatch task if catalogue says so (in addition to urgency rule). # 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) self._create_dispatch_task(repair)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -410,29 +425,61 @@ class FusionRepairIntakeService(models.AbstractModel):
'description': repair.internal_notes or repair.name, 'description': repair.internal_notes or repair.name,
} }
# technician_id is required AND constrained to x_fc_is_field_staff. # technician_id is required AND constrained to x_fc_is_field_staff.
# Use the intake user if they qualify, otherwise the lowest-id active # D2: prefer a tech whose x_fc_repair_skills covers this repair's
# field-staff user as a placeholder for the dispatcher to reassign. # category. Falls back to ANY active field-staff user if no skilled
if repair.user_id and repair.user_id.x_fc_is_field_staff: # tech exists, then to the lowest-id field-staff user as a placeholder.
vals['technician_id'] = repair.user_id.id tech_id = self._pick_dispatch_technician(repair)
else: if not tech_id:
fallback = self.env['res.users'].sudo().search([ _logger.warning(
('x_fc_is_field_staff', '=', True), 'No field-staff user available - skipping auto-dispatch '
('active', '=', True), 'task for repair %s (mark a user as Field Staff under '
], order='id', limit=1) 'Settings > Users).',
if not fallback: repair.name,
_logger.warning( )
'No field-staff user available - skipping auto-dispatch ' return
'task for repair %s (mark a user as Field Staff under ' vals['technician_id'] = tech_id
'Settings > Users).',
repair.name,
)
return
vals['technician_id'] = fallback.id
Task.create(vals) Task.create(vals)
except Exception as e: except Exception as e:
_logger.warning('Failed to auto-create dispatch task for repair %s: %s', _logger.warning('Failed to auto-create dispatch task for repair %s: %s',
repair.name, e) 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 # EMAILS
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View File

@@ -4,7 +4,7 @@
from markupsafe import Markup from markupsafe import Markup
from odoo import fields, models from odoo import _, fields, models
class FusionTechnicianTaskRepairs(models.Model): class FusionTechnicianTaskRepairs(models.Model):
@@ -69,3 +69,34 @@ class FusionTechnicianTaskRepairs(models.Model):
'view_mode': 'form', 'view_mode': 'form',
'res_id': self.x_fc_repair_order_id.id, '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',
}

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Add Maps + View Repair buttons to the technician task form
(header so they're prominent on mobile). -->
<record id="view_technician_task_form_inherit_fusion_repairs"
model="ir.ui.view">
<field name="name">fusion.technician.task.form.inherit.fusion_repairs</field>
<field name="model">fusion.technician.task</field>
<field name="inherit_id" ref="fusion_tasks.view_technician_task_form"/>
<field name="arch" type="xml">
<xpath expr="//header" position="inside">
<button name="action_open_in_maps"
type="object"
string="Open in Maps"
class="btn-secondary"
icon="fa-map-marker"/>
<button name="action_view_repair_order"
type="object"
string="View Repair"
class="btn-secondary"
icon="fa-wrench"
invisible="not x_fc_repair_order_id"/>
</xpath>
<xpath expr="//field[@name='partner_id']" position="after">
<field name="x_fc_repair_order_id" readonly="1"
invisible="not x_fc_repair_order_id"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -10,9 +10,16 @@ repair.order(s). The shared service guarantees identical behaviour to the
sales rep portal and the public client portal added in later phases. sales rep portal and the public client portal added in later phases.
Multi-equipment per call is supported via the equipment_ids One2many. Multi-equipment per call is supported via the equipment_ids One2many.
Includes Phase 1 polish:
- C1: duplicate-call detection (yellow banner if the partner has an open
repair from the last N days)
- C5: outstanding-balance warning (red banner if open invoice total > config)
- C6: quote-only mode (creates the repair but does NOT dispatch a tech)
""" """
import logging import logging
from datetime import timedelta
from odoo import _, api, fields, models from odoo import _, api, fields, models
from odoo.exceptions import UserError from odoo.exceptions import UserError
@@ -45,6 +52,43 @@ class RepairIntakeWizard(models.TransientModel):
readonly=True, readonly=True,
) )
# ------------------------------------------------------------------
# CONTEXTUAL BANNERS (C1 + C5)
# Computed reactively when the partner is selected. Shown in the form
# so CS knows immediately about duplicate calls or unpaid invoices.
# ------------------------------------------------------------------
duplicate_repair_ids = fields.Many2many(
'repair.order',
compute='_compute_partner_context',
string='Open Repairs (last N days)',
)
duplicate_count = fields.Integer(
compute='_compute_partner_context',
string='Duplicate Call Count',
)
outstanding_balance = fields.Float(
compute='_compute_partner_context',
string='Open Invoice Balance',
)
outstanding_invoice_count = fields.Integer(
compute='_compute_partner_context',
string='Open Invoices',
)
show_outstanding_warning = fields.Boolean(
compute='_compute_partner_context',
string='Show Outstanding Balance Warning',
)
# ------------------------------------------------------------------
# OPTIONS (C6 quote-only mode)
# ------------------------------------------------------------------
quote_only = fields.Boolean(
string='Quote Only - Do Not Dispatch',
help='Create the service request and quote the client, but do NOT '
'auto-create a technician dispatch task. Use this when the client '
'is gathering quotes or has not yet authorised the repair.',
)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# EQUIPMENT (one-or-many) # EQUIPMENT (one-or-many)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -55,6 +99,56 @@ class RepairIntakeWizard(models.TransientModel):
required=True, required=True,
) )
# ------------------------------------------------------------------
# COMPUTES
# ------------------------------------------------------------------
@api.depends('partner_id')
def _compute_partner_context(self):
ICP = self.env['ir.config_parameter'].sudo()
try:
window_days = int(ICP.get_param(
'fusion_repairs.duplicate_call_window_days', '14'
))
except (ValueError, TypeError):
window_days = 14
try:
threshold = float(ICP.get_param(
'fusion_repairs.outstanding_balance_threshold', '100'
))
except (ValueError, TypeError):
threshold = 100.0
Repair = self.env['repair.order'].sudo()
Move = self.env['account.move'].sudo()
cutoff = fields.Date.context_today(self) - timedelta(days=window_days)
for w in self:
if not w.partner_id:
w.duplicate_repair_ids = False
w.duplicate_count = 0
w.outstanding_balance = 0.0
w.outstanding_invoice_count = 0
w.show_outstanding_warning = False
continue
dupes = Repair.search([
('partner_id', '=', w.partner_id.id),
('state', 'not in', ('done', 'cancel')),
('create_date', '>=', cutoff),
], order='create_date desc', limit=5)
w.duplicate_repair_ids = dupes
w.duplicate_count = len(dupes)
open_invoices = Move.search([
('partner_id', 'child_of', w.partner_id.id),
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('payment_state', 'in', ('not_paid', 'partial')),
])
balance = sum(open_invoices.mapped('amount_residual'))
w.outstanding_balance = balance
w.outstanding_invoice_count = len(open_invoices)
w.show_outstanding_warning = balance >= threshold
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# SUBMIT # SUBMIT
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -66,6 +160,7 @@ class RepairIntakeWizard(models.TransientModel):
payload = { payload = {
'partner_id': self.partner_id.id, 'partner_id': self.partner_id.id,
'intake_user_id': self.intake_user_id.id, 'intake_user_id': self.intake_user_id.id,
'quote_only': self.quote_only,
'equipment_items': [self._equipment_payload(eq) for eq in self.equipment_ids], 'equipment_items': [self._equipment_payload(eq) for eq in self.equipment_ids],
} }
@@ -91,6 +186,39 @@ class RepairIntakeWizard(models.TransientModel):
'domain': [('id', 'in', repairs.ids)], 'domain': [('id', 'in', repairs.ids)],
} }
def action_open_existing_repair(self):
"""C1: jump to the most recent duplicate repair so CS can add a note
instead of creating a new repair."""
self.ensure_one()
if not self.duplicate_repair_ids:
return False
repair = self.duplicate_repair_ids[0]
return {
'type': 'ir.actions.act_window',
'name': repair.name,
'res_model': 'repair.order',
'view_mode': 'form',
'res_id': repair.id,
'target': 'current',
}
def action_view_outstanding_invoices(self):
"""C5: open the list of unpaid invoices for context."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Open Invoices - %s', self.partner_id.name or ''),
'res_model': 'account.move',
'view_mode': 'list,form',
'domain': [
('partner_id', 'child_of', self.partner_id.id),
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('payment_state', 'in', ('not_paid', 'partial')),
],
'target': 'current',
}
def _equipment_payload(self, eq): def _equipment_payload(self, eq):
"""Render an equipment record as a dict the intake service expects.""" """Render an equipment record as a dict the intake service expects."""
return { return {

View File

@@ -16,6 +16,40 @@
</group> </group>
</group> </group>
<!-- C1: duplicate-call detection banner -->
<div class="alert alert-warning d-flex justify-content-between align-items-center"
role="alert"
invisible="duplicate_count == 0">
<div>
<i class="fa fa-exclamation-triangle me-1"/>
<strong>Open repair already exists for this client</strong>
(<field name="duplicate_count" nolabel="1" readonly="1" class="d-inline"/> in last 14 days).
Consider adding a note to the existing repair instead.
</div>
<button name="action_open_existing_repair"
type="object"
string="Open Existing Repair"
class="btn btn-sm btn-warning"/>
</div>
<field name="duplicate_repair_ids" invisible="1"/>
<!-- C5: outstanding-balance warning banner -->
<div class="alert alert-danger d-flex justify-content-between align-items-center"
role="alert"
invisible="not show_outstanding_warning">
<div>
<i class="fa fa-money me-1"/>
<strong>Outstanding balance:</strong>
<field name="outstanding_balance" widget="monetary" nolabel="1" readonly="1" class="d-inline"/>
across <field name="outstanding_invoice_count" nolabel="1" readonly="1" class="d-inline"/> invoice(s).
Worth mentioning during this call.
</div>
<button name="action_view_outstanding_invoices"
type="object"
string="View Invoices"
class="btn btn-sm btn-danger"/>
</div>
<separator string="Equipment Items (one repair per item)"/> <separator string="Equipment Items (one repair per item)"/>
<field name="equipment_ids"> <field name="equipment_ids">
<list editable="bottom"> <list editable="bottom">
@@ -54,6 +88,11 @@
<field name="photo_ids" widget="many2many_binary"/> <field name="photo_ids" widget="many2many_binary"/>
</form> </form>
</field> </field>
<!-- C6: quote-only mode -->
<separator string="Options"/>
<group>
<field name="quote_only"/>
</group>
</sheet> </sheet>
<footer> <footer>
<button string="Submit" <button string="Submit"