feat(fusion_repairs): Phase 2 - service catalogue, visit report, warranty, Poynt

Service catalogue
- New fusion.repair.service.catalog model: named service entries per
  equipment category with symptom keywords, estimated hours / cost,
  default parts, auto_schedule flag, optional pricelist override
- find_best_match() scores candidates by symptom-keyword overlap against
  intake text hints (issue summary + category + notes)
- Intake service wires it in: on submit, the matcher sets
  x_fc_service_catalog_id + x_fc_estimated_duration + x_fc_estimated_cost
  and (when auto_schedule=True) creates a draft dispatch task
- Double-task guard: if catalogue match already created a task, the
  urgency-based dispatch skips so we never duplicate

Visit report wizard
- fusion.repair.visit.report.wizard with labour hours + parts lines +
  technician notes + 'found another issue' branch
- Computes actual cost = (labour x service_product.list_price) + parts
- Compares against estimate -> sets requires_requote when variance
  exceeds configured threshold (% or $); shows warning banner inline
- On confirm: writes actuals back to repair, posts notes to chatter,
  optionally spawns a follow-up repair (T5 'found another issue')

Repair warranty
- New fusion.repair.warranty.coverage model (start/expiry, partner,
  product, lot, active flag)
- find_active_for(partner, product, lot) returns the most-recent active
  coverage
- Intake service auto-checks: when a new repair lands on an equipment
  that has active warranty coverage, posts a chatter banner so the
  office knows the work may be free under our 30/90-day re-do policy
  (manager review still required; never auto-zeros pricing)

Repair form
- Header: Visit Report + Collect Payment buttons (gated by group)
- action_collect_payment looks up the linked posted unpaid invoice on
  the repair SO and opens the Poynt wizard (action_open_poynt_payment_wizard)

AI intake summary
- _generate_ai_summary calls self.env['fusion.api.service'].call_openai
  with consumer='fusion_repairs', feature='intake_triage'
- Strict system prompt: no medical advice, no diagnoses, no recommending
  stop equipment use; ~80 words; plain English
- Try/fallback per fusion-api-integration.mdc: if fusion_api not
  installed or call fails -> silently skip; intake never blocked

Verified end-to-end on local westin-v19:
- Stairlift motor intake -> catalogue match -> estimated $500/2h -> auto
  dispatch task (count=1, not duplicated)
- Visit report: 2.5h x $250 + $100 parts = $725 actual vs $500 estimated
  = 45% variance -> requires_requote=True
- Warranty: 30-day coverage on the completed repair; second repair on
  same partner triggers warranty banner in chatter

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
gsinghpal
2026-05-20 21:57:33 -04:00
parent ad553b1082
commit 7727745b73
14 changed files with 845 additions and 2 deletions

View File

@@ -76,6 +76,8 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
# Views
'views/repair_product_category_views.xml',
'views/intake_template_views.xml',
'views/service_catalog_views.xml',
'views/repair_warranty_views.xml',
'views/repair_order_views.xml',
'views/res_partner_views.xml',
'views/res_users_views.xml',
@@ -83,8 +85,9 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
# Portal templates
'views/portal_sales_rep_templates.xml',
'views/portal_client_repair_templates.xml',
# Wizard
# Wizards
'wizard/repair_intake_wizard_views.xml',
'wizard/repair_visit_report_wizard_views.xml',
# Menus (last, after all referenced actions exist)
'views/menus.xml',
],

View File

@@ -6,6 +6,8 @@ from . import repair_product_category
from . import intake_template
from . import intake_question
from . import intake_answer
from . import service_catalog
from . import repair_warranty
from . import product_template
from . import res_partner
from . import res_users

View File

