1065 lines
48 KiB
Python
1065 lines
48 KiB
Python
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 ""
|
|
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"
|
|
),
|
|
"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 "",
|
|
"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]
|