feat(fusion_repairs): Bundle 6 - M7 failure analytics + M9 margin per repair

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>
This commit is contained in:
gsinghpal
2026-05-21 00:21:57 -04:00
parent f463600585
commit 638b223d3b
6 changed files with 240 additions and 2 deletions

View File

@@ -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,
}

View File

@@ -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