Files
nocobase-mcp/nocobase_crm_mcp/crm.py
T

2161 lines
85 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, date, datetime
from typing import Any, Mapping
from zoneinfo import ZoneInfo
from .config import CrmConfig
from .filters import (
build_and_filter,
build_date_range_condition,
build_eq_condition,
build_relation_id_condition,
build_relation_id_in_condition,
build_search_filter,
)
from .nb_client import NbClient, NbCommandError
DEFAULT_PAGE_SIZE = 20
MAX_PAGE_SIZE = 100
MAX_SCAN_RECORDS = 5000
UNKNOWN_NAME_PREFIX = "#"
PROJECTION_FALLBACK_WARNING = (
"NocoBase rejected relation field projection; check nameFields in the CRM config"
)
CUSTOMER_STATUS_LABELS = {
"kh_p_not_start": "未开始",
"kh_p_inprogress": "进行中",
"kh_p_ordered": "已下单",
"kh_p_audited": "已审核",
}
FOLLOWUP_CONTENT_KEYS = ("f_19iplxz0nds", "content", "description", "remark")
FOLLOWUP_DATE_KEYS = ("f_srysmjkwfq1", "followedAt", "createdAt", "updatedAt")
FOLLOWUP_TYPE_LABELS = {
"tdwfk5h1ohv": "邮件",
"s48pnyhutwc": "电话",
"mwyy8xwk66m": "视频会议",
"m34aocz3ebo": "拜访/请客",
"0lmj78zpdnm": "审厂/来访",
"o7rjlg4guzh": "论坛/大会",
"2csix8r3h0f": "代理跟进情况代填",
}
FOLLOWUP_STATUS_LABELS = {
"2rowk1bc8vc": "审批中",
"3ge6geal2u1": "OK",
"68vqz8raus6": "迟交",
"b5pf5njqgp6": "无效",
}
INVALID_FOLLOWUP_STATUS = "b5pf5njqgp6"
@dataclass(frozen=True)
class LabelSpec:
"""How to identify and name one related record.
Every relation the API renders (customer, contact, user, project) resolves to
the same ``{"id": ..., "name": ...}`` shape through this spec, so the same
entity looks identical in every tool response.
"""
id_field: str = "id"
name_fields: tuple[str, ...] = ()
def projection_fields(self) -> list[str]:
return _unique_strings([self.id_field, *self.name_fields])
class CrmService:
def __init__(self, config: CrmConfig, client: NbClient) -> None:
self.config = config
self.client = client
def _customer_spec(self) -> LabelSpec:
return LabelSpec(self.config.customer.id_field, tuple(self.config.customer.name_fields or ()))
def _contact_spec(self) -> LabelSpec:
return LabelSpec(self.config.contact.id_field, tuple(self.config.contact.name_fields or ()))
def _user_spec(self) -> LabelSpec:
return LabelSpec(self.config.user.id_field, tuple(self.config.user.label_fields or ()))
def _project_spec(self) -> LabelSpec:
return LabelSpec(self.config.project.id_field, tuple(self.config.project.name_fields or ()))
def _team_fields(self) -> list[str]:
# Only the related user ids are needed: _team_users re-queries the users
# collection for their details, and _compact_team just counts members.
team = self.config.team
id_only = LabelSpec(self.config.user.id_field, ())
return _fields_with_projections(
[team.id_field, team.name_field],
[
*self._projection(team.manager_field, id_only, None),
*self._projection(team.member_field, id_only, None),
],
)
def _projection(self, relation: str | None, spec: LabelSpec, configured_fields: list[str] | None) -> list[str]:
"""Build dotted field paths that fetch only a relation's id and name.
Without this NocoBase expands every related row in full -- junction table
rows, audit user objects, long free text -- none of which is rendered.
The configured-fields whitelist still applies, just to the relation root
rather than to each dotted path.
"""
if not relation:
return []
if configured_fields and relation not in configured_fields:
return []
return [f"{relation}.{name}" for name in spec.projection_fields()]
def _list_with_projection_fallback(self, resource: str, *, fields: list[str], **kwargs: Any) -> dict[str, Any]:
"""Run a projected list query, degrading to relation roots if it is rejected.
A misconfigured ``nameFields`` entry would otherwise turn every read into
a hard failure; falling back keeps the tool usable and says why.
"""
try:
return self._list_records_window(resource, fields=fields, **kwargs)
except NbCommandError:
flat = _unique_strings([path.split(".", 1)[0] for path in fields])
if flat == _unique_strings(fields):
raise
result = self._list_records_window(resource, fields=flat, **kwargs)
result.setdefault("data_quality_warnings", []).append(PROJECTION_FALLBACK_WARNING)
return result
def crm_health(self) -> dict[str, Any]:
health = self.client.health(self.config.customer.resource, self.config.customer.id_field)
configured = {
"env": self.config.env_name,
"data_source": self.config.data_source,
"collections": {
"customers": self.config.customer.resource,
"contacts": self.config.contact.resource,
"followups": self.config.followup.resource,
"teams": self.config.team.resource,
"projects": self.config.project.resource,
"project_updates": self.config.project.update_resource,
},
}
return {"ok": bool(health.get("ok")), "nb": health, "configured": configured}
def crm_schema(self, include_fields: bool = False) -> dict[str, Any]:
schema: dict[str, Any] = {
"env": self.config.env_name,
"data_source": self.config.data_source,
"collections": {
"customers": self.config.customer.__dict__,
"contacts": self.config.contact.__dict__,
"followups": self.config.followup.__dict__,
"users": self.config.user.__dict__,
"teams": self.config.team.__dict__,
"projects": self.config.project.__dict__,
},
}
if include_fields:
schema["metadata"] = self._safe_metadata()
return schema
def crm_config_template(self) -> dict[str, Any]:
metadata = self._safe_metadata()
fields_by_kind = metadata.get("fields", {}) if isinstance(metadata, dict) else {}
customer_fields = _field_list(fields_by_kind.get("customers"))
contact_fields = _field_list(fields_by_kind.get("contacts"))
followup_fields = _field_list(fields_by_kind.get("followups"))
customer_resource = self.config.customer.resource
contact_resource = self.config.contact.resource
return {
"env": self.config.env_name,
"dataSource": self.config.data_source,
"users": {
"resource": self.config.user.resource,
"fields": self.config.user.fields,
"matchFields": self.config.user.match_fields,
"labelFields": self.config.user.label_fields,
},
"collections": {
"customers": {
"resource": customer_resource,
"fields": _field_names(customer_fields),
"searchFields": _configured_or_search_fields(
customer_fields,
["f_q2q60q7bqbg", "f_kh_english_name", "f_territorial_name"],
),
# Ordered: the first non-empty one becomes the customer's
# displayed name everywhere it is referenced.
"nameFields": _existing_field_names(
customer_fields,
["f_q2q60q7bqbg", "f_kh_english_name", "f_territorial_name"],
)
or self.config.customer.name_fields,
"managerField": _find_named_field(customer_fields, ["kehu_manage_person"])
or self.config.customer.manager_field,
"detailFields": {
key: value
for key, value in self.config.customer.detail_fields.items()
if _find_named_field(customer_fields, [value])
},
"statusField": _find_named_field(customer_fields, ["kh_progress"])
or _find_field(customer_fields, ["进度", "状态", "status"]),
"sourceField": _find_named_field(customer_fields, ["f_225h6n6v654"])
or _find_field(customer_fields, ["客户来源", "来源", "source"]),
"ownerField": _find_named_field(customer_fields, ["kehu_followup_person"])
or self.config.customer.owner_field,
"sort": self.config.customer.sort,
},
"contacts": {
"resource": contact_resource,
"fields": _field_names(contact_fields),
"searchFields": _configured_or_search_fields(
contact_fields,
["f_2zpg91gtmwp", "khlxr_phone_number", "khlxr_email"],
),
"nameFields": _existing_field_names(contact_fields, ["f_2zpg91gtmwp"])
or self.config.contact.name_fields,
"titleField": _find_named_field(contact_fields, ["f_tn8zdm2f938"])
or self.config.contact.title_field,
"phoneField": _find_named_field(contact_fields, ["khlxr_phone_number"])
or self.config.contact.phone_field,
"emailField": _find_named_field(contact_fields, ["khlxr_email"])
or self.config.contact.email_field,
"customerField": _find_named_field(contact_fields, ["kehulianxiren_client"])
or _relation_field(contact_fields, ["公司", "客户"])
or self.config.contact.customer_field,
"sort": self.config.contact.sort,
},
"followups": {
"resource": self.config.followup.resource,
"fields": _field_names(followup_fields),
"searchFields": _configured_or_search_fields(followup_fields, ["f_19iplxz0nds"]),
"customerField": _find_named_field(followup_fields, ["gjjl_client"])
or _relation_field(followup_fields, ["客户"])
or self.config.followup.customer_field,
"contactField": _find_named_field(followup_fields, ["gjjl_client_contacts"])
or _relation_field(followup_fields, ["客户联系人", "联系人"])
or self.config.followup.contact_field,
"ownerField": _find_named_field(followup_fields, ["gjjl_followup_person"])
or self.config.followup.owner_field,
"ownerFields": _existing_field_names(
followup_fields,
["gjjl_followup_person", "createdBy"],
)
or self.config.followup.owner_fields,
"dateField": _find_named_field(followup_fields, ["f_srysmjkwfq1"])
or _date_field(followup_fields)
or self.config.followup.date_field,
"contentField": _find_named_field(followup_fields, ["f_19iplxz0nds"])
or self.config.followup.content_field,
"typeField": _find_named_field(followup_fields, ["gjjl_followup_type"])
or self.config.followup.type_field,
"statusField": _find_named_field(followup_fields, ["f_hskl7gjvos9"])
or self.config.followup.status_field,
"projectField": _find_named_field(followup_fields, ["f_gjjl_projects"])
or self.config.followup.project_field,
"locationField": _find_named_field(followup_fields, ["f_b5c4hfi5og6"])
or self.config.followup.location_field,
"sort": _template_sort(
_find_named_field(followup_fields, ["f_srysmjkwfq1"])
or _date_field(followup_fields)
or self.config.followup.date_field,
self.config.followup.sort,
),
},
},
"metadata": {
"field_errors": metadata.get("field_errors", {}) if isinstance(metadata, dict) else {},
"note": "Review inferred owner/status/source/date fields before using this template in production.",
},
}
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 = {
"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 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 _attach_customer_names(
self,
rows: list[dict[str, Any]],
*,
id_key: str = "customer_id",
max_lookups: int = MAX_PAGE_SIZE,
) -> list[dict[str, Any]]:
"""Fill in customer names for id-keyed aggregation buckets.
Grouping runs on the aggregation endpoint and only ever yields ids, which
left callers reading rows identified by a bare number. Counts are never
touched -- this only adds a name.
"""
identifiers = _unique_values([row.get(id_key) for row in rows if row.get(id_key) is not None])
if not identifiers:
return rows
names: dict[Any, str] = {}
try:
lookup = self.client.list_records(
self.config.customer.resource,
filter={self.config.customer.id_field: {"$in": identifiers[:max_lookups]}},
fields=_unique_strings(
[self.config.customer.id_field, *(self.config.customer.name_fields or [])]
),
page=1,
page_size=max_lookups,
)
except NbCommandError:
return rows
for customer in _data(lookup):
ref = _ref_from_mapping(customer, self._customer_spec())
if ref.get("id") is not None and ref.get("name"):
names[ref["id"]] = str(ref["name"])
for row in rows:
name = names.get(row.get(id_key))
if name:
row["customer_name"] = name
return rows
def resolve_owner(self, query: str) -> dict[str, Any]:
normalized = (query or "").strip()
if not normalized:
return {"query": "", "results": [], "count": 0}
rows = self._owner_matches(normalized)
results = [_format_user_candidate(row, self.config.user.id_field) for row in rows]
return {"query": normalized, "results": results, "count": len(results)}
def list_teams(
self,
query: str | None = None,
*,
include_members: bool = False,
formal_members_only: bool = True,
limit: int = 20,
offset: int = 0,
) -> dict[str, Any]:
limit, offset = _normalize_pagination(limit, offset)
result = self._list_records_window(
self.config.team.resource,
limit=limit,
offset=offset,
filter=build_and_filter([build_search_filter(query, [self.config.team.name_field])]),
fields=self._team_fields(),
sort=[self.config.team.name_field],
)
result["results"] = [
self._compact_team(
row,
include_members=include_members,
formal_members_only=formal_members_only,
)
for row in result["results"]
]
return result
def resolve_team(self, query: str) -> dict[str, Any]:
normalized = (query or "").strip()
if not normalized:
return {"query": "", "results": [], "count": 0}
rows = self._team_matches(normalized)
results = [self._compact_team(row, include_members=False, formal_members_only=True) for row in rows]
return {"query": normalized, "results": results, "count": len(results)}
def get_team(
self,
team_id: Any,
*,
include_members: bool = True,
formal_members_only: bool = True,
) -> dict[str, Any]:
row = self._single(
self.client.get_record(
self.config.team.resource,
team_id,
fields=self._team_fields(),
)
)
if not isinstance(row, Mapping):
return {"error": {"code": "team_not_found", "message": f"No team matched id {team_id!r}"}}
return {
"team": self._compact_team(
row,
include_members=include_members,
formal_members_only=formal_members_only,
)
}
def list_customers(
self,
query: str | None = None,
status: str | None = None,
owner: str | None = None,
team: str | None = None,
source: str | None = None,
limit: int = 20,
offset: int = 0,
include_raw: bool = False,
) -> dict[str, Any]:
limit, offset = _normalize_pagination(limit, offset)
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}
filter_value = build_and_filter(
[
build_eq_condition(self.config.customer.status_field, status),
build_relation_id_condition(self.config.customer.owner_field, owner_id),
build_relation_id_in_condition(self.config.customer.owner_field, team_member_ids),
build_eq_condition(self.config.customer.source_field, source),
build_search_filter(query, self.config.customer.search_fields),
]
)
result = self._list_with_projection_fallback(
self.config.customer.resource,
limit=limit,
offset=offset,
filter=filter_value,
fields=self.config.customer.fields if include_raw else self._customer_compact_fields(),
sort=self.config.customer.sort,
)
result["results"] = self._compact_customers(result["results"], include_raw=include_raw)
result["timezone"] = self.config.timezone
return result
def get_customer(
self,
customer_id: Any,
include_contacts: bool = True,
include_recent_followups: bool = True,
followup_limit: int = 10,
include_raw: bool = False,
) -> dict[str, Any]:
fields = self.config.customer.fields if include_raw else self._customer_compact_fields(detail=True)
row = self._single(
self.client.get_record(
self.config.customer.resource,
customer_id,
fields=fields,
)
)
if not isinstance(row, Mapping):
return {
"error": {
"code": "customer_not_found",
"message": f"No customer matches id {customer_id!r}.",
"hint": "Use list_customers to find the customer id.",
"retryable": False,
}
}
result: dict[str, Any] = {
"customer": self._compact_customer(row, detail=True, include_raw=include_raw),
}
if include_contacts:
# Phone and email are deliberately withheld here: a customer overview
# does not need personal contact details. list_contacts still serves them.
result["contacts"] = self.list_contacts(
customer_id=customer_id,
limit=50,
offset=0,
include_details=False,
)["results"]
if include_recent_followups:
result["recent_followups"] = self.list_followups(
customer_id=customer_id,
limit=followup_limit,
offset=0,
include_raw=include_raw,
)["results"]
result["timezone"] = self.config.timezone
return result
def list_projects(
self,
*,
customer_id: Any | None = None,
team: str | None = None,
stage: str | None = None,
limit: int = 20,
offset: int = 0,
include_raw: bool = False,
) -> dict[str, Any]:
limit, offset = _normalize_pagination(limit, offset)
team_member_ids, team_error = self._resolve_team_member_ids_or_error(team)
if team_error:
return {"error": team_error}
stage_value = stage
if stage:
if stage in self.config.project.progress_values:
stage_value = self.config.project.progress_values[stage]
elif stage not in self.config.project.progress_values.values():
return {
"error": {
"code": "invalid_project_stage",
"message": "stage must be one of the configured project progress labels or values",
}
}
filter_value = build_and_filter(
[
build_eq_condition(self.config.project.customer_field, customer_id),
build_relation_id_in_condition(
[self.config.project.customer_relation_field, self.config.customer.owner_field, "id"],
team_member_ids,
),
build_eq_condition(self.config.project.progress_field, stage_value),
]
)
result = self._list_with_projection_fallback(
self.config.project.resource,
limit=limit,
offset=offset,
filter=filter_value,
fields=self._project_fields(),
sort=["-updatedAt"],
)
result["results"] = [
self._compact_project(row, include_raw=include_raw) for row in result["results"]
]
result["timezone"] = self.config.timezone
return result
def get_project(
self,
project_id: Any,
*,
include_contacts: bool = True,
include_followups: bool = True,
include_updates: bool = True,
) -> dict[str, Any]:
row = self._single(
self.client.get_record(
self.config.project.resource,
project_id,
fields=self._project_fields(),
)
)
if not isinstance(row, Mapping):
return {"error": {"code": "project_not_found", "message": f"No project matched id {project_id!r}"}}
project = self._compact_project(row, include_raw=False)
result: dict[str, Any] = {"project": project, "timezone": self.config.timezone}
if include_contacts:
# Was returning raw contact rows, phone and email included.
result["contacts"] = _relation_refs(
row, [self.config.project.contact_field], self._contact_spec()
)
if include_followups:
result["followups"] = self.list_followups(project_id=project_id, limit=100, offset=0)["results"]
if include_updates:
updates, truncated = self._scan_records(
self.config.project.update_resource,
filter=build_and_filter(
[build_relation_id_condition(self.config.project.update_project_field, project_id)]
),
fields=[
"id",
self.config.project.update_project_field,
self.config.project.update_date_field,
self.config.project.update_content_field,
self.config.project.update_attachment_field,
"createdBy",
"createdAt",
],
sort=[self.config.project.update_date_field, "createdAt"],
)
result["updates"] = [self._compact_project_update(update) for update in updates]
if truncated:
result["data_quality_warnings"] = ["project updates were truncated at the scan limit"]
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_truncated = 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_truncated = 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] = []
if followups_truncated:
warnings.append("follow-up events were truncated at the scan limit")
if updates_truncated:
warnings.append("project update events were truncated at the scan limit")
if warnings:
result["data_quality_warnings"] = warnings
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_truncated = self._scan_records(
self.config.customer.resource,
filter=customer_filter,
fields=self._customer_compact_fields(),
sort=self.config.customer.sort,
)
followup_rows, followups_truncated = 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] = []
if customers_truncated:
warnings.append("customer scan was truncated")
if followups_truncated:
warnings.append("follow-up scan was truncated")
if warnings:
result["data_quality_warnings"] = warnings
return result
def list_contacts(
self,
customer_id: Any | None = None,
query: str | None = None,
limit: int = 20,
offset: int = 0,
include_details: bool = True,
include_raw: bool = False,
) -> dict[str, Any]:
limit, offset = _normalize_pagination(limit, offset)
filter_value = build_and_filter(
[
build_relation_id_condition(self.config.contact.customer_field, customer_id),
build_search_filter(query, self.config.contact.search_fields),
]
)
result = self._list_with_projection_fallback(
self.config.contact.resource,
limit=limit,
offset=offset,
filter=filter_value,
fields=(
self.config.contact.fields
if include_raw
else self._contact_compact_fields(include_details=include_details)
),
sort=self.config.contact.sort,
)
result["results"] = self._compact_contacts(
result["results"], include_details=include_details, include_raw=include_raw
)
result["timezone"] = self.config.timezone
return result
def list_followups(
self,
customer_id: Any | None = None,
contact_id: Any | None = None,
owner: str | None = None,
team: str | None = None,
project_id: Any | None = None,
followup_type: str | None = None,
record_status: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
limit: int = 20,
offset: int = 0,
include_raw: bool = False,
) -> dict[str, Any]:
limit, offset = _normalize_pagination(limit, offset)
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}
filter_value = build_and_filter(
[
build_relation_id_condition(self.config.followup.customer_field, customer_id),
build_relation_id_condition(self.config.followup.contact_field, contact_id),
build_relation_id_condition(self.config.followup.owner_field, owner_id),
build_relation_id_in_condition(self.config.followup.owner_field, team_member_ids),
build_relation_id_condition(self.config.followup.project_field, project_id),
build_eq_condition(self.config.followup.type_field, followup_type),
build_eq_condition(self.config.followup.status_field, record_status),
build_date_range_condition(self.config.followup.date_field, date_from, date_to),
]
)
result = self._list_with_projection_fallback(
self.config.followup.resource,
limit=limit,
offset=offset,
filter=filter_value,
fields=self.config.followup.fields if include_raw else self._followup_compact_fields(),
sort=self.config.followup.sort,
)
result["results"] = self._compact_followups(result["results"], include_raw=include_raw)
result["timezone"] = self.config.timezone
return result
def _list_records_window(
self,
resource: str,
*,
limit: int,
offset: int,
**kwargs: Any,
) -> dict[str, Any]:
page = _page_from_offset(limit, offset)
intra_page_offset = offset % limit
response = self.client.list_records(
resource,
**kwargs,
page=page,
page_size=limit,
)
rows = self._data(response)
selected = rows[intra_page_offset:]
if intra_page_offset and len(rows) == limit and len(selected) < limit:
next_response = self.client.list_records(
resource,
**kwargs,
page=page + 1,
page_size=limit,
)
selected.extend(self._data(next_response)[: limit - len(selected)])
selected = selected[:limit]
total = _total(response, offset + len(selected))
return _paginated_rows(selected, total, limit, offset)
def _scan_records(
self,
resource: str,
*,
page_size: int = MAX_PAGE_SIZE,
max_records: int = MAX_SCAN_RECORDS,
**kwargs: Any,
) -> tuple[list[dict[str, Any]], bool]:
rows: list[dict[str, Any]] = []
page = 1
total: int | None = None
while len(rows) < max_records:
response = self.client.list_records(
resource,
**kwargs,
page=page,
page_size=page_size,
)
page_rows = self._data(response)
if total is None:
total = _total(response, len(page_rows))
rows.extend(page_rows)
if not page_rows or len(page_rows) < page_size or len(rows) >= total:
break
page += 1
truncated = total is not None and len(rows) < total
return rows[:max_records], truncated
def _project_fields(self) -> list[str]:
project = self.config.project
scalars = [
project.id_field,
project.name_field,
project.customer_field,
project.inquiry_date_field,
project.progress_field,
project.applications_field,
project.part_numbers_field,
project.period_usage_field,
project.details_field,
project.attachment_field,
"createdAt",
"updatedAt",
]
projections = [
*self._projection(project.customer_relation_field, self._customer_spec(), None),
*self._projection(project.contact_field, self._contact_spec(), None),
]
return _fields_with_projections(scalars, projections)
def _compact_project(self, row: Mapping[str, Any], *, include_raw: bool) -> dict[str, Any]:
project = self.config.project
progress = row.get(project.progress_field)
progress_labels = {value: label for label, value in project.progress_values.items()}
compact: dict[str, Any] = {
"id": row.get(project.id_field),
"name": row.get(project.name_field),
"customer_id": row.get(project.customer_field),
"customers": _relation_refs(row, [project.customer_relation_field], self._customer_spec()),
"inquiry_date": row.get(project.inquiry_date_field),
"stage": progress_labels.get(progress, progress),
"applications": row.get(project.applications_field),
"part_numbers": row.get(project.part_numbers_field),
"period_usage": row.get(project.period_usage_field),
"details": row.get(project.details_field),
"contacts": _relation_refs(row, [project.contact_field], self._contact_spec()),
"createdAt": _beijing_datetime(row.get("createdAt"), self.config.timezone),
"updatedAt": _beijing_datetime(row.get("updatedAt"), self.config.timezone),
}
compact = _prune(compact)
if include_raw:
compact["raw"] = dict(row)
return compact
def _compact_project_update(self, row: Mapping[str, Any]) -> dict[str, Any]:
creators = _relation_refs(row, ["createdBy"], self._user_spec())
return _prune(
{
"id": row.get("id"),
"updated_at": _beijing_datetime(
row.get(self.config.project.update_date_field) or row.get("createdAt"),
self.config.timezone,
),
"content": row.get(self.config.project.update_content_field),
"creator": creators[0] if creators else None,
}
)
def _safe_metadata(self) -> dict[str, Any]:
collections = {
"customers": self.config.customer.resource,
"contacts": self.config.contact.resource,
"followups": self.config.followup.resource,
"teams": self.config.team.resource,
"projects": self.config.project.resource,
"project_updates": self.config.project.update_resource,
}
fields: dict[str, list[dict[str, Any]]] = {}
field_errors: dict[str, dict[str, str]] = {}
for label, collection_name in collections.items():
try:
response = self.client.list_collection_fields(
collection_name,
page_size=500,
paginate=False,
)
fields[label] = self._data(response)
except Exception as error:
field_errors[label] = _metadata_error(error)
return {"collections": collections, "fields": fields, "field_errors": field_errors}
def _team_matches(self, team: str) -> list[dict[str, Any]]:
normalized = team.strip()
if not normalized:
return []
rows = self._data(
self.client.list_records(
self.config.team.resource,
filter=build_search_filter(normalized, [self.config.team.name_field]),
fields=self._team_fields(),
page=1,
page_size=50,
)
)
exact = [
row
for row in rows
if str(row.get(self.config.team.name_field) or "").strip().casefold() == normalized.casefold()
]
return exact or rows
def _resolve_team_member_ids_or_error(
self,
team: str | None,
) -> tuple[list[Any] | None, dict[str, Any] | None]:
normalized = (team or "").strip()
if not normalized:
return None, None
matches = self._team_matches(normalized)
candidates = [self._compact_team(row, include_members=False, formal_members_only=True) for row in matches[:10]]
if not matches:
return None, {
"code": "team_not_found",
"message": f"No team matched {normalized!r}",
"query": normalized,
"candidates": [],
}
if len(matches) > 1:
return None, {
"code": "team_ambiguous",
"message": f"Multiple teams matched {normalized!r}",
"query": normalized,
"candidates": candidates,
}
members = self._team_users(matches[0], self.config.team.member_field, formal_only=True)
member_ids = [
row.get(self.config.user.id_field)
for row in members
if row.get(self.config.user.id_field) is not None
]
if not member_ids:
return None, {
"code": "team_has_no_formal_members",
"message": f"Team {normalized!r} has no formal members",
"query": normalized,
"candidates": candidates,
}
return member_ids, None
def _team_users(
self,
team_row: Mapping[str, Any],
relation_field: str,
*,
formal_only: bool,
) -> list[dict[str, Any]]:
related = _relation_rows(team_row.get(relation_field))
user_ids = [
row.get(self.config.user.id_field)
for row in related
if row.get(self.config.user.id_field) is not None
]
if not user_ids:
return []
conditions: list[dict[str, Any] | None] = [
{self.config.user.id_field: {"$in": user_ids}},
]
if formal_only:
conditions.append(
build_eq_condition(
self.config.team.user_status_field,
self.config.team.formal_user_status_value,
)
)
fields = _unique_strings(
[
*(self.config.user.fields or []),
self.config.team.user_status_field,
]
)
return self._data(
self.client.list_records(
self.config.user.resource,
filter=build_and_filter(conditions),
fields=fields,
page=1,
page_size=min(MAX_PAGE_SIZE, max(1, len(user_ids))),
)
)
def _compact_team(
self,
row: Mapping[str, Any],
*,
include_members: bool,
formal_members_only: bool,
) -> dict[str, Any]:
managers = self._team_users(row, self.config.team.manager_field, formal_only=False)
raw_members = _relation_rows(row.get(self.config.team.member_field))
result: dict[str, Any] = {
"id": row.get(self.config.team.id_field),
"name": row.get(self.config.team.name_field),
"manager_names": [
candidate["label"]
for manager in managers
if (candidate := _format_user_candidate(manager, self.config.user.id_field)).get("label")
],
"member_count": len(raw_members),
}
if include_members:
members = self._team_users(
row,
self.config.team.member_field,
formal_only=formal_members_only,
)
result["members"] = [
{
**_format_user_candidate(member, self.config.user.id_field),
"status": member.get(self.config.team.user_status_field),
}
for member in members
]
result["returned_member_count"] = len(members)
result["formal_members_only"] = formal_members_only
return result
def _owner_matches(self, owner: str) -> list[dict[str, Any]]:
normalized = owner.strip()
if not normalized:
return []
rows = self._data(
self.client.list_records(
self.config.user.resource,
filter=build_search_filter(normalized, self.config.user.match_fields),
fields=self.config.user.fields,
page=1,
page_size=50,
)
)
return _pick_user_matches(rows, normalized, self.config.user.match_fields or [])
def _resolve_owner_id_or_error(self, owner: str | None) -> tuple[Any | None, dict[str, Any] | None]:
normalized = (owner or "").strip()
if not normalized:
return None, None
matches = self._owner_matches(normalized)
candidates = [_format_user_candidate(row, self.config.user.id_field) for row in matches[:10]]
if not matches:
return None, {
"code": "owner_not_found",
"message": f"No user matched owner {normalized!r}",
"query": normalized,
"candidates": [],
}
if len(matches) > 1:
return None, {
"code": "owner_ambiguous",
"message": f"Multiple users matched owner {normalized!r}",
"query": normalized,
"candidates": candidates,
}
user_id = matches[0].get(self.config.user.id_field)
if user_id is None or user_id == "":
return None, {
"code": "owner_invalid",
"message": f"Matched owner {normalized!r} has no {self.config.user.id_field!r}",
"query": normalized,
"candidates": candidates,
}
return user_id, None
def _resolve_owner_id(self, owner: str | None) -> Any | None:
owner_id, error = self._resolve_owner_id_or_error(owner)
if error:
raise ValueError(str(error["message"]))
return owner_id
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 len(rows) < MAX_SCAN_RECORDS:
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
offset += batch_size
return rows[:MAX_SCAN_RECORDS]
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
def _compact_customers(self, rows: list[dict[str, Any]], *, include_raw: bool = False) -> list[dict[str, Any]]:
return [self._compact_customer(row, include_raw=include_raw) for row in rows]
def _customer_name_candidates(self) -> list[str]:
return [*(self.config.customer.name_fields or []), "name", "title"]
def _customer_compact_fields(self, *, detail: bool = False) -> list[str]:
customer = self.config.customer
scalars = _compact_field_list(
customer.fields,
[
customer.id_field,
*self._customer_name_candidates(),
customer.english_name_field,
customer.territorial_name_field,
customer.status_field,
customer.source_field,
"createdAt",
"updatedAt",
*(customer.detail_fields.values() if detail else []),
],
)
projections = [
*self._projection(customer.owner_field, self._user_spec(), customer.fields),
*self._projection(customer.manager_field, self._user_spec(), customer.fields),
]
return _fields_with_projections(scalars, projections)
def _compact_customer(
self,
row: Mapping[str, Any],
*,
detail: bool = False,
include_raw: bool = False,
) -> dict[str, Any]:
customer = self.config.customer
identifier = row.get(customer.id_field)
name = _first_text(row, self._customer_name_candidates())
if name is None and identifier is not None:
name = f"{UNKNOWN_NAME_PREFIX}{identifier}"
english_name = _first_text(row, [customer.english_name_field, "english_name"])
territorial_name = _first_text(row, [customer.territorial_name_field, "territorial_name"])
status = _first_value(row, [customer.status_field, "status"])
compact: dict[str, Any] = {
"id": identifier,
"name": name,
# Only worth repeating when they carry information the name does not.
"english_name": english_name if english_name != name else None,
"territorial_name": territorial_name if territorial_name != name else None,
"status": CUSTOMER_STATUS_LABELS.get(status, str(status) if status is not None else None),
"source": _first_value(row, [customer.source_field, "source"]),
"owners": _relation_refs(row, [customer.owner_field], self._user_spec()),
"managers": _relation_refs(row, [customer.manager_field], self._user_spec()),
"createdAt": _beijing_datetime(row.get("createdAt"), self.config.timezone),
"updatedAt": _beijing_datetime(row.get("updatedAt"), self.config.timezone),
}
if detail:
for output_key, source_field in customer.detail_fields.items():
if output_key not in compact:
compact[output_key] = row.get(source_field)
compact = _prune(compact)
if include_raw:
compact["raw"] = dict(row)
return compact
def _compact_contacts(
self,
rows: list[dict[str, Any]],
*,
include_details: bool = True,
include_raw: bool = False,
) -> list[dict[str, Any]]:
return [
self._compact_contact(row, include_details=include_details, include_raw=include_raw) for row in rows
]
def _contact_name_candidates(self) -> list[str]:
return [*(self.config.contact.name_fields or []), "name", "title"]
def _contact_compact_fields(self, *, include_details: bool = True) -> list[str]:
contact = self.config.contact
scalars = _compact_field_list(
contact.fields,
[
contact.id_field,
*self._contact_name_candidates(),
contact.title_field,
"position",
*([contact.phone_field, "phone", contact.email_field, "email"] if include_details else []),
"createdAt",
"updatedAt",
],
)
projections = self._projection(contact.customer_field, self._customer_spec(), contact.fields)
return _fields_with_projections(scalars, projections)
def _compact_contact(
self,
row: Mapping[str, Any],
*,
include_details: bool = True,
include_raw: bool = False,
) -> dict[str, Any]:
contact = self.config.contact
compact: dict[str, Any] = {
"id": row.get(contact.id_field),
"name": _first_text(row, self._contact_name_candidates()),
"title": _first_text(row, [contact.title_field, "title", "position"]),
"customers": _relation_refs(row, [contact.customer_field], self._customer_spec()),
"createdAt": _beijing_datetime(row.get("createdAt"), self.config.timezone),
"updatedAt": _beijing_datetime(row.get("updatedAt"), self.config.timezone),
}
if include_details:
compact["phone"] = _first_text(row, [contact.phone_field, "phone"])
compact["email"] = _first_text(row, [contact.email_field, "email"])
compact = _prune(compact)
if include_raw:
compact["raw"] = dict(row)
return compact
def _compact_followups(self, rows: list[dict[str, Any]], *, include_raw: bool = False) -> list[dict[str, Any]]:
return [self._compact_followup(row, include_raw=include_raw) for row in rows]
def _followup_content_fields(self) -> list[str]:
return [
self.config.followup.content_field,
*(self.config.followup.search_fields or []),
*FOLLOWUP_CONTENT_KEYS,
]
def _followup_compact_fields(self) -> list[str]:
followup = self.config.followup
scalars = _compact_field_list(
followup.fields,
[
followup.id_field,
followup.date_field,
*FOLLOWUP_DATE_KEYS,
*self._followup_content_fields(),
followup.type_field,
followup.status_field,
followup.location_field,
],
)
projections = [
*self._projection(followup.customer_field, self._customer_spec(), followup.fields),
*self._projection(followup.contact_field, self._contact_spec(), followup.fields),
*self._projection(followup.project_field, self._project_spec(), followup.fields),
*self._projection(followup.owner_field, self._user_spec(), followup.fields),
*self._projection("createdBy", self._user_spec(), followup.fields),
]
return _fields_with_projections(scalars, projections)
def _compact_followup(self, row: Mapping[str, Any], *, include_raw: bool = False) -> dict[str, Any]:
followup = self.config.followup
summary = self._followup_summary(row)
participants = summary["participants"]
followup_type = row.get(followup.type_field)
record_status = row.get(followup.status_field)
compact: dict[str, Any] = {
"id": row.get(followup.id_field),
"followedAt": _beijing_datetime(summary["followedAt"], self.config.timezone),
"customers": summary["customers"],
"contacts": summary["contacts"],
"participants": participants,
"projects": summary["projects"],
"type": FOLLOWUP_TYPE_LABELS.get(followup_type, followup_type),
"status": FOLLOWUP_STATUS_LABELS.get(record_status, record_status),
"location": _first_text(row, [followup.location_field]),
"content": summary["content"],
}
# A record is usually submitted by one of its own participants; naming the
# submitter again in that case is pure repetition, so it only shows up when
# it actually tells the reader something new.
creator = summary["creator"]
if creator and creator.get("id") not in _ref_ids(participants):
compact["creator"] = creator
compact = _prune(compact)
if include_raw:
compact["raw"] = dict(row)
return compact
def _followup_summary(self, row: Mapping[str, Any]) -> dict[str, Any]:
followup = self.config.followup
date_fields = [followup.date_field, *FOLLOWUP_DATE_KEYS]
# owner_fields drives *filtering* and may include createdBy; participants
# are only ever the follow-up person relation (the contribution basis).
participant_fields = [followup.owner_field]
creators = _relation_refs(row, ["createdBy"], self._user_spec())
return {
"content": _first_text(row, self._followup_content_fields()),
"followedAt": _first_text(row, date_fields),
"customers": _relation_refs(row, [followup.customer_field], self._customer_spec()),
"contacts": _relation_refs(row, [followup.contact_field], self._contact_spec()),
"participants": _relation_refs(row, participant_fields, self._user_spec()),
"creator": creators[0] if creators else None,
"projects": _relation_refs(row, [followup.project_field], self._project_spec()),
}
@staticmethod
def _data(response: Mapping[str, Any]) -> list[dict[str, Any]]:
return _data(response)
@staticmethod
def _single(response: Mapping[str, Any]) -> Any:
data = response.get("data")
if isinstance(data, list):
return data[0] if data else None
return data
def _page_from_offset(limit: int, offset: int) -> int:
bounded_limit = max(1, int(limit))
bounded_offset = max(0, int(offset))
return bounded_offset // bounded_limit + 1
def _normalize_pagination(limit: Any, offset: Any) -> tuple[int, int]:
return min(max(1, _int_or_default(limit, DEFAULT_PAGE_SIZE)), MAX_PAGE_SIZE), max(0, _int_or_default(offset, 0))
def _compact_field_list(configured_fields: list[str] | None, candidates: list[str]) -> list[str]:
available = set(configured_fields or [])
selected: list[str] = []
for candidate in _unique_strings(candidates):
if available and candidate not in available:
continue
selected.append(candidate)
return selected
def _int_or_default(value: Any, default: int) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def _data(response: Mapping[str, Any]) -> list[dict[str, Any]]:
data = response.get("data")
if isinstance(data, list):
return [item for item in data if isinstance(item, dict)]
if isinstance(data, dict):
for key in ("data", "rows", "results"):
nested = data.get(key)
if isinstance(nested, list):
return [item for item in nested if isinstance(item, dict)]
return []
def _total(response: Mapping[str, Any], fallback: int) -> int:
meta = response.get("meta")
if isinstance(meta, dict):
for key in ("count", "total", "totalCount"):
value = meta.get(key)
if isinstance(value, int):
return value
data = response.get("data")
if isinstance(data, dict):
for key in ("count", "total", "totalCount"):
value = data.get(key)
if isinstance(value, int):
return value
return fallback
def _paginated_response(response: Mapping[str, Any], limit: int, offset: int) -> dict[str, Any]:
rows = _data(response)
total = _total(response, len(rows))
return _paginated_rows(rows, total, limit, offset)
def _paginated_rows(rows: list[dict[str, Any]], total: int, limit: int, offset: int) -> dict[str, Any]:
next_offset = offset + len(rows)
return {
"results": rows,
"total": total,
"returned": len(rows),
"limit": limit,
"offset": offset,
"has_more": bool(rows) and next_offset < total,
"next_offset": next_offset if next_offset < total else None,
}
def _beijing_datetime(value: Any, timezone: str) -> Any:
if not isinstance(value, str) or not value.strip():
return value
normalized = value.strip()
if len(normalized) == 10:
try:
datetime.strptime(normalized, "%Y-%m-%d")
except ValueError:
return value
return normalized
candidate = normalized[:-1] + "+00:00" if normalized.endswith("Z") else normalized
try:
parsed = datetime.fromisoformat(candidate)
except ValueError:
return value
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
try:
return parsed.astimezone(ZoneInfo(timezone)).isoformat()
except (ValueError, KeyError):
return parsed.astimezone(ZoneInfo("Asia/Shanghai")).isoformat()
def _parse_datetime(value: Any, timezone: str) -> datetime | None:
normalized = _beijing_datetime(value, timezone)
if not isinstance(normalized, str) or len(normalized) == 10:
return None
try:
return datetime.fromisoformat(normalized)
except ValueError:
return None
def _first_numeric(rows: list[dict[str, Any]], preferred_key: str) -> int:
for row in rows:
value = row.get(preferred_key)
if isinstance(value, (int, float)):
return int(value)
for nested_value in row.values():
if isinstance(nested_value, (int, float)):
return int(nested_value)
return 0
def _decorate_dimension_counts(
rows: list[dict[str, Any]],
*,
preferred_keys: list[str],
labels: Mapping[Any, str],
) -> list[dict[str, Any]]:
decorated: list[dict[str, Any]] = []
for row in rows:
value, key = _dimension_value(row, preferred_keys)
item = dict(row)
if value is not None:
label = labels.get(value) or str(value)
# The raw dimension key is dropped once normalized -- keeping it meant
# every bucket carried the same value under an internal field code too.
if key is not None and key not in {"value", "label"}:
item.pop(key, None)
item["value"] = value
item["label"] = label
decorated.append(item)
decorated.sort(key=lambda item: (_is_blank(item.get("value")), -_count_value(item), str(item.get("label") or "")))
return decorated
def _decorate_id_counts(
rows: list[dict[str, Any]],
*,
preferred_keys: list[str],
output_key: str,
) -> list[dict[str, Any]]:
decorated: list[dict[str, Any]] = []
for row in rows:
value, key = _dimension_value(row, preferred_keys)
item = dict(row)
if value is not None:
if key is not None and key != output_key:
item.pop(key, None)
item[output_key] = value
decorated.append(item)
decorated.sort(key=lambda item: (-_count_value(item), str(item.get(output_key) or "")))
return decorated
def _dimension_value(row: Mapping[str, Any], preferred_keys: list[str]) -> tuple[Any | None, str | None]:
for key in preferred_keys:
if key in row:
return row[key], key
for key, value in row.items():
if key == "count":
continue
if not isinstance(value, (dict, list)):
return value, str(key)
return None, None
def _count_value(row: Mapping[str, Any]) -> int:
value = row.get("count")
if isinstance(value, (int, float)):
return int(value)
return 0
def _is_blank(value: Any) -> bool:
return value is None or str(value).strip() == ""
def _first_value(row: Mapping[str, Any], fields: list[str]) -> Any:
for field in _unique_strings(fields):
value = row.get(field)
if value is not None and value != "":
return value
return None
def _first_text(row: Mapping[str, Any], fields: list[str]) -> str | None:
for field in _unique_strings(fields):
value = row.get(field)
if value is None or value == "":
continue
if isinstance(value, (dict, list)):
continue
return str(value)
return None
def _prune(mapping: Mapping[str, Any]) -> dict[str, Any]:
"""Drop keys whose value carries no information.
An absent key means "empty", never "unknown" -- callers reconciling records
should not have to skip past a screenful of nulls and empty lists. ``id`` is
always kept so a record stays addressable even when nothing else resolved.
"""
pruned: dict[str, Any] = {}
for key, value in mapping.items():
if key == "id" or not _is_empty(value):
pruned[key] = value
return pruned
def _is_empty(value: Any) -> bool:
if value is None:
return True
if isinstance(value, str):
return value.strip() == ""
if isinstance(value, (list, tuple, dict, set)):
return len(value) == 0
return False
def _ref_from_mapping(item: Mapping[str, Any], spec: LabelSpec) -> dict[str, Any]:
"""Render one related record as ``{"id": ..., "name": ...}``.
Deliberately never falls back to the raw id as the *name*: a customer whose
name fields are all empty used to surface as e.g. ``"4249"``, which reads
like a real name. It becomes ``"#4249"`` instead -- unmistakably an id, and
a signal that the source record needs its name filled in.
"""
ident = item.get(spec.id_field)
name = _first_text(item, list(spec.name_fields))
if name is None and ident is not None:
name = f"{UNKNOWN_NAME_PREFIX}{ident}"
return _prune({"id": ident, "name": name})
def _relation_refs(row: Mapping[str, Any], fields: list[str], spec: LabelSpec) -> list[dict[str, Any]]:
refs: list[dict[str, Any]] = []
seen: set[Any] = set()
for field in _unique_strings(fields):
for item in _relation_rows(row.get(field)):
ref = _ref_from_mapping(item, spec)
if not ref:
continue
key = ref.get("id", ref.get("name"))
if key in seen:
continue
seen.add(key)
refs.append(ref)
return refs
def _ref_names(refs: list[dict[str, Any]]) -> list[str]:
return [str(ref["name"]) for ref in refs if ref.get("name")]
def _ref_ids(refs: list[dict[str, Any]]) -> list[Any]:
return [ref["id"] for ref in refs if ref.get("id") is not None]
def _fields_with_projections(scalars: list[str], projections: list[str]) -> list[str]:
"""Combine scalar field names with dotted relation projections.
A relation requested bare (``gjjl_client``) next to a dotted projection
(``gjjl_client.id``) makes NocoBase expand the whole related row again, which
is exactly the bloat the projections exist to avoid -- so any scalar that is
also a projection root is dropped.
"""
roots = {path.split(".", 1)[0] for path in projections}
kept = [name for name in _unique_strings(scalars) if name not in roots]
return kept + _unique_strings(projections)
def _relation_rows(value: Any) -> list[dict[str, Any]]:
if isinstance(value, Mapping):
nested = value.get("data")
if isinstance(nested, list):
return [dict(item) for item in nested if isinstance(item, Mapping)]
return [dict(value)]
if isinstance(value, list):
return [dict(item) for item in value if isinstance(item, Mapping)]
return []
def _relation_ids(value: Any, id_field: str = "id") -> list[Any]:
return [
row.get(id_field)
for row in _relation_rows(value)
if row.get(id_field) is not None
]
def _unique_values(values: list[Any]) -> list[Any]:
unique: list[Any] = []
seen: set[Any] = set()
for value in values:
if value in seen:
continue
seen.add(value)
unique.append(value)
return unique
def _unique_strings(values: list[str]) -> list[str]:
unique: list[str] = []
for value in values:
normalized = str(value or "").strip()
if normalized and normalized not in unique:
unique.append(normalized)
return unique
def _metadata_error(error: Exception) -> dict[str, str]:
message = str(error)
if "403" in message or "No permissions" in message:
return {
"code": "metadata_permission_required",
"message": message,
"hint": (
"Grant the agent role read-only permission for "
"`data-modeling collections fields list`, then retry crm_schema(include_fields=true)."
),
}
return {"code": "metadata_error", "message": message, "hint": "Check NocoBase metadata API reachability and ACL."}
def _field_list(value: Any) -> list[dict[str, Any]]:
return [item for item in value or [] if isinstance(item, dict)]
def _field_names(fields: list[dict[str, Any]]) -> list[str]:
return [str(field["name"]) for field in fields if field.get("name")]
def _field_path(field: str | list[str]) -> list[str]:
if isinstance(field, str):
return [part for part in field.split(".") if part]
return [str(part) for part in field if str(part)]
def _field_text(field: dict[str, Any]) -> str:
pieces = [
str(field.get("name") or ""),
str(field.get("title") or ""),
str(field.get("interface") or ""),
str(field.get("type") or ""),
]
ui_schema = field.get("uiSchema")
if isinstance(ui_schema, dict):
pieces.append(str(ui_schema.get("title") or ""))
return " ".join(pieces).lower()
def _find_named_field(fields: list[dict[str, Any]], names: list[str]) -> str | None:
available = {str(field.get("name")) for field in fields if field.get("name")}
for name in names:
if name in available:
return name
return None
def _existing_field_names(fields: list[dict[str, Any]], names: list[str]) -> list[str]:
available = {str(field.get("name")) for field in fields if field.get("name")}
return [name for name in names if name in available]
def _configured_or_search_fields(fields: list[dict[str, Any]], preferred_names: list[str]) -> list[str]:
preferred = _existing_field_names(fields, preferred_names)
if preferred:
return preferred
return _search_fields(fields)
def _search_fields(fields: list[dict[str, Any]]) -> list[str]:
preferred_interfaces = {"input", "textarea", "email", "phone", "url"}
preferred_types = {"string", "text"}
names = [
str(field["name"])
for field in fields
if field.get("name")
and (
str(field.get("interface") or "").lower() in preferred_interfaces
or str(field.get("type") or "").lower() in preferred_types
)
]
if names:
return names[:4]
fallback = [str(field["name"]) for field in fields if field.get("name") and str(field.get("name")) != "id"]
return fallback[:2]
def _find_field(fields: list[dict[str, Any]], needles: list[str]) -> str | None:
lowered = [needle.lower() for needle in needles]
for field in fields:
name = field.get("name")
if not name:
continue
text = _field_text(field)
if any(needle in text for needle in lowered):
return str(name)
return None
def _relation_field(fields: list[dict[str, Any]], needles: list[str]) -> str | None:
lowered = [needle.lower() for needle in needles]
for field in fields:
if not field.get("name"):
continue
if str(field.get("type") or "").lower() not in {"belongsto", "belongstomany", "hasmany", "hasone"}:
continue
text = _field_text(field)
if any(needle in text for needle in lowered):
return str(field["name"])
return None
def _date_field(fields: list[dict[str, Any]]) -> str | None:
preferred = _find_field(fields, ["跟进日期", "跟进时间", "follow", "date", "time"])
if preferred:
return preferred
for field in fields:
if field.get("name") and str(field.get("interface") or "").lower() in {"date", "datetime", "createdat"}:
return str(field["name"])
if field.get("name") and str(field.get("type") or "").lower() in {"date", "datetime"}:
return str(field["name"])
return None
def _template_sort(date_field: str | None, fallback: list[str] | None) -> list[str] | None:
if date_field:
sort = [f"-{date_field}"]
if date_field != "createdAt":
sort.append("-createdAt")
return sort
return fallback
def _pick_user_matches(rows: list[dict[str, Any]], query: str, match_fields: list[str]) -> list[dict[str, Any]]:
normalized = query.casefold()
exact = [
row
for row in rows
if any(str(row.get(field) or "").strip().casefold() == normalized for field in match_fields)
]
if exact:
return exact
fuzzy = [
row
for row in rows
if any(normalized in str(row.get(field) or "").strip().casefold() for field in match_fields)
]
return fuzzy
def _user_label(row: dict[str, Any]) -> str:
for key in ("nickname", "username", "email", "f_yh_english_name", "id"):
value = row.get(key)
if value is not None and value != "":
return str(value)
return "<unnamed>"
def _format_user_candidate(row: dict[str, Any], id_field: str) -> dict[str, Any]:
candidate: dict[str, Any] = {
"id": row.get(id_field),
"label": _user_label(row),
}
for key in ("username", "nickname", "email", "f_yh_english_name"):
value = row.get(key)
if value is not None and value != "":
candidate[key] = value
return candidate