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:
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Repairs',
|
'name': 'Fusion Repairs',
|
||||||
'version': '19.0.1.5.0',
|
'version': '19.0.1.6.0',
|
||||||
'category': 'Inventory/Repairs',
|
'category': 'Inventory/Repairs',
|
||||||
'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal',
|
'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ recent activity, and upcoming maintenance. Lives as an AbstractModel
|
|||||||
because it stores nothing - all values are computed on demand.
|
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
|
from odoo import api, fields, models
|
||||||
|
|
||||||
@@ -121,6 +121,61 @@ class FusionRepairDashboard(models.AbstractModel):
|
|||||||
'sales_rep_portal_url': base_url + '/my/repair/new',
|
'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 {
|
return {
|
||||||
'stats': stats,
|
'stats': stats,
|
||||||
'urgency_breakdown': urgency_breakdown,
|
'urgency_breakdown': urgency_breakdown,
|
||||||
@@ -128,4 +183,7 @@ class FusionRepairDashboard(models.AbstractModel):
|
|||||||
'recent': recent,
|
'recent': recent,
|
||||||
'upcoming': upcoming,
|
'upcoming': upcoming,
|
||||||
'portals': portals,
|
'portals': portals,
|
||||||
|
'failures_by_product': failures_by_product,
|
||||||
|
'failures_by_symptom': failures_by_symptom,
|
||||||
|
'margin_summary': margin_summary,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,6 +163,63 @@ class RepairOrder(models.Model):
|
|||||||
'long-running repair (M3). Avoids re-posting daily.',
|
'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):
|
def write(self, vals):
|
||||||
# H2: stamp x_fc_done_at the first time state transitions to 'done'
|
# 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
|
# so the NPS cron has a stable timestamp (write_date moves on every
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export class FusionRepairsDashboard extends Component {
|
|||||||
recent: [],
|
recent: [],
|
||||||
upcoming: [],
|
upcoming: [],
|
||||||
portals: {},
|
portals: {},
|
||||||
|
failures_by_product: [],
|
||||||
|
failures_by_symptom: [],
|
||||||
|
margin_summary: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
onWillStart(async () => {
|
onWillStart(async () => {
|
||||||
@@ -45,6 +48,9 @@ export class FusionRepairsDashboard extends Component {
|
|||||||
this.state.recent = data.recent || [];
|
this.state.recent = data.recent || [];
|
||||||
this.state.upcoming = data.upcoming || [];
|
this.state.upcoming = data.upcoming || [];
|
||||||
this.state.portals = data.portals || {};
|
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) {
|
} catch (e) {
|
||||||
this.notification.add(_t("Could not load dashboard data."), {
|
this.notification.add(_t("Could not load dashboard data."), {
|
||||||
type: "danger",
|
type: "danger",
|
||||||
@@ -112,6 +118,20 @@ export class FusionRepairsDashboard extends Component {
|
|||||||
return value.slice(0, 10);
|
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) {
|
urgencyPillClass(urgency) {
|
||||||
if (urgency === "safety") return "fr-pill fr-pill-safety";
|
if (urgency === "safety") return "fr-pill fr-pill-safety";
|
||||||
if (urgency === "urgent") return "fr-pill fr-pill-urgent";
|
if (urgency === "urgent") return "fr-pill fr-pill-urgent";
|
||||||
|
|||||||
@@ -211,6 +211,94 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Analytics (M7 + M9) -->
|
||||||
|
<div class="fr-section-title">Last 90 Days</div>
|
||||||
|
<div class="fr-grid fr-grid-lists">
|
||||||
|
<div class="fr-list">
|
||||||
|
<h3><i class="fa fa-line-chart me-2"/>Margin Summary</h3>
|
||||||
|
<t t-if="!state.margin_summary or !state.margin_summary.sample_size">
|
||||||
|
<div class="fr-list-empty">No data yet for the last 90 days</div>
|
||||||
|
</t>
|
||||||
|
<t t-if="state.margin_summary and state.margin_summary.sample_size">
|
||||||
|
<div class="fr-list-row">
|
||||||
|
<div class="fr-list-main">
|
||||||
|
<span class="fr-list-title">Revenue</span>
|
||||||
|
<span class="fr-list-sub">Posted invoices on repair SOs</span>
|
||||||
|
</div>
|
||||||
|
<span class="fr-list-meta">
|
||||||
|
<t t-out="formatMoney(state.margin_summary.revenue)"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="fr-list-row">
|
||||||
|
<div class="fr-list-main">
|
||||||
|
<span class="fr-list-title">Labour Cost</span>
|
||||||
|
<span class="fr-list-sub">Hours x tech cost rate</span>
|
||||||
|
</div>
|
||||||
|
<span class="fr-list-meta">
|
||||||
|
- <t t-out="formatMoney(state.margin_summary.labour_cost)"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="fr-list-row">
|
||||||
|
<div class="fr-list-main">
|
||||||
|
<span class="fr-list-title">Parts Cost</span>
|
||||||
|
<span class="fr-list-sub">Standard price of consumed parts</span>
|
||||||
|
</div>
|
||||||
|
<span class="fr-list-meta">
|
||||||
|
- <t t-out="formatMoney(state.margin_summary.parts_cost)"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="fr-list-row" style="border-top:2px solid #d8dadd;">
|
||||||
|
<div class="fr-list-main">
|
||||||
|
<span class="fr-list-title">Margin</span>
|
||||||
|
<span class="fr-list-sub">
|
||||||
|
<t t-out="formatPercent(state.margin_summary.margin_pct)"/>
|
||||||
|
on <t t-out="state.margin_summary.sample_size"/> repairs
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="fr-list-meta" style="font-weight:600;">
|
||||||
|
<t t-out="formatMoney(state.margin_summary.margin)"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fr-list">
|
||||||
|
<h3><i class="fa fa-bar-chart me-2"/>Failure Rate by Product</h3>
|
||||||
|
<t t-if="state.failures_by_product.length === 0">
|
||||||
|
<div class="fr-list-empty">No repairs in the last 90 days</div>
|
||||||
|
</t>
|
||||||
|
<t t-foreach="state.failures_by_product" t-as="p" t-key="p.product_id">
|
||||||
|
<div class="fr-list-row">
|
||||||
|
<div class="fr-list-main">
|
||||||
|
<span class="fr-list-title"><t t-out="p.product_name"/></span>
|
||||||
|
</div>
|
||||||
|
<span class="fr-list-meta">
|
||||||
|
<t t-out="p.repair_count"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fr-grid fr-grid-lists">
|
||||||
|
<div class="fr-list">
|
||||||
|
<h3><i class="fa fa-tags me-2"/>Failure Rate by Symptom</h3>
|
||||||
|
<t t-if="state.failures_by_symptom.length === 0">
|
||||||
|
<div class="fr-list-empty">No symptoms tagged in the last 90 days</div>
|
||||||
|
</t>
|
||||||
|
<t t-foreach="state.failures_by_symptom" t-as="s" t-key="s.symptom">
|
||||||
|
<div class="fr-list-row">
|
||||||
|
<div class="fr-list-main">
|
||||||
|
<span class="fr-list-title"><t t-out="s.symptom"/></span>
|
||||||
|
</div>
|
||||||
|
<span class="fr-list-meta">
|
||||||
|
<t t-out="s.repair_count"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Configuration -->
|
<!-- Configuration -->
|
||||||
<div class="fr-section-title">Configuration</div>
|
<div class="fr-section-title">Configuration</div>
|
||||||
<div class="fr-grid fr-grid-config">
|
<div class="fr-grid fr-grid-config">
|
||||||
|
|||||||
@@ -114,6 +114,21 @@
|
|||||||
<page string="AI Brief" name="fusion_ai" invisible="not x_fc_ai_summary">
|
<page string="AI Brief" name="fusion_ai" invisible="not x_fc_ai_summary">
|
||||||
<field name="x_fc_ai_summary" readonly="1"/>
|
<field name="x_fc_ai_summary" readonly="1"/>
|
||||||
</page>
|
</page>
|
||||||
|
<page string="Margin" name="fusion_margin"
|
||||||
|
groups="fusion_repairs.group_fusion_repairs_manager">
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="x_fc_revenue" widget="monetary"/>
|
||||||
|
<field name="x_fc_labour_cost" widget="monetary"/>
|
||||||
|
<field name="x_fc_parts_cost" widget="monetary"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="x_fc_margin" widget="monetary"/>
|
||||||
|
<field name="x_fc_margin_pct" widget="float" digits="[12,1]"/>
|
||||||
|
<field name="company_currency_id" invisible="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
</xpath>
|
</xpath>
|
||||||
|
|
||||||
</field>
|
</field>
|
||||||
|
|||||||
Reference in New Issue
Block a user