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>
150 lines
4.4 KiB
JavaScript
150 lines
4.4 KiB
JavaScript
/** @odoo-module **/
|
|
// Fusion Repairs dashboard - OWL client action.
|
|
// Uses standalone rpc() from @web/core/network/rpc per project rule #3
|
|
// and useService("action") to navigate to backend act_window actions.
|
|
|
|
import { Component, useState, onWillStart } from "@odoo/owl";
|
|
import { registry } from "@web/core/registry";
|
|
import { rpc } from "@web/core/network/rpc";
|
|
import { useService } from "@web/core/utils/hooks";
|
|
import { _t } from "@web/core/l10n/translation";
|
|
|
|
export class FusionRepairsDashboard extends Component {
|
|
static template = "fusion_repairs.Dashboard";
|
|
static props = ["*"];
|
|
|
|
setup() {
|
|
this.action = useService("action");
|
|
this.notification = useService("notification");
|
|
this.state = useState({
|
|
loading: true,
|
|
stats: {},
|
|
urgency_breakdown: [],
|
|
source_breakdown: [],
|
|
recent: [],
|
|
upcoming: [],
|
|
portals: {},
|
|
failures_by_product: [],
|
|
failures_by_symptom: [],
|
|
margin_summary: {},
|
|
});
|
|
|
|
onWillStart(async () => {
|
|
await this._loadData();
|
|
});
|
|
}
|
|
|
|
async _loadData() {
|
|
try {
|
|
const data = await rpc("/web/dataset/call_kw", {
|
|
model: "fusion.repair.dashboard",
|
|
method: "get_dashboard_data",
|
|
args: [],
|
|
kwargs: {},
|
|
});
|
|
this.state.stats = data.stats || {};
|
|
this.state.urgency_breakdown = data.urgency_breakdown || [];
|
|
this.state.source_breakdown = data.source_breakdown || [];
|
|
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",
|
|
});
|
|
} finally {
|
|
this.state.loading = false;
|
|
}
|
|
}
|
|
|
|
async refresh() {
|
|
this.state.loading = true;
|
|
await this._loadData();
|
|
}
|
|
|
|
openAction(xmlId, extraContext) {
|
|
return this.action.doAction(xmlId, {
|
|
additionalContext: extraContext || {},
|
|
});
|
|
}
|
|
|
|
openWizard() {
|
|
return this.action.doAction("fusion_repairs.action_open_repair_intake_wizard");
|
|
}
|
|
|
|
openRepair(repairId) {
|
|
return this.action.doAction({
|
|
type: "ir.actions.act_window",
|
|
res_model: "repair.order",
|
|
res_id: repairId,
|
|
views: [[false, "form"]],
|
|
target: "current",
|
|
});
|
|
}
|
|
|
|
openContract(contractId) {
|
|
return this.action.doAction({
|
|
type: "ir.actions.act_window",
|
|
res_model: "fusion.repair.maintenance.contract",
|
|
res_id: contractId,
|
|
views: [[false, "form"]],
|
|
target: "current",
|
|
});
|
|
}
|
|
|
|
openUrl(url) {
|
|
if (url) {
|
|
window.open(url, "_blank", "noopener");
|
|
}
|
|
}
|
|
|
|
async copyUrl(url) {
|
|
if (!url) return;
|
|
try {
|
|
await navigator.clipboard.writeText(url);
|
|
this.notification.add(_t("Copied to clipboard."), { type: "success" });
|
|
} catch (e) {
|
|
this.notification.add(_t("Could not copy URL. Select and copy manually."), {
|
|
type: "warning",
|
|
});
|
|
}
|
|
}
|
|
|
|
formatDate(value) {
|
|
if (!value) return "";
|
|
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";
|
|
return "fr-pill fr-pill-normal";
|
|
}
|
|
|
|
urgencyLabel(urgency) {
|
|
const map = { safety: "Safety", urgent: "Urgent", normal: "Normal" };
|
|
return map[urgency] || "Normal";
|
|
}
|
|
}
|
|
|
|
registry
|
|
.category("actions")
|
|
.add("fusion_repairs.dashboard", FusionRepairsDashboard);
|