Fix CEO dashboard multi-company revenue totals

This commit is contained in:
metatroncubeswdev 2026-08-31 17:11:46 -04:00
parent 7cf6f1413b
commit 2ca8373b19
3 changed files with 325 additions and 22 deletions

View File

@ -1,3 +1,5 @@
from collections import defaultdict
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models
@ -188,12 +190,14 @@ class McsOperatingDashboard(models.Model):
week_start = today - relativedelta(days=today.weekday())
week_end = week_start + relativedelta(days=6)
company_ids = self._dashboard_company_ids(selected_company_ids)
default_currency_ids = self._default_currency_ids(company_ids)
Lead = self.env["crm.lead"]
Project = self.env["project.project"]
Task = self.env["project.task"]
Timesheet = self.env["account.analytic.line"]
Invoice = self.env["account.move"]
Payment = self.env["account.payment"]
Employee = self.env["hr.employee"]
Partner = self.env["res.partner"]
CeoAction = self.env["mcs.ceo.action"]
@ -249,6 +253,16 @@ class McsOperatingDashboard(models.Model):
[("move_type", "=", "out_invoice"), ("state", "=", "posted")]
+ self._company_domain(Invoice, company_ids, include_shared=False)
)
payments_month = Payment.search(
[
("state", "=", "posted"),
("payment_type", "=", "inbound"),
("partner_type", "=", "customer"),
("date", ">=", month_start),
("date", "<=", month_end),
]
+ self._company_domain(Payment, company_ids, include_shared=False)
)
overdue_receivables = receivables.filtered(
lambda invoice: invoice.invoice_date_due and invoice.invoice_date_due < today
)
@ -271,10 +285,8 @@ class McsOperatingDashboard(models.Model):
monthly_target = sum(active_projects.mapped("mcs_monthly_revenue_target"))
contracted_revenue = sum(client_projects.mapped("mcs_contracted_revenue"))
invoiced_month = sum(invoices_month.mapped("amount_total"))
cash_collected = sum(
invoice.amount_total - invoice.amount_residual for invoice in invoices_month
)
invoiced_month = sum(invoices_month.mapped("amount_total_signed"))
cash_collected = sum(payments_month.mapped("amount_company_currency_signed"))
operating_cost = self._monthly_operating_cost(company_ids)
revenue_gap = max(monthly_target - contracted_revenue, 0)
open_pipeline_value = sum(open_pipeline.mapped("expected_revenue"))
@ -325,22 +337,50 @@ class McsOperatingDashboard(models.Model):
self._dashboard_company_ids()
)
],
"currency_summary": self._company_currency_summary(company_ids),
},
"company_pulse": {
"revenue_coverage": self._format_percent(revenue_coverage),
"revenue_coverage_status": self._coverage_status(revenue_coverage),
"remaining_gap": remaining_gap,
"remaining_gap_display": self._company_amount_display(
active_projects, "mcs_contracted_revenue", company_ids, invert_from=operating_cost
),
"monthly_revenue_target": monthly_target,
"monthly_revenue_target_display": self._project_amount_display(
active_projects, "mcs_monthly_revenue_target"
),
"contracted_monthly_revenue": contracted_revenue,
"contracted_monthly_revenue_display": self._project_amount_display(
client_projects, "mcs_contracted_revenue"
),
"revenue_invoiced_month": invoiced_month,
"revenue_invoiced_month_display": self._invoice_amount_display(
invoices_month, "amount_total", default_currency_ids
),
"cash_collected_month": cash_collected,
"cash_collected_month_display": self._payment_amount_display(
payments_month, "amount", default_currency_ids
),
"operating_cost": operating_cost,
"operating_cost_display": self._operating_cost_display(company_ids),
"revenue_gap": revenue_gap,
"revenue_gap_display": self._company_amount_display(
client_projects, "mcs_contracted_revenue", company_ids, invert_from=monthly_target
),
"new_mrr_won": sum(won_month.mapped("mcs_expected_monthly_revenue")),
"open_pipeline": open_pipeline_value,
"open_pipeline_display": self._lead_amount_display(open_pipeline),
"weighted_pipeline": weighted_pipeline,
"weighted_pipeline_display": self._lead_weighted_amount_display(open_pipeline),
"outstanding_receivables": sum(receivables.mapped("amount_residual")),
"outstanding_receivables_display": self._invoice_amount_display(
receivables, "amount_residual", default_currency_ids
),
"overdue_receivables": sum(overdue_receivables.mapped("amount_residual")),
"overdue_receivables_display": self._invoice_amount_display(
overdue_receivables, "amount_residual", default_currency_ids
),
"team_utilization": self._format_percent(utilization),
"billable_utilization": self._format_percent(billable_utilization),
"projects_at_risk": len(active_projects.filtered(lambda project: project.mcs_portfolio_health == "at_risk")),
@ -355,6 +395,25 @@ class McsOperatingDashboard(models.Model):
overdue_receivables,
utilization,
active_projects,
{
"monthly_target": self._project_amount_display(
active_projects, "mcs_monthly_revenue_target"
),
"contracted_revenue": self._project_amount_display(
client_projects, "mcs_contracted_revenue"
),
"revenue_gap": self._company_amount_display(
client_projects,
"mcs_contracted_revenue",
company_ids,
invert_from=monthly_target,
),
"open_pipeline": self._lead_amount_display(open_pipeline),
"weighted_pipeline": self._lead_weighted_amount_display(open_pipeline),
"overdue_receivables": self._invoice_amount_display(
overdue_receivables, "amount_residual", default_currency_ids
),
},
),
"revenue": {
"target": monthly_target,
@ -362,11 +421,36 @@ class McsOperatingDashboard(models.Model):
"invoiced": invoiced_month,
"collected": cash_collected,
"forecast": contracted_revenue + weighted_pipeline,
"display": {
"target": self._project_amount_display(
active_projects, "mcs_monthly_revenue_target"
),
"contracted": self._project_amount_display(
client_projects, "mcs_contracted_revenue"
),
"invoiced": self._invoice_amount_display(
invoices_month, "amount_total", default_currency_ids
),
"collected": self._payment_amount_display(
payments_month, "amount", default_currency_ids
),
"forecast": self._forecast_amount_display(client_projects, open_pipeline),
},
"qualified_pipeline": qualified_pipeline,
"required_pipeline": required_pipeline,
"pipeline_coverage": self._format_percent(pipeline_coverage),
"pipeline_coverage_status": self._coverage_status(pipeline_coverage),
"funnel": self._crm_funnel_data(open_pipeline),
"company_summaries": self._dashboard_company_summaries(
company_ids,
active_projects,
client_projects,
invoices_month,
payments_month,
receivables,
overdue_receivables,
posted_invoices,
),
},
"portfolio": self._portfolio_rows(Partner, client_projects, posted_invoices),
"project_health": self._project_health_distribution(active_projects),
@ -383,6 +467,111 @@ class McsOperatingDashboard(models.Model):
def _format_percent(self, value):
return round(value or 0, 1)
def _format_currency_amount(self, currency, amount):
amount = round(amount or 0, 2)
currency_name = currency.name if currency else self.env.company.currency_id.name
return "%s %s" % (currency_name, "{:,.2f}".format(amount))
def _format_currency_buckets(self, buckets, default_currency_ids=None):
if not buckets:
buckets = {currency_id: 0 for currency_id in (default_currency_ids or [])}
if not buckets:
buckets = {self.env.company.currency_id.id: 0}
Currency = self.env["res.currency"].sudo()
parts = []
for currency_id, amount in sorted(buckets.items()):
parts.append(self._format_currency_amount(Currency.browse(currency_id), amount))
return " / ".join(parts)
def _default_currency_ids(self, company_ids):
return self.env["res.company"].sudo().browse(company_ids).mapped("currency_id").ids
def _company_currency_summary(self, company_ids):
return [
{
"company_id": company.id,
"company": company.name,
"currency": company.currency_id.name,
}
for company in self.env["res.company"].sudo().browse(company_ids)
]
def _invoice_amount_display(self, invoices, amount_field, default_currency_ids=None):
buckets = defaultdict(float)
for invoice in invoices:
currency = invoice.currency_id or invoice.company_currency_id
buckets[currency.id] += invoice[amount_field] or 0
return self._format_currency_buckets(buckets, default_currency_ids)
def _payment_amount_display(self, payments, amount_field, default_currency_ids=None):
buckets = defaultdict(float)
for payment in payments:
currency = payment.currency_id or payment.company_currency_id
buckets[currency.id] += payment[amount_field] or 0
return self._format_currency_buckets(buckets, default_currency_ids)
def _project_amount_display(self, projects, amount_field):
buckets = defaultdict(float)
for project in projects:
currency = project.company_id.currency_id or self.env.company.currency_id
buckets[currency.id] += project[amount_field] or 0
return self._format_currency_buckets(buckets)
def _lead_amount_display(self, leads):
buckets = defaultdict(float)
for lead in leads:
currency = lead.company_id.currency_id or self.env.company.currency_id
buckets[currency.id] += lead.expected_revenue or 0
return self._format_currency_buckets(buckets)
def _lead_weighted_amount_display(self, leads):
buckets = defaultdict(float)
for lead in leads:
currency = lead.company_id.currency_id or self.env.company.currency_id
buckets[currency.id] += (lead.expected_revenue or 0) * (lead.probability or 0) / 100
return self._format_currency_buckets(buckets)
def _forecast_amount_display(self, projects, leads):
buckets = defaultdict(float)
for project in projects:
currency = project.company_id.currency_id or self.env.company.currency_id
buckets[currency.id] += project.mcs_contracted_revenue or 0
for lead in leads:
currency = lead.company_id.currency_id or self.env.company.currency_id
buckets[currency.id] += (lead.expected_revenue or 0) * (lead.probability or 0) / 100
return self._format_currency_buckets(buckets)
def _operating_cost_display(self, company_ids):
try:
Contract = self.env["hr.contract"]
except KeyError:
return self._format_currency_amount(self.env.company.currency_id, 0)
buckets = defaultdict(float)
contracts = Contract.sudo().search(
[("state", "=", "open"), ("employee_id.active", "=", True)]
+ self._company_domain(Contract, company_ids, include_shared=True)
)
for contract in contracts:
currency = contract.company_id.currency_id or self.env.company.currency_id
buckets[currency.id] += contract.wage or 0
return self._format_currency_buckets(buckets)
def _company_amount_display(self, records, amount_field, company_ids, invert_from=None):
buckets = defaultdict(float)
for company in self.env["res.company"].sudo().browse(company_ids):
buckets[company.currency_id.id] += 0
for record in records:
currency = record.company_id.currency_id or self.env.company.currency_id
buckets[currency.id] += record[amount_field] or 0
if invert_from is not None:
total = sum(buckets.values())
currency = (
self.env["res.company"].sudo().browse(company_ids[:1]).currency_id
or self.env.company.currency_id
)
return self._format_currency_amount(currency, max((invert_from or 0) - total, 0))
return self._format_currency_buckets(buckets)
def _dashboard_company_ids(self, selected_company_ids=None):
allowed_company_ids = (
self.env.context.get("allowed_company_ids") or self.env.companies.ids
@ -434,28 +623,41 @@ class McsOperatingDashboard(models.Model):
overdue_receivables,
utilization,
active_projects,
display_values=None,
):
display_values = display_values or {}
return [
{
"label": "Revenue Target",
"value": monthly_target,
"value": display_values.get("monthly_target", monthly_target),
"action_key": "monthly_revenue_projects",
},
{
"label": "Contracted Revenue",
"value": contracted_revenue,
"value": display_values.get("contracted_revenue", contracted_revenue),
"action_key": "contracted_projects",
},
{"label": "Revenue Gap", "value": revenue_gap, "action_key": "pipeline"},
{"label": "Open Pipeline", "value": open_pipeline_value, "action_key": "pipeline"},
{
"label": "Revenue Gap",
"value": display_values.get("revenue_gap", revenue_gap),
"action_key": "pipeline",
},
{
"label": "Open Pipeline",
"value": display_values.get("open_pipeline", open_pipeline_value),
"action_key": "pipeline",
},
{
"label": "Weighted Pipeline",
"value": weighted_pipeline,
"value": display_values.get("weighted_pipeline", weighted_pipeline),
"action_key": "pipeline",
},
{
"label": "Overdue Receivables",
"value": sum(overdue_receivables.mapped("amount_residual")),
"value": display_values.get(
"overdue_receivables",
sum(overdue_receivables.mapped("amount_residual")),
),
"action_key": "overdue_receivables",
},
{
@ -521,11 +723,7 @@ class McsOperatingDashboard(models.Model):
):
continue
currencies = invoices.mapped("currency_id") or partner.mcs_currency_id
currency_name = (
currencies[0].name
if len(currencies) == 1
else "Mixed"
)
currency_name = " / ".join(currencies.mapped("name")) if currencies else ""
invoice_dates = [date for date in invoices.mapped("invoice_date") if date]
rows.append(
{
@ -536,8 +734,17 @@ class McsOperatingDashboard(models.Model):
"active_workstreams": len(projects),
"revenue": sum(projects.mapped("mcs_monthly_revenue_target")),
"total_invoiced": sum(invoices.mapped("amount_total_signed")),
"total_invoiced_display": self._invoice_amount_display(
invoices, "amount_total"
),
"outstanding": sum(unpaid_invoices.mapped("amount_residual_signed")),
"outstanding_display": self._invoice_amount_display(
unpaid_invoices, "amount_residual"
),
"overdue": sum(overdue_invoices.mapped("amount_residual_signed")),
"overdue_display": self._invoice_amount_display(
overdue_invoices, "amount_residual"
),
"last_invoice_date": fields.Date.to_string(max(invoice_dates))
if invoice_dates
else "",
@ -554,6 +761,59 @@ class McsOperatingDashboard(models.Model):
)
return sorted(rows, key=lambda row: row["risk"], reverse=True)
def _dashboard_company_summaries(
self,
company_ids,
active_projects,
client_projects,
invoices_month,
payments_month,
receivables,
overdue_receivables,
posted_invoices,
):
rows = []
for company in self.env["res.company"].sudo().browse(company_ids):
company_projects = active_projects.filtered(lambda project: project.company_id == company)
company_client_projects = client_projects.filtered(lambda project: project.company_id == company)
company_invoices_month = invoices_month.filtered(lambda invoice: invoice.company_id == company)
company_payments_month = payments_month.filtered(lambda payment: payment.company_id == company)
company_receivables = receivables.filtered(lambda invoice: invoice.company_id == company)
company_overdue = overdue_receivables.filtered(lambda invoice: invoice.company_id == company)
company_posted = posted_invoices.filtered(lambda invoice: invoice.company_id == company)
rows.append(
{
"company_id": company.id,
"company": company.name,
"currency": company.currency_id.name,
"target": self._format_currency_amount(
company.currency_id,
sum(company_projects.mapped("mcs_monthly_revenue_target")),
),
"contracted": self._format_currency_amount(
company.currency_id,
sum(company_client_projects.mapped("mcs_contracted_revenue")),
),
"invoiced_this_month": self._invoice_amount_display(
company_invoices_month, "amount_total", [company.currency_id.id]
),
"collected_this_month": self._payment_amount_display(
company_payments_month, "amount", [company.currency_id.id]
),
"outstanding": self._invoice_amount_display(
company_receivables, "amount_residual", [company.currency_id.id]
),
"overdue": self._invoice_amount_display(
company_overdue, "amount_residual", [company.currency_id.id]
),
"lifetime_invoiced": self._invoice_amount_display(
company_posted, "amount_total", [company.currency_id.id]
),
"invoice_count": len(company_posted),
}
)
return rows
def _nearest_deadline(self, projects):
deadlines = projects.mapped("tasks.date_deadline")
deadlines = [deadline for deadline in deadlines if deadline]

View File

@ -185,7 +185,7 @@
.o_mcs_capacity_row,
.o_mcs_distribution_row {
display: grid;
grid-template-columns: 120px minmax(80px, 1fr) 80px;
grid-template-columns: 120px minmax(80px, 1fr) minmax(150px, max-content);
align-items: center;
gap: 10px;
}
@ -231,6 +231,32 @@
gap: 10px;
}
.o_mcs_company_summary {
display: grid;
gap: 10px;
}
.o_mcs_company_summary_row {
display: grid;
grid-template-columns: minmax(180px, 1.2fr) minmax(110px, .7fr) repeat(4, minmax(140px, 1fr));
gap: 10px;
align-items: start;
padding: 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #ffffff;
span {
color: #667085;
}
b {
display: block;
color: #111827;
font-weight: 700;
}
}
.o_mcs_funnel_stage,
.o_mcs_health_item,
.o_mcs_action_item,
@ -333,7 +359,8 @@
.o_mcs_bar_row,
.o_mcs_capacity_row,
.o_mcs_distribution_row {
.o_mcs_distribution_row,
.o_mcs_company_summary_row {
grid-template-columns: 1fr;
}
}

View File

@ -34,7 +34,7 @@
<div t-attf-class="o_mcs_coverage o_mcs_status_{{ state.data.company_pulse.revenue_coverage_status }}">
<span>Revenue Coverage</span>
<strong><t t-esc="state.data.company_pulse.revenue_coverage"/>%</strong>
<small>Remaining gap: <t t-esc="state.data.company_pulse.remaining_gap"/></small>
<small>Remaining gap: <t t-esc="state.data.company_pulse.remaining_gap_display"/></small>
</div>
<t t-foreach="state.data.kpis" t-as="kpi" t-key="kpi.label">
<button class="o_mcs_kpi" t-on-click="() => this.openAction(kpi.action_key)">
@ -57,7 +57,7 @@
<div class="o_mcs_bar_track">
<div class="o_mcs_bar_fill" t-att-style="this.barStyle(state.data.revenue[key], state.data.revenue.target)"/>
</div>
<strong t-esc="state.data.revenue[key]"/>
<strong t-esc="state.data.revenue.display[key]"/>
</div>
</t>
</div>
@ -97,9 +97,9 @@
<span t-attf-class="o_mcs_badge o_mcs_status_{{ row.health }}" t-esc="row.health"/>
<span t-esc="row.currency"/>
<span t-esc="row.active_workstreams"/>
<span t-esc="row.total_invoiced"/>
<span t-esc="row.outstanding"/>
<span t-esc="row.overdue"/>
<span t-esc="row.total_invoiced_display"/>
<span t-esc="row.outstanding_display"/>
<span t-esc="row.overdue_display"/>
<span t-esc="row.last_invoice_date"/>
<span t-esc="row.next_action"/>
</button>
@ -121,6 +121,22 @@
</section>
<section class="o_mcs_grid o_mcs_grid_two">
<div class="o_mcs_panel">
<header><h2>Company Revenue Summary</h2></header>
<div class="o_mcs_company_summary">
<t t-foreach="state.data.revenue.company_summaries" t-as="company" t-key="company.company_id">
<div class="o_mcs_company_summary_row">
<strong t-esc="company.company"/>
<span><t t-esc="company.invoice_count"/> posted invoices</span>
<span>Month invoiced: <b t-esc="company.invoiced_this_month"/></span>
<span>Month collected: <b t-esc="company.collected_this_month"/></span>
<span>Outstanding: <b t-esc="company.outstanding"/></span>
<span>Lifetime invoiced: <b t-esc="company.lifetime_invoiced"/></span>
</div>
</t>
</div>
</div>
<div class="o_mcs_panel">
<header>
<h2>Team Capacity</h2>