from collections import defaultdict from dateutil.relativedelta import relativedelta from odoo import api, fields, models class McsOperatingDashboard(models.Model): _name = "mcs.operating.dashboard" _description = "Operating Dashboard" name = fields.Char(default="CEO Dashboard", required=True) currency_id = fields.Many2one( "res.currency", default=lambda self: self.env.company.currency_id ) month_start = fields.Date(compute="_compute_metrics") month_end = fields.Date(compute="_compute_metrics") monthly_revenue_target = fields.Monetary(compute="_compute_metrics") revenue_closed_month = fields.Monetary(compute="_compute_metrics") monthly_cash_gap = fields.Monetary(compute="_compute_metrics") new_mrr_won = fields.Monetary(compute="_compute_metrics") pipeline_value = fields.Monetary(compute="_compute_metrics") weighted_pipeline = fields.Monetary(compute="_compute_metrics") proposals_outstanding = fields.Integer(compute="_compute_metrics") deals_expected_month = fields.Integer(compute="_compute_metrics") cash_collected = fields.Monetary(compute="_compute_metrics") invoices_overdue = fields.Integer(compute="_compute_metrics") active_clients = fields.Integer(compute="_compute_metrics") opportunities_created = fields.Integer(compute="_compute_metrics") opportunities_won = fields.Integer(compute="_compute_metrics") conversion_rate = fields.Float(compute="_compute_metrics") followups_due_today = fields.Integer(compute="_compute_metrics") projects_at_risk = fields.Integer(compute="_compute_metrics") blockers = fields.Integer(compute="_compute_metrics") overdue_tasks = fields.Integer(compute="_compute_metrics") team_capacity_hours = fields.Float(compute="_compute_metrics") planned_hours = fields.Float(compute="_compute_metrics") actual_hours = fields.Float(compute="_compute_metrics") billable_hours = fields.Float(compute="_compute_metrics") non_billable_hours = fields.Float(compute="_compute_metrics") utilization = fields.Float(compute="_compute_metrics") @api.depends_context("uid") def _compute_metrics(self): today = fields.Date.context_today(self) month_start = today.replace(day=1) month_end = month_start + relativedelta(months=1, days=-1) 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"] Employee = self.env["hr.employee"] open_pipeline = Lead.search( [("type", "=", "opportunity"), ("active", "=", True), ("probability", "<", 100)] ) won_month = Lead.search( [ ("type", "=", "opportunity"), ("active", "=", True), ("probability", "=", 100), ("date_closed", ">=", month_start), ("date_closed", "<=", month_end), ] ) invoices_month = Invoice.search( [ ("move_type", "=", "out_invoice"), ("state", "=", "posted"), ("invoice_date", ">=", month_start), ("invoice_date", "<=", month_end), ] ) overdue_invoices = Invoice.search( [ ("move_type", "=", "out_invoice"), ("state", "=", "posted"), ("payment_state", "not in", ["paid", "in_payment", "reversed"]), ("invoice_date_due", "<", today), ] ) timesheets_month = Timesheet.search( [("date", ">=", month_start), ("date", "<=", month_end)] ) active_projects = Project.search([("active", "=", True)]) active_tasks = Task.search([("active", "=", True)]) employees = Employee.search([("active", "=", True)]) for dashboard in self: dashboard.month_start = month_start dashboard.month_end = month_end dashboard.monthly_revenue_target = sum( active_projects.mapped("mcs_monthly_revenue_target") ) dashboard.revenue_closed_month = sum(won_month.mapped("expected_revenue")) dashboard.monthly_cash_gap = ( dashboard.monthly_revenue_target - dashboard.revenue_closed_month ) dashboard.new_mrr_won = sum(won_month.mapped("mcs_expected_monthly_revenue")) dashboard.pipeline_value = sum(open_pipeline.mapped("expected_revenue")) dashboard.weighted_pipeline = sum( lead.expected_revenue * lead.probability / 100 for lead in open_pipeline ) dashboard.proposals_outstanding = Lead.search_count( [ ("type", "=", "opportunity"), ("active", "=", True), ("mcs_proposal_sent_date", "!=", False), ("probability", "<", 100), ] ) dashboard.deals_expected_month = Lead.search_count( [ ("type", "=", "opportunity"), ("active", "=", True), ("date_deadline", ">=", month_start), ("date_deadline", "<=", month_end), ("probability", "<", 100), ] ) dashboard.cash_collected = sum( invoice.amount_total - invoice.amount_residual for invoice in invoices_month ) dashboard.invoices_overdue = len(overdue_invoices) dashboard.active_clients = len(set(active_projects.mapped("partner_id").ids)) created = Lead.search_count( [ ("type", "=", "opportunity"), ("create_date", ">=", month_start), ("create_date", "<=", month_end), ] ) dashboard.opportunities_created = created dashboard.opportunities_won = len(won_month) dashboard.conversion_rate = len(won_month) / created * 100 if created else 0 dashboard.followups_due_today = self.env["mail.activity"].search_count( [ ("user_id", "=", self.env.user.id), ("date_deadline", "<=", today), ("res_model", "=", "crm.lead"), ] ) dashboard.projects_at_risk = len(active_projects.filtered("mcs_at_risk")) dashboard.blockers = len(active_tasks.filtered("mcs_is_blocked")) dashboard.overdue_tasks = len( active_tasks.filtered( lambda task: task.date_deadline and fields.Date.to_date(task.date_deadline) < today and not (task.stage_id and task.stage_id.fold) ) ) dashboard.team_capacity_hours = sum(employees.mapped("mcs_available_hours_week")) dashboard.planned_hours = sum(active_tasks.mapped("allocated_hours")) dashboard.actual_hours = sum(timesheets_month.mapped("unit_amount")) dashboard.billable_hours = sum( timesheets_month.filtered( lambda line: line.mcs_billable_classification == "billable" ).mapped("unit_amount") ) dashboard.non_billable_hours = dashboard.actual_hours - dashboard.billable_hours dashboard.utilization = ( dashboard.billable_hours / dashboard.team_capacity_hours * 100 if dashboard.team_capacity_hours else 0 ) def action_refresh(self): return { "type": "ir.actions.act_window", "name": "CEO Dashboard", "res_model": "mcs.operating.dashboard", "view_mode": "form", "target": "current", "context": {"create": False}, } @api.model def action_mcs_seed_v2_operating_data(self): from odoo.addons.mcs_operating_system.hooks import OperatingSystemSetup OperatingSystemSetup(self.env).run() return True @api.model def get_ceo_dashboard_v2_data(self, selected_company_ids=None): today = fields.Date.context_today(self) month_start = today.replace(day=1) month_end = month_start + relativedelta(months=1, days=-1) 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"] active_projects = Project.search( [("active", "=", True)] + self._company_domain(Project, company_ids, include_shared=False) ) client_projects = active_projects.filtered( lambda project: project.mcs_delivery_type not in ("internal_product", "internal_r_and_d", "sales_initiative") ) internal_projects = active_projects.filtered( lambda project: project.mcs_delivery_type in ("internal_product", "internal_r_and_d") ) open_pipeline = Lead.search( [ ("type", "=", "opportunity"), ("active", "=", True), ("probability", "<", 100), ] + self._company_domain(Lead, company_ids, include_shared=True) ) won_month = Lead.search( [ ("type", "=", "opportunity"), ("active", "=", True), ("probability", "=", 100), ("date_closed", ">=", month_start), ("date_closed", "<=", month_end), ] + self._company_domain(Lead, company_ids, include_shared=True) ) invoices_month = Invoice.search( [ ("move_type", "=", "out_invoice"), ("state", "=", "posted"), ("invoice_date", ">=", month_start), ("invoice_date", "<=", month_end), ] + self._company_domain(Invoice, company_ids, include_shared=False) ) receivables = Invoice.search( [ ("move_type", "=", "out_invoice"), ("state", "=", "posted"), ("payment_state", "not in", ["paid", "in_payment", "reversed"]), ] + self._company_domain(Invoice, company_ids, include_shared=False) ) posted_invoices = Invoice.search( [("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 ) timesheets_month = Timesheet.search( [("date", ">=", month_start), ("date", "<=", month_end)] + self._company_domain(Timesheet, company_ids, include_shared=True) ) timesheets_week = Timesheet.search( [("date", ">=", week_start), ("date", "<=", week_end)] + self._company_domain(Timesheet, company_ids, include_shared=True) ) active_employees = Employee.search( [("active", "=", True)] + self._company_domain(Employee, company_ids, include_shared=True) ) active_tasks = Task.search( [("active", "=", True)] + self._company_domain(Task, company_ids, include_shared=False) ) 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_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")) weighted_pipeline = sum( lead.expected_revenue * lead.probability / 100 for lead in open_pipeline ) qualified_stage_names = ["Qualified", "Proposal", "Negotiation"] qualified_pipeline = sum( open_pipeline.filtered( lambda lead: lead.stage_id.name in qualified_stage_names ).mapped("expected_revenue") ) required_pipeline = revenue_gap * 3 pipeline_coverage = ( qualified_pipeline / required_pipeline * 100 if required_pipeline else 100 ) revenue_coverage = ( contracted_revenue / operating_cost * 100 if operating_cost else 0 ) remaining_gap = max(operating_cost - contracted_revenue, 0) utilization_capacity = sum(active_employees.mapped("mcs_available_hours_week")) billable_hours = sum( timesheets_week.filtered( lambda line: line.mcs_billable_classification == "billable" ).mapped("unit_amount") ) total_week_hours = sum(timesheets_week.mapped("unit_amount")) billable_utilization = ( billable_hours / utilization_capacity * 100 if utilization_capacity else 0 ) utilization = ( total_week_hours / utilization_capacity * 100 if utilization_capacity else 0 ) return { "period": { "today": fields.Date.to_string(today), "month_start": fields.Date.to_string(month_start), "month_end": fields.Date.to_string(month_end), "week_start": fields.Date.to_string(week_start), "week_end": fields.Date.to_string(week_end), "company_ids": company_ids, "companies": self.env["res.company"].browse(company_ids).mapped("name"), "company_options": [ {"id": company.id, "name": company.name} for company in self.env["res.company"].browse( 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")), "blocked_projects": len(active_projects.filtered(lambda project: project.mcs_portfolio_health == "blocked")), }, "kpis": self._dashboard_kpis( monthly_target, contracted_revenue, revenue_gap, open_pipeline_value, weighted_pipeline, 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, "contracted": contracted_revenue, "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), "capacity": self._capacity_rows(active_employees, active_tasks, timesheets_week), "time_distribution": self._time_distribution(timesheets_week), "allocation_guardrail": self._allocation_guardrail(timesheets_week), "ceo_actions": self._ceo_action_rows(CeoAction), "internal_bets": self._internal_bet_rows(internal_projects, timesheets_month), "actions": self._dashboard_actions( today, month_start, month_end, week_start, week_end, company_ids ), } 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 ) allowed_company_ids = allowed_company_ids or [self.env.company.id] if not selected_company_ids: return allowed_company_ids selected_company_ids = [int(company_id) for company_id in selected_company_ids] selected_company_ids = [ company_id for company_id in selected_company_ids if company_id in allowed_company_ids ] return selected_company_ids or allowed_company_ids def _company_domain(self, Model, company_ids, include_shared=True): if "company_id" not in Model._fields: return [] if include_shared: return ["|", ("company_id", "=", False), ("company_id", "in", company_ids)] return [("company_id", "in", company_ids)] def _monthly_operating_cost(self, company_ids=None): try: Contract = self.env["hr.contract"] except KeyError: return 0 company_ids = company_ids or self._dashboard_company_ids() contracts = Contract.sudo().search( [("state", "=", "open"), ("employee_id.active", "=", True)] + self._company_domain(Contract, company_ids, include_shared=True) ) return sum(contracts.mapped("wage")) def _coverage_status(self, value): if value >= 100: return "covered" if value >= 80: return "watch" return "critical" def _dashboard_kpis( self, monthly_target, contracted_revenue, revenue_gap, open_pipeline_value, weighted_pipeline, overdue_receivables, utilization, active_projects, display_values=None, ): display_values = display_values or {} return [ { "label": "Revenue Target", "value": display_values.get("monthly_target", monthly_target), "action_key": "monthly_revenue_projects", }, { "label": "Contracted Revenue", "value": display_values.get("contracted_revenue", contracted_revenue), "action_key": "contracted_projects", }, { "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": display_values.get("weighted_pipeline", weighted_pipeline), "action_key": "pipeline", }, { "label": "Overdue Receivables", "value": display_values.get( "overdue_receivables", sum(overdue_receivables.mapped("amount_residual")), ), "action_key": "overdue_receivables", }, { "label": "Team Utilization", "value": "%s%%" % self._format_percent(utilization), "action_key": "capacity", }, { "label": "At Risk Projects", "value": len(active_projects.filtered(lambda project: project.mcs_portfolio_health == "at_risk")), "action_key": "projects_at_risk", }, ] def _crm_funnel_data(self, open_pipeline): stage_names = [ "Target", "Contacted", "Replied", "Discovery", "Qualified", "Proposal", "Negotiation", "Won", ] rows = [] for stage_name in stage_names: leads = open_pipeline.filtered(lambda lead: lead.stage_id.name == stage_name) rows.append( { "stage": stage_name, "count": len(leads), "value": sum(leads.mapped("expected_revenue")), } ) return rows def _portfolio_rows(self, Partner, client_projects, posted_invoices): partners = ( Partner.search([("mcs_is_client_account", "=", True)]) | client_projects.mapped("partner_id") | posted_invoices.mapped("commercial_partner_id") ) rows = [] for partner in partners: projects = client_projects.filtered(lambda project: project.partner_id == partner) invoices = posted_invoices.filtered( lambda invoice: invoice.commercial_partner_id == partner.commercial_partner_id ) unpaid_invoices = invoices.filtered( lambda invoice: invoice.payment_state not in ("paid", "in_payment", "reversed") ) overdue_invoices = unpaid_invoices.filtered( lambda invoice: invoice.invoice_date_due and invoice.invoice_date_due < fields.Date.context_today(self) ) if partner.parent_id and not projects: continue if ( not projects and not invoices ): continue currencies = invoices.mapped("currency_id") or partner.mcs_currency_id currency_name = " / ".join(currencies.mapped("name")) if currencies else "" default_currency_ids = currencies.ids invoice_dates = [date for date in invoices.mapped("invoice_date") if date] rows.append( { "id": partner.id, "name": partner.display_name, "currency": currency_name, "health": partner.mcs_account_health, "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", default_currency_ids ), "outstanding": sum(unpaid_invoices.mapped("amount_residual_signed")), "outstanding_display": self._invoice_amount_display( unpaid_invoices, "amount_residual", default_currency_ids ), "overdue": sum(overdue_invoices.mapped("amount_residual_signed")), "overdue_display": self._invoice_amount_display( overdue_invoices, "amount_residual", default_currency_ids ), "last_invoice_date": fields.Date.to_string(max(invoice_dates)) if invoice_dates else "", "hours": sum(projects.mapped("mcs_actual_hours")), "next_milestone": next( (project.mcs_next_milestone for project in projects if project.mcs_next_milestone), "", ), "nearest_deadline": self._nearest_deadline(projects), "risk": max(projects.mapped("mcs_risk_score") or [0]), "next_action": partner.mcs_next_account_action or next((project.mcs_next_action for project in projects if project.mcs_next_action), ""), } ) 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] return fields.Date.to_string(min(deadlines)) if deadlines else "" def _project_health_distribution(self, projects): return [ {"label": "Healthy", "value": len(projects.filtered(lambda project: project.mcs_portfolio_health == "healthy"))}, {"label": "Watch", "value": len(projects.filtered(lambda project: project.mcs_portfolio_health == "watch"))}, {"label": "At Risk", "value": len(projects.filtered(lambda project: project.mcs_portfolio_health == "at_risk"))}, {"label": "Blocked", "value": len(projects.filtered(lambda project: project.mcs_portfolio_health == "blocked"))}, {"label": "Stale", "value": len(projects.filtered("mcs_stale"))}, {"label": "No Next Action", "value": len(projects.filtered(lambda project: not project.mcs_next_action))}, {"label": "Missing Planned Hours", "value": len(projects.filtered(lambda project: not project.mcs_planned_hours))}, {"label": "Over Planned Hours", "value": len(projects.filtered(lambda project: project.mcs_planned_hours and project.mcs_actual_hours > project.mcs_planned_hours))}, ] def _capacity_rows(self, employees, active_tasks, timesheets_week): rows = [] for employee in employees: employee_lines = timesheets_week.filtered(lambda line: line.employee_id == employee) employee_tasks = active_tasks.filtered( lambda task: employee.user_id and employee.user_id in task.user_ids ) available = employee.mcs_available_hours_week actual = sum(employee_lines.mapped("unit_amount")) billable = sum( employee_lines.filtered( lambda line: line.mcs_billable_classification == "billable" ).mapped("unit_amount") ) utilization = actual / available * 100 if available else 0 billable_utilization = billable / available * 100 if available else 0 rows.append( { "id": employee.id, "name": employee.name, "available": available, "allocated": sum(employee_tasks.mapped("allocated_hours")), "actual": actual, "billable": billable, "internal": sum( employee_lines.filtered( lambda line: line.mcs_billable_classification == "internal" ).mapped("unit_amount") ), "rework": sum(employee_lines.filtered("mcs_is_rework").mapped("unit_amount")), "utilization": self._format_percent(utilization), "billable_utilization": self._format_percent(billable_utilization), "status": self._capacity_status(utilization), } ) return sorted(rows, key=lambda row: row["utilization"], reverse=True) def _capacity_status(self, utilization): if utilization > 100: return "overloaded" if utilization >= 90: return "near_capacity" if utilization >= 50: return "normal" return "underutilized" def _time_distribution(self, timesheets): category_labels = dict(self.env["account.analytic.line"]._fields["mcs_work_category"].selection) billable_labels = dict(self.env["account.analytic.line"]._fields["mcs_billable_classification"].selection) by_category = [] by_billable = [] for key, label in category_labels.items(): by_category.append( { "label": label, "hours": sum(timesheets.filtered(lambda line: line.mcs_work_category == key).mapped("unit_amount")), } ) for key, label in billable_labels.items(): by_billable.append( { "label": label, "hours": sum(timesheets.filtered(lambda line: line.mcs_billable_classification == key).mapped("unit_amount")), } ) return {"by_category": by_category, "by_billable": by_billable} def _allocation_guardrail(self, timesheets): total = sum(timesheets.mapped("unit_amount")) groups = [ ("Client Delivery", ["billable_delivery", "client_support"], 75), ("Sales Support", ["sales_support"], 10), ("Internal Product/R&D", ["internal_product", "research"], 10), ("Admin/Other", ["admin", "meetings", "qa", "rework"], 5), ] rows = [] for label, categories, target in groups: hours = sum( timesheets.filtered( lambda line, categories=categories: line.mcs_work_category in categories ).mapped("unit_amount") ) rows.append( { "label": label, "hours": hours, "percentage": self._format_percent(hours / total * 100 if total else 0), "target": target, } ) return rows def _ceo_action_rows(self, CeoAction): actions = CeoAction.search( [("state", "in", ["open", "waiting", "blocked"])], order="priority_rank, date_due, id desc", limit=12, ) return [ { "id": action.id, "title": action.title, "category": action.category, "priority": action.priority, "state": action.state, "date_due": fields.Date.to_string(action.date_due) if action.date_due else "", "next_action": action.next_action or "", } for action in actions ] def _internal_bet_rows(self, projects, timesheets_month): rows = [] for project in projects: project_lines = timesheets_month.filtered(lambda line: line.project_id == project) hours = sum(project_lines.mapped("unit_amount")) rows.append( { "id": project.id, "name": project.name, "stage": project.mcs_internal_product_stage, "hours": hours, "estimated_cost": 0, "expected_revenue": project.mcs_internal_expected_revenue, "actual_revenue": project.mcs_internal_actual_revenue, "owner": project.user_id.name or "", "next_milestone": project.mcs_next_milestone or "", "next_decision_date": fields.Date.to_string(project.mcs_next_decision_date) if project.mcs_next_decision_date else "", } ) return rows def _dashboard_actions(self, today, month_start, month_end, week_start, week_end, company_ids): Project = self.env["project.project"] Lead = self.env["crm.lead"] Invoice = self.env["account.move"] Timesheet = self.env["account.analytic.line"] return { "monthly_revenue_projects": { "type": "ir.actions.act_window", "name": "Monthly Revenue Projects", "res_model": "project.project", "view_mode": "tree,form,kanban", "views": self._action_views("tree", "form", "kanban"), "domain": [("active", "=", True), ("mcs_monthly_revenue_target", ">", 0)] + self._company_domain(Project, company_ids, include_shared=False), }, "contracted_projects": { "type": "ir.actions.act_window", "name": "Contracted Projects", "res_model": "project.project", "view_mode": "tree,form,kanban", "views": self._action_views("tree", "form", "kanban"), "domain": [("active", "=", True), ("mcs_contracted_revenue", ">", 0)] + self._company_domain(Project, company_ids, include_shared=False), }, "pipeline": { "type": "ir.actions.act_window", "name": "Open Pipeline", "res_model": "crm.lead", "view_mode": "kanban,tree,form,pivot,graph", "views": self._action_views("kanban", "tree", "form", "pivot", "graph"), "domain": [ ("type", "=", "opportunity"), ("active", "=", True), ("probability", "<", 100), ] + self._company_domain(Lead, company_ids, include_shared=True), }, "overdue_receivables": { "type": "ir.actions.act_window", "name": "Overdue Receivables", "res_model": "account.move", "view_mode": "tree,form", "views": self._action_views("tree", "form"), "domain": [ ("move_type", "=", "out_invoice"), ("state", "=", "posted"), ("payment_state", "not in", ["paid", "in_payment", "reversed"]), ("invoice_date_due", "<", fields.Date.to_string(today)), ] + self._company_domain(Invoice, company_ids, include_shared=False), }, "capacity": { "type": "ir.actions.act_window", "name": "This Week Timesheets", "res_model": "account.analytic.line", "view_mode": "tree,pivot,graph,form", "views": self._action_views("tree", "pivot", "graph", "form"), "domain": [ ("date", ">=", fields.Date.to_string(week_start)), ("date", "<=", fields.Date.to_string(week_end)), ] + self._company_domain(Timesheet, company_ids, include_shared=True), "context": {"group_by": "employee_id"}, }, "projects_at_risk": { "type": "ir.actions.act_window", "name": "Projects At Risk", "res_model": "project.project", "view_mode": "tree,form,kanban", "views": self._action_views("tree", "form", "kanban"), "domain": [ ("active", "=", True), ("mcs_portfolio_health", "in", ["at_risk", "blocked"]), ] + self._company_domain(Project, company_ids, include_shared=False), }, "ceo_actions": { "type": "ir.actions.act_window", "name": "CEO Actions", "res_model": "mcs.ceo.action", "view_mode": "tree,form,kanban", "views": self._action_views("tree", "form", "kanban"), "domain": [("state", "in", ["open", "waiting", "blocked"])], }, "internal_bets": { "type": "ir.actions.act_window", "name": "Internal Bets", "res_model": "project.project", "view_mode": "tree,form,kanban", "views": self._action_views("tree", "form", "kanban"), "domain": [("mcs_delivery_type", "in", ["internal_product", "internal_r_and_d"])] + self._company_domain(Project, company_ids, include_shared=False), }, } def _action_views(self, *view_types): return [[False, view_type] for view_type in view_types]