fix(fusion_repairs): Bundle 1 code-review fixes (H1-H5 + M1-M6)

H1 Float -> Monetary for outstanding_balance
  Added currency_id companion field on the wizard so widget="monetary"
  renders properly. Currency defaults to env.company.currency_id.

H2 Maps URL address duplication
  fusion_tasks address_street often contains the full Google-Places-
  formatted address. Concatenating address_street + address_city + zip
  was producing "15 Fisherman Dr, Brampton, ON L7A 1B7, Canada, Brampton,
  L7A 1B7". Now uses the existing address_display field (fusion_tasks
  computes it correctly for both Google Places and manual entries), with
  a partner-based fallback that includes street, street2, city,
  state_id.name, zip, country_id.name.

H3 Banner copy hardcoded "14 days"
  Added duplicate_window_days compute field; banner now reads
  "in last <N> days" from the ir.config_parameter.

H4 Outstanding-balance multi-company + child_of direction
  - Dropped .sudo() (CS users already have access to their own company's
    invoices via standard groups + the Repairs Office rule)
  - Replaced child_of (which only walks descendants) with
    commercial_partner_id (the canonical Odoo "billed-to root" - covers
    child contacts AND walks up from a child if the caller IS a child)
  - Added ('company_id', 'in', env.companies.ids) filter to both the
    invoice search AND the duplicate-repair search so a CS rep in
    Westin Healthcare doesn't see NEXA Systems balances

H5 duplicate_count capped at 5 (false reassurance)
  Now uses search_count for the true total + search(limit=5) for the
  display list. Earlier verification showed count=5 was actually
  capped; running again shows 15 for the same partner.

M1 Function-level imports
  Moved urllib.parse.quote_plus and odoo.exceptions.UserError to module
  top in technician_task.py.

M2 Many2many 'in' with scalar
  Changed ('x_fc_repair_skills', 'in', category.id) to
  ('x_fc_repair_skills', 'in', [category.id]) - safer against future
  ORM tightening.

M4 C6 - added x_fc_is_quote_only field + filter + form indicator
  Boolean tracked field on repair.order (was previously discoverable
  only via chatter text). Indexed. Visible on the form's intake metadata
  row and filterable on the dashboard search view as "Quote Only".

M5 Account-move read perf
  Replaced Move.search() + Python sum with _read_group(
    aggregates=['amount_residual:sum', '__count']) - pushes the SUM to
  Postgres; O(1) record load vs O(N).

M6 Hide Maps button when no address
  Added invisible="not address_display and not partner_id" on the
  Open in Maps button so it doesn't appear on in-store tasks.

Plus the dispatch-task cutoff is now a datetime (was a date) so the
create_date >= cutoff comparison is type-correct.

Verified end-to-end on local westin-v19 after fixes:
  C1 count: 15 (was capped at 5)  window_days: 14
  C5 balance: 0.0  currency: CAD  warning: False (correct)
  C6 x_fc_is_quote_only: True  tech_tasks: 0 (urgent intake, NOT dispatched)
  T1 URL: https://www.google.com/maps?q=15+Fisherman+Dr%2C+Brampton%2C+ON+L7A+1B7%2C+Canada%2C+Unit+7
       (no duplicated city/zip)

Bumped to 19.0.1.1.1.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
gsinghpal
2026-05-20 23:34:34 -04:00
parent 194850e3cf
commit 3a15164605
8 changed files with 85 additions and 36 deletions

View File

