377 lines
15 KiB
Python
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
|