Files
Odoo-Modules/fusion_repairs/wizard/repair_intake_wizard.py
gsinghpal ebbadb3002 feat(fusion_repairs): Bundle 8 - rush service + emergency pricing + parts-ordered workflow
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>
2026-05-21 01:28:13 -04:00

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