@@ -132,6 +132,15 @@ class FusionRepairIntakeService(models.AbstractModel):
# Persist intake answers.
self._create_answers(repair, item.get('answers') or [])
# Service catalogue auto-match.
self._match_service_catalog(repair, item)
# Check our own repair-warranty (30/90 day re-do free).
self._check_repair_warranty(repair)
# Optional AI brief generation - never blocks intake.
self._generate_ai_summary(repair, item)
# Attach photos.
photo_ids = item.get('photo_attachment_ids') or []
if photo_ids:
@@ -146,7 +155,11 @@ class FusionRepairIntakeService(models.AbstractModel):
self._schedule_activities(repair)
# Optional dispatch draft task (urgent / safety).
if repair.x_fc_urgency in ('urgent', 'safety'):
# Skip if the catalogue match already auto-created one.
if (
repair.x_fc_urgency in ('urgent', 'safety')
and not repair.x_fc_technician_task_ids
):
self._create_dispatch_task(repair)
# Emails (client + office).
@@ -178,6 +191,102 @@ class FusionRepairIntakeService(models.AbstractModel):
parts.append('<p><strong>Notes:</strong> %s</p>' % notes)
return ''.join(parts)
# ------------------------------------------------------------------
# SERVICE CATALOGUE MATCH
# ------------------------------------------------------------------
@api.model
def _match_service_catalog(self, repair, item):
category = repair.x_fc_repair_category_id
if not category:
return
text_hints = [
(item.get('issue_summary') or ''),
(item.get('issue_category') or ''),
(item.get('internal_notes') or ''),
]
catalog = self.env['fusion.repair.service.catalog'].sudo().find_best_match(
category.id, text_hints,
)
if not catalog:
return
repair.write({
'x_fc_service_catalog_id': catalog.id,
'x_fc_estimated_duration': catalog.estimated_hours,
'x_fc_estimated_cost': catalog.estimated_cost,
})
# Auto-create dispatch task if catalogue says so (in addition to urgency rule).
if catalog.auto_schedule and repair.x_fc_technician_task_count == 0:
self._create_dispatch_task(repair)
# ------------------------------------------------------------------
# REPAIR WARRANTY (our 30/90-day re-do free)
# ------------------------------------------------------------------
@api.model
def _check_repair_warranty(self, repair):
if not repair.partner_id:
return
warranty = self.env['fusion.repair.warranty.coverage'].sudo() \
.find_active_for(repair.partner_id.id, repair.product_id.id or None,
repair.lot_id.id or None)
if not warranty:
return
repair.message_post(
body=_(
'This repair MAY be covered by our active warranty <b>%(ref)s</b> '
'(expires %(exp)s). Manager review recommended before invoicing.',
ref=warranty.name,
exp=warranty.expiry_date,
),
message_type='comment',
)
# ------------------------------------------------------------------
# AI SUMMARY (try/fallback per fusion-api-integration rule)
# ------------------------------------------------------------------
@api.model
def _generate_ai_summary(self, repair, item):
try:
ApiService = self.env.get('fusion.api.service')
if not ApiService:
return
issue = (item.get('issue_summary') or '').strip()
if not issue:
return
category = repair.x_fc_repair_category_id.name or 'medical equipment'
urgency = repair.x_fc_urgency or 'normal'
messages = [
{
'role': 'system',
'content': (
'You are an assistant for a medical equipment repair service. '
'Given an intake note, output ONE short paragraph (under 80 words) '
'briefing the technician about: likely cause, what to bring, and '
'any safety considerations. NEVER provide medical advice. NEVER '
'recommend stopping equipment use. NEVER claim a definitive cause. '
'Plain English, no jargon.'
),
},
{
'role': 'user',
'content': (
f'Equipment category: {category}\n'
f'Urgency: {urgency}\n'
f'Issue: {issue}\n'
f'Notes: {(item.get("internal_notes") or "").strip()}'
),
},
]
summary = ApiService.call_openai(
consumer='fusion_repairs',
feature='intake_triage',
messages=messages,
max_tokens=200,
)
if summary:
repair.x_fc_ai_summary = summary.strip()
except Exception as e:
_logger.info('AI intake summary skipped: %s', e)
# ------------------------------------------------------------------
# ORIGINAL SO AUTO-LINK
# ------------------------------------------------------------------

View File

