Files
Odoo-Modules/fusion_repairs/wizard/repair_intake_wizard.py
gsinghpal 194850e3cf 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>
2026-05-20 23:27:43 -04:00

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