Files
nocobase-mcp/nocobase_crm_mcp/crm.py
T
2026-07-13 02:56:36 +00:00

1026 lines
40 KiB
Python

from __future__ import annotations
from typing import Any, Mapping
from .config import CrmConfig
from .filters import (
build_and_filter,
build_date_range_condition,
build_eq_condition,
build_relation_id_condition,
build_relation_id_or_condition,
build_search_filter,
)
from .nb_client import NbClient, NbCommandError
DEFAULT_PAGE_SIZE = 20
MAX_PAGE_SIZE = 100
CONTENT_PREVIEW_CHARS = 500
CUSTOMER_STATUS_LABELS = {
"kh_p_not_start": "未开始",
"kh_p_inprogress": "进行中",
"kh_p_ordered": "已下单",
"kh_p_audited": "已审核",
}
CUSTOMER_NAME_KEYS = ("f_q2q60q7bqbg", "name", "title", "code", "id")
CONTACT_NAME_KEYS = ("f_2zpg91gtmwp", "name", "phone", "email", "title", "id")
USER_LABEL_KEYS = ("nickname", "username", "email", "f_yh_english_name", "name", "id")
FOLLOWUP_CONTENT_KEYS = ("f_19iplxz0nds", "content", "description", "remark")
FOLLOWUP_DATE_KEYS = ("f_srysmjkwfq1", "followedAt", "createdAt", "updatedAt")
class CrmService:
def __init__(self, config: CrmConfig, client: NbClient) -> None:
self.config = config
self.client = client
def crm_health(self) -> dict[str, Any]:
health = self.client.health()
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,
},
}
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__,
},
}
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,
},
"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"],
),
"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"],
),
"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,
"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_or_condition(self.config.followup.owner_fields, 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.config.followup.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,
{},
)
),
"owner_candidates": owner_candidates,
"max_limit": MAX_PAGE_SIZE,
}
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_customers(
self,
query: str | None = None,
status: str | None = None,
owner: 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}
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_eq_condition(self.config.customer.source_field, source),
build_search_filter(query, self.config.customer.search_fields),
]
)
response = self.client.list_records(
self.config.customer.resource,
filter=filter_value,
fields=self.config.customer.fields if include_raw else self._customer_compact_fields(),
sort=self.config.customer.sort,
page=_page_from_offset(limit, offset),
page_size=limit,
)
result = _paginated_response(response, limit, offset)
result["results"] = self._compact_customers(result["results"], include_raw=include_raw)
return result
def get_customer(
self,
customer_id: Any,
include_contacts: bool = True,
include_recent_followups: bool = True,
followup_limit: int = 10,
include_raw_followups: bool = False,
) -> dict[str, Any]:
customer = self._single(
self.client.get_record(
self.config.customer.resource,
customer_id,
fields=self.config.customer.fields,
)
)
result: dict[str, Any] = {"customer": customer}
if include_contacts:
result["contacts"] = self.list_contacts(customer_id=customer_id, limit=50, offset=0)["results"]
if include_recent_followups:
result["recent_followups"] = self.list_followups(
customer_id=customer_id,
limit=followup_limit,
offset=0,
include_raw=include_raw_followups,
)["results"]
return result
def list_contacts(
self,
customer_id: Any | None = None,
query: str | None = None,
limit: int = 20,
offset: int = 0,
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),
]
)
response = self.client.list_records(
self.config.contact.resource,
filter=filter_value,
fields=self.config.contact.fields if include_raw else self._contact_compact_fields(),
sort=self.config.contact.sort,
page=_page_from_offset(limit, offset),
page_size=limit,
)
result = _paginated_response(response, limit, offset)
result["results"] = self._compact_contacts(result["results"], include_raw=include_raw)
return result
def list_followups(
self,
customer_id: Any | None = None,
contact_id: Any | None = None,
owner: 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}
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_or_condition(self.config.followup.owner_fields, owner_id),
build_date_range_condition(self.config.followup.date_field, date_from, date_to),
]
)
response = self.client.list_records(
self.config.followup.resource,
filter=filter_value,
fields=self.config.followup.fields if include_raw else self._followup_compact_fields(),
sort=self.config.followup.sort,
page=_page_from_offset(limit, offset),
page_size=limit,
)
result = _paginated_response(response, limit, offset)
result["results"] = self._compact_followups(result["results"], include_raw=include_raw)
return result
def _safe_metadata(self) -> dict[str, Any]:
collections = {
"customers": self.config.customer.resource,
"contacts": self.config.contact.resource,
"followups": self.config.followup.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 _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)
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=50,
)
return self._data(response)
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,
display_key="display",
)
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:
item["owner_id"] = value
item.setdefault("value", 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_compact_fields(self) -> list[str]:
return _compact_field_list(
self.config.customer.fields,
[
self.config.customer.id_field,
"f_q2q60q7bqbg",
"name",
"title",
*(self.config.customer.search_fields or []),
"f_kh_english_name",
"english_name",
"code",
"f_territorial_name",
"territorial_name",
self.config.customer.status_field,
"kh_progress",
"status",
self.config.customer.source_field,
"f_225h6n6v654",
"source",
self.config.customer.owner_field,
"kehu_followup_person",
"owner",
"createdBy",
"createdAt",
"updatedAt",
],
)
def _compact_customer(self, row: Mapping[str, Any], *, include_raw: bool = False) -> dict[str, Any]:
status = _first_value(row, [self.config.customer.status_field, "kh_progress", "status"])
source = _first_value(row, [self.config.customer.source_field, "f_225h6n6v654", "source"])
compact: dict[str, Any] = {
"id": row.get(self.config.customer.id_field),
"name": _first_text(row, ["f_q2q60q7bqbg", "name", "title", *(self.config.customer.search_fields or [])]),
"english_name": _first_text(row, ["f_kh_english_name", "english_name", "code"]),
"territorial_name": _first_text(row, ["f_territorial_name", "territorial_name"]),
"status": status,
"status_display": CUSTOMER_STATUS_LABELS.get(status, str(status) if status is not None else None),
"source": source,
"owner_names": _relation_labels(
row,
[self.config.customer.owner_field, "kehu_followup_person", "owner", "createdBy"],
USER_LABEL_KEYS,
),
"createdAt": row.get("createdAt"),
"updatedAt": row.get("updatedAt"),
}
if include_raw:
compact["raw"] = dict(row)
return compact
def _compact_contacts(self, rows: list[dict[str, Any]], *, include_raw: bool = False) -> list[dict[str, Any]]:
return [self._compact_contact(row, include_raw=include_raw) for row in rows]
def _contact_compact_fields(self) -> list[str]:
return _compact_field_list(
self.config.contact.fields,
[
self.config.contact.id_field,
"f_2zpg91gtmwp",
"name",
*(self.config.contact.search_fields or []),
"khlxr_phone_number",
"phone",
"khlxr_email",
"email",
"f_tn8zdm2f938",
"title",
"position",
self.config.contact.customer_field,
"kehulianxiren_client",
"customer",
"client",
"createdAt",
"updatedAt",
],
)
def _compact_contact(self, row: Mapping[str, Any], *, include_raw: bool = False) -> dict[str, Any]:
compact: dict[str, Any] = {
"id": row.get(self.config.contact.id_field),
"name": _first_text(row, [*(self.config.contact.search_fields or []), "f_2zpg91gtmwp", "name", "title"]),
"phone": _first_text(row, ["khlxr_phone_number", "phone"]),
"email": _first_text(row, ["khlxr_email", "email"]),
"title": _first_text(row, ["f_tn8zdm2f938", "title", "position"]),
"customer_names": _relation_labels_first_available(
row,
[self.config.contact.customer_field, "kehulianxiren_client", "customer", "client"],
CUSTOMER_NAME_KEYS,
),
"createdAt": row.get("createdAt"),
"updatedAt": row.get("updatedAt"),
}
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_compact_fields(self) -> list[str]:
return _compact_field_list(
self.config.followup.fields,
[
self.config.followup.id_field,
self.config.followup.date_field,
"f_srysmjkwfq1",
"followedAt",
*(self.config.followup.search_fields or []),
*FOLLOWUP_CONTENT_KEYS,
self.config.followup.customer_field,
"gjjl_client",
"customer",
"client",
self.config.followup.contact_field,
"gjjl_client_contacts",
"contact",
"contacts",
*self.config.followup.owner_fields,
"gjjl_followup_person",
"createdBy",
"owner",
"createdAt",
"updatedAt",
],
)
def _compact_followup(self, row: Mapping[str, Any], *, include_raw: bool = False) -> dict[str, Any]:
summary = self._followup_summary(row)
content = summary["content"]
compact: dict[str, Any] = {
"id": row.get(self.config.followup.id_field),
"followedAt": summary["followedAt"],
"content_preview": _text_preview(content, CONTENT_PREVIEW_CHARS),
"content_truncated": isinstance(content, str) and len(content) > CONTENT_PREVIEW_CHARS,
"customer_names": summary["customer_names"],
"contact_names": summary["contact_names"],
"owner_names": summary["owner_names"],
"createdAt": row.get("createdAt"),
"updatedAt": row.get("updatedAt"),
}
if include_raw:
compact["raw"] = dict(row)
return compact
def _followup_summary(self, row: Mapping[str, Any]) -> dict[str, Any]:
content_fields = list(self.config.followup.search_fields or []) + list(FOLLOWUP_CONTENT_KEYS)
date_fields = [self.config.followup.date_field, *FOLLOWUP_DATE_KEYS]
customer_fields = [self.config.followup.customer_field, "gjjl_client", "customer", "client"]
contact_fields = [self.config.followup.contact_field, "gjjl_client_contacts", "contact", "contacts"]
owner_fields = [*self.config.followup.owner_fields, "gjjl_followup_person", "createdBy", "owner"]
return {
"content": _first_text(row, content_fields),
"followedAt": _first_text(row, date_fields),
"customer_names": _relation_labels(row, customer_fields, CUSTOMER_NAME_KEYS),
"contact_names": _relation_labels(row, contact_fields, CONTACT_NAME_KEYS),
"owner_names": _relation_labels(row, owner_fields, USER_LABEL_KEYS),
}
@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))
next_offset = offset + limit
return {
"results": rows,
"total": total,
"returned": len(rows),
"limit": limit,
"offset": offset,
"has_more": next_offset < total,
"next_offset": next_offset if next_offset < total else 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],
display_key: str = "label",
) -> 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)
item["value"] = value
item["label"] = label
item[display_key] = 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 _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 _text_preview(value: str | None, max_chars: int) -> str | None:
if value is None:
return None
if len(value) <= max_chars:
return value
return value[: max(0, max_chars - 3)].rstrip() + "..."
def _relation_labels(row: Mapping[str, Any], fields: list[str], label_keys: tuple[str, ...]) -> list[str]:
labels: list[str] = []
for field in _unique_strings(fields):
_append_relation_labels(labels, row.get(field), label_keys)
return labels
def _relation_labels_first_available(row: Mapping[str, Any], fields: list[str], label_keys: tuple[str, ...]) -> list[str]:
for field in _unique_strings(fields):
labels: list[str] = []
_append_relation_labels(labels, row.get(field), label_keys)
if labels:
return labels
return []
def _append_relation_labels(labels: list[str], value: Any, label_keys: tuple[str, ...]) -> None:
if value is None or value == "":
return
if isinstance(value, list):
for item in value:
_append_relation_labels(labels, item, label_keys)
return
if isinstance(value, dict):
label = _label_from_mapping(value, label_keys)
if label and label not in labels:
labels.append(label)
return
label = str(value)
if label not in labels:
labels.append(label)
def _label_from_mapping(row: Mapping[str, Any], label_keys: tuple[str, ...]) -> str | None:
for key in label_keys:
value = row.get(key)
if value is not None and value != "":
return str(value)
return None
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