@@ -5,6 +5,7 @@
from datetime import timedelta
from odoo import api, fields, models, _
from odoo.exceptions import UserError
INTAKE_SOURCES = [
@@ -62,6 +63,14 @@ class RepairOrder(models.Model):
'repair_id',
string='Intake Answers',
)
# Catalogue match (Phase 2)
x_fc_service_catalog_id = fields.Many2one(
'fusion.repair.service.catalog',
string='Service Catalogue Match',
index=True,
help='Auto-matched catalogue entry that pre-fills estimated cost and duration.',
)
x_fc_intake_answer_count = fields.Integer(
compute='_compute_intake_answer_count',
)
@@ -279,3 +288,35 @@ class RepairOrder(models.Model):
'view_mode': 'form',
'res_id': self.x_fc_original_sale_order_id.id,
}
# ------------------------------------------------------------------
# WIZARDS / PAYMENT
# ------------------------------------------------------------------
def action_open_visit_report(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Visit Report'),
'res_model': 'fusion.repair.visit.report.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_repair_id': self.id,
'default_labour_hours': self.x_fc_estimated_duration or 1.0,
},
}
def action_collect_payment(self):
"""Open the Poynt payment wizard for the linked posted invoice."""
self.ensure_one()
# Resolve the linked invoice via the standard repair -> SO -> invoice chain.
if not self.sale_order_id:
raise UserError(_('Confirm a sale order from this repair first.'))
invoice = self.sale_order_id.invoice_ids.filtered(
lambda m: m.state == 'posted' and m.payment_state in ('not_paid', 'partial')
)[:1]
if not invoice:
raise UserError(_('No posted, unpaid invoice was found for this repair.'))
if hasattr(invoice, 'action_open_poynt_payment_wizard'):
return invoice.action_open_poynt_payment_wizard()
raise UserError(_('Poynt payment is not available - install or configure fusion_poynt.'))

View File

