diff --git a/addons/mcs_operating_system/models/dashboard.py b/addons/mcs_operating_system/models/dashboard.py index 40f9364..657d53a 100644 --- a/addons/mcs_operating_system/models/dashboard.py +++ b/addons/mcs_operating_system/models/dashboard.py @@ -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] diff --git a/addons/mcs_operating_system/static/src/scss/ceo_dashboard_v2.scss b/addons/mcs_operating_system/static/src/scss/ceo_dashboard_v2.scss index 1e74eb7..20e7c5d 100644 --- a/addons/mcs_operating_system/static/src/scss/ceo_dashboard_v2.scss +++ b/addons/mcs_operating_system/static/src/scss/ceo_dashboard_v2.scss @@ -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; } } diff --git a/addons/mcs_operating_system/static/src/xml/ceo_dashboard_v2.xml b/addons/mcs_operating_system/static/src/xml/ceo_dashboard_v2.xml index 10f3e68..4d98b29 100644 --- a/addons/mcs_operating_system/static/src/xml/ceo_dashboard_v2.xml +++ b/addons/mcs_operating_system/static/src/xml/ceo_dashboard_v2.xml @@ -34,7 +34,7 @@
Revenue Coverage % - Remaining gap: + Remaining gap:
@@ -121,6 +121,22 @@
+
+

Company Revenue Summary

+
+ +
+ + posted invoices + Month invoiced: + Month collected: + Outstanding: + Lifetime invoiced: +
+
+
+
+

Team Capacity