2161 lines
85 KiB
Python
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
|