@@ -0,0 +1,121 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Repair warranty coverage.
Tracks the 30/90-day warranty we offer on completed repair work.
When a new repair is created on the same equipment within the
coverage window, the intake wizard / portal shows a banner:
"This repair may be covered by our warranty - no charge".
Phase 2 ships the model + manual creation from a completed repair.
Phase 4 will add automatic creation when a repair moves to 'done'.
"""
from datetime import timedelta
from odoo import api, fields, models
class FusionRepairWarrantyCoverage(models.Model):
_name = 'fusion.repair.warranty.coverage'
_description = 'Repair Warranty Coverage'
_order = 'expiry_date desc, id desc'
name = fields.Char(string='Reference', compute='_compute_name', store=True)
repair_id = fields.Many2one(
'repair.order',
string='Original Repair',
required=True,
ondelete='cascade',
index=True,
)
partner_id = fields.Many2one(
'res.partner',
string='Client',
related='repair_id.partner_id',
store=True,
index=True,
)
product_id = fields.Many2one(
'product.product',
string='Equipment',
related='repair_id.product_id',
store=True,
index=True,
)
lot_id = fields.Many2one(
'stock.lot',
string='Serial Number',
related='repair_id.lot_id',
store=True,
)
start_date = fields.Date(
string='Start Date',
required=True,
default=fields.Date.context_today,
)
coverage_days = fields.Integer(
string='Coverage Window (days)',
default=30,
required=True,
)
expiry_date = fields.Date(
string='Expires',
compute='_compute_expiry_date',
store=True,
)
is_active = fields.Boolean(
string='Active',
compute='_compute_is_active',
store=True,
)
notes = fields.Text()
company_id = fields.Many2one(
'res.company',
string='Company',
related='repair_id.company_id',
store=True,
)
@api.depends('repair_id.name', 'expiry_date')
def _compute_name(self):
for w in self:
w.name = (
f"Warranty {w.repair_id.name or '?'} (until {w.expiry_date or '?'})"
)
@api.depends('start_date', 'coverage_days')
def _compute_expiry_date(self):
for w in self:
if w.start_date and w.coverage_days:
w.expiry_date = w.start_date + timedelta(days=w.coverage_days)
else:
w.expiry_date = False
@api.depends('expiry_date')
def _compute_is_active(self):
today = fields.Date.context_today(self)
for w in self:
w.is_active = bool(w.expiry_date and w.expiry_date >= today)
# ------------------------------------------------------------------
# LOOKUP
# ------------------------------------------------------------------
@api.model
def find_active_for(self, partner_id, product_id=None, lot_id=None):
"""Return active warranty coverage matching the partner + equipment, if any."""
if not partner_id:
return self.browse()
domain = [
('partner_id', '=', partner_id),
('is_active', '=', True),
]
if lot_id:
domain.append(('lot_id', '=', lot_id))
elif product_id:
domain.append(('product_id', '=', product_id))
return self.search(domain, order='expiry_date desc', limit=1)

View File

@@ -0,0 +1,141 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Service catalogue.
Each fusion.repair.service.catalog record is a named repair / maintenance
service (e.g. "Stairlift motor replacement", "Bed remote troubleshoot")
with estimated duration, estimated cost, default parts, and symptom
keywords used to auto-match an intake to the right catalogue entry.
The catalogue feeds:
- intake auto-match -> sets x_fc_service_catalog_id +
x_fc_estimated_duration + x_fc_estimated_cost on the repair
- visit report -> default labour line + parts pre-fill
- pricing variance -> compares estimate vs actual
"""
from odoo import api, fields, models
class FusionRepairServiceCatalog(models.Model):
_name = 'fusion.repair.service.catalog'
_description = 'Repair Service Catalogue Entry'
_order = 'sequence, name'
name = fields.Char(string='Service Name', required=True, translate=True)
code = fields.Char(string='Code', help='Stable identifier (lowercase, no spaces).')
sequence = fields.Integer(default=10)
active = fields.Boolean(default=True)
company_id = fields.Many2one(
'res.company', string='Company',
default=lambda self: self.env.company,
)
# Routing & matching
product_category_id = fields.Many2one(
'fusion.repair.product.category',
string='Equipment Category',
required=True,
index=True,
)
symptom_keywords = fields.Char(
string='Symptom Keywords',
help='Comma-separated keywords used to auto-match an intake to this catalogue entry. '
'Matched against the issue summary, issue category, and intake answer text.',
)
# Service product (what actually gets invoiced)
service_product_id = fields.Many2one(
'product.product',
string='Service Product',
domain=[('type', '=', 'service')],
help='Product line added to the repair sale order for the labour portion.',
)
default_parts_product_ids = fields.Many2many(
'product.product',
'fusion_repair_catalog_parts_rel',
'catalog_id', 'product_id',
string='Default Parts',
help='Parts typically used. Pre-loaded onto the visit report wizard for the tech to confirm.',
)
pricelist_id = fields.Many2one(
'product.pricelist',
string='Pricelist Override',
help='Optional pricelist applied to repair SOs from this catalogue entry. '
'Leave blank to use the partner default pricelist.',
)
# Estimates
estimated_hours = fields.Float(
string='Estimated Labour (h)',
default=1.0,
help='Used to size the technician task and the visit report labour default.',
)
estimated_cost = fields.Monetary(
string='Estimated Cost',
currency_field='company_currency_id',
help='Headline estimate shown to the client/CS during intake. Phase 1 is a flat number; '
'Phase 2+ may compute from labour + parts.',
)
# Automation hints
auto_schedule = fields.Boolean(
string='Auto-Create Tech Task',
help='When True, the intake service creates a draft technician task immediately for any '
'repair matched to this catalogue entry (even at normal urgency).',
)
task_type = fields.Selection(
[('delivery', 'Delivery'), ('repair', 'Repair'), ('pickup', 'Pickup'),
('troubleshoot', 'Troubleshoot'), ('assessment', 'Assessment'),
('installation', 'Installation'), ('maintenance', 'Maintenance'),
('other', 'Other')],
string='Default Task Type',
default='repair',
)
company_currency_id = fields.Many2one(
'res.currency',
related='company_id.currency_id',
readonly=True,
)
@api.depends('name', 'code')
def _compute_display_name(self):
for c in self:
c.display_name = c.name or c.code or ''
# ------------------------------------------------------------------
# MATCHING
# ------------------------------------------------------------------
@api.model
def find_best_match(self, product_category_id, text_hints):
"""Return the best-matching catalogue entry, or empty recordset.
:param product_category_id: int id of the equipment category
:param text_hints: list[str] - text snippets to look for symptom keywords in
(typically: issue_summary, issue_category, recent intake answer values)
"""
if not product_category_id:
return self.browse()
haystack = ' '.join(s.lower() for s in (text_hints or []) if s).strip()
candidates = self.search([
('product_category_id', '=', product_category_id),
('active', '=', True),
], order='sequence')
if not candidates:
return self.browse()
if not haystack:
return candidates[:1]
best = None
best_score = 0
for c in candidates:
kws = [k.strip().lower() for k in (c.symptom_keywords or '').split(',') if k.strip()]
score = sum(1 for kw in kws if kw and kw in haystack)
if score > best_score:
best = c
best_score = score
if best:
return best
return candidates[:1]

