Map operating accounts to existing customers

This commit is contained in:
metatroncubeswdev 2026-08-30 11:14:20 -04:00
parent 84b23c268e
commit 7859d6a7a1
7 changed files with 243 additions and 51 deletions

View File

@ -16,56 +16,118 @@ class OperatingSystemSetup:
self.Project = env["project.project"].sudo()
def run(self):
self._promote_existing_customers()
self._ensure_active_portfolio()
self._hide_empty_seed_accounts()
self._ensure_followup_cron_active()
def _promote_existing_customers(self):
invoice_partners = self.env["account.move"].sudo().search(
[
("move_type", "=", "out_invoice"),
("state", "=", "posted"),
("commercial_partner_id", "!=", False),
]
).mapped("commercial_partner_id")
customers = self.Partner.search([("customer_rank", ">", 0)])
for partner in customers | invoice_partners:
values = {"mcs_is_client_account": True}
if not partner.mcs_account_status:
values["mcs_account_status"] = "active"
if partner.currency_id and not partner.mcs_currency_id:
values["mcs_currency_id"] = partner.currency_id.id
partner.write(values)
def _ensure_active_portfolio(self):
specs = {
"ClicksToCart": [
("ERP Implementation", "fixed_project"),
("App Development", "fixed_project"),
("Social Media Handling", "recurring"),
],
"Wallaceton": [
("SEO", "recurring"),
("Social Media Handling", "recurring"),
("Ads Management", "recurring"),
],
"Maison de Treats": [("Social Media Handling", "recurring")],
"Lens and Frames": [
("SEO", "recurring"),
("Social Media Handling", "recurring"),
("Ads Management", "recurring"),
],
"TNCSC": [
("Ads Management", "recurring"),
("Videos", "fixed_project"),
("ERP", "fixed_project"),
],
"Infini": [("Social Media Handling", "recurring")],
"Organic Healthy Family": [("Shopify Store Development", "fixed_project")],
"Hondavert": [
("Website Development", "fixed_project"),
("App Development", "fixed_project"),
],
"RaceNation": [("Website Development", "fixed_project")],
"Racewerks": [("Website Development", "fixed_project")],
"Internal": [
("Shopify App for Revenue Generation", "internal_product"),
("Custom Product Development", "internal_r_and_d"),
],
"ClicksToCart": {
"aliases": ["Clickstocart Inc", "ClicksToCart"],
"workstreams": [
("ERP Implementation", "fixed_project"),
("App Development", "fixed_project"),
("Social Media Handling", "recurring"),
],
},
"Wallaceton": {
"aliases": ["Wallaceton Eye Care", "Wallaceton"],
"workstreams": [
("SEO", "recurring"),
("Social Media Handling", "recurring"),
("Ads Management", "recurring"),
],
},
"Maison de Treats": {
"aliases": ["Maison de Treats"],
"workstreams": [("Social Media Handling", "recurring")],
},
"Lens and Frames": {
"aliases": ["Lens & Frames Optical", "Lens and Frames"],
"workstreams": [
("SEO", "recurring"),
("Social Media Handling", "recurring"),
("Ads Management", "recurring"),
],
},
"TNCSC": {
"aliases": ["Tamil Nadu Cultural Society of Canada", "TNCSC"],
"workstreams": [
("Ads Management", "recurring"),
("Videos", "fixed_project"),
("ERP", "fixed_project"),
],
},
"Infini": {
"aliases": ["INFINI BRAND ENHANCERZ", "Infini"],
"workstreams": [("Social Media Handling", "recurring")],
},
"Organic Healthy Family": {
"aliases": ["Organic Healthy Family"],
"workstreams": [("Shopify Store Development", "fixed_project")],
},
"Hondavert": {
"aliases": ["Hondavert"],
"workstreams": [
("Website Development", "fixed_project"),
("App Development", "fixed_project"),
],
},
"RaceNation": {
"aliases": ["RaceNation"],
"workstreams": [("Website Development", "fixed_project")],
},
"Racewerks": {
"aliases": ["Racewerks"],
"workstreams": [("Website Development", "fixed_project")],
},
"Internal": {
"aliases": ["Internal"],
"workstreams": [
("Shopify App for Revenue Generation", "internal_product"),
("Custom Product Development", "internal_r_and_d"),
],
},
}
for account_name, workstreams in specs.items():
partner = self._ensure_client_account(account_name)
for workstream, delivery_type in workstreams:
self._ensure_project(partner, workstream, delivery_type)
for account_name, spec in specs.items():
partner = self._ensure_client_account(account_name, spec["aliases"])
for workstream, delivery_type in spec["workstreams"]:
self._ensure_project(partner, account_name, workstream, delivery_type)
def _ensure_client_account(self, name):
partner = self.Partner.search([("name", "=", name)], limit=1)
def _ensure_client_account(self, name, aliases=None):
aliases = aliases or [name]
partner = self.Partner.browse()
for alias in aliases:
partner = self.Partner.search([("name", "=", alias)], limit=1)
if partner:
break
if not partner:
partner = self.Partner.search(
[("name", "ilike", aliases[0]), ("customer_rank", ">", 0)],
limit=1,
)
if not partner:
partner = self.Partner.create(
{
"name": name,
"name": aliases[0] if aliases else name,
"company_type": "company",
"mcs_is_client_account": True,
"mcs_account_status": "active",
@ -77,18 +139,34 @@ class OperatingSystemSetup:
values["mcs_is_client_account"] = True
if not partner.mcs_account_status:
values["mcs_account_status"] = "active"
if partner.currency_id and not partner.mcs_currency_id:
values["mcs_currency_id"] = partner.currency_id.id
if values:
partner.write(values)
return partner
def _ensure_project(self, partner, workstream, delivery_type):
def _ensure_project(self, partner, account_name, workstream, delivery_type):
is_internal = partner.name == "Internal"
project_name = "%s - %s" % (partner.name, workstream)
project = self.Project.search([("name", "=", project_name)], limit=1)
legacy_project_name = "%s - %s" % (account_name, workstream)
project = self.Project.search(
[
"|",
("name", "=", project_name),
("name", "=", legacy_project_name),
],
limit=1,
)
if not project:
project = self.Project.search(
[("partner_id", "=", partner.id), ("name", "ilike", workstream)],
limit=1,
)
if not project:
project = self.Project.create(
{
"name": project_name,
"partner_id": partner.id if partner.name != "Internal" else False,
"partner_id": partner.id if not is_internal else False,
"allow_timesheets": True,
"allow_milestones": True,
"mcs_delivery_type": delivery_type,
@ -105,7 +183,9 @@ class OperatingSystemSetup:
return project
values = {}
if not project.partner_id and partner.name != "Internal":
if not project.partner_id and not is_internal:
values["partner_id"] = partner.id
if project.partner_id and project.partner_id != partner and not is_internal:
values["partner_id"] = partner.id
if not project.mcs_delivery_type:
values["mcs_delivery_type"] = delivery_type
@ -137,3 +217,34 @@ class OperatingSystemSetup:
)
if cron and not cron.active:
cron.sudo().write({"active": True})
def _hide_empty_seed_accounts(self):
names = [
"ClicksToCart",
"Wallaceton",
"Lens and Frames",
"TNCSC",
"Infini",
"Organic Healthy Family",
"Hondavert",
"RaceNation",
"Racewerks",
]
for partner in self.Partner.search(
[
("name", "in", names),
("customer_rank", "=", 0),
("mcs_is_client_account", "=", True),
]
):
has_invoice = bool(
self.env["account.move"].sudo().search_count(
[
("move_type", "=", "out_invoice"),
("state", "=", "posted"),
("commercial_partner_id", "=", partner.commercial_partner_id.id),
]
)
)
if not has_invoice and not partner.mcs_project_ids:
partner.write({"mcs_is_client_account": False})

View File

@ -440,15 +440,26 @@ class McsOperatingDashboard(models.Model):
rows = []
for partner in partners:
projects = client_projects.filtered(lambda project: project.partner_id == partner)
if not projects:
if (
not projects
and not partner.mcs_total_invoiced
and not partner.mcs_total_outstanding
):
continue
rows.append(
{
"id": partner.id,
"name": partner.display_name,
"currency": partner.mcs_currency_id.name or "",
"health": partner.mcs_account_health,
"active_workstreams": len(projects),
"revenue": sum(projects.mapped("mcs_monthly_revenue_target")),
"total_invoiced": partner.mcs_total_invoiced,
"outstanding": partner.mcs_total_outstanding,
"overdue": partner.mcs_total_overdue,
"last_invoice_date": fields.Date.to_string(partner.mcs_last_invoice_date)
if partner.mcs_last_invoice_date
else "",
"hours": sum(projects.mapped("mcs_actual_hours")),
"next_milestone": next(
(project.mcs_next_milestone for project in projects if project.mcs_next_milestone),

View File

@ -58,6 +58,25 @@ class ResPartner(models.Model):
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_outstanding = fields.Monetary(
string="Outstanding Receivables",
currency_field="mcs_currency_id",
compute="_compute_mcs_account_metrics",
)
mcs_total_overdue = fields.Monetary(
string="Overdue Receivables",
currency_field="mcs_currency_id",
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
)
@ -85,6 +104,10 @@ class ResPartner(models.Model):
"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",
@ -101,10 +124,31 @@ class ResPartner(models.Model):
"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),
]
)
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]
partner.mcs_currency_id = (
partner.currency_id.id
or self.env.company.currency_id.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"))
@ -113,6 +157,15 @@ class ResPartner(models.Model):
if partner.mcs_monthly_hours
else 0
)
partner.mcs_total_invoiced = sum(invoices.mapped("amount_total_signed"))
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_overdue = sum(overdue_invoices.mapped("amount_residual_signed"))
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)
)

View File

@ -237,10 +237,10 @@
.o_mcs_table_head,
.o_mcs_table_row {
display: grid;
grid-template-columns: 1.3fr 92px 70px 90px 70px minmax(180px, 1.2fr);
grid-template-columns: 1.3fr 92px 70px 70px 100px 100px 100px 96px minmax(180px, 1.2fr);
gap: 10px;
align-items: center;
min-width: 760px;
min-width: 980px;
}
.o_mcs_table_head {

View File

@ -77,15 +77,18 @@
</header>
<div class="o_mcs_table">
<div class="o_mcs_table_head">
<span>Client</span><span>Health</span><span>Streams</span><span>Revenue</span><span>Hours</span><span>Next Action</span>
<span>Client</span><span>Health</span><span>Currency</span><span>Streams</span><span>Invoiced</span><span>Outstanding</span><span>Overdue</span><span>Last Invoice</span><span>Next Action</span>
</div>
<t t-foreach="state.data.portfolio" t-as="row" t-key="row.id">
<button class="o_mcs_table_row" t-on-click="() => this.openClient(row.id)">
<span t-esc="row.name"/>
<span t-attf-class="o_mcs_badge o_mcs_status_{{ row.health }}" t-esc="row.health"/>
<span t-esc="row.currency"/>
<span t-esc="row.active_workstreams"/>
<span t-esc="row.revenue"/>
<span t-esc="row.hours"/>
<span t-esc="row.total_invoiced"/>
<span t-esc="row.outstanding"/>
<span t-esc="row.overdue"/>
<span t-esc="row.last_invoice_date"/>
<span t-esc="row.next_action"/>
</button>
</t>

View File

@ -35,6 +35,12 @@ class TestCeoDashboardV2(TransactionCase):
self.assertEqual(self.partner.mcs_monthly_revenue, 100000)
self.assertEqual(self.partner.mcs_contracted_value, 80000)
def test_client_account_invoice_rollup_fields_exist(self):
self.assertIn("mcs_total_invoiced", self.partner._fields)
self.assertIn("mcs_total_outstanding", self.partner._fields)
self.assertIn("mcs_total_overdue", self.partner._fields)
self.assertIn("mcs_last_invoice_date", self.partner._fields)
def test_account_health_uses_extensible_signals(self):
signals = self.partner._mcs_get_account_health_signals(self.project)
self.assertIn("project_health_values", signals)

View File

@ -14,11 +14,15 @@
<field name="mcs_account_owner_id"/>
</group>
<group string="Commercial Rollup">
<field name="mcs_currency_id" invisible="1"/>
<field name="mcs_currency_id"/>
<field name="mcs_monthly_revenue"/>
<field name="mcs_contracted_value"/>
<field name="mcs_monthly_hours"/>
<field name="mcs_effective_hourly_revenue"/>
<field name="mcs_total_invoiced"/>
<field name="mcs_total_outstanding"/>
<field name="mcs_total_overdue"/>
<field name="mcs_last_invoice_date"/>
</group>
</group>
<group>
@ -52,6 +56,10 @@
<field name="mcs_currency_id" column_invisible="True"/>
<field name="mcs_monthly_revenue"/>
<field name="mcs_contracted_value"/>
<field name="mcs_total_invoiced"/>
<field name="mcs_total_outstanding"/>
<field name="mcs_total_overdue"/>
<field name="mcs_last_invoice_date"/>
<field name="mcs_monthly_hours"/>
<field name="mcs_next_account_action"/>
</tree>