Files
nocobase-mcp/nocobase_crm_mcp/crm_analytics.py
T

184 lines
7.3 KiB
Python

from __future__ import annotations
from .crm_mixin import CrmMixin
from .crm_schema_helpers import _field_path
from .crm_shared import (
CUSTOMER_STATUS_LABELS,
FOLLOWUP_STATUS_LABELS,
FOLLOWUP_TYPE_LABELS,
MAX_PAGE_SIZE,
MAX_SCAN_RECORDS,
Any,
Mapping,
NbCommandError,
_decorate_dimension_counts,
_dimension_value,
_first_numeric,
build_and_filter,
build_date_range_condition,
build_relation_id_condition,
)
class CrmAnalyticsMixin(CrmMixin):
def crm_overview(
self,
date_from: str | None = None,
date_to: str | None = None,
owner: str | None = None,
) -> dict[str, Any]:
owner_id, owner_error = self._resolve_owner_id_or_error(owner)
if owner_error:
return {"error": owner_error}
customer_filter = build_and_filter([build_relation_id_condition(self.config.customer.owner_field, owner_id)])
followup_filter = build_and_filter(
[
build_relation_id_condition(self.config.followup.owner_field, owner_id),
build_date_range_condition(self.config.followup.date_field, date_from, date_to),
]
)
totals = {
"customers": self._count(self.config.customer.resource, self.config.customer.id_field, customer_filter),
"contacts": self._count(self.config.contact.resource, self.config.contact.id_field, {}),
"followups": self._count(self.config.followup.resource, self.config.followup.id_field, followup_filter),
}
overview: dict[str, Any] = {
"totals": totals,
"customer_status": self._group_count(
self.config.customer.resource,
self.config.customer.id_field,
self.config.customer.status_field,
customer_filter,
),
"customer_source": self._group_count(
self.config.customer.resource,
self.config.customer.id_field,
self.config.customer.source_field,
customer_filter,
),
"customer_owner": self._group_count(
self.config.customer.resource,
self.config.customer.id_field,
[self.config.customer.owner_field, "id"],
customer_filter,
),
"recent_followups": self._compact_followups(
self._data(
self.client.list_records(
self.config.followup.resource,
filter=followup_filter,
fields=self._followup_compact_fields(),
sort=self.config.followup.sort,
page=1,
page_size=10,
)
)
),
}
overview["customer_status"] = self._decorate_status_counts(overview["customer_status"])
overview["customer_source"] = self._decorate_source_counts(overview["customer_source"])
overview["customer_owner"] = self._decorate_owner_counts(overview["customer_owner"])
return overview
def crm_filter_options(self, owner_query: str | None = None) -> dict[str, Any]:
owner_candidates: list[dict[str, Any]] = []
if (owner_query or "").strip():
owner_candidates = self.resolve_owner(owner_query or "")["results"]
return {
"customer_status": self._decorate_status_counts(
self._group_count(
self.config.customer.resource,
self.config.customer.id_field,
self.config.customer.status_field,
{},
)
),
"customer_source": self._decorate_source_counts(
self._group_count(
self.config.customer.resource,
self.config.customer.id_field,
self.config.customer.source_field,
{},
)
),
# Follow-up rows render type and status as labels only, so the raw
# codes the filters expect are published here instead.
"followup_type": [{"value": value, "label": label} for value, label in FOLLOWUP_TYPE_LABELS.items()],
"followup_record_status": [
{"value": value, "label": label} for value, label in FOLLOWUP_STATUS_LABELS.items()
],
"owner_candidates": owner_candidates,
"max_limit": MAX_PAGE_SIZE,
}
def _count(self, resource: str, id_field: str, filter_value: Mapping[str, Any]) -> int:
response = self.client.query_records(
resource,
measures=[{"field": [id_field], "aggregation": "count", "alias": "count"}],
filter=filter_value,
limit=1,
)
return _first_numeric(self._data(response), "count")
def _group_count(
self,
resource: str,
id_field: str,
dimension_field: str | list[str],
filter_value: Mapping[str, Any],
) -> list[dict[str, Any]]:
if not dimension_field:
return []
dimension_path = _field_path(dimension_field)
dimension_alias = "_".join(dimension_path)
rows: list[dict[str, Any]] = []
batch_size = MAX_PAGE_SIZE
offset = 0
while True:
response = self.client.query_records(
resource,
dimensions=[{"field": dimension_path, "alias": dimension_alias}],
measures=[{"field": [id_field], "aggregation": "count", "alias": "count"}],
orders=[{"field": dimension_path, "alias": dimension_alias, "order": "asc"}],
filter=filter_value,
limit=batch_size,
offset=offset,
)
batch = self._data(response)
rows.extend(batch)
if len(batch) < batch_size:
break
if len(rows) >= MAX_SCAN_RECORDS:
raise NbCommandError(
f"Aggregation produced at least {MAX_SCAN_RECORDS} buckets; add filters to narrow the result"
)
offset += batch_size
return rows
def _decorate_status_counts(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
return _decorate_dimension_counts(
rows,
preferred_keys=[self.config.customer.status_field, "kh_progress", "status"],
labels=CUSTOMER_STATUS_LABELS,
)
def _decorate_source_counts(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
return _decorate_dimension_counts(
rows,
preferred_keys=[self.config.customer.source_field, "f_225h6n6v654", "source"],
labels={},
)
def _decorate_owner_counts(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
owner_alias = "_".join(_field_path([self.config.customer.owner_field, "id"]))
decorated: list[dict[str, Any]] = []
for row in rows:
value, key = _dimension_value(row, [owner_alias, "owner_id", "kehu_followup_person_id"])
item = dict(row)
if value is not None:
if key is not None and key not in {"owner_id", "value"}:
item.pop(key, None)
item["owner_id"] = value
decorated.append(item)
return decorated