View File

@@ -10,3 +10,9 @@ access_repair_intake_answer_manager,Intake Answer Manager Full,model_fusion_repa
access_repair_intake_answer_tech_portal,Intake Answer Technician Read,model_fusion_repair_intake_answer,fusion_tasks.group_field_technician,1,0,0,0
access_repair_intake_wizard_user,Intake Wizard User Full,model_fusion_repair_intake_wizard,group_fusion_repairs_user,1,1,1,1
access_repair_intake_wizard_equipment_user,Intake Wizard Equipment User Full,model_fusion_repair_intake_wizard_equipment,group_fusion_repairs_user,1,1,1,1
access_repair_service_catalog_user,Catalogue User Read,model_fusion_repair_service_catalog,group_fusion_repairs_user,1,0,0,0
access_repair_service_catalog_manager,Catalogue Manager Full,model_fusion_repair_service_catalog,group_fusion_repairs_manager,1,1,1,1
access_repair_warranty_user,Warranty User Read,model_fusion_repair_warranty_coverage,group_fusion_repairs_user,1,0,0,0
access_repair_warranty_manager,Warranty Manager Full,model_fusion_repair_warranty_coverage,group_fusion_repairs_manager,1,1,1,1
access_repair_visit_report_wizard_user,Visit Report Wizard User,model_fusion_repair_visit_report_wizard,group_fusion_repairs_user,1,1,1,1
access_repair_visit_report_wizard_line_user,Visit Report Line User,model_fusion_repair_visit_report_wizard_line,group_fusion_repairs_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
10 access_repair_intake_answer_tech_portal Intake Answer Technician Read model_fusion_repair_intake_answer fusion_tasks.group_field_technician 1 0 0 0
11 access_repair_intake_wizard_user Intake Wizard User Full model_fusion_repair_intake_wizard group_fusion_repairs_user 1 1 1 1
12 access_repair_intake_wizard_equipment_user Intake Wizard Equipment User Full model_fusion_repair_intake_wizard_equipment group_fusion_repairs_user 1 1 1 1
13 access_repair_service_catalog_user Catalogue User Read model_fusion_repair_service_catalog group_fusion_repairs_user 1 0 0 0
14 access_repair_service_catalog_manager Catalogue Manager Full model_fusion_repair_service_catalog group_fusion_repairs_manager 1 1 1 1
15 access_repair_warranty_user Warranty User Read model_fusion_repair_warranty_coverage group_fusion_repairs_user 1 0 0 0
16 access_repair_warranty_manager Warranty Manager Full model_fusion_repair_warranty_coverage group_fusion_repairs_manager 1 1 1 1
17 access_repair_visit_report_wizard_user Visit Report Wizard User model_fusion_repair_visit_report_wizard group_fusion_repairs_user 1 1 1 1
18 access_repair_visit_report_wizard_line_user Visit Report Line User model_fusion_repair_visit_report_wizard_line group_fusion_repairs_user 1 1 1 1

View File

