The grumpy-old-customer-with-broken-stairlift scenario. Four real workflows
the office faces every week, with comms baked in so the client never has to
call back asking for status.
NEW MODELS
- fusion.repair.emergency.charge (rate card)
Per (category, tier) rate with per_tech_multiplier; 5 tiers
(same_day / next_day / after_hours / weekend / holiday). Each category
can have its own rates - bed motors need 2 techs, stairlift is single.
Seeded with realistic Westin rates: stairlift same-day $250, weekend
$450; porch lift same-day $300; bed same-day $175 with 0.6 multiplier
(2-tech jobs frequent); powerchair same-day $200.
- fusion.repair.part.order (procurement-facing record)
One per distinct part the tech needs from the manufacturer. Carries
description + OEM # + manufacturer + quantity + photos + notes.
4-state lifecycle: draft -> ordered -> received -> fitted (or
cancelled). On state transitions:
draft -> ordered: email client "ordered, expected by X"
ordered -> received: email client "arrived, scheduling return visit"
+ auto-create follow-up dispatch task when ALL
outstanding parts on the repair have arrived.
REPAIR.ORDER EXTENSIONS
- Rush fields: x_fc_rush_requested, x_fc_rush_tier,
x_fc_rush_techs_required, x_fc_rush_surcharge (computed via rate card),
x_fc_rush_acknowledged_at + x_fc_rush_acknowledged_by_id (audit trail
proving CS got verbal OK before charging).
- Parts-awaiting fields: x_fc_parts_awaiting + x_fc_parts_eta_date +
x_fc_part_order_ids One2many + x_fc_part_order_count.
- New methods:
* action_acknowledge_rush() - one-click "client agreed" with audit.
* action_squeeze_into_today() - picks the lightest-loaded skilled tech,
finds their first free 1-hour slot between 9am-6pm, schedules the
task in it, sends:
1) live bus.bus push to the tech (sticky notification in their
web client - so they see it MID-SHIFT)
2) rush-alert email (force_send=True - this can't wait in the queue)
3) chatter post on the tech task itself
Validates against fusion_tasks' time-conflict rule by passing
force_schedule via context (intake.service honours it).
* action_view_part_orders() - smart button.
WIZARD EXTENSIONS
- repair.intake.wizard:
New rush_requested + rush_tier + rush_techs_required + rush_acknowledged
controls. Live rush_surcharge_preview compute shows CS the price in
real-time as they change category / tier / tech count. Yellow alert
reminds CS to read the price to the client BEFORE submitting.
- repair.visit.report.wizard:
New outcome radio: completed / parts_needed / rescheduled.
When outcome=parts_needed, needs_parts_line_ids One2many appears for
the tech to capture each part (description, OEM, manufacturer, qty,
lead days, notes, photos). On submit each line creates a
fusion.repair.part.order, the repair flips to x_fc_parts_awaiting=True
with an ETA, and the client gets the "we found the problem, here's the
plan" email immediately.
INTAKE SERVICE
- _create_dispatch_task now honours force_schedule (date + time_start +
time_end) via context so squeeze + auto-redispatch don't crash on
fusion_tasks' time-window validator.
- _create_single_repair carries rush_requested/tier/techs through to
the new repair fields.
MAIL TEMPLATES (4 new)
- email_template_rush_tech_alert: red 4px accent, address + phone + the
$surcharge - what the tech needs to know mid-shift.
- email_template_repair_awaiting_parts: amber accent, "we found the
problem, parts ordered, return visit ~ETA, no action needed".
- email_template_parts_ordered: blue, per-part confirmation.
- email_template_parts_received: green, "arrived, office will call to
confirm visit".
UI / NAVIGATION
- Backend wizard: rush controls + live surcharge preview + verbal-OK alert.
- repair.order form: new Rush / Parts notebook tab with all the fields
+ linked part orders list. Two new header buttons (Squeeze into
Today / Client Agreed to Rush Price). Two new search filters
(Rush, Awaiting Parts).
- Part Order form: statusbar with the 4 transitions + Cancel; notes +
photos notebook tabs; full chatter for audit.
- Menus: 'Parts to Order' under root; 'Emergency Surcharges' under
Configuration.
SECURITY
- 8 new ACL entries (emergency_charge user/manager; part_order
user/dispatcher/manager/technician; visit_report partline for office
and field tech). Office sees parts but only managers can edit
emergency rates.
Verified end-to-end on local westin-v19 - all 4 scenarios green:
S1 Same-day rush stairlift -> $250 surcharge, ack stamped, squeeze
assigned garry@ at first free 1h slot today, alert email queued,
chatter posted.
S2 Next-day priority bed -> $0 surcharge (no rate seeded for bed
next_day - office can configure), 4 emails queued (client + office).
S3 2-tech weekend stairlift -> $675 (450 base + 0.5x base for 2nd tech).
S4 Parts-needed visit-report -> 2 PART-#### records created, repair
awaiting_parts=True, ETA=2026-06-06, office activity scheduled,
client email sent. Marking part ordered -> client mail. Marking
all parts received -> auto-dispatch follow-up + client mail.
Bumped to 19.0.1.9.1.
Co-authored-by: Cursor <cursoragent@cursor.com>
420 lines
16 KiB
Python
420 lines
16 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.',
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Bundle 8: rush / emergency options + live surcharge preview
|
|
# ------------------------------------------------------------------
|
|
rush_requested = fields.Boolean(
|
|
string='Rush / Emergency Service',
|
|
help='Tick when the client needs faster-than-normal turnaround. '
|
|
'Surcharge is calculated automatically from the rate card.',
|
|
)
|
|
rush_tier = fields.Selection(
|
|
[
|
|
('same_day', 'Same Day (during business hours)'),
|
|
('next_day', 'Next Day Priority'),
|
|
('after_hours', 'After Hours (5pm-9pm weekday)'),
|
|
('weekend', 'Weekend'),
|
|
('holiday', 'Statutory Holiday'),
|
|
],
|
|
string='Rush Tier',
|
|
)
|
|
rush_techs_required = fields.Integer(
|
|
string='Technicians Required',
|
|
default=1,
|
|
)
|
|
rush_surcharge_preview = fields.Monetary(
|
|
string='Quoted Surcharge',
|
|
compute='_compute_rush_surcharge_preview',
|
|
currency_field='currency_id',
|
|
)
|
|
rush_acknowledged = fields.Boolean(
|
|
string='Client Agreed to Price',
|
|
help='Tick this AFTER you have read the surcharge to the client over the '
|
|
'phone and they have said yes. The repair will record the '
|
|
'acknowledgement timestamp + your user id for audit.',
|
|
)
|
|
|
|
@api.depends('rush_tier', 'rush_techs_required', 'equipment_ids.repair_category_id')
|
|
def _compute_rush_surcharge_preview(self):
|
|
Rates = self.env['fusion.repair.emergency.charge'].sudo()
|
|
for w in self:
|
|
if not w.rush_tier or not w.equipment_ids:
|
|
w.rush_surcharge_preview = 0.0
|
|
continue
|
|
# Use the FIRST equipment's category for the preview - per-equipment
|
|
# surcharges land on each repair.order after create.
|
|
cat = w.equipment_ids[:1].repair_category_id
|
|
w.rush_surcharge_preview = Rates.calculate(
|
|
cat, w.rush_tier, w.rush_techs_required or 1,
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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,
|
|
'rush_requested': self.rush_requested,
|
|
'rush_tier': self.rush_tier if self.rush_requested else False,
|
|
'rush_techs_required': self.rush_techs_required if self.rush_requested else 1,
|
|
'rush_acknowledged': self.rush_acknowledged,
|
|
'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 CS ticked "rush" and "client agreed", stamp the ack on every spawned repair.
|
|
if self.rush_requested and self.rush_acknowledged:
|
|
for r in repairs:
|
|
r.x_fc_rush_acknowledged_at = fields.Datetime.now()
|
|
r.x_fc_rush_acknowledged_by_id = self.intake_user_id.id or self.env.uid
|
|
|
|
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
|