M9 margin per repair
- New non-stored computes on repair.order: x_fc_revenue, x_fc_labour_cost,
x_fc_parts_cost, x_fc_margin, x_fc_margin_pct.
- Revenue: sum of posted out_invoice.amount_untaxed on the repair's sale
order (handles partial / multi invoice scenarios).
- Labour: sum of (task.duration_hours x technician.x_fc_tech_cost_rate)
over COMPLETED visits only - avoids counting scheduled-but-not-done time.
- Parts: sum of standard_price x qty for stock moves where
repair_line_type='add' (parts consumed, not removed).
- New 'Margin' notebook tab on repair.order form, manager-group gated.
M7 failure analytics on the dashboard
- Three new keys in get_dashboard_data():
* failures_by_product - top 8 products by repair_count in last 90 days
via _read_group (efficient - no record load)
* failures_by_symptom - top 8 x_fc_issue_category values
* margin_summary - revenue/labour/parts/margin/margin_pct + sample_size
over the same 90-day window
- Three new tiles on the OWL dashboard 'Last 90 Days' section:
Margin Summary (revenue/labour/parts/margin breakdown),
Failure Rate by Product, Failure Rate by Symptom.
- New formatMoney + formatPercent helpers on the dashboard JS so values
display as 'CAD 12,345' rather than raw floats.
Verified end-to-end on local westin-v19:
Dashboard returned all 9 expected keys.
Top product: 'M6 X 27 THREADED BARREL' (2 repairs) - actual test data.
Margin summary over 26 repairs (dev has $0 invoices so values 0.0,
but the compute path is exercised and shapes are correct).
Bumped to 19.0.1.6.0.
Co-authored-by: Cursor <cursoragent@cursor.com>
190 lines
7.3 KiB
Python
190 lines
7.3 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright 2024-2026 Nexa Systems Inc.
|
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
|
|
|
"""Repair dashboard data provider.
|
|
|
|
Feeds the OWL client action `fusion_repairs.dashboard` with KPI counts,
|
|
recent activity, and upcoming maintenance. Lives as an AbstractModel
|
|
because it stores nothing - all values are computed on demand.
|
|
"""
|
|
|
|
from datetime import datetime, timedelta
|
|
|
|
from odoo import api, fields, models
|
|
|
|
|
|
class FusionRepairDashboard(models.AbstractModel):
|
|
_name = 'fusion.repair.dashboard'
|
|
_description = 'Repair Dashboard Data Provider'
|
|
|
|
@api.model
|
|
def get_dashboard_data(self):
|
|
"""Return everything the dashboard needs in a single call."""
|
|
Repair = self.env['repair.order']
|
|
Contract = self.env['fusion.repair.maintenance.contract']
|
|
today = fields.Date.context_today(self)
|
|
month_start = today.replace(day=1)
|
|
thirty_days = today + timedelta(days=30)
|
|
|
|
# ---------------- KPI counters ----------------
|
|
open_domain = [('state', 'not in', ('done', 'cancel'))]
|
|
urgent_domain = open_domain + [('x_fc_urgency', 'in', ('urgent', 'safety'))]
|
|
new_this_month_domain = [('create_date', '>=', month_start)]
|
|
no_task_domain = open_domain + [
|
|
('x_fc_technician_task_ids', '=', False),
|
|
]
|
|
requote_domain = open_domain + [('x_fc_requires_requote', '=', True)]
|
|
|
|
stats = {
|
|
'open_count': Repair.search_count(open_domain),
|
|
'urgent_count': Repair.search_count(urgent_domain),
|
|
'new_this_month': Repair.search_count(new_this_month_domain),
|
|
'awaiting_dispatch': Repair.search_count(no_task_domain),
|
|
'requires_requote': Repair.search_count(requote_domain),
|
|
'maintenance_due_30d': Contract.search_count([
|
|
('state', '=', 'active'),
|
|
('next_due_date', '<=', thirty_days),
|
|
]),
|
|
'maintenance_active_total': Contract.search_count([
|
|
('state', '=', 'active'),
|
|
]),
|
|
}
|
|
|
|
# ---------------- Source breakdown for the doughnut ----------------
|
|
source_rows = Repair._read_group(
|
|
open_domain,
|
|
['x_fc_intake_source'],
|
|
['__count'],
|
|
)
|
|
source_breakdown = []
|
|
source_labels = dict(Repair._fields['x_fc_intake_source'].selection)
|
|
for src, count in source_rows:
|
|
source_breakdown.append({
|
|
'key': src or 'manual',
|
|
'label': source_labels.get(src or 'manual', src or 'Other'),
|
|
'count': count,
|
|
})
|
|
|
|
# ---------------- Urgency breakdown ----------------
|
|
urgency_rows = Repair._read_group(
|
|
open_domain,
|
|
['x_fc_urgency'],
|
|
['__count'],
|
|
)
|
|
urgency_labels = dict(Repair._fields['x_fc_urgency'].selection)
|
|
urgency_breakdown = [{
|
|
'key': u or 'normal',
|
|
'label': urgency_labels.get(u or 'normal', 'Normal'),
|
|
'count': c,
|
|
} for u, c in urgency_rows]
|
|
|
|
# ---------------- Recent service calls (last 5) ----------------
|
|
recent = []
|
|
for r in Repair.search([], order='create_date desc', limit=5):
|
|
recent.append({
|
|
'id': r.id,
|
|
'name': r.name,
|
|
'partner_name': r.partner_id.name or '',
|
|
'category': r.x_fc_repair_category_id.name or '',
|
|
'urgency': r.x_fc_urgency,
|
|
'state': r.state,
|
|
'state_label': dict(Repair._fields['state'].selection).get(r.state, r.state),
|
|
'create_date': fields.Datetime.to_string(r.create_date),
|
|
'source': r.x_fc_intake_source or '',
|
|
'source_label': source_labels.get(r.x_fc_intake_source, ''),
|
|
})
|
|
|
|
# ---------------- Upcoming maintenance (next 5 due) ----------------
|
|
upcoming = []
|
|
for c in Contract.search(
|
|
[('state', '=', 'active'), ('next_due_date', '!=', False)],
|
|
order='next_due_date asc', limit=5,
|
|
):
|
|
upcoming.append({
|
|
'id': c.id,
|
|
'name': c.name,
|
|
'partner_name': c.partner_id.name or '',
|
|
'product_name': c.product_id.display_name or '',
|
|
'next_due_date': fields.Date.to_string(c.next_due_date),
|
|
'days_until': (c.next_due_date - today).days if c.next_due_date else 0,
|
|
'reminder_band': c.last_reminder_band or '',
|
|
})
|
|
|
|
# ---------------- Portal URLs (resolved server-side) ----------------
|
|
ICP = self.env['ir.config_parameter'].sudo()
|
|
base_url = ICP.get_param('web.base.url', '').rstrip('/')
|
|
portals = {
|
|
'client_portal_url': base_url + (ICP.get_param(
|
|
'fusion_repairs.client_portal_url', '/repair'
|
|
) or '/repair'),
|
|
'sales_rep_portal_url': base_url + '/my/repair/new',
|
|
}
|
|
|
|
# ---------------- M7: failure-rate analytics ----------------
|
|
# Top products by repair count in the last 90 days (excludes draft).
|
|
ninety = datetime.now() - timedelta(days=90)
|
|
failure_rows = Repair._read_group(
|
|
[
|
|
('create_date', '>=', ninety),
|
|
('product_id', '!=', False),
|
|
('state', '!=', 'cancel'),
|
|
],
|
|
['product_id'],
|
|
['__count'],
|
|
order='__count desc',
|
|
limit=8,
|
|
)
|
|
failures_by_product = [{
|
|
'product_id': p.id,
|
|
'product_name': p.display_name,
|
|
'repair_count': c,
|
|
} for p, c in failure_rows]
|
|
|
|
# Top symptom categories (issue_category) in the last 90 days.
|
|
symptom_rows = Repair._read_group(
|
|
[
|
|
('create_date', '>=', ninety),
|
|
('x_fc_issue_category', '!=', False),
|
|
('state', '!=', 'cancel'),
|
|
],
|
|
['x_fc_issue_category'],
|
|
['__count'],
|
|
order='__count desc',
|
|
limit=8,
|
|
)
|
|
failures_by_symptom = [{
|
|
'symptom': s or 'Other',
|
|
'repair_count': c,
|
|
} for s, c in symptom_rows]
|
|
|
|
# M9: margin summary (open + done in the last 90 days).
|
|
margin_rows = self.env['repair.order'].search([
|
|
('create_date', '>=', ninety),
|
|
('state', '!=', 'cancel'),
|
|
])
|
|
total_revenue = sum(margin_rows.mapped('x_fc_revenue'))
|
|
total_labour = sum(margin_rows.mapped('x_fc_labour_cost'))
|
|
total_parts = sum(margin_rows.mapped('x_fc_parts_cost'))
|
|
total_margin = total_revenue - total_labour - total_parts
|
|
margin_summary = {
|
|
'revenue': total_revenue,
|
|
'labour_cost': total_labour,
|
|
'parts_cost': total_parts,
|
|
'margin': total_margin,
|
|
'margin_pct': (total_margin / total_revenue * 100) if total_revenue else 0.0,
|
|
'sample_size': len(margin_rows),
|
|
}
|
|
|
|
return {
|
|
'stats': stats,
|
|
'urgency_breakdown': urgency_breakdown,
|
|
'source_breakdown': source_breakdown,
|
|
'recent': recent,
|
|
'upcoming': upcoming,
|
|
'portals': portals,
|
|
'failures_by_product': failures_by_product,
|
|
'failures_by_symptom': failures_by_symptom,
|
|
'margin_summary': margin_summary,
|
|
}
|