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>
This commit is contained in:
5
fusion_repairs/wizard/__init__.py
Normal file
5
fusion_repairs/wizard/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from . import repair_intake_wizard
|
||||
199
fusion_repairs/wizard/repair_intake_wizard.py
Normal file
199
fusion_repairs/wizard/repair_intake_wizard.py
Normal file
@@ -0,0 +1,199 @@
|
||||
# -*- 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
|
||||
69
fusion_repairs/wizard/repair_intake_wizard_views.xml
Normal file
69
fusion_repairs/wizard/repair_intake_wizard_views.xml
Normal file
@@ -0,0 +1,69 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_repair_intake_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fusion.repair.intake.wizard.form</field>
|
||||
<field name="model">fusion.repair.intake.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="New Service Call">
|
||||
<sheet>
|
||||
<group>
|
||||
<group string="Caller / Client">
|
||||
<field name="intake_user_id" options="{'no_create': True}"/>
|
||||
<field name="partner_id"
|
||||
options="{'no_create_edit': False, 'no_quick_create': False}"/>
|
||||
<field name="partner_phone" readonly="1" invisible="not partner_id"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<separator string="Equipment Items (one repair per item)"/>
|
||||
<field name="equipment_ids">
|
||||
<list editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="repair_category_id" options="{'no_create': True}"/>
|
||||
<field name="product_id" optional="show"/>
|
||||
<field name="lot_id" optional="hide"/>
|
||||
<field name="third_party" optional="show"/>
|
||||
<field name="urgency" widget="badge"
|
||||
decoration-success="urgency == 'normal'"
|
||||
decoration-warning="urgency == 'urgent'"
|
||||
decoration-danger="urgency == 'safety'"/>
|
||||
<field name="issue_summary"/>
|
||||
<field name="scheduled_date" optional="hide"/>
|
||||
</list>
|
||||
<form>
|
||||
<group>
|
||||
<group>
|
||||
<field name="repair_category_id" options="{'no_create': True}"/>
|
||||
<field name="product_id"/>
|
||||
<field name="lot_id"/>
|
||||
<field name="third_party"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="urgency"/>
|
||||
<field name="scheduled_date"/>
|
||||
<field name="intake_template_id" options="{'no_create': True}"/>
|
||||
</group>
|
||||
</group>
|
||||
<field name="issue_summary"
|
||||
placeholder="One-line summary (e.g. 'stairlift stops halfway up')"/>
|
||||
<field name="issue_category"
|
||||
placeholder="Symptom tag (e.g. battery, motor, remote)"/>
|
||||
<field name="internal_notes" placeholder="Internal notes"/>
|
||||
<separator string="Photos / Videos"/>
|
||||
<field name="photo_ids" widget="many2many_binary"/>
|
||||
</form>
|
||||
</field>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button string="Submit"
|
||||
name="action_submit"
|
||||
type="object"
|
||||
class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user