184 lines
7.3 KiB
Python
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
|