Files
Odoo-Modules/fusion_repairs/wizard/repair_intake_wizard.py
gsinghpal 429084e0bf feat(fusion_repairs): Phase 1 MVP - backend intake wizard + core models
Scaffolds the fusion_repairs module that extends Odoo 19 repair.order with
a guided medical-equipment intake workflow.

Models
- fusion.repair.product.category (8 medical equipment categories seeded)
- fusion.repair.intake.template / .question / .answer (7 templates,
  32 questions seeded across hospital bed, stairlift, porch lift,
  wheelchair, walker/rollator, mattress)
- fusion.repair.intake.service (AbstractModel) - single entry point used
  by backend wizard, sales rep portal, and public client portal so all
  three surfaces produce identical outcomes
- repair.order extensions (x_fc_intake_*, x_fc_third_party_equipment,
  x_fc_photo_ids, x_fc_urgency, x_fc_estimated/actual_cost, AI summary)
- fusion.technician.task back-link (x_fc_repair_order_id)
- res.partner service preferences (preferred tech, time window, access notes)
- res.users repair extensions (skills, cost rate, on-call rotation fields)
- res.config.settings for variance thresholds, portal URL, rate limit

UI
- Backend intake wizard with multi-equipment loop, third-party flag, photos
- repair.order form: Intake tab, Photos, Pricing tab, AI tab, smart buttons
  (technician tasks, intake answers, original SO)
- Kanban + list view urgency badges
- Fusion Repairs app menu (New Service Call, Repair Orders, Config)

Activities & Email
- 4 follow-up activity types (CS callback, tech dispatch, visit follow-up,
  manager review) with urgency-tiered deadlines
- 2 mail templates (client confirmation + office notification) with the
  same dark/light-safe styling as fusion_claims ADP templates

Security
- New res.groups.privilege + 3 groups (User, Dispatcher, Manager)
- Reuses fusion_tasks.group_field_technician (do NOT recreate)
- Reuses fusion_authorizer_portal.group_sales_rep_portal
- Multi-company global rule + technician scoping rule on repair.order

Verified end-to-end on local westin-v19 dev DB via odoo-shell - creates
multiple repairs in one session, auto-creates dispatch task for urgent,
attaches 4 activity types correctly per urgency tier and third-party flag.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 21:35:52 -04:00

200 lines
6.8 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.
"""
import logging
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,
)
# ------------------------------------------------------------------
# EQUIPMENT (one-or-many)
# ------------------------------------------------------------------
equipment_ids = fields.One2many(
'fusion.repair.intake.wizard.equipment',
'wizard_id',
string='Equipment Items',
required=True,
)
# ------------------------------------------------------------------
# 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,
'equipment_items': [self._equipment_payload(eq) for eq in self.equipment_ids],
}
repairs = self.env['fusion.repair.intake.service'].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 _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