271 lines
11 KiB
Python
271 lines
11 KiB
Python
from odoo import api, fields, models
|
|
|
|
|
|
class ResPartner(models.Model):
|
|
_inherit = "res.partner"
|
|
|
|
mcs_is_client_account = fields.Boolean(string="MCS Client Account", tracking=True)
|
|
mcs_account_status = fields.Selection(
|
|
[
|
|
("active", "Active"),
|
|
("watch", "Watch"),
|
|
("paused", "Paused"),
|
|
("completed", "Completed"),
|
|
("lost", "Lost"),
|
|
],
|
|
string="Account Status",
|
|
default="active",
|
|
tracking=True,
|
|
)
|
|
mcs_account_health = fields.Selection(
|
|
[
|
|
("healthy", "Healthy"),
|
|
("watch", "Watch"),
|
|
("at_risk", "At Risk"),
|
|
("blocked", "Blocked"),
|
|
("completed", "Completed"),
|
|
],
|
|
string="Account Health",
|
|
compute="_compute_mcs_account_metrics",
|
|
store=True,
|
|
tracking=True,
|
|
)
|
|
mcs_currency_id = fields.Many2one(
|
|
"res.currency",
|
|
string="MCS Currency",
|
|
compute="_compute_mcs_account_metrics",
|
|
default=lambda self: self.env.company.currency_id,
|
|
)
|
|
mcs_client_company_id = fields.Many2one(
|
|
"res.company",
|
|
string="MCS Company",
|
|
compute="_compute_mcs_account_metrics",
|
|
help="Company inferred from this client's invoices or delivery projects.",
|
|
)
|
|
mcs_monthly_revenue = fields.Monetary(
|
|
string="Monthly Revenue",
|
|
currency_field="mcs_currency_id",
|
|
compute="_compute_mcs_account_metrics",
|
|
store=True,
|
|
)
|
|
mcs_contracted_value = fields.Monetary(
|
|
string="Contracted Value",
|
|
currency_field="mcs_currency_id",
|
|
compute="_compute_mcs_account_metrics",
|
|
store=True,
|
|
)
|
|
mcs_monthly_hours = fields.Float(
|
|
string="Monthly Hours",
|
|
compute="_compute_mcs_account_metrics",
|
|
store=True,
|
|
)
|
|
mcs_effective_hourly_revenue = fields.Monetary(
|
|
string="Effective Hourly Revenue",
|
|
currency_field="mcs_currency_id",
|
|
compute="_compute_mcs_account_metrics",
|
|
store=True,
|
|
)
|
|
mcs_total_invoiced = fields.Monetary(
|
|
string="Total Invoiced",
|
|
currency_field="mcs_currency_id",
|
|
compute="_compute_mcs_account_metrics",
|
|
)
|
|
mcs_total_invoiced_display = fields.Char(
|
|
string="Total Invoiced",
|
|
compute="_compute_mcs_account_metrics",
|
|
)
|
|
mcs_total_outstanding = fields.Monetary(
|
|
string="Outstanding Receivables",
|
|
currency_field="mcs_currency_id",
|
|
compute="_compute_mcs_account_metrics",
|
|
)
|
|
mcs_total_outstanding_display = fields.Char(
|
|
string="Outstanding Receivables",
|
|
compute="_compute_mcs_account_metrics",
|
|
)
|
|
mcs_total_overdue = fields.Monetary(
|
|
string="Overdue Receivables",
|
|
currency_field="mcs_currency_id",
|
|
compute="_compute_mcs_account_metrics",
|
|
)
|
|
mcs_total_overdue_display = fields.Char(
|
|
string="Overdue Receivables",
|
|
compute="_compute_mcs_account_metrics",
|
|
)
|
|
mcs_last_invoice_date = fields.Date(
|
|
string="Last Invoice Date",
|
|
compute="_compute_mcs_account_metrics",
|
|
)
|
|
mcs_account_owner_id = fields.Many2one(
|
|
"res.users", string="Account Owner", tracking=True
|
|
)
|
|
mcs_next_account_action = fields.Char(string="Next Account Action", tracking=True)
|
|
mcs_account_risk_reason = fields.Text(string="Account Risk Reason", tracking=True)
|
|
mcs_project_ids = fields.One2many(
|
|
"project.project", "partner_id", string="MCS Workstreams"
|
|
)
|
|
|
|
def init(self):
|
|
self._mcs_ensure_client_account_columns()
|
|
|
|
@api.model
|
|
def action_mcs_ensure_client_account_columns(self):
|
|
self._mcs_ensure_client_account_columns()
|
|
return True
|
|
|
|
def _mcs_ensure_client_account_columns(self):
|
|
columns = {
|
|
"mcs_is_client_account": "boolean",
|
|
"mcs_account_status": "varchar",
|
|
"mcs_account_health": "varchar",
|
|
"mcs_monthly_revenue": "numeric",
|
|
"mcs_contracted_value": "numeric",
|
|
"mcs_monthly_hours": "double precision",
|
|
"mcs_effective_hourly_revenue": "numeric",
|
|
"mcs_total_invoiced": "numeric",
|
|
"mcs_total_outstanding": "numeric",
|
|
"mcs_total_overdue": "numeric",
|
|
"mcs_last_invoice_date": "date",
|
|
"mcs_account_owner_id": "integer",
|
|
"mcs_next_account_action": "varchar",
|
|
"mcs_account_risk_reason": "text",
|
|
}
|
|
for column, column_type in columns.items():
|
|
self.env.cr.execute(
|
|
'ALTER TABLE res_partner ADD COLUMN IF NOT EXISTS "%s" %s'
|
|
% (column, column_type)
|
|
)
|
|
|
|
@api.depends(
|
|
"mcs_project_ids.mcs_monthly_revenue_target",
|
|
"mcs_project_ids.mcs_contracted_revenue",
|
|
"mcs_project_ids.mcs_actual_hours",
|
|
"mcs_project_ids.mcs_portfolio_health",
|
|
"mcs_project_ids.active",
|
|
"currency_id",
|
|
)
|
|
def _compute_mcs_account_metrics(self):
|
|
today = fields.Date.context_today(self)
|
|
Invoice = self.env["account.move"].sudo()
|
|
for partner in self:
|
|
projects = partner.mcs_project_ids.filtered(lambda project: project.active)
|
|
commercial_partner = partner.commercial_partner_id
|
|
invoices = Invoice.search(
|
|
[
|
|
("move_type", "=", "out_invoice"),
|
|
("state", "=", "posted"),
|
|
("commercial_partner_id", "=", commercial_partner.id),
|
|
],
|
|
order="invoice_date desc, id desc",
|
|
)
|
|
overdue_invoices = invoices.filtered(
|
|
lambda invoice: invoice.invoice_date_due
|
|
and invoice.invoice_date_due < today
|
|
and invoice.payment_state not in ("paid", "in_payment", "reversed")
|
|
)
|
|
invoice_dates = [date for date in invoices.mapped("invoice_date") if date]
|
|
account_company = partner._mcs_resolve_client_company(invoices, projects)
|
|
account_currency = partner._mcs_resolve_client_currency(invoices, projects, account_company)
|
|
partner.mcs_client_company_id = account_company.id if account_company else False
|
|
partner.mcs_currency_id = account_currency.id
|
|
partner.mcs_monthly_revenue = sum(projects.mapped("mcs_monthly_revenue_target"))
|
|
partner.mcs_contracted_value = sum(projects.mapped("mcs_contracted_revenue"))
|
|
partner.mcs_monthly_hours = sum(projects.mapped("mcs_actual_hours"))
|
|
partner.mcs_effective_hourly_revenue = (
|
|
partner.mcs_monthly_revenue / partner.mcs_monthly_hours
|
|
if partner.mcs_monthly_hours
|
|
else 0
|
|
)
|
|
partner.mcs_total_invoiced = sum(invoices.mapped("amount_total_signed"))
|
|
partner.mcs_total_invoiced_display = partner._mcs_invoice_amount_display(
|
|
invoices, "amount_total", account_currency
|
|
)
|
|
partner.mcs_total_outstanding = sum(
|
|
invoices.filtered(
|
|
lambda invoice: invoice.payment_state
|
|
not in ("paid", "in_payment", "reversed")
|
|
).mapped("amount_residual_signed")
|
|
)
|
|
partner.mcs_total_outstanding_display = partner._mcs_invoice_amount_display(
|
|
invoices.filtered(
|
|
lambda invoice: invoice.payment_state
|
|
not in ("paid", "in_payment", "reversed")
|
|
),
|
|
"amount_residual",
|
|
account_currency,
|
|
)
|
|
partner.mcs_total_overdue = sum(overdue_invoices.mapped("amount_residual_signed"))
|
|
partner.mcs_total_overdue_display = partner._mcs_invoice_amount_display(
|
|
overdue_invoices, "amount_residual", account_currency
|
|
)
|
|
partner.mcs_last_invoice_date = max(invoice_dates) if invoice_dates else False
|
|
partner.mcs_account_health = partner._mcs_resolve_account_health(
|
|
partner._mcs_get_account_health_signals(projects)
|
|
)
|
|
|
|
def _mcs_resolve_client_company(self, invoices, projects):
|
|
company = invoices[:1].company_id if invoices else self.env["res.company"]
|
|
if not company and projects:
|
|
company = projects[:1].company_id
|
|
if not company:
|
|
company = self.company_id
|
|
return company
|
|
|
|
def _mcs_resolve_client_currency(self, invoices, projects, company):
|
|
currencies = invoices.mapped("currency_id")
|
|
if len(currencies) == 1:
|
|
return currencies
|
|
if invoices:
|
|
return invoices[:1].currency_id or invoices[:1].company_currency_id
|
|
if projects and projects[:1].company_id:
|
|
return projects[:1].company_id.currency_id
|
|
if company:
|
|
return company.currency_id
|
|
return self.currency_id or self.env.company.currency_id
|
|
|
|
def _mcs_invoice_amount_display(self, invoices, amount_field, default_currency):
|
|
buckets = {}
|
|
for invoice in invoices:
|
|
currency = invoice.currency_id or invoice.company_currency_id or default_currency
|
|
buckets[currency.id] = buckets.get(currency.id, 0) + (invoice[amount_field] or 0)
|
|
if not buckets and default_currency:
|
|
buckets[default_currency.id] = 0
|
|
if not buckets:
|
|
buckets[self.env.company.currency_id.id] = 0
|
|
Currency = self.env["res.currency"].sudo()
|
|
return " / ".join(
|
|
"%s %s" % (currency.name, "{:,.2f}".format(round(amount or 0, 2)))
|
|
for currency, amount in sorted(
|
|
((Currency.browse(currency_id), amount) for currency_id, amount in buckets.items()),
|
|
key=lambda item: item[0].name,
|
|
)
|
|
)
|
|
|
|
def _mcs_get_account_health_signals(self, projects):
|
|
"""Return health signals used to resolve client account health.
|
|
|
|
V2.1 support/helpdesk integration should extend this method and add
|
|
ticket/SLA signals instead of replacing the account rollup model.
|
|
"""
|
|
health_values = set(projects.mapped("mcs_portfolio_health"))
|
|
return {
|
|
"has_projects": bool(projects),
|
|
"project_health_values": health_values,
|
|
"has_blocked_project": "blocked" in health_values,
|
|
"has_at_risk_project": "at_risk" in health_values,
|
|
"has_watch_project": "watch" in health_values,
|
|
"all_projects_completed": bool(projects) and health_values == {"completed"},
|
|
}
|
|
|
|
def _mcs_resolve_account_health(self, signals):
|
|
"""Resolve account health from extensible management signals."""
|
|
if signals.get("has_blocked_project"):
|
|
return "blocked"
|
|
if signals.get("has_at_risk_project"):
|
|
return "at_risk"
|
|
if signals.get("has_watch_project"):
|
|
return "watch"
|
|
if signals.get("all_projects_completed"):
|
|
return "completed"
|
|
return "healthy"
|