@@ -4,7 +4,7 @@
{
'name': 'Fusion Repairs',
'version': '19.0.1.1.0',
'version': '19.0.1.1.1',
'category': 'Inventory/Repairs',
'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal',
'description': """

View File

@@ -122,6 +122,7 @@ class FusionRepairIntakeService(models.AbstractModel):
'x_fc_third_party_equipment': bool(item.get('third_party')),
'x_fc_urgency': item.get('urgency') or 'normal',
'x_fc_issue_category': item.get('issue_category') or False,
'x_fc_is_quote_only': bool(quote_only),
'internal_notes': self._wrap_internal_notes(item),
}
if product_id:
@@ -468,7 +469,7 @@ class FusionRepairIntakeService(models.AbstractModel):
skilled = Users.search([
('x_fc_is_field_staff', '=', True),
('active', '=', True),
('x_fc_repair_skills', 'in', category.id),
('x_fc_repair_skills', 'in', [category.id]),
], order='id', limit=1)
if skilled:
return skilled.id

View File

@@ -88,6 +88,15 @@ class RepairOrder(models.Model):
help='Auto-matched catalogue entry that pre-fills estimated cost and duration.',
)
# C6: quote-only flag (set when intake submitted in quote-only mode).
x_fc_is_quote_only = fields.Boolean(
string='Quote Only',
tracking=True,
index=True,
help='True when the intake was submitted in "Quote Only" mode - the '
'office has not yet authorised dispatching a technician.',
)
# Maintenance contract back-link (Phase 3)
x_fc_maintenance_contract_id = fields.Many2one(
'fusion.repair.maintenance.contract',

View File

@@ -2,9 +2,12 @@
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from urllib.parse import quote_plus
from markupsafe import Markup
from odoo import _, fields, models
from odoo.exceptions import UserError
class FusionTechnicianTaskRepairs(models.Model):
@@ -78,25 +81,23 @@ class FusionTechnicianTaskRepairs(models.Model):
# ------------------------------------------------------------------
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
# Prefer fusion_tasks.address_display because in real data address_street
# often contains the full Google-Places-formatted address; concatenating
# the other address_* fields would duplicate city/zip.
addr = (getattr(self, 'address_display', '') or '').strip()
if not addr and self.partner_id:
p = self.partner_id
parts = [
p.street, p.street2, p.city,
p.state_id.name if p.state_id else False,
p.zip,
p.country_id.name if p.country_id else False,
]
addr = ', '.join(str(x) for x in parts if x)
if not addr:
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}',
'url': f'https://www.google.com/maps?q={quote_plus(addr)}',
'target': 'new',
}

View File

@@ -59,6 +59,7 @@
decoration-warning="x_fc_urgency == 'urgent'"
decoration-danger="x_fc_urgency == 'safety'"/>
<field name="x_fc_third_party_equipment"/>
<field name="x_fc_is_quote_only"/>
<field name="x_fc_intake_source" readonly="1"/>
<field name="x_fc_intake_user_id" readonly="1" invisible="not x_fc_intake_user_id"/>
<field name="x_fc_intake_session_id" readonly="1" invisible="not x_fc_intake_session_id"/>
@@ -233,6 +234,8 @@
domain="[('x_fc_urgency', '=', 'urgent')]"/>
<filter string="Third-Party" name="thirdparty"
domain="[('x_fc_third_party_equipment', '=', True)]"/>
<filter string="Quote Only" name="quote_only"
domain="[('x_fc_is_quote_only', '=', True)]"/>
<separator/>
<filter string="From Backend Wizard" name="src_backend"
domain="[('x_fc_intake_source', '=', 'backend_wizard')]"/>

View File

@@ -14,7 +14,8 @@
type="object"
string="Open in Maps"
class="btn-secondary"
icon="fa-map-marker"/>
icon="fa-map-marker"
invisible="not address_display and not partner_id"/>
<button name="action_view_repair_order"
type="object"
string="View Repair"

View File

@@ -66,8 +66,18 @@ class RepairIntakeWizard(models.TransientModel):
compute='_compute_partner_context',
string='Duplicate Call Count',
)
outstanding_balance = fields.Float(
duplicate_window_days = fields.Integer(
compute='_compute_partner_context',
string='Duplicate Window (days)',
)
currency_id = fields.Many2one(
'res.currency',
compute='_compute_partner_context',
string='Currency',
)
outstanding_balance = fields.Monetary(
compute='_compute_partner_context',
currency_field='currency_id',
string='Open Invoice Balance',
)
outstanding_invoice_count = fields.Integer(
@@ -118,36 +128,58 @@ class RepairIntakeWizard(models.TransientModel):
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)
# Avoid sudo - CS users already have access to their own company's
# repairs/invoices via the standard groups + the Repairs Office rule.
Repair = self.env['repair.order']
Move = self.env['account.move']
company_ids = self.env.companies.ids
default_currency = self.env.company.currency_id
cutoff = fields.Datetime.now() - timedelta(days=window_days)
for w in self:
w.duplicate_window_days = window_days
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
w.currency_id = default_currency
continue
dupes = Repair.search([
# Multi-company scoped duplicate detection. search_count for the
# real total + search(limit=5) for the display list - so the banner
# never lies about a partner with >5 open calls.
dup_domain = [
('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)
('company_id', 'in', company_ids),
]
w.duplicate_repair_ids = Repair.search(
dup_domain, order='create_date desc', limit=5,
)
w.duplicate_count = Repair.search_count(dup_domain)
open_invoices = Move.search([
('partner_id', 'child_of', w.partner_id.id),
# commercial_partner_id is the canonical "billed-to root" - covers
# child contacts AND walks up from a child if the caller IS a child.
commercial = w.partner_id.commercial_partner_id or w.partner_id
inv_domain = [
('commercial_partner_id', '=', commercial.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
('company_id', 'in', company_ids),
]
# _read_group pushes the SUM to Postgres - O(1) load vs O(N) records.
rows = Move._read_group(
inv_domain, aggregates=['amount_residual:sum', '__count'],
)
balance, invoice_count = rows[0] if rows else (0.0, 0)
w.currency_id = default_currency
w.outstanding_balance = balance or 0.0
w.outstanding_invoice_count = invoice_count or 0
w.show_outstanding_warning = (balance or 0.0) >= threshold
# ------------------------------------------------------------------
# SUBMIT

View File

@@ -23,7 +23,8 @@
<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).
(<field name="duplicate_count" nolabel="1" readonly="1" class="d-inline"/>
in last <field name="duplicate_window_days" nolabel="1" readonly="1" class="d-inline"/> days).
Consider adding a note to the existing repair instead.
</div>
<button name="action_open_existing_repair"
@@ -32,6 +33,7 @@
class="btn btn-sm btn-warning"/>
</div>
<field name="duplicate_repair_ids" invisible="1"/>
<field name="currency_id" invisible="1"/>
<!-- C5: outstanding-balance warning banner -->
<div class="alert alert-danger d-flex justify-content-between align-items-center"