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>
330 lines
12 KiB
Python
330 lines
12 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',
|
|
)
|
|
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_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
|
|
|
|
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
|
|
# ------------------------------------------------------------------
|
|
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
|