# -*- 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, }