@@ -45,4 +45,16 @@
action="action_repair_intake_template"
sequence="20"/>
<menuitem id="menu_fusion_repairs_service_catalog"
name="Service Catalogue"
parent="menu_fusion_repairs_configuration"
action="action_repair_service_catalog"
sequence="30"/>
<menuitem id="menu_fusion_repairs_warranty"
name="Repair Warranties"
parent="menu_fusion_repairs_configuration"
action="action_repair_warranty_coverage"
sequence="40"/>
</odoo>

View File

@@ -10,6 +10,22 @@
<field name="inherit_id" ref="repair.view_repair_order_form"/>
<field name="arch" type="xml">
<!-- Header action buttons (visit report + collect payment) -->
<xpath expr="//header" position="inside">
<button name="action_open_visit_report"
type="object"
string="Visit Report"
class="btn-primary"
invisible="state == 'cancel'"
groups="fusion_repairs.group_fusion_repairs_user"/>
<button name="action_collect_payment"
type="object"
string="Collect Payment"
class="btn-secondary"
invisible="state != 'done'"
groups="fusion_repairs.group_fusion_repairs_user"/>
</xpath>
<!-- Smart buttons: Technician Tasks + Intake Answers + Original SO. -->
<xpath expr="//div[hasclass('oe_button_box')]" position="inside">
<button name="action_view_technician_tasks"

View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_repair_warranty_coverage_list" model="ir.ui.view">
<field name="name">fusion.repair.warranty.coverage.list</field>
<field name="model">fusion.repair.warranty.coverage</field>
<field name="arch" type="xml">
<list string="Repair Warranty Coverage">
<field name="name"/>
<field name="repair_id"/>
<field name="partner_id"/>
<field name="product_id"/>
<field name="start_date"/>
<field name="coverage_days"/>
<field name="expiry_date"/>
<field name="is_active" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="view_repair_warranty_coverage_form" model="ir.ui.view">
<field name="name">fusion.repair.warranty.coverage.form</field>
<field name="model">fusion.repair.warranty.coverage</field>
<field name="arch" type="xml">
<form string="Warranty Coverage">
<sheet>
<div class="oe_title">
<h1>
<field name="name" readonly="1"/>
</h1>
</div>
<group>
<group>
<field name="repair_id" options="{'no_create': True}"/>
<field name="partner_id" readonly="1"/>
<field name="product_id" readonly="1"/>
<field name="lot_id" readonly="1"/>
</group>
<group>
<field name="start_date"/>
<field name="coverage_days"/>
<field name="expiry_date" readonly="1"/>
<field name="is_active" readonly="1" widget="boolean_toggle"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<field name="notes"/>
</sheet>
</form>
</field>
</record>
<record id="action_repair_warranty_coverage" model="ir.actions.act_window">
<field name="name">Repair Warranties</field>
<field name="res_model">fusion.repair.warranty.coverage</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_repair_service_catalog_list" model="ir.ui.view">
<field name="name">fusion.repair.service.catalog.list</field>
<field name="model">fusion.repair.service.catalog</field>
<field name="arch" type="xml">
<list string="Service Catalogue">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="product_category_id"/>
<field name="service_product_id"/>
<field name="estimated_hours"/>
<field name="estimated_cost" widget="monetary"/>
<field name="auto_schedule"/>
<field name="active" widget="boolean_toggle"/>
<field name="company_currency_id" column_invisible="True"/>
</list>
</field>
</record>
<record id="view_repair_service_catalog_form" model="ir.ui.view">
<field name="name">fusion.repair.service.catalog.form</field>
<field name="model">fusion.repair.service.catalog</field>
<field name="arch" type="xml">
<form string="Service Catalogue Entry">
<sheet>
<div class="oe_title">
<label for="name"/>
<h1>
<field name="name" placeholder="e.g. Stairlift Motor Replacement"/>
</h1>
</div>
<group>
<group>
<field name="code"/>
<field name="product_category_id"
options="{'no_create': True}"/>
<field name="task_type"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
<group>
<field name="estimated_hours" widget="float_time"/>
<field name="estimated_cost" widget="monetary"/>
<field name="auto_schedule"/>
<field name="active"/>
<field name="company_currency_id" invisible="1"/>
</group>
</group>
<separator string="Auto-Match"/>
<field name="symptom_keywords"
placeholder="Comma-separated keywords (e.g. motor, stuck, won't move)"/>
<separator string="Billing"/>
<group>
<field name="service_product_id"
options="{'no_create': True}"/>
<field name="pricelist_id"
options="{'no_create': True}"/>
</group>
<field name="default_parts_product_ids" widget="many2many_tags"
options="{'no_create': True}"/>
</sheet>
</form>
</field>
</record>
<record id="action_repair_service_catalog" model="ir.actions.act_window">
<field name="name">Service Catalogue</field>
<field name="res_model">fusion.repair.service.catalog</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -3,3 +3,4 @@
# License OPL-1 (Odoo Proprietary License v1.0)
from . import repair_intake_wizard
from . import repair_visit_report_wizard

