Files
Odoo-Modules/fusion_repairs/models/repair_order.py
gsinghpal 3a15164605 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>
2026-05-20 23:34:34 -04:00

357 lines
14 KiB
Python

# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, _
from odoo.exceptions import UserError
INTAKE_SOURCES = [
('backend_wizard', 'Backend Wizard (CS)'),
('sales_rep_portal', 'Sales Rep Portal'),
('client_portal', 'Client Self-Service'),
('manual', 'Manual / Other'),
]
URGENCY_LEVELS = [
('normal', 'Normal'),
('urgent', 'Urgent'),
('safety', 'Safety Issue'),
]
class RepairOrder(models.Model):
"""Extend Odoo Repairs with intake context, dispatch link, warranty
determination, and pricing variance tracking for Fusion Repairs."""
_inherit = 'repair.order'
# ------------------------------------------------------------------
# CREATE - replace the picking-type default sequence with our
# date-based RO-YYYYMM-NN reference. We set vals['name'] BEFORE
# super() so Odoo's native create() (which only assigns the picking
# type sequence when name is empty or 'New') skips its own numbering.
# ------------------------------------------------------------------
@api.model_create_multi
def create(self, vals_list):
Sequence = self.env['ir.sequence'].sudo()
for vals in vals_list:
if not vals.get('name') or vals.get('name') == 'New':
next_name = Sequence.next_by_code('fusion.repair.order.monthly')
if next_name:
vals['name'] = next_name
return super().create(vals_list)
# ------------------------------------------------------------------
# INTAKE METADATA
# ------------------------------------------------------------------
x_fc_intake_source = fields.Selection(
INTAKE_SOURCES,
string='Intake Source',
default='manual',
tracking=True,
help='Which intake surface created this repair (backend CS wizard, '
'sales rep portal, public client portal, or manual entry).',
)
x_fc_intake_user_id = fields.Many2one(
'res.users',
string='Intake By',
tracking=True,
index=True,
help='User who took the call / submitted the intake. For client portal, '
'this is the OdooBot or admin user.',
)
x_fc_intake_session_id = fields.Char(
string='Intake Session',
index=True,
copy=False,
help='Reference shared by multiple repair orders created during the same call.',
)
x_fc_intake_template_id = fields.Many2one(
'fusion.repair.intake.template',
string='Intake Template',
help='Question template used during intake.',
)
x_fc_intake_answer_ids = fields.One2many(
'fusion.repair.intake.answer',
'repair_id',
string='Intake Answers',
)
# Catalogue match (Phase 2)
x_fc_service_catalog_id = fields.Many2one(
'fusion.repair.service.catalog',
string='Service Catalogue Match',
index=True,
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',
string='Maintenance Contract',
index=True,
help='Set when this repair was spawned from a maintenance reminder booking. '
'Completing the related technician task rolls the contract to its next cycle.',
)
x_fc_intake_answer_count = fields.Integer(
compute='_compute_intake_answer_count',
)
# ------------------------------------------------------------------
# EQUIPMENT / WARRANTY
# ------------------------------------------------------------------
x_fc_repair_category_id = fields.Many2one(
'fusion.repair.product.category',
string='Equipment Category',
tracking=True,
index=True,
help='Medical equipment category - drives intake template and tech skills filter.',
)
x_fc_third_party_equipment = fields.Boolean(
string='Third-Party Equipment',
tracking=True,
help='True if the equipment was not sold by us. Forces under_warranty=False '
'and typically triggers a service call-out fee.',
)
x_fc_original_sale_order_id = fields.Many2one(
'sale.order',
string='Original Purchase SO',
tracking=True,
index=True,
help='Sale order through which the customer originally purchased this unit. '
'Auto-matched on intake by partner + lot/serial.',
)
x_fc_warranty_override_reason = fields.Char(
string='Warranty Override Reason',
help='Required when CS overrides the auto-detected warranty status.',
)
# ------------------------------------------------------------------
# TRIAGE / URGENCY
# ------------------------------------------------------------------
x_fc_urgency = fields.Selection(
URGENCY_LEVELS,
string='Urgency',
default='normal',
tracking=True,
index=True,
)
x_fc_issue_category = fields.Char(
string='Issue Category',
help='Symptom classification (e.g. "battery", "motor", "remote"). Used by '
'service catalogue matcher and AI prompt context.',
)
# ------------------------------------------------------------------
# PHOTOS
# ------------------------------------------------------------------
x_fc_photo_ids = fields.Many2many(
'ir.attachment',
'fusion_repair_order_photo_rel',
'repair_id',
'attachment_id',
string='Intake Photos / Videos',
help='Photos and videos uploaded during intake.',
)
x_fc_photo_count = fields.Integer(
compute='_compute_photo_count',
)
# ------------------------------------------------------------------
# PRICING (estimate vs actual - Phase 2 reconciliation)
# ------------------------------------------------------------------
x_fc_estimated_duration = fields.Float(
string='Estimated Duration (h)',
help='Estimated visit duration from service catalogue, used to size technician slot.',
)
x_fc_estimated_cost = fields.Monetary(
string='Estimated Cost',
currency_field='company_currency_id',
help='Estimated total from catalogue match at intake (pre-visit).',
)
x_fc_actual_cost = fields.Monetary(
string='Actual Cost',
currency_field='company_currency_id',
help='Actual total recorded from the visit report (post-visit).',
)
x_fc_cost_variance_pct = fields.Float(
string='Cost Variance %',
compute='_compute_cost_variance',
store=True,
help='(actual - estimated) / estimated * 100',
)
x_fc_requires_requote = fields.Boolean(
string='Requires Re-Quote',
help='Set when actual cost exceeds estimate beyond the configured threshold; '
'blocks automatic invoicing until manager approves or client re-confirms.',
)
company_currency_id = fields.Many2one(
'res.currency',
related='company_id.currency_id',
readonly=True,
)
# ------------------------------------------------------------------
# FIELD SERVICE LINK
# ------------------------------------------------------------------
x_fc_technician_task_ids = fields.One2many(
'fusion.technician.task',
'x_fc_repair_order_id',
string='Technician Tasks',
)
x_fc_technician_task_count = fields.Integer(
compute='_compute_technician_task_count',
)
# ------------------------------------------------------------------
# AI SUMMARY (Phase 2)
# ------------------------------------------------------------------
x_fc_ai_summary = fields.Text(
string='AI Pre-Visit Brief',
help='AI-generated short brief for the technician based on intake answers. '
'Optional - never blocks intake submit.',
)
# ------------------------------------------------------------------
# COMPUTES
# ------------------------------------------------------------------
@api.depends('x_fc_intake_answer_ids')
def _compute_intake_answer_count(self):
for repair in self:
repair.x_fc_intake_answer_count = len(repair.x_fc_intake_answer_ids)
@api.depends('x_fc_photo_ids')
def _compute_photo_count(self):
for repair in self:
repair.x_fc_photo_count = len(repair.x_fc_photo_ids)
@api.depends('x_fc_technician_task_ids')
def _compute_technician_task_count(self):
for repair in self:
repair.x_fc_technician_task_count = len(repair.x_fc_technician_task_ids)
@api.depends('x_fc_estimated_cost', 'x_fc_actual_cost')
def _compute_cost_variance(self):
for repair in self:
if repair.x_fc_estimated_cost:
repair.x_fc_cost_variance_pct = (
(repair.x_fc_actual_cost - repair.x_fc_estimated_cost)
/ repair.x_fc_estimated_cost * 100
)
else:
repair.x_fc_cost_variance_pct = 0.0
# ------------------------------------------------------------------
# WARRANTY DETERMINATION
# ------------------------------------------------------------------
def _fc_compute_warranty_status(self):
"""Auto-detect warranty: not third-party AND within warranty window."""
self.ensure_one()
if self.x_fc_third_party_equipment:
return False
if not self.x_fc_original_sale_order_id:
return False
original = self.x_fc_original_sale_order_id
delivery_date = original.commitment_date or original.date_order
if not delivery_date:
return False
warranty_months = (
self.product_id.product_tmpl_id.x_fc_warranty_months
if self.product_id else 0
)
if not warranty_months:
return False
# relativedelta handles month boundaries correctly (28/29/30/31).
cutoff = fields.Datetime.from_string(str(delivery_date)) + relativedelta(months=warranty_months)
return fields.Datetime.now() <= cutoff
# ------------------------------------------------------------------
# SMART BUTTONS
# ------------------------------------------------------------------
def action_view_intake_answers(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Intake Answers'),
'res_model': 'fusion.repair.intake.answer',
'view_mode': 'list,form',
'domain': [('repair_id', '=', self.id)],
'context': {'default_repair_id': self.id},
}
def action_view_technician_tasks(self):
self.ensure_one()
if len(self.x_fc_technician_task_ids) == 1:
return {
'type': 'ir.actions.act_window',
'name': self.x_fc_technician_task_ids.name,
'res_model': 'fusion.technician.task',
'view_mode': 'form',
'res_id': self.x_fc_technician_task_ids.id,
}
return {
'type': 'ir.actions.act_window',
'name': _('Technician Tasks'),
'res_model': 'fusion.technician.task',
'view_mode': 'list,form',
'domain': [('x_fc_repair_order_id', '=', self.id)],
'context': {'default_x_fc_repair_order_id': self.id},
}
def action_view_original_sale_order(self):
self.ensure_one()
if not self.x_fc_original_sale_order_id:
return False
return {
'type': 'ir.actions.act_window',
'name': self.x_fc_original_sale_order_id.name,
'res_model': 'sale.order',
'view_mode': 'form',
'res_id': self.x_fc_original_sale_order_id.id,
}
# ------------------------------------------------------------------
# WIZARDS / PAYMENT
# ------------------------------------------------------------------
def action_open_visit_report(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Visit Report'),
'res_model': 'fusion.repair.visit.report.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_repair_id': self.id,
'default_labour_hours': self.x_fc_estimated_duration or 1.0,
},
}
def action_collect_payment(self):
"""Open the Poynt payment wizard for the linked posted invoice."""
self.ensure_one()
# Resolve the linked invoice via the standard repair -> SO -> invoice chain.
if not self.sale_order_id:
raise UserError(_('Confirm a sale order from this repair first.'))
invoice = self.sale_order_id.invoice_ids.filtered(
lambda m: m.state == 'posted' and m.payment_state in ('not_paid', 'partial')
)[:1]
if not invoice:
raise UserError(_('No posted, unpaid invoice was found for this repair.'))
if hasattr(invoice, 'action_open_poynt_payment_wizard'):
return invoice.action_open_poynt_payment_wizard()
raise UserError(_('Poynt payment is not available - install or configure fusion_poynt.'))