Files
nocobase-mcp/nocobase_crm_mcp/crm_reporting.py
T

377 lines
15 KiB
Python

from __future__ import annotations
from .crm_mixin import CrmMixin
from .crm_shared import (
FOLLOWUP_DATE_KEYS,
FOLLOWUP_TYPE_LABELS,
INVALID_FOLLOWUP_STATUS,
Any,
ZoneInfo,
_decorate_dimension_counts,
_decorate_id_counts,
_first_text,
_format_user_candidate,
_int_or_default,
_normalize_pagination,
_paginated_rows,
_parse_datetime,
_ref_names,
_relation_ids,
build_and_filter,
build_date_range_condition,
build_relation_id_condition,
build_relation_id_in_condition,
date,
datetime,
)
class CrmReportingMixin(CrmMixin):
def team_crm_overview(
self,
team: str,
*,
date_from: str | None = None,
date_to: str | None = None,
include_member_breakdown: bool = True,
) -> dict[str, Any]:
matches = self._team_matches((team or "").strip())
member_ids, error = self._resolve_team_member_ids_or_error(team)
if error:
return {"error": error}
assert member_ids is not None
team_row = matches[0]
members = self._team_users(team_row, self.config.team.member_field, formal_only=True)
customer_filter = build_and_filter(
[build_relation_id_in_condition(self.config.customer.owner_field, member_ids)]
)
followup_filter = build_and_filter(
[
build_relation_id_in_condition(self.config.followup.owner_field, member_ids),
build_date_range_condition(self.config.followup.date_field, date_from, date_to),
{self.config.followup.status_field: {"$ne": INVALID_FOLLOWUP_STATUS}},
]
)
project_filter = build_and_filter(
[
build_relation_id_in_condition(
[self.config.project.customer_relation_field, self.config.customer.owner_field, "id"],
member_ids,
),
{
self.config.project.progress_field: {
"$ne": self.config.project.progress_values["终止"],
}
},
]
)
result: dict[str, Any] = {
"team": self._compact_team(team_row, include_members=True, formal_members_only=True),
"period": {"date_from": date_from, "date_to": date_to},
"timezone": self.config.timezone,
"totals": {
"owned_customers": self._count(
self.config.customer.resource,
self.config.customer.id_field,
customer_filter,
),
"followup_records": self._count(
self.config.followup.resource,
self.config.followup.id_field,
followup_filter,
),
"active_projects": self._count(
self.config.project.resource,
self.config.project.id_field,
project_filter,
),
},
"by_followup_type": self._group_count(
self.config.followup.resource,
self.config.followup.id_field,
self.config.followup.type_field,
followup_filter,
),
"by_customer": self._group_count(
self.config.followup.resource,
self.config.followup.id_field,
[self.config.followup.customer_field, "id"],
followup_filter,
),
"by_project_stage": self._group_count(
self.config.project.resource,
self.config.project.id_field,
self.config.project.progress_field,
project_filter,
),
"contribution_basis": {
"participant_field": self.config.followup.owner_field,
"team_totals": "unique follow-up record IDs",
"member_contribution": "one participation per member per follow-up",
},
}
if include_member_breakdown:
breakdown: list[dict[str, Any]] = []
for member in members:
member_id = member.get(self.config.user.id_field)
if member_id is None:
continue
member_customer_filter = build_and_filter(
[build_relation_id_condition(self.config.customer.owner_field, member_id)]
)
member_followup_filter = build_and_filter(
[
build_relation_id_condition(self.config.followup.owner_field, member_id),
build_date_range_condition(self.config.followup.date_field, date_from, date_to),
{self.config.followup.status_field: {"$ne": INVALID_FOLLOWUP_STATUS}},
]
)
candidate = _format_user_candidate(member, self.config.user.id_field)
breakdown.append(
{
"member": candidate,
"owned_customers": self._count(
self.config.customer.resource,
self.config.customer.id_field,
member_customer_filter,
),
"followup_participations": self._count(
self.config.followup.resource,
self.config.followup.id_field,
member_followup_filter,
),
}
)
result["by_member"] = sorted(
breakdown,
key=lambda item: (-item["followup_participations"], str(item["member"].get("label") or "")),
)
result["by_followup_type"] = _decorate_dimension_counts(
result["by_followup_type"],
preferred_keys=[self.config.followup.type_field],
labels=FOLLOWUP_TYPE_LABELS,
)
result["by_project_stage"] = _decorate_dimension_counts(
result["by_project_stage"],
preferred_keys=[self.config.project.progress_field],
labels={value: label for label, value in self.config.project.progress_values.items()},
)
result["by_customer"] = self._attach_customer_names(
_decorate_id_counts(
result["by_customer"],
preferred_keys=[
f"{self.config.followup.customer_field}_id",
"gjjl_client_id",
],
output_key="customer_id",
)
)
return result
def get_project_timeline(
self,
project_id: Any,
*,
date_from: str | None = None,
date_to: str | None = None,
) -> dict[str, Any]:
project_result = self.get_project(
project_id,
include_contacts=False,
include_followups=False,
include_updates=False,
)
if "error" in project_result:
return project_result
project = project_result["project"]
followup_rows, followups_truncation = self._scan_records(
self.config.followup.resource,
filter=build_and_filter(
[
build_relation_id_condition(self.config.followup.project_field, project_id),
build_date_range_condition(self.config.followup.date_field, date_from, date_to),
]
),
fields=self._followup_compact_fields(),
sort=self.config.followup.sort,
)
update_rows, updates_truncation = self._scan_records(
self.config.project.update_resource,
filter=build_and_filter(
[
build_relation_id_condition(self.config.project.update_project_field, project_id),
build_date_range_condition(self.config.project.update_date_field, date_from, date_to),
]
),
fields=[
"id",
self.config.project.update_date_field,
self.config.project.update_content_field,
"createdBy",
"createdAt",
],
sort=[self.config.project.update_date_field, "createdAt"],
)
events: list[dict[str, Any]] = []
for row in followup_rows:
compact = self._compact_followup(row)
events.append(
{
"event_type": "followup",
"occurred_at": compact.get("followedAt"),
"actors": _ref_names(compact.get("participants", [])),
"summary": compact.get("content"),
"source_resource": self.config.followup.resource,
"source_id": compact.get("id"),
}
)
for row in update_rows:
compact = self._compact_project_update(row)
events.append(
{
"event_type": "project_update",
"occurred_at": compact.get("updated_at"),
"actors": _ref_names([compact["creator"]] if compact.get("creator") else []),
"summary": compact.get("content"),
"source_resource": self.config.project.update_resource,
"source_id": compact.get("id"),
}
)
events.sort(key=lambda event: str(event.get("occurred_at") or ""))
result = {
"project": project,
"events": events,
"timezone": self.config.timezone,
}
warnings: list[str] = []
truncation: dict[str, Any] = {}
if followups_truncation:
warnings.append("follow-up events were truncated at the scan limit")
truncation["followups"] = followups_truncation
if updates_truncation:
warnings.append("project update events were truncated at the scan limit")
truncation["updates"] = updates_truncation
if warnings:
result["data_quality_warnings"] = warnings
result["truncation"] = truncation
return result
def list_stale_customers(
self,
*,
team: str | None = None,
owner: str | None = None,
inactive_days: int = 30,
as_of: str | None = None,
limit: int = 20,
offset: int = 0,
) -> dict[str, Any]:
limit, offset = _normalize_pagination(limit, offset)
inactive_days = max(1, _int_or_default(inactive_days, 30))
owner_id, owner_error = self._resolve_owner_id_or_error(owner)
if owner_error:
return {"error": owner_error}
team_member_ids, team_error = self._resolve_team_member_ids_or_error(team)
if team_error:
return {"error": team_error}
try:
as_of_date = date.fromisoformat(as_of) if as_of else datetime.now(ZoneInfo(self.config.timezone)).date()
except ValueError:
return {"error": {"code": "invalid_date", "message": "as_of must use YYYY-MM-DD"}}
customer_filter = build_and_filter(
[
build_relation_id_condition(self.config.customer.owner_field, owner_id),
build_relation_id_in_condition(self.config.customer.owner_field, team_member_ids),
]
)
followup_filter = build_and_filter(
[
build_relation_id_condition(self.config.followup.owner_field, owner_id),
build_relation_id_in_condition(self.config.followup.owner_field, team_member_ids),
{self.config.followup.date_field: {"$dateNotAfter": as_of_date.isoformat()}},
{self.config.followup.status_field: {"$ne": INVALID_FOLLOWUP_STATUS}},
]
)
customer_rows, customers_truncation = self._scan_records(
self.config.customer.resource,
filter=customer_filter,
fields=self._customer_compact_fields(),
sort=self.config.customer.sort,
)
followup_rows, followups_truncation = self._scan_records(
self.config.followup.resource,
filter=followup_filter,
# This scan can pull thousands of rows and only needs the customer id,
# so the customer relation is projected down to exactly that.
fields=[
self.config.followup.id_field,
f"{self.config.followup.customer_field}.{self.config.customer.id_field}",
self.config.followup.date_field,
self.config.followup.status_field,
],
sort=self.config.followup.sort,
)
last_followup: dict[Any, datetime] = {}
for followup in followup_rows:
followed_at = _parse_datetime(
_first_text(
followup,
[self.config.followup.date_field, *FOLLOWUP_DATE_KEYS],
),
self.config.timezone,
)
if followed_at is None:
continue
for customer_id in _relation_ids(
followup.get(self.config.followup.customer_field),
self.config.customer.id_field,
):
previous = last_followup.get(customer_id)
if previous is None or followed_at > previous:
last_followup[customer_id] = followed_at
stale: list[dict[str, Any]] = []
for customer in customer_rows:
customer_id = customer.get(self.config.customer.id_field)
last = last_followup.get(customer_id)
days_since = (as_of_date - last.astimezone(ZoneInfo(self.config.timezone)).date()).days if last else None
if days_since is not None and days_since < inactive_days:
continue
stale.append(
{
"customer": self._compact_customer(customer),
"last_followup_at": (last.astimezone(ZoneInfo(self.config.timezone)).isoformat() if last else None),
"days_since_last_followup": days_since,
"never_followed_up": last is None,
}
)
stale.sort(
key=lambda item: (
item["days_since_last_followup"] is not None,
-(item["days_since_last_followup"] or 0),
str(item["customer"].get("name") or ""),
)
)
selected = stale[offset : offset + limit]
result = _paginated_rows(selected, len(stale), limit, offset)
result.update(
{
"as_of": as_of_date.isoformat(),
"inactive_days": inactive_days,
"timezone": self.config.timezone,
}
)
warnings: list[str] = []
truncation: dict[str, Any] = {}
if customers_truncation:
warnings.append("customer scan was truncated")
truncation["customers"] = customers_truncation
if followups_truncation:
warnings.append("follow-up scan was truncated")
truncation["followups"] = followups_truncation
if warnings:
result["data_quality_warnings"] = warnings
result["truncation"] = truncation
return result