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>
362 lines
13 KiB
Python
362 lines
13 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright 2024-2026 Nexa Systems Inc.
|
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
|
|
|
"""Backend intake wizard.
|
|
|
|
A simple Phase 1 transient model that captures one-or-many equipment items
|
|
per call, then delegates to fusion.repair.intake.service to create the
|
|
repair.order(s). The shared service guarantees identical behaviour to the
|
|
sales rep portal and the public client portal added in later phases.
|
|
|
|
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
|
|
from datetime import timedelta
|
|
|
|
from odoo import _, api, fields, models
|
|
from odoo.exceptions import UserError
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class RepairIntakeWizard(models.TransientModel):
|
|
_name = 'fusion.repair.intake.wizard'
|
|
_description = 'Repair Intake Wizard'
|
|
|
|
# ------------------------------------------------------------------
|
|
# CALLER / CLIENT
|
|
# ------------------------------------------------------------------
|
|
intake_user_id = fields.Many2one(
|
|
'res.users',
|
|
string='Taken By',
|
|
default=lambda self: self.env.user,
|
|
required=True,
|
|
)
|
|
partner_id = fields.Many2one(
|
|
'res.partner',
|
|
string='Client',
|
|
required=True,
|
|
help='Existing client. Use the create-and-edit dialog to add a new contact.',
|
|
)
|
|
partner_phone = fields.Char(
|
|
related='partner_id.phone',
|
|
string='Phone',
|
|
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',
|
|
)
|
|
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(
|
|
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_ids = fields.One2many(
|
|
'fusion.repair.intake.wizard.equipment',
|
|
'wizard_id',
|
|
string='Equipment Items',
|
|
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
|
|
|
|
# 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
|
|
|
|
# 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),
|
|
('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)
|
|
|
|
# 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')),
|
|
('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
|
|
# ------------------------------------------------------------------
|
|
def action_submit(self):
|
|
self.ensure_one()
|
|
if not self.equipment_ids:
|
|
raise UserError(_('Please add at least one piece of equipment.'))
|
|
|
|
payload = {
|
|
'partner_id': self.partner_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],
|
|
}
|
|
|
|
# sudo() so sub-operations (mail.activity, mail.mail, fusion.technician.task)
|
|
# never trip on permission checks - x_fc_intake_user_id preserves audit identity.
|
|
repairs = self.env['fusion.repair.intake.service'].sudo().create_repair_orders(
|
|
payload, source='backend_wizard',
|
|
)
|
|
|
|
if len(repairs) == 1:
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': repairs.name,
|
|
'res_model': 'repair.order',
|
|
'view_mode': 'form',
|
|
'res_id': repairs.id,
|
|
}
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': _('Service Calls Created (%(count)s)', count=len(repairs)),
|
|
'res_model': 'repair.order',
|
|
'view_mode': 'list,form',
|
|
'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):
|
|
"""Render an equipment record as a dict the intake service expects."""
|
|
return {
|
|
'product_id': eq.product_id.id or False,
|
|
'lot_id': eq.lot_id.id or False,
|
|
'repair_category_id': eq.repair_category_id.id or False,
|
|
'intake_template_id': eq.intake_template_id.id or False,
|
|
'third_party': eq.third_party,
|
|
'urgency': eq.urgency,
|
|
'issue_summary': eq.issue_summary or '',
|
|
'issue_category': eq.issue_category or '',
|
|
'internal_notes': eq.internal_notes or '',
|
|
'schedule_date': eq.scheduled_date or False,
|
|
'photo_attachment_ids': eq.photo_ids.ids if eq.photo_ids else [],
|
|
'answers': [], # Phase 1 wizard doesn't expose per-question answer rows yet
|
|
}
|
|
|
|
|
|
class RepairIntakeWizardEquipment(models.TransientModel):
|
|
"""A single piece of equipment captured in the wizard.
|
|
|
|
Multiple lines = multi-equipment intake (one repair.order per line).
|
|
"""
|
|
|
|
_name = 'fusion.repair.intake.wizard.equipment'
|
|
_description = 'Repair Intake Wizard - Equipment Line'
|
|
_order = 'sequence, id'
|
|
|
|
wizard_id = fields.Many2one(
|
|
'fusion.repair.intake.wizard',
|
|
string='Wizard',
|
|
required=True,
|
|
ondelete='cascade',
|
|
)
|
|
sequence = fields.Integer(default=10)
|
|
|
|
# Equipment identification
|
|
repair_category_id = fields.Many2one(
|
|
'fusion.repair.product.category',
|
|
string='Category',
|
|
required=True,
|
|
)
|
|
product_id = fields.Many2one(
|
|
'product.product',
|
|
string='Product',
|
|
help='Specific product if known. Leave blank for generic equipment.',
|
|
)
|
|
lot_id = fields.Many2one(
|
|
'stock.lot',
|
|
string='Serial Number',
|
|
domain="[('product_id', '=', product_id)]",
|
|
help='Lot or serial number if known.',
|
|
)
|
|
third_party = fields.Boolean(
|
|
string='Not Purchased From Us',
|
|
help='Tick if this equipment was bought elsewhere - we still service it but '
|
|
'warranty is not honoured and a service call-out fee applies.',
|
|
)
|
|
|
|
# Intake context
|
|
intake_template_id = fields.Many2one(
|
|
'fusion.repair.intake.template',
|
|
string='Question Template',
|
|
help='Defaults to the template configured on the category if left blank.',
|
|
)
|
|
|
|
# Triage
|
|
urgency = fields.Selection(
|
|
[('normal', 'Normal'), ('urgent', 'Urgent'), ('safety', 'Safety Issue')],
|
|
string='Urgency',
|
|
default='normal',
|
|
required=True,
|
|
)
|
|
scheduled_date = fields.Datetime(
|
|
string='Preferred Date',
|
|
default=fields.Datetime.now,
|
|
)
|
|
issue_summary = fields.Char(
|
|
string='Issue Summary',
|
|
help='One-line summary of what is wrong (e.g. "stairlift stops halfway up").',
|
|
)
|
|
issue_category = fields.Char(
|
|
string='Symptom Category',
|
|
help='Optional symptom tag for catalogue matching (e.g. "battery", "motor").',
|
|
)
|
|
internal_notes = fields.Text(string='Internal Notes')
|
|
|
|
photo_ids = fields.Many2many(
|
|
'ir.attachment',
|
|
'fusion_repair_intake_wizard_eq_photo_rel',
|
|
'eq_id',
|
|
'attachment_id',
|
|
string='Photos / Videos',
|
|
)
|
|
|
|
@api.onchange('repair_category_id')
|
|
def _onchange_repair_category_id(self):
|
|
"""Pre-fill the intake template from the category default."""
|
|
if self.repair_category_id and not self.intake_template_id:
|
|
self.intake_template_id = self.repair_category_id.intake_template_id
|
|
|
|
@api.onchange('product_id')
|
|
def _onchange_product_id(self):
|
|
"""Pre-fill the category from the product if defined."""
|
|
if self.product_id and not self.repair_category_id:
|
|
cat = self.product_id.product_tmpl_id.x_fc_repair_category_id
|
|
if cat:
|
|
self.repair_category_id = cat
|