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"