diff --git a/fusion_repairs/__manifest__.py b/fusion_repairs/__manifest__.py index a810fa10..ca76d998 100644 --- a/fusion_repairs/__manifest__.py +++ b/fusion_repairs/__manifest__.py @@ -4,7 +4,7 @@ { 'name': 'Fusion Repairs', - 'version': '19.0.1.5.0', + 'version': '19.0.1.6.0', 'category': 'Inventory/Repairs', 'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal', 'description': """ diff --git a/fusion_repairs/models/repair_dashboard.py b/fusion_repairs/models/repair_dashboard.py index c15b7407..bde7633e 100644 --- a/fusion_repairs/models/repair_dashboard.py +++ b/fusion_repairs/models/repair_dashboard.py @@ -9,7 +9,7 @@ recent activity, and upcoming maintenance. Lives as an AbstractModel because it stores nothing - all values are computed on demand. """ -from datetime import timedelta +from datetime import datetime, timedelta from odoo import api, fields, models @@ -121,6 +121,61 @@ class FusionRepairDashboard(models.AbstractModel): '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, @@ -128,4 +183,7 @@ class FusionRepairDashboard(models.AbstractModel): 'recent': recent, 'upcoming': upcoming, 'portals': portals, + 'failures_by_product': failures_by_product, + 'failures_by_symptom': failures_by_symptom, + 'margin_summary': margin_summary, } diff --git a/fusion_repairs/models/repair_order.py b/fusion_repairs/models/repair_order.py index 6c55cffe..07d4e46b 100644 --- a/fusion_repairs/models/repair_order.py +++ b/fusion_repairs/models/repair_order.py @@ -163,6 +163,63 @@ class RepairOrder(models.Model): 'long-running repair (M3). Avoids re-posting daily.', ) + # ------------------------------------------------------------------ + # M9 - Margin per repair (revenue - labour cost - parts cost) + # All non-stored computes; surfaced in the M7 analytics dashboard. + # ------------------------------------------------------------------ + x_fc_revenue = fields.Monetary( + string='Revenue', + currency_field='company_currency_id', + compute='_compute_margin', + help='Sum of posted invoice totals for the repair sale order.', + ) + x_fc_labour_cost = fields.Monetary( + string='Labour Cost', + currency_field='company_currency_id', + compute='_compute_margin', + help='Sum of (hours x technician cost rate) over all completed visits.', + ) + x_fc_parts_cost = fields.Monetary( + string='Parts Cost', + currency_field='company_currency_id', + compute='_compute_margin', + help='Sum of standard_price for parts consumed via repair operations.', + ) + x_fc_margin = fields.Monetary( + string='Margin', + currency_field='company_currency_id', + compute='_compute_margin', + help='Revenue - labour cost - parts cost.', + ) + x_fc_margin_pct = fields.Float( + string='Margin %', + compute='_compute_margin', + ) + + def _compute_margin(self): + for r in self: + revenue = 0.0 + if r.sale_order_id and hasattr(r.sale_order_id, 'invoice_ids'): + for inv in r.sale_order_id.invoice_ids.filtered( + lambda m: m.state == 'posted' and m.move_type == 'out_invoice' + ): + revenue += inv.amount_untaxed or 0.0 + labour = 0.0 + for task in r.x_fc_technician_task_ids: + if task.status != 'completed': + continue + rate = task.technician_id.x_fc_tech_cost_rate or 0.0 + labour += (task.duration_hours or 0.0) * rate + parts = 0.0 + for move in r.move_ids.filtered(lambda m: m.repair_line_type == 'add'): + parts += (move.product_id.standard_price or 0.0) * (move.product_uom_qty or 0.0) + r.x_fc_revenue = revenue + r.x_fc_labour_cost = labour + r.x_fc_parts_cost = parts + margin = revenue - labour - parts + r.x_fc_margin = margin + r.x_fc_margin_pct = (margin / revenue * 100) if revenue else 0.0 + def write(self, vals): # H2: stamp x_fc_done_at the first time state transitions to 'done' # so the NPS cron has a stable timestamp (write_date moves on every diff --git a/fusion_repairs/static/src/components/dashboard/dashboard.js b/fusion_repairs/static/src/components/dashboard/dashboard.js index 7b62aade..d0ef352b 100644 --- a/fusion_repairs/static/src/components/dashboard/dashboard.js +++ b/fusion_repairs/static/src/components/dashboard/dashboard.js @@ -24,6 +24,9 @@ export class FusionRepairsDashboard extends Component { recent: [], upcoming: [], portals: {}, + failures_by_product: [], + failures_by_symptom: [], + margin_summary: {}, }); onWillStart(async () => { @@ -45,6 +48,9 @@ export class FusionRepairsDashboard extends Component { this.state.recent = data.recent || []; this.state.upcoming = data.upcoming || []; this.state.portals = data.portals || {}; + this.state.failures_by_product = data.failures_by_product || []; + this.state.failures_by_symptom = data.failures_by_symptom || []; + this.state.margin_summary = data.margin_summary || {}; } catch (e) { this.notification.add(_t("Could not load dashboard data."), { type: "danger", @@ -112,6 +118,20 @@ export class FusionRepairsDashboard extends Component { return value.slice(0, 10); } + formatMoney(value) { + const v = Number(value || 0); + return v.toLocaleString("en-CA", { + style: "currency", + currency: "CAD", + maximumFractionDigits: 0, + }); + } + + formatPercent(value) { + const v = Number(value || 0); + return `${v.toFixed(1)}%`; + } + urgencyPillClass(urgency) { if (urgency === "safety") return "fr-pill fr-pill-safety"; if (urgency === "urgent") return "fr-pill fr-pill-urgent"; diff --git a/fusion_repairs/static/src/components/dashboard/dashboard.xml b/fusion_repairs/static/src/components/dashboard/dashboard.xml index a59f2f02..d2407682 100644 --- a/fusion_repairs/static/src/components/dashboard/dashboard.xml +++ b/fusion_repairs/static/src/components/dashboard/dashboard.xml @@ -211,6 +211,94 @@ + +