View File

@@ -0,0 +1,194 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Technician visit report wizard.
Opened from a completed (or in-progress) repair.order. Captures:
- labour hours
- parts/consumables used
- recommended upsell products
- optional client signature
On confirm:
- writes labour + parts as repair.order lines (Odoo native operations)
- updates x_fc_actual_cost on the repair
- triggers variance reconciliation (sets x_fc_requires_requote if over threshold)
- if not requote: confirms the repair (state='under_repair' -> 'done' via Odoo native flow)
- offers an action_collect_payment shortcut to fire Poynt on the resulting invoice
"""
import logging
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class RepairVisitReportWizard(models.TransientModel):
_name = 'fusion.repair.visit.report.wizard'
_description = 'Repair Visit Report Wizard'
repair_id = fields.Many2one(
'repair.order',
string='Repair Order',
required=True,
readonly=True,
)
technician_id = fields.Many2one(
'res.users',
string='Technician',
default=lambda self: self.env.user,
domain="[('x_fc_is_field_staff', '=', True)]",
)
# Labour
labour_hours = fields.Float(
string='Labour Hours',
required=True,
default=1.0,
)
# Parts used (simple line model below)
parts_line_ids = fields.One2many(
'fusion.repair.visit.report.wizard.line',
'wizard_id',
string='Parts Used',
)
# Outcome
notes = fields.Html(string='Technician Notes')
found_another_issue = fields.Boolean(
string='Found Another Issue',
help='Tick to spawn a follow-up repair after saving this visit.',
)
# Variance display
estimated_cost = fields.Monetary(
related='repair_id.x_fc_estimated_cost',
currency_field='company_currency_id',
readonly=True,
)
actual_cost = fields.Monetary(
string='Actual Cost',
compute='_compute_actual_cost',
currency_field='company_currency_id',
)
variance_pct = fields.Float(
string='Variance %',
compute='_compute_actual_cost',
)
requires_requote = fields.Boolean(
compute='_compute_actual_cost',
)
company_currency_id = fields.Many2one(
'res.currency',
related='repair_id.company_currency_id',
readonly=True,
)
@api.depends('labour_hours', 'parts_line_ids.subtotal', 'repair_id.x_fc_estimated_cost')
def _compute_actual_cost(self):
ICP = self.env['ir.config_parameter'].sudo()
try:
threshold_pct = float(ICP.get_param('fusion_repairs.variance_threshold_pct', '20'))
except (ValueError, TypeError):
threshold_pct = 20.0
try:
threshold_amt = float(ICP.get_param('fusion_repairs.variance_threshold_amount', '100'))
except (ValueError, TypeError):
threshold_amt = 100.0
for w in self:
catalog = w.repair_id.x_fc_service_catalog_id
labour_rate = 0.0
if catalog and catalog.service_product_id:
labour_rate = catalog.service_product_id.list_price
parts_total = sum(w.parts_line_ids.mapped('subtotal'))
w.actual_cost = (w.labour_hours * labour_rate) + parts_total
est = w.estimated_cost or 0.0
variance_pct = ((w.actual_cost - est) / est * 100) if est else 0.0
w.variance_pct = variance_pct
w.requires_requote = est > 0 and (
abs(variance_pct) >= threshold_pct
or abs(w.actual_cost - est) >= threshold_amt
)
# ------------------------------------------------------------------
# ACTION
# ------------------------------------------------------------------
def action_confirm(self):
self.ensure_one()
repair = self.repair_id
if not repair:
raise UserError(_('No repair selected.'))
# Persist actual cost + requote flag on the repair.
repair.write({
'x_fc_actual_cost': self.actual_cost,
'x_fc_requires_requote': self.requires_requote,
})
# Append technician notes to chatter.
if self.notes:
repair.message_post(body=self.notes)
# If found another issue: spawn a stub repair (same partner, same equipment).
if self.found_another_issue:
stub = repair.copy({
'state': 'draft',
'internal_notes': _(
'<p><em>Spawned from visit report on %(ref)s. Add details for the new issue.</em></p>',
ref=repair.name,
),
'x_fc_intake_source': 'manual',
'x_fc_intake_session_id': repair.x_fc_intake_session_id,
'x_fc_estimated_cost': 0.0,
'x_fc_actual_cost': 0.0,
'x_fc_requires_requote': False,
'x_fc_intake_template_id': False,
'x_fc_service_catalog_id': False,
})
repair.message_post(
body=_('Spawned follow-up repair <b>%(name)s</b> for "found another issue".',
name=stub.name),
)
return {
'type': 'ir.actions.act_window',
'name': repair.name,
'res_model': 'repair.order',
'view_mode': 'form',
'res_id': repair.id,
}
class RepairVisitReportWizardLine(models.TransientModel):
_name = 'fusion.repair.visit.report.wizard.line'
_description = 'Repair Visit Report Wizard - Part Line'
wizard_id = fields.Many2one(
'fusion.repair.visit.report.wizard',
required=True,
ondelete='cascade',
)
product_id = fields.Many2one(
'product.product',
string='Part',
required=True,
)
quantity = fields.Float(default=1.0, required=True)
unit_price = fields.Float(string='Unit Price')
subtotal = fields.Float(compute='_compute_subtotal', store=True)
@api.onchange('product_id')
def _onchange_product_id(self):
if self.product_id:
self.unit_price = self.product_id.list_price
@api.depends('quantity', 'unit_price')
def _compute_subtotal(self):
for line in self:
line.subtotal = line.quantity * line.unit_price

View File

@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_repair_visit_report_wizard_form" model="ir.ui.view">
<field name="name">fusion.repair.visit.report.wizard.form</field>
<field name="model">fusion.repair.visit.report.wizard</field>
<field name="arch" type="xml">
<form string="Visit Report">
<sheet>
<group>
<group>
<field name="repair_id" readonly="1"/>
<field name="technician_id"
options="{'no_create': True}"/>
</group>
<group>
<field name="labour_hours"/>
</group>
</group>
<separator string="Parts Used"/>
<field name="parts_line_ids">
<list editable="bottom">
<field name="product_id"/>
<field name="quantity"/>
<field name="unit_price" widget="monetary"/>
<field name="subtotal" widget="monetary"/>
</list>
</field>
<separator string="Cost Reconciliation"/>
<group>
<group>
<field name="estimated_cost" widget="monetary"/>
<field name="actual_cost" widget="monetary"/>
</group>
<group>
<field name="variance_pct" widget="float" digits="[12,1]"/>
<field name="requires_requote"/>
<field name="company_currency_id" invisible="1"/>
</group>
</group>
<div class="alert alert-warning" role="alert"
invisible="not requires_requote">
Variance exceeds the configured threshold. Saving will
mark the repair as <strong>Requires Re-Quote</strong>;
a manager must review before invoicing.
</div>
<separator string="Outcome"/>
<field name="notes"/>
<field name="found_another_issue"/>
</sheet>
<footer>
<button string="Save Visit Report"
name="action_confirm"
type="object"
class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>