diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore index 9de0f16..d20c0fe 100644 --- a/.codegraph/.gitignore +++ b/.codegraph/.gitignore @@ -1,16 +1,5 @@ -# CodeGraph data files -# These are local to each machine and should not be committed - -# Database -*.db -*.db-wal -*.db-shm - -# Cache -cache/ - -# Logs -*.log - -# Hook markers -.dirty +# CodeGraph data files — local to each machine, not for committing. +# Ignore everything in .codegraph/ except this file itself, so transient +# files (the database, daemon.pid, sockets, logs) never show up in git. +* +!.gitignore diff --git a/README.md b/README.md index 2b132d6..20d75f2 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,11 @@ The checked-in example config uses the confirmed mapping: - contact-to-customer relation: `kehulianxiren_client` - follow-up-to-customer relation: `gjjl_client` - follow-up-to-contact relation: `gjjl_client_contacts` -- follow-up owner fields: `gjjl_followup_person` and `createdBy` +- actual follow-up participants: `gjjl_followup_person` +- follow-up record submitter: `createdBy` (audit only; not treated as the actual follower) - follow-up date/content: `f_srysmjkwfq1` and `f_19iplxz0nds` +- teams: `t_team_management`, with `tdgl_manager` and `tdgl_team_members` +- formal employee status: `users.yh_status = nkluuumb1r1` The default NocoBase CLI env is `nb-mcp`. Override it with: @@ -49,10 +52,18 @@ Exposed tools: - `crm_overview` - `crm_filter_options` - `resolve_owner` +- `list_teams` +- `resolve_team` +- `get_team` +- `team_crm_overview` - `list_customers` - `get_customer` - `list_contacts` - `list_followups` +- `list_projects` +- `get_project` +- `get_project_timeline` +- `list_stale_customers` `crm_config_template` is hidden by default because it needs NocoBase data-modeling metadata permissions. Enable it only during configuration work: @@ -60,13 +71,64 @@ Exposed tools: NOCOBASE_MCP_ENABLE_CONFIG_TOOLS=1 uv run nocobase-crm-mcp --config config.json ``` -The `owner` parameter on `crm_overview`, `list_customers`, and `list_followups` accepts a user's username, nickname, email, or English name. The server resolves that value through the `users` resource, then filters CRM relation fields by user id. If the value is missing or ambiguous, the tool returns a structured `error` object with candidate users instead of throwing an MCP error. Remote agents should call `resolve_owner` first when they are unsure which user the requester means. +The `owner` parameter on `crm_overview`, `list_customers`, and `list_followups` accepts a user's username, nickname, email, or English name. The server resolves that value through the `users` resource, then filters CRM relation fields by user id. Customer ownership uses `kehu_followup_person`; follow-up contribution uses `gjjl_followup_person` only. `createdBy` remains visible as the record submitter but is not counted as the actual follower. If a value is missing or ambiguous, the tool returns a structured `error` object with candidate users instead of throwing an MCP error. + +Team-aware tools resolve `t_team_management.f_team_name`, then use only members whose `users.yh_status` is the confirmed formal-employee value. Team totals count each follow-up record once; member contribution counts each participant in `gjjl_followup_person`. Team managers, customer owners, participants, and record submitters remain separate in compact results. Use `crm_filter_options` to discover status/source values and owner candidates before constructing filtered queries. Status/source options are sorted by count descending, with blank source values last. -List tools clamp `limit` to `1..100` and normalize negative offsets to `0`. `list_customers`, `list_contacts`, and `list_followups` return compact rows by default so remote agents do not receive large nested NocoBase relation payloads. Pass `include_raw=true` only when the original NocoBase row is needed; the raw row is then attached under `raw`. +List tools clamp `limit` to `1..100`, normalize negative offsets to `0`, and implement exact arbitrary offsets even when an offset is not divisible by the limit. Responses include `has_more` and `next_offset`. Compact date-time values use `Asia/Shanghai` and include an explicit `+08:00` offset; date-only project fields stay unchanged. -`list_followups` returns `content_preview` in compact mode. Use `include_raw=true` when the full follow-up text is needed. `get_customer` keeps the customer detail record intact, and its related follow-ups stay compact unless `include_raw_followups=true`. +## Response shapes + +Every tool returns readable keys, never NocoBase field codes. Relations always +resolve to the same `{"id": ..., "name": ...}` object, so the same customer looks +identical whether it appears in a follow-up row, a contact row, or a customer +detail. Keys whose value is empty are omitted — **an absent key means empty, not +unknown**. Pass `include_raw=true` on any of these tools when the original +NocoBase row is genuinely needed; it is then attached under `raw`. + +A `list_followups` row: + +```json +{ + "id": 40, + "followedAt": "2026-07-08T17:00:00+08:00", + "customers": [{"id": 4249, "name": "Zomato(Eternal)"}], + "contacts": [{"id": 8, "name": "张三"}], + "participants": [{"id": 54, "name": "王小明"}], + "projects": [{"id": 41, "name": "EV inverter"}], + "type": "拜访/请客", + "status": "OK", + "location": "佛山", + "content": "full follow-up text, never truncated" +} +``` + +`content` is always the complete follow-up text. `type` and `status` are display +labels; `crm_filter_options` publishes the raw codes that the `followup_type` and +`record_status` filters expect. `creator` appears only when the record submitter +is not also one of the participants, since a self-submitted record would +otherwise name the same person twice. + +`get_customer` returns `{customer, contacts, recent_followups, timezone}`. The +customer carries `id`, `name`, `english_name`, `territorial_name`, `status`, +`source`, `owners`, `managers`, plus the readable detail fields configured under +`detailFields` (`address`, `intro`, `tags`, `followup_interval_days`). Its +embedded contacts are `{id, name, title}` — phone and email are deliberately +withheld here; call `list_contacts` when they are actually needed, or +`include_details=false` there to omit them. + +A customer name rendered as `#4249` means that record has no value in any of its +configured `nameFields`, so only its id could be shown. That is a data-quality +signal, not a name — fix the source record. The server never renders a bare id as +if it were a name. + +`nameFields` (customers, contacts, projects) and `labelFields` (users) are +ordered: the first non-empty field wins. Add every field that can hold a name — +omitting one is how a customer ends up displayed as `#4249`. + +The current NocoBase model has no structured next-step owner, due date, or completion status. The server therefore does not expose action-item, overdue-action, or generated daily/weekly report tools. Tool runtime failures are returned as structured errors: diff --git a/config.example.json b/config.example.json index 602926b..6323ea6 100644 --- a/config.example.json +++ b/config.example.json @@ -1,23 +1,44 @@ { "env": "nb-mcp", "dataSource": "main", + "timezone": "Asia/Shanghai", "users": { "resource": "users", "fields": ["id", "username", "nickname", "email", "f_yh_english_name"], - "matchFields": ["username", "nickname", "email", "f_yh_english_name"] + "matchFields": ["username", "nickname", "email", "f_yh_english_name"], + "labelFields": ["nickname", "f_yh_english_name", "username", "email"] + }, + "teams": { + "resource": "t_team_management", + "idField": "id", + "nameField": "f_team_name", + "managerField": "tdgl_manager", + "memberField": "tdgl_team_members", + "userStatusField": "yh_status", + "formalUserStatusValue": "nkluuumb1r1" }, "projects": { "resource": "t_projects", "idField": "id", "nameField": "f_project_name", + "nameFields": ["f_project_name"], "customerField": "client_ID", + "customerRelationField": "f_projects_client", "inquiryDateField": "xm_project_inquiry_date", "progressField": "f_projects_progress", "applicationsField": "f_projects_applications", "partNumbersField": "f_projects_part_numbers", "periodUsageField": "f_projects_period_usage", "detailsField": "f_projects_details", - "attachmentField": "f_projects_attachments" + "attachmentField": "f_projects_attachments", + "contactField": "f_projects_contacts", + "followupField": "f_projects_followup_records", + "updateField": "f_u28gm9h2uzl", + "updateResource": "t_qxeepjvi39s", + "updateProjectField": "f_k37rbpi4vye", + "updateDateField": "f_6g9fw5oqef9", + "updateContentField": "f_ei16g0ihwwd", + "updateAttachmentField": "f_6pk5pv17i27" }, "collections": { "customers": { @@ -61,9 +82,19 @@ "kh_tags" ], "searchFields": ["f_q2q60q7bqbg", "f_kh_english_name", "f_territorial_name"], + "nameFields": ["f_q2q60q7bqbg", "f_kh_english_name", "f_territorial_name"], + "englishNameField": "f_kh_english_name", + "territorialNameField": "f_territorial_name", "statusField": "kh_progress", "sourceField": "f_225h6n6v654", "ownerField": "kehu_followup_person", + "managerField": "kehu_manage_person", + "detailFields": { + "address": "f_y8iyf2jhana", + "intro": "f_spgoie0arjq", + "tags": "kh_tags", + "followup_interval_days": "f_kyf51uh0ky7" + }, "sort": ["-updatedAt"] }, "contacts": { @@ -89,6 +120,10 @@ "f_xmlxr_projects" ], "searchFields": ["f_2zpg91gtmwp", "khlxr_phone_number", "khlxr_email"], + "nameFields": ["f_2zpg91gtmwp"], + "titleField": "f_tn8zdm2f938", + "phoneField": "khlxr_phone_number", + "emailField": "khlxr_email", "customerField": "kehulianxiren_client", "sort": ["-updatedAt"] }, @@ -117,8 +152,13 @@ "customerField": "gjjl_client", "contactField": "gjjl_client_contacts", "ownerField": "gjjl_followup_person", - "ownerFields": ["gjjl_followup_person", "createdBy"], + "ownerFields": ["gjjl_followup_person"], "dateField": "f_srysmjkwfq1", + "contentField": "f_19iplxz0nds", + "typeField": "gjjl_followup_type", + "statusField": "f_hskl7gjvos9", + "projectField": "f_gjjl_projects", + "locationField": "f_b5c4hfi5og6", "sort": ["-f_srysmjkwfq1", "-createdAt"] } } diff --git a/docs/superpowers/specs/2026-07-24-team-crm-query-design.md b/docs/superpowers/specs/2026-07-24-team-crm-query-design.md new file mode 100644 index 0000000..1cbcb67 --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-team-crm-query-design.md @@ -0,0 +1,408 @@ +# 团队 CRM 查询与项目追踪设计 + +## 目标 + +在保持现有只读 CRM MCP 和受控项目写入兼容的前提下,增加: + +- 基于 CRM 正式团队关系的查询; +- 团队与成员双层客户、跟进和项目统计; +- 长期未跟进客户查询,并明确标注当前 NocoBase 不支持的闭环能力; +- 客户归属、实际记录人、参与人员等角色的明确区分; +- CRM 项目完整跟进时间线; +- 北京时间、完整分页、稳定 JSON 解析和数据质量提示。 + +本设计以 2026-07-24 从目标 NocoBase 读取的集合和字段元数据为依据。元数据盘点未读取或修改业务记录。 + +## 已确认的数据模型 + +### 团队与用户 + +团队集合为 `t_team_management`(团队管理)。 + +| 业务含义 | 字段 | 类型 | 目标 | +| --- | --- | --- | --- | +| 团队名 | `f_team_name` | string | - | +| 团队负责人 | `tdgl_manager` | belongsToMany | `users` | +| 团队成员 | `tdgl_team_members` | belongsToMany | `users` | + +`users` 中存在反向关系: + +- `yh_belong_teams`:所属团队; +- `yh_managed_team`:管理的团队; +- `yh_status`:员工状态。 + +员工状态中已确认: + +| 标签 | 内部值 | +| --- | --- | +| 正式员工 | `nkluuumb1r1` | +| 实习生 | `n7cnn29kzo1` | +| 已离职 | `bpsfm63uc8i` | +| 交接中 | `yv6pkhjxtfz` | +| 临时员工 | `4g6m1y5uy1s` | + +“正式团队成员”定义为: + +1. 用户出现在团队的 `tdgl_team_members` 关系中; +2. 用户的 `yh_status` 等于 `nkluuumb1r1`。 + +负责人可以有多人,不应在 MCP 中强制压缩为单个负责人。 + +系统另有 `departments` 树形部门集合。部门和 CRM 团队是两套不同关系,首版团队查询以 `t_team_management` 为准,不用部门关系替代团队关系。 + +### 客户角色 + +客户集合为 `t_nt3pjadelqi`。 + +| 字段 | 标题 | 建议语义 | +| --- | --- | --- | +| `kehu_manage_person` | 管理人 | 管理或权限相关人员,不代表客户归属 | +| `kehu_followup_person` | 跟进人 | 客户归属人员 | +| `kehu_lookup_people` | 可查看人 | ACL 可见人员,不代表业务协同 | +| `createdBy` | 创建人 | 数据创建者,不代表客户负责人 | + +上述前三个字段均可能包含多人。客户归属以 `kehu_followup_person` 为准。MCP 不应将 +`kehu_manage_person` 或 `kehu_lookup_people` 计为客户归属或协同贡献,也不应把 +`createdBy` 当作客户负责人。 + +由于 `kehu_followup_person` 是多值关系,接口使用复数 `customer_owners`,不能从列表 +顺序推断唯一主负责人。 + +### 跟进角色与分类 + +跟进集合为 `t_07667a5evtr`。 + +| 字段 | 标题 | 建议语义 | +| --- | --- | --- | +| `createdBy` | 创建人 | 跟进记录提交人 | +| `gjjl_followup_person` | 参与跟进人员 | 明确的参与人员 | +| `gjjl_client` | 客户 | 关联客户 | +| `gjjl_client_contacts` | 客户联系人 | 关联联系人 | +| `f_gjjl_projects` | 相关项目 | 关联 CRM 项目 | +| `f_srysmjkwfq1` | 跟进时间 | 业务发生时间 | +| `gjjl_followup_type` | 跟进类型 | 沟通方式 | +| `f_19iplxz0nds` | 跟进内容 | 跟进正文 | +| `f_hskl7gjvos9` | 状态 | 记录状态 | + +已确认的跟进方式: + +- 邮件 +- 电话 +- 视频会议 +- 拜访/请客 +- 审厂/来访 +- 论坛/大会 +- 代理跟进情况代填 + +已确认的跟进状态为“审批中、OK、迟交、无效”。它更接近记录审批或提交质量状态,不能直接解释为下一步事项的完成状态。 + +首版角色输出必须同时保留: + +- `record_creator`:`createdBy`,只表示记录提交人; +- `participants`:`gjjl_followup_person`; +- `customer_owners`:关联客户的 `kehu_followup_person`; +- `customer_management_users`:关联客户的 `kehu_manage_person`,仅作为补充信息; +- `team_managers`:命中团队的 `tdgl_manager`。 + +`createdBy` 不一定是实际跟进人,因此不得用于实际跟进贡献统计。实际跟进贡献只按 +`gjjl_followup_person` 统计;多人参与时每位参与人各获得一次参与贡献,同时团队总跟进 +记录按记录 ID 去重。`record_creator` 仅用于审计和数据质量检查。 + +### CRM 项目 + +CRM 项目集合为 `t_projects`,与现有 MCP 项目写入使用同一集合。 + +已确认关系: + +- 客户:`f_projects_client` / 外键 `client_ID`; +- 联系人:`f_projects_contacts`; +- 跟进记录:`f_projects_followup_records`; +- 项目进展:`f_projects_progress`; +- 项目情况更新:`f_u28gm9h2uzl`; +- 附件:`f_projects_attachments`。 + +项目进展为: + +- 询价阶段 +- 已报价 +- 送样 +- 已定点 +- SOP +- 终止 + +项目情况更新集合为 `t_qxeepjvi39s`,包含: + +- 项目:`f_k37rbpi4vye`; +- 更新日期:`f_6g9fw5oqef9`; +- 更新内容:`f_ei16g0ihwwd`; +- 附件:`f_6pk5pv17i27`; +- 创建人:`createdBy`。 + +当前没有独立的报价、样品、测试或定点事件集合映射。首版只能使用项目阶段和项目情况更新正文展示这些信息,不能可靠统计每类业务事件的数量或状态。 + +### 另一套项目管理模型 + +系统还存在: + +- `market_pm`(项目管理); +- `project_updates`(项目节点)。 + +这套模型有项目负责人、预计完成时间、项目状态,以及节点负责人、节点状态和节点预计完成时间,但没有已确认的客户关系,也没有与 `t_projects` 的关联字段。 + +因此: + +- `t_projects` 是 CRM 项目查询的权威来源; +- `market_pm` / `project_updates` 作为独立的任务型项目模型; +- 在没有稳定关联键之前,两套项目不得按名称自动合并; +- 如后续需要统一展示,返回值必须带 `project_source`,例如 `crm` 或 `management`。 + +## 当前数据模型缺口 + +跟进记录中没有已确认的以下结构化字段: + +- 下一步事项; +- 事项责任人; +- 截止日期; +- 完成状态; +- 完成时间; +- 优先级; +- 风险类型。 + +因此“逾期未完成事项”和“无下一步的重点项目”不能仅凭现有跟进记录准确计算。 + +本项目不得修改 NocoBase 数据模型,因此不新增行动项集合,也不增加跟进字段。以下能力 +明确跳过: + +- 精确查询逾期未完成事项; +- 按行动项完成状态统计; +- 权威判断跟进是否存在下一步; +- 权威判断下一步责任人和截止日期。 + +不从跟进正文推断权威的下一步、风险或逾期状态。 + +报价、样品、测试等完整链路同样需要结构化事件模型。项目阶段只能表示当前状态,不能还原每次状态变化的时间和责任人。 + +## 配置扩展 + +建议在 `config.json` 增加以下可配置映射,默认值采用本次确认字段: + +```json +{ + "timezone": "Asia/Shanghai", + "teams": { + "resource": "t_team_management", + "idField": "id", + "nameField": "f_team_name", + "managerField": "tdgl_manager", + "memberField": "tdgl_team_members", + "userStatusField": "yh_status", + "formalUserStatusValue": "nkluuumb1r1" + }, + "roles": { + "customerOwnerField": "kehu_followup_person", + "customerManagementField": "kehu_manage_person", + "customerViewerField": "kehu_lookup_people", + "followupCreatorField": "createdBy", + "followupParticipantField": "gjjl_followup_person" + }, + "crmProjects": { + "contactField": "f_projects_contacts", + "followupField": "f_projects_followup_records", + "updateField": "f_u28gm9h2uzl", + "updateResource": "t_qxeepjvi39s", + "updateProjectField": "f_k37rbpi4vye", + "updateDateField": "f_6g9fw5oqef9", + "updateContentField": "f_ei16g0ihwwd" + } +} +``` + +不设计或预留会写入 NocoBase 的闭环字段。相关能力在 MCP capability 中明确标为不可用。 + +## MCP 工具设计 + +### 团队发现 + +```text +list_teams(query?, include_members=false, formal_members_only=true) +resolve_team(query) +get_team(team_id, include_members=true, formal_members_only=true) +``` + +团队解析规则与负责人解析一致:无匹配或多匹配返回结构化候选结果,不静默选择。 + +### 查询工具兼容扩展 + +现有工具保留原参数,并增加: + +```text +list_customers(..., team?, team_id?) +list_followups(..., team?, team_id?, project_id?, followup_type?, record_status?) +crm_overview(..., team?, team_id?) +``` + +`team` 和 `team_id` 不得同时提供。团队过滤使用正式成员 ID 集合,客户和跟进分别应用对应角色关系,不能把“团队客户”和“团队实际跟进”混成同一口径。 + +### 团队统计 + +```text +team_crm_overview( + team, + date_from?, + date_to?, + formal_members_only=true, + include_member_breakdown=true +) +``` + +建议返回: + +```json +{ + "team": {}, + "period": {}, + "totals": { + "assigned_customers": 0, + "owned_customers": 0, + "followup_records": 0, + "active_customers": 0, + "active_projects": 0 + }, + "by_member": [], + "by_customer": [], + "by_followup_type": [], + "by_project_stage": [], + "contribution_basis": { + "participant": "gjjl_followup_person" + } +} +``` + +同一条多人参与的跟进: + +- 团队总量只计一次; +- 成员贡献中每位参与人各计一次; +- 返回 `unique_followups` 和 `participation_count`,防止将两种口径混淆。 + +### 项目追溯 + +```text +get_project(project_id, include_contacts=true, include_followups=true) +get_project_timeline(project_id, date_from?, date_to?) +list_projects(customer_id?, team?, stage?, updated_since?) +``` + +时间线事件来源: + +- 项目创建与阶段; +- 项目情况更新; +- 关联跟进记录; +- 联系人; +- 附件元数据。 + +每个事件返回 `event_type`、`occurred_at`、`actors`、`source_resource` 和 `source_id`。 + +### 长期未跟进客户 + +现有模型可以支持: + +```text +list_stale_customers(team?, owner?, inactive_days=30, as_of?) +``` + +该工具仅依据客户最后一次有效跟进时间计算。以下工具不实现: + +- `list_action_items` +- `list_overdue_action_items` + +不得以跟进记录的“迟交”状态代替行动项逾期状态。 + +## 时间、分页与统计规则 + +### 北京时间 + +- 服务内部使用带时区的 `datetime`; +- NocoBase UTC 时间转换到 `Asia/Shanghai`; +- `dateOnly` 保持日期不做时区偏移; +- 输出统一为 ISO 8601,例如 `2026-07-24T15:30:00+08:00`; +- 响应增加 `"timezone": "Asia/Shanghai"`。 + +### 分页 + +保持现有 `limit`、`offset` 参数兼容,但修复任意 offset: + +- 不再直接用 `offset // limit + 1` 近似; +- 读取覆盖目标窗口的底层页并在服务层精确切片; +- 返回 `limit`、`offset`、`total`、`has_more`、`next_offset`; +- 内部全量统计循环读取所有页; +- 设置独立的最大扫描记录数和超时保护,超过时返回明确的截断警告。 + +### 聚合 + +- 团队总量按记录 ID 去重; +- 成员贡献保留参与次数; +- 所有聚合返回统计口径; +- “无效”跟进不计入有效贡献;“审批中、OK、迟交”保留并分别展示; +- 不再使用固定 50 个分组作为完整统计结果。 + +## JSON 与元数据稳定性 + +`nb` 输出解析需要支持 stdout 混入非 JSON 日志的情况: + +1. 继续设置 `NB_CLI_LOG_DISABLED=1` 和 `NB_SKIP_STARTUP_UPDATE=1`; +2. 首先尝试对完整 stdout 执行 `json.loads`; +3. 失败时使用 `json.JSONDecoder.raw_decode` 定位完整顶层 JSON 对象; +4. 仅接受唯一、完整的 JSON 对象; +5. 其余文本作为已脱敏诊断信息,不进入正常 MCP 数据; +6. 无法可靠定位时返回 `nb_invalid_json`,不返回部分业务数据; +7. API Token 必须同时从命令、stdout 和 stderr 中脱敏。 + +## 数据质量提示 + +查询响应增加统一的 `data_quality_warnings`,包括: + +- 客户有多个归属跟进人; +- 跟进没有参与人员,无法归属实际贡献; +- 跟进没有客户或项目; +- 项目更新没有业务日期; +- 项目阶段有变化但没有结构化事件记录; +- 团队成员不是正式员工; +- 统计因扫描上限被截断。 + +## 兼容与实施顺序 + +### 第一阶段:基础稳定性 + +- 北京时间转换; +- 精确分页和全量聚合; +- JSON 日志混入容错; +- 统一响应元数据和测试。 + +### 第二阶段:团队查询 + +- 团队配置、解析和成员发现; +- 客户与跟进增加团队过滤; +- 分离客户归属跟进人、管理人、记录创建人和参与人员; +- 团队整体与成员贡献统计。 + +### 第三阶段:CRM 项目时间线 + +- CRM 项目只读查询; +- 关联客户、联系人、跟进和项目情况更新; +- 长期未跟进客户查询。 + +现有工具名、参数和默认紧凑输出保持兼容。新字段只做增量添加;团队和项目查询工具为新工具。现有 stdio 模式继续只读,HTTP 写入范围仍只限当前项目字段和附件操作。 + +## 已确认业务决策 + +1. 客户归属以 `kehu_followup_person`(跟进人)为准。 +2. `createdBy` 不一定是实际跟进人,只作为记录提交人展示。 +3. 不修改 NocoBase 数据模型;现有模型不支持的闭环能力直接跳过。 + +## 实施时采用的默认口径 + +1. 实际跟进贡献按 `gjjl_followup_person`(参与跟进人员)统计。 +2. 正式成员只包含 `yh_status = nkluuumb1r1`,排除实习、离职、交接中和临时员工。 +3. `t_projects` 作为 CRM 项目权威表;不与 `market_pm` 按名称自动合并。 +4. 团队统计默认排除“无效”跟进;“审批中、OK、迟交”保留并分别展示。 +5. 长期未跟进默认阈值为 30 天,允许调用方覆盖。 diff --git a/nocobase_crm_mcp/config.py b/nocobase_crm_mcp/config.py index 3b7f1c1..0d0555b 100644 --- a/nocobase_crm_mcp/config.py +++ b/nocobase_crm_mcp/config.py @@ -16,6 +16,16 @@ PROJECT_PROGRESS_VALUES = { "终止": "a9t9awhtsfasg3r", } +# Output key -> NocoBase field code, used to render `get_customer` detail with +# human-readable names instead of internal field codes. Entries whose field is +# absent from the configured `fields` list are simply skipped. +CUSTOMER_DETAIL_FIELDS = { + "address": "f_y8iyf2jhana", + "intro": "f_spgoie0arjq", + "tags": "kh_tags", + "followup_interval_days": "f_kyf51uh0ky7", +} + @dataclass(frozen=True) class CustomerConfig: @@ -23,9 +33,14 @@ class CustomerConfig: id_field: str = "id" fields: list[str] | None = None search_fields: list[str] | None = None + name_fields: list[str] | None = None + english_name_field: str = "f_kh_english_name" + territorial_name_field: str = "f_territorial_name" status_field: str = "status" source_field: str = "source" owner_field: str = "owner" + manager_field: str = "kehu_manage_person" + detail_fields: dict[str, str] = field(default_factory=lambda: CUSTOMER_DETAIL_FIELDS.copy()) sort: list[str] | None = None def __post_init__(self) -> None: @@ -37,6 +52,12 @@ class CustomerConfig: ) if self.search_fields is None: object.__setattr__(self, "search_fields", ["name", "code"]) + if self.name_fields is None: + object.__setattr__( + self, + "name_fields", + ["f_q2q60q7bqbg", "f_kh_english_name", "f_territorial_name", "name", "title", "code"], + ) if self.sort is None: object.__setattr__(self, "sort", ["-updatedAt"]) @@ -47,6 +68,10 @@ class ContactConfig: id_field: str = "id" fields: list[str] | None = None search_fields: list[str] | None = None + name_fields: list[str] | None = None + title_field: str = "f_tn8zdm2f938" + phone_field: str = "khlxr_phone_number" + email_field: str = "khlxr_email" customer_field: str = "customer" sort: list[str] | None = None @@ -59,6 +84,8 @@ class ContactConfig: ) if self.search_fields is None: object.__setattr__(self, "search_fields", ["name", "phone", "email"]) + if self.name_fields is None: + object.__setattr__(self, "name_fields", ["f_2zpg91gtmwp", "name", "title"]) if self.sort is None: object.__setattr__(self, "sort", ["-updatedAt"]) @@ -74,6 +101,11 @@ class FollowupConfig: owner_field: str = "owner" owner_fields: list[str] | None = None date_field: str = "followedAt" + content_field: str = "f_19iplxz0nds" + type_field: str = "gjjl_followup_type" + status_field: str = "f_hskl7gjvos9" + project_field: str = "f_gjjl_projects" + location_field: str = "f_b5c4hfi5og6" sort: list[str] | None = None def __post_init__(self) -> None: @@ -107,6 +139,7 @@ class UserConfig: id_field: str = "id" fields: list[str] | None = None match_fields: list[str] | None = None + label_fields: list[str] | None = None def __post_init__(self) -> None: if self.fields is None: @@ -117,6 +150,19 @@ class UserConfig: ) if self.match_fields is None: object.__setattr__(self, "match_fields", ["username", "nickname", "email", "f_yh_english_name"]) + if self.label_fields is None: + object.__setattr__(self, "label_fields", ["nickname", "f_yh_english_name", "username", "email"]) + + +@dataclass(frozen=True) +class TeamConfig: + resource: str = "t_team_management" + id_field: str = "id" + name_field: str = "f_team_name" + manager_field: str = "tdgl_manager" + member_field: str = "tdgl_team_members" + user_status_field: str = "yh_status" + formal_user_status_value: str = "nkluuumb1r1" @dataclass(frozen=True) @@ -124,7 +170,9 @@ class ProjectConfig: resource: str = "t_projects" id_field: str = "id" name_field: str = "f_project_name" + name_fields: list[str] | None = None customer_field: str = "client_ID" + customer_relation_field: str = "f_projects_client" inquiry_date_field: str = "xm_project_inquiry_date" progress_field: str = "f_projects_progress" applications_field: str = "f_projects_applications" @@ -132,17 +180,31 @@ class ProjectConfig: period_usage_field: str = "f_projects_period_usage" details_field: str = "f_projects_details" attachment_field: str = "f_projects_attachments" + contact_field: str = "f_projects_contacts" + followup_field: str = "f_projects_followup_records" + update_field: str = "f_u28gm9h2uzl" + update_resource: str = "t_qxeepjvi39s" + update_project_field: str = "f_k37rbpi4vye" + update_date_field: str = "f_6g9fw5oqef9" + update_content_field: str = "f_ei16g0ihwwd" + update_attachment_field: str = "f_6pk5pv17i27" progress_values: dict[str, str] = field(default_factory=lambda: PROJECT_PROGRESS_VALUES.copy()) + def __post_init__(self) -> None: + if self.name_fields is None: + object.__setattr__(self, "name_fields", [self.name_field, "name", "title"]) + @dataclass(frozen=True) class CrmConfig: env_name: str = "nb-mcp" data_source: str = "main" + timezone: str = "Asia/Shanghai" customer: CustomerConfig = CustomerConfig() contact: ContactConfig = ContactConfig() followup: FollowupConfig = FollowupConfig() user: UserConfig = UserConfig() + team: TeamConfig = TeamConfig() project: ProjectConfig = ProjectConfig() @@ -162,6 +224,14 @@ def _string_list(value: Any) -> list[str] | None: return [str(item) for item in value] +def _string_map(value: Any) -> dict[str, str] | None: + if value is None: + return None + if not isinstance(value, dict): + raise ValueError("Expected a JSON object mapping strings to strings") + return {str(key): str(item) for key, item in value.items()} + + def _pick(data: dict[str, Any], *names: str) -> Any: for name in names: if name in data: @@ -179,9 +249,18 @@ def _customer_config(data: dict[str, Any] | None) -> CustomerConfig: id_field=str(_pick(data, "idField", "id_field") or base.id_field), fields=_string_list(_pick(data, "fields")) or base.fields, search_fields=_string_list(_pick(data, "searchFields", "search_fields")) or base.search_fields, + name_fields=_string_list(_pick(data, "nameFields", "name_fields")) or base.name_fields, + english_name_field=str( + _pick(data, "englishNameField", "english_name_field") or base.english_name_field + ), + territorial_name_field=str( + _pick(data, "territorialNameField", "territorial_name_field") or base.territorial_name_field + ), status_field=str(_pick(data, "statusField", "status_field") or base.status_field), source_field=str(_pick(data, "sourceField", "source_field") or base.source_field), owner_field=str(_pick(data, "ownerField", "owner_field") or base.owner_field), + manager_field=str(_pick(data, "managerField", "manager_field") or base.manager_field), + detail_fields=_string_map(_pick(data, "detailFields", "detail_fields")) or base.detail_fields, sort=_string_list(_pick(data, "sort")) or base.sort, ) @@ -196,6 +275,10 @@ def _contact_config(data: dict[str, Any] | None) -> ContactConfig: id_field=str(_pick(data, "idField", "id_field") or base.id_field), fields=_string_list(_pick(data, "fields")) or base.fields, search_fields=_string_list(_pick(data, "searchFields", "search_fields")) or base.search_fields, + name_fields=_string_list(_pick(data, "nameFields", "name_fields")) or base.name_fields, + title_field=str(_pick(data, "titleField", "title_field") or base.title_field), + phone_field=str(_pick(data, "phoneField", "phone_field") or base.phone_field), + email_field=str(_pick(data, "emailField", "email_field") or base.email_field), customer_field=str(_pick(data, "customerField", "customer_field") or base.customer_field), sort=_string_list(_pick(data, "sort")) or base.sort, ) @@ -218,6 +301,11 @@ def _followup_config(data: dict[str, Any] | None) -> FollowupConfig: owner_field=owner_field, owner_fields=owner_fields, date_field=str(_pick(data, "dateField", "date_field") or base.date_field), + content_field=str(_pick(data, "contentField", "content_field") or base.content_field), + type_field=str(_pick(data, "typeField", "type_field") or base.type_field), + status_field=str(_pick(data, "statusField", "status_field") or base.status_field), + project_field=str(_pick(data, "projectField", "project_field") or base.project_field), + location_field=str(_pick(data, "locationField", "location_field") or base.location_field), sort=_string_list(_pick(data, "sort")) or base.sort, ) @@ -232,6 +320,25 @@ def _user_config(data: dict[str, Any] | None) -> UserConfig: id_field=str(_pick(data, "idField", "id_field") or base.id_field), fields=_string_list(_pick(data, "fields")) or base.fields, match_fields=_string_list(_pick(data, "matchFields", "match_fields")) or base.match_fields, + label_fields=_string_list(_pick(data, "labelFields", "label_fields")) or base.label_fields, + ) + + +def _team_config(data: dict[str, Any] | None) -> TeamConfig: + if not data: + return TeamConfig() + base = TeamConfig() + return replace( + base, + resource=str(_pick(data, "resource") or base.resource), + id_field=str(_pick(data, "idField", "id_field") or base.id_field), + name_field=str(_pick(data, "nameField", "name_field") or base.name_field), + manager_field=str(_pick(data, "managerField", "manager_field") or base.manager_field), + member_field=str(_pick(data, "memberField", "member_field") or base.member_field), + user_status_field=str(_pick(data, "userStatusField", "user_status_field") or base.user_status_field), + formal_user_status_value=str( + _pick(data, "formalUserStatusValue", "formal_user_status_value") or base.formal_user_status_value + ), ) @@ -244,7 +351,12 @@ def _project_config(data: dict[str, Any] | None) -> ProjectConfig: resource=str(_pick(data, "resource") or base.resource), id_field=str(_pick(data, "idField", "id_field") or base.id_field), name_field=str(_pick(data, "nameField", "name_field") or base.name_field), + # Left as None when unset so __post_init__ re-derives it from the configured name_field. + name_fields=_string_list(_pick(data, "nameFields", "name_fields")), customer_field=str(_pick(data, "customerField", "customer_field") or base.customer_field), + customer_relation_field=str( + _pick(data, "customerRelationField", "customer_relation_field") or base.customer_relation_field + ), inquiry_date_field=str(_pick(data, "inquiryDateField", "inquiry_date_field") or base.inquiry_date_field), progress_field=str(_pick(data, "progressField", "progress_field") or base.progress_field), applications_field=str(_pick(data, "applicationsField", "applications_field") or base.applications_field), @@ -252,6 +364,20 @@ def _project_config(data: dict[str, Any] | None) -> ProjectConfig: period_usage_field=str(_pick(data, "periodUsageField", "period_usage_field") or base.period_usage_field), details_field=str(_pick(data, "detailsField", "details_field") or base.details_field), attachment_field=str(_pick(data, "attachmentField", "attachment_field") or base.attachment_field), + contact_field=str(_pick(data, "contactField", "contact_field") or base.contact_field), + followup_field=str(_pick(data, "followupField", "followup_field") or base.followup_field), + update_field=str(_pick(data, "updateField", "update_field") or base.update_field), + update_resource=str(_pick(data, "updateResource", "update_resource") or base.update_resource), + update_project_field=str( + _pick(data, "updateProjectField", "update_project_field") or base.update_project_field + ), + update_date_field=str(_pick(data, "updateDateField", "update_date_field") or base.update_date_field), + update_content_field=str( + _pick(data, "updateContentField", "update_content_field") or base.update_content_field + ), + update_attachment_field=str( + _pick(data, "updateAttachmentField", "update_attachment_field") or base.update_attachment_field + ), ) @@ -267,13 +393,16 @@ def load_config(path: str | Path | None = None) -> CrmConfig: env_name = os.environ.get("NOCOBASE_MCP_ENV") or raw.get("env") or raw.get("envName") or "nb-mcp" data_source = os.environ.get("NOCOBASE_MCP_DATA_SOURCE") or raw.get("dataSource") or raw.get("data_source") or "main" + timezone = os.environ.get("NOCOBASE_MCP_TIMEZONE") or raw.get("timezone") or "Asia/Shanghai" return CrmConfig( env_name=str(env_name), data_source=str(data_source), + timezone=str(timezone), customer=_customer_config(collections.get("customers")), contact=_contact_config(collections.get("contacts")), followup=_followup_config(collections.get("followups")), user=_user_config(raw.get("users") or raw.get("user")), + team=_team_config(raw.get("teams") or raw.get("team")), project=_project_config(raw.get("projects")), ) diff --git a/nocobase_crm_mcp/crm.py b/nocobase_crm_mcp/crm.py index fe27485..db7324b 100644 --- a/nocobase_crm_mcp/crm.py +++ b/nocobase_crm_mcp/crm.py @@ -1,6 +1,9 @@ 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 ( @@ -8,7 +11,7 @@ from .filters import ( build_date_range_condition, build_eq_condition, build_relation_id_condition, - build_relation_id_or_condition, + build_relation_id_in_condition, build_search_filter, ) from .nb_client import NbClient, NbCommandError @@ -16,7 +19,11 @@ from .nb_client import NbClient, NbCommandError DEFAULT_PAGE_SIZE = 20 MAX_PAGE_SIZE = 100 -CONTENT_PREVIEW_CHARS = 500 +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": "未开始", @@ -25,11 +32,40 @@ CUSTOMER_STATUS_LABELS = { "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") +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: @@ -37,6 +73,61 @@ class CrmService: 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 = { @@ -46,6 +137,9 @@ class CrmService: "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} @@ -59,6 +153,8 @@ class CrmService: "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: @@ -80,6 +176,7 @@ class CrmService: "resource": self.config.user.resource, "fields": self.config.user.fields, "matchFields": self.config.user.match_fields, + "labelFields": self.config.user.label_fields, }, "collections": { "customers": { @@ -89,6 +186,20 @@ class CrmService: 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"]) @@ -104,6 +215,14 @@ class CrmService: 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, @@ -129,6 +248,16 @@ class CrmService: "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) @@ -155,7 +284,7 @@ class CrmService: 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_relation_id_condition(self.config.followup.owner_field, owner_id), build_date_range_condition(self.config.followup.date_field, date_from, date_to), ] ) @@ -189,7 +318,7 @@ class CrmService: self.client.list_records( self.config.followup.resource, filter=followup_filter, - fields=self.config.followup.fields, + fields=self._followup_compact_fields(), sort=self.config.followup.sort, page=1, page_size=10, @@ -223,10 +352,198 @@ class CrmService: {}, ) ), + # 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: @@ -235,11 +552,72 @@ class CrmService: 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, @@ -249,24 +627,28 @@ class CrmService: 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), ] ) - response = self.client.list_records( + 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, - 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) + result["timezone"] = self.config.timezone return result def get_customer( @@ -275,25 +657,343 @@ class CrmService: include_contacts: bool = True, include_recent_followups: bool = True, followup_limit: int = 10, - include_raw_followups: bool = False, + include_raw: bool = False, ) -> dict[str, Any]: - customer = self._single( + 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=self.config.customer.fields, + fields=fields, ) ) - result: dict[str, Any] = {"customer": customer} + 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: - result["contacts"] = self.list_contacts(customer_id=customer_id, limit=50, offset=0)["results"] + # 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_followups, + 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( @@ -302,6 +1002,7 @@ class CrmService: 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) @@ -311,16 +1012,22 @@ class CrmService: build_search_filter(query, self.config.contact.search_fields), ] ) - response = self.client.list_records( + 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(), + fields=( + self.config.contact.fields + if include_raw + else self._contact_compact_fields(include_details=include_details) + ), 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) + 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( @@ -328,6 +1035,10 @@ class CrmService: 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, @@ -338,31 +1049,159 @@ class CrmService: 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_or_condition(self.config.followup.owner_fields, owner_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), ] ) - response = self.client.list_records( + 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, - page=_page_from_offset(limit, offset), + ) + 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, ) - result = _paginated_response(response, limit, offset) - result["results"] = self._compact_followups(result["results"], include_raw=include_raw) - return result + 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]] = {} @@ -378,6 +1217,141 @@ class CrmService: 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: @@ -449,22 +1423,31 @@ class CrmService: 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) + 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, - display_key="display", ) def _decorate_source_counts(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: @@ -478,111 +1461,131 @@ class CrmService: 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"]) + 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 - 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, + 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, [ - 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", + 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], *, 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"]) + 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": 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"), + "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_raw: bool = False) -> list[dict[str, Any]]: - return [self._compact_contact(row, include_raw=include_raw) for row in rows] + 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_compact_fields(self) -> list[str]: - return _compact_field_list( - self.config.contact.fields, + 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, [ - self.config.contact.id_field, - "f_2zpg91gtmwp", - "name", - *(self.config.contact.search_fields or []), - "khlxr_phone_number", - "phone", - "khlxr_email", - "email", - "f_tn8zdm2f938", - "title", + contact.id_field, + *self._contact_name_candidates(), + contact.title_field, "position", - self.config.contact.customer_field, - "kehulianxiren_client", - "customer", - "client", + *([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_raw: bool = False) -> dict[str, Any]: + 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(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"), + "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 @@ -590,63 +1593,80 @@ class CrmService: 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]: - return _compact_field_list( - self.config.followup.fields, + followup = self.config.followup + scalars = _compact_field_list( + 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", + 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) - content = summary["content"] + participants = summary["participants"] + followup_type = row.get(followup.type_field) + record_status = row.get(followup.status_field) 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"), + "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]: - 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"] + 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, content_fields), + "content": _first_text(row, self._followup_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), + "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 @@ -719,18 +1739,55 @@ def _total(response: Mapping[str, Any], fallback: int) -> int: 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 _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": next_offset < total, + "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) @@ -747,22 +1804,43 @@ def _decorate_dimension_counts( *, 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) + 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 - 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 _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: @@ -805,53 +1883,110 @@ def _first_text(row: Mapping[str, Any], fields: list[str]) -> str | None: return None -def _text_preview(value: str | None, max_chars: int) -> str | 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 None - if len(value) <= max_chars: - return value - return value[: max(0, max_chars - 3)].rstrip() + "..." + return True + if isinstance(value, str): + return value.strip() == "" + if isinstance(value, (list, tuple, dict, set)): + return len(value) == 0 + return False -def _relation_labels(row: Mapping[str, Any], fields: list[str], label_keys: tuple[str, ...]) -> list[str]: - labels: list[str] = [] +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): - _append_relation_labels(labels, row.get(field), label_keys) - return labels + 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 _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 +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 _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 _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 _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_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]: diff --git a/nocobase_crm_mcp/filters.py b/nocobase_crm_mcp/filters.py index b556eff..77122ec 100644 --- a/nocobase_crm_mcp/filters.py +++ b/nocobase_crm_mcp/filters.py @@ -40,6 +40,24 @@ def build_relation_id_or_condition(fields: Iterable[str | Sequence[str]] | None, return {"$or": conditions} +def build_relation_id_in_condition( + field: str | Sequence[str] | None, + values: Iterable[Any] | None, +) -> Filter | None: + if not field: + return None + normalized_values = [value for value in values or [] if value is not None and value != ""] + if not normalized_values: + return None + path = _relation_path(field) + if not path: + return None + current: Any = {"$in": normalized_values} + for part in reversed(path): + current = {part: current} + return current + + def build_date_range_condition(field: str | None, date_from: str | None, date_to: str | None) -> Filter | None: if not field: return None diff --git a/nocobase_crm_mcp/nb_client.py b/nocobase_crm_mcp/nb_client.py index 7ce5e4e..a6b1284 100644 --- a/nocobase_crm_mcp/nb_client.py +++ b/nocobase_crm_mcp/nb_client.py @@ -232,15 +232,51 @@ class NbClient: error_type = NbAuthenticationError if self._is_authentication_failure(result) else NbCommandError raise error_type(self._format_error(command, result)) try: - parsed = json.loads(result.stdout) + parsed = self._parse_json_object(result.stdout) except json.JSONDecodeError as error: raise NbCommandError( f"nb returned invalid JSON: {error.msg}\nstdout={self._redact(result.stdout.strip())}" ) from error - if not isinstance(parsed, dict): - raise NbCommandError("nb returned JSON that is not an object") return parsed + @staticmethod + def _parse_json_object(output: str) -> dict[str, Any]: + stripped = output.strip() + try: + parsed = json.loads(stripped) + except json.JSONDecodeError: + parsed = None + if parsed is not None: + if not isinstance(parsed, dict): + raise NbCommandError("nb returned JSON that is not an object") + return parsed + + decoder = json.JSONDecoder() + candidates: list[tuple[int, int, dict[str, Any]]] = [] + for start, character in enumerate(output): + if character != "{": + continue + try: + value, end = decoder.raw_decode(output, start) + except json.JSONDecodeError: + continue + if isinstance(value, dict): + candidates.append((start, end, value)) + + outer_candidates = [ + candidate + for candidate in candidates + if not any( + other_start <= candidate[0] + and other_end >= candidate[1] + and (other_start, other_end) != (candidate[0], candidate[1]) + for other_start, other_end, _ in candidates + ) + ] + if len(outer_candidates) != 1: + raise json.JSONDecodeError("could not locate one complete JSON object", output, 0) + return outer_candidates[0][2] + def _run_raw( self, args: list[str], diff --git a/nocobase_crm_mcp/server.py b/nocobase_crm_mcp/server.py index 0e67f1c..3775020 100644 --- a/nocobase_crm_mcp/server.py +++ b/nocobase_crm_mcp/server.py @@ -210,7 +210,7 @@ def create_server( ] = None, owner: Annotated[ str | None, - Field(description="Optional owner name, username, email, or English name to filter customers and follow-ups."), + Field(description="Optional user to filter customer ownership and follow-up participation."), ] = None, ) -> dict[str, Any]: """Return CRM totals, common customer distributions, and recent follow-ups.""" @@ -233,6 +233,73 @@ def create_server( """Return candidate NocoBase users for an owner filter before querying CRM data.""" return _safe_tool_result(lambda: service.resolve_owner(query)) + @server.tool() + def list_teams( + query: Annotated[str | None, Field(description="Optional team name search.")] = None, + include_members: Annotated[bool, Field(description="Include resolved team members.")] = False, + formal_members_only: Annotated[ + bool, + Field(description="When including members, keep only formal employees."), + ] = True, + limit: Annotated[int, Field(description="Maximum teams to return.")] = 20, + offset: Annotated[int, Field(description="Zero-based result offset.")] = 0, + ) -> dict[str, Any]: + """List configured CRM teams and optionally their formal members.""" + return _safe_tool_result( + lambda: service.list_teams( + query, + include_members=include_members, + formal_members_only=formal_members_only, + limit=limit, + offset=offset, + ) + ) + + @server.tool() + def resolve_team( + query: Annotated[str, Field(description="Team name to resolve.")], + ) -> dict[str, Any]: + """Resolve a team name before using it as a CRM filter.""" + return _safe_tool_result(lambda: service.resolve_team(query)) + + @server.tool() + def get_team( + team_id: Annotated[Any, Field(description="NocoBase team ID.")], + include_members: Annotated[bool, Field(description="Include team members.")] = True, + formal_members_only: Annotated[ + bool, + Field(description="Return only formal employees when members are included."), + ] = True, + ) -> dict[str, Any]: + """Get one CRM team, its managers, and optionally its members.""" + return _safe_tool_result( + lambda: service.get_team( + team_id, + include_members=include_members, + formal_members_only=formal_members_only, + ) + ) + + @server.tool() + def team_crm_overview( + team: Annotated[str, Field(description="Exact or uniquely matching CRM team name.")], + date_from: Annotated[str | None, Field(description="Optional lower follow-up date bound.")] = None, + date_to: Annotated[str | None, Field(description="Optional upper follow-up date bound.")] = None, + include_member_breakdown: Annotated[ + bool, + Field(description="Include per-member customer ownership and follow-up contribution."), + ] = True, + ) -> dict[str, Any]: + """Return team totals and member contributions using formal CRM team membership.""" + return _safe_tool_result( + lambda: service.team_crm_overview( + team, + date_from=date_from, + date_to=date_to, + include_member_breakdown=include_member_breakdown, + ) + ) + if os.environ.get(ENABLE_CONFIG_TOOLS_ENV, "").strip() == "1": @server.tool() @@ -245,17 +312,24 @@ def create_server( query: Annotated[str | None, Field(description="Optional customer keyword search.")] = None, status: Annotated[str | None, Field(description="Optional exact customer status.")] = None, owner: Annotated[str | None, Field(description="Optional customer owner name, username, email, or English name.")] = None, + team: Annotated[str | None, Field(description="Optional CRM team name.")] = None, source: Annotated[str | None, Field(description="Optional exact customer source.")] = None, limit: Annotated[int, Field(description="Maximum rows to return. Defaults to 20.")] = 20, offset: Annotated[int, Field(description="Zero-based result offset. Defaults to 0.")] = 0, include_raw: Annotated[bool, Field(description="When true, include the original NocoBase row under raw. Defaults to false.")] = False, ) -> dict[str, Any]: - """List CRM customers with compact rows and pagination metadata.""" + """List CRM customers with compact rows and pagination metadata. + + Rows are {id, name, english_name, territorial_name, status, source, + owners: [{id, name}], managers, createdAt, updatedAt}. Empty values are + omitted. A name shown as "#123" means that customer has no name recorded. + """ return _safe_tool_result( lambda: service.list_customers( query=query, status=status, owner=owner, + team=team, source=source, limit=limit, offset=offset, @@ -269,19 +343,27 @@ def create_server( include_contacts: Annotated[bool, Field(description="Include related contacts.")] = True, include_recent_followups: Annotated[bool, Field(description="Include recent related follow-ups.")] = True, followup_limit: Annotated[int, Field(description="Maximum related follow-ups to return.")] = 10, - include_raw_followups: Annotated[ + include_raw: Annotated[ bool, - Field(description="When true, include original NocoBase follow-up rows under raw. Defaults to false."), + Field(description="When true, include original NocoBase rows under raw. Defaults to false."), ] = False, ) -> dict[str, Any]: - """Get one customer and optionally include contacts and recent follow-ups.""" + """Get one customer with its contacts and recent follow-ups. + + Returns {customer, contacts, recent_followups, timezone}. The customer is + {id, name, english_name, territorial_name, status, source, owners, managers, + address, intro, tags, ...} using readable keys, never NocoBase field codes. + Contacts are {id, name, title} without phone or email -- call list_contacts + for those. Follow-ups use the same row shape as list_followups. Empty values + are omitted, so a missing key means empty rather than unknown. + """ return _safe_tool_result( lambda: service.get_customer( customer_id, include_contacts=include_contacts, include_recent_followups=include_recent_followups, followup_limit=followup_limit, - include_raw_followups=include_raw_followups, + include_raw=include_raw, ) ) @@ -291,15 +373,25 @@ def create_server( query: Annotated[str | None, Field(description="Optional contact keyword search.")] = None, limit: Annotated[int, Field(description="Maximum rows to return. Defaults to 20.")] = 20, offset: Annotated[int, Field(description="Zero-based result offset. Defaults to 0.")] = 0, + include_details: Annotated[ + bool, + Field(description="Include contact phone and email. Defaults to true."), + ] = True, include_raw: Annotated[bool, Field(description="When true, include the original NocoBase row under raw. Defaults to false.")] = False, ) -> dict[str, Any]: - """List CRM contacts as compact rows, optionally filtered by customer.""" + """List CRM contacts as compact rows, optionally filtered by customer. + + Rows are {id, name, title, phone, email, customers: [{id, name}], + createdAt, updatedAt}. Set include_details=false to omit phone and email. + Empty values are omitted. + """ return _safe_tool_result( lambda: service.list_contacts( customer_id=customer_id, query=query, limit=limit, offset=offset, + include_details=include_details, include_raw=include_raw, ) ) @@ -308,19 +400,35 @@ def create_server( def list_followups( customer_id: Annotated[Any | None, Field(description="Optional customer primary key for relation filtering.")] = None, contact_id: Annotated[Any | None, Field(description="Optional contact primary key for relation filtering.")] = None, - owner: Annotated[str | None, Field(description="Optional follow-up owner name, username, email, or English name.")] = None, + owner: Annotated[str | None, Field(description="Optional follow-up participant name, username, email, or English name.")] = None, + team: Annotated[str | None, Field(description="Optional CRM team name.")] = None, + project_id: Annotated[Any | None, Field(description="Optional CRM project ID.")] = None, + followup_type: Annotated[str | None, Field(description="Optional exact follow-up type value.")] = None, + record_status: Annotated[str | None, Field(description="Optional exact follow-up record status value.")] = None, date_from: Annotated[str | None, Field(description="Optional lower follow-up date bound.")] = None, date_to: Annotated[str | None, Field(description="Optional upper follow-up date bound.")] = None, limit: Annotated[int, Field(description="Maximum rows to return. Defaults to 20.")] = 20, offset: Annotated[int, Field(description="Zero-based result offset. Defaults to 0.")] = 0, include_raw: Annotated[bool, Field(description="When true, include the original NocoBase row under raw. Defaults to false.")] = False, ) -> dict[str, Any]: - """List customer follow-up records as compact rows with pagination metadata.""" + """List customer follow-up records as compact rows with pagination metadata. + + Rows are {id, followedAt, customers: [{id, name}], contacts, participants, + projects, type, status, location, content}. Content is the full follow-up + text, never truncated. type and status are the display labels; use + crm_filter_options for the codes the followup_type and record_status + filters expect. creator appears only when the submitter is not also a + participant. Empty values are omitted. + """ return _safe_tool_result( lambda: service.list_followups( customer_id=customer_id, contact_id=contact_id, owner=owner, + team=team, + project_id=project_id, + followup_type=followup_type, + record_status=record_status, date_from=date_from, date_to=date_to, limit=limit, @@ -329,6 +437,76 @@ def create_server( ) ) + @server.tool() + def list_projects( + customer_id: Annotated[Any | None, Field(description="Optional customer ID.")] = None, + team: Annotated[str | None, Field(description="Optional CRM team name.")] = None, + stage: Annotated[str | None, Field(description="Optional project stage label or configured value.")] = None, + limit: Annotated[int, Field(description="Maximum projects to return.")] = 20, + offset: Annotated[int, Field(description="Zero-based result offset.")] = 0, + include_raw: Annotated[bool, Field(description="Include original NocoBase project rows.")] = False, + ) -> dict[str, Any]: + """List CRM projects linked to customers, teams, and project stages.""" + return _safe_tool_result( + lambda: service.list_projects( + customer_id=customer_id, + team=team, + stage=stage, + limit=limit, + offset=offset, + include_raw=include_raw, + ) + ) + + @server.tool() + def get_project( + project_id: Annotated[Any, Field(description="NocoBase CRM project ID.")], + include_contacts: Annotated[bool, Field(description="Include related contacts.")] = True, + include_followups: Annotated[bool, Field(description="Include related follow-ups.")] = True, + include_updates: Annotated[bool, Field(description="Include project update records.")] = True, + ) -> dict[str, Any]: + """Get a CRM project with its existing NocoBase relations.""" + return _safe_tool_result( + lambda: service.get_project( + project_id, + include_contacts=include_contacts, + include_followups=include_followups, + include_updates=include_updates, + ) + ) + + @server.tool() + def get_project_timeline( + project_id: Annotated[Any, Field(description="NocoBase CRM project ID.")], + date_from: Annotated[str | None, Field(description="Optional lower event date bound.")] = None, + date_to: Annotated[str | None, Field(description="Optional upper event date bound.")] = None, + ) -> dict[str, Any]: + """Return a chronological CRM project timeline from follow-ups and project updates.""" + return _safe_tool_result( + lambda: service.get_project_timeline(project_id, date_from=date_from, date_to=date_to) + ) + + @server.tool() + def list_stale_customers( + team: Annotated[str | None, Field(description="Optional CRM team name.")] = None, + owner: Annotated[str | None, Field(description="Optional customer owner.")] = None, + inactive_days: Annotated[int, Field(description="Days without a valid follow-up.")] = 30, + as_of: Annotated[str | None, Field(description="Reference date in YYYY-MM-DD format.")] = None, + limit: Annotated[int, Field(description="Maximum customers to return.")] = 20, + offset: Annotated[int, Field(description="Zero-based result offset.")] = 0, + ) -> dict[str, Any]: + """List customers whose last valid follow-up is older than the configured threshold.""" + return _safe_tool_result( + lambda: service.list_stale_customers( + team=team, + owner=owner, + inactive_days=inactive_days, + as_of=as_of, + limit=limit, + offset=offset, + ) + ) + if not readonly: @server.tool() diff --git a/tests/test_config_filters.py b/tests/test_config_filters.py index 3d56e62..734d70e 100644 --- a/tests/test_config_filters.py +++ b/tests/test_config_filters.py @@ -10,6 +10,7 @@ from nocobase_crm_mcp.filters import ( build_and_filter, build_date_range_condition, build_relation_id_condition, + build_relation_id_in_condition, build_relation_id_or_condition, build_search_filter, ) @@ -28,6 +29,64 @@ class ConfigTests(unittest.TestCase): self.assertIn("name", config.customer.search_fields) self.assertEqual(config.contact.customer_field, "customer") self.assertEqual(config.followup.customer_field, "customer") + self.assertEqual(config.timezone, "Asia/Shanghai") + self.assertEqual(config.team.resource, "t_team_management") + self.assertEqual(config.team.formal_user_status_value, "nkluuumb1r1") + # Label resolution order: the first non-empty field wins. f_kh_english_name + # must stay in the list -- leaving it out is what made customer 4249 render + # as its own id. + self.assertIn("f_kh_english_name", config.customer.name_fields) + self.assertIn("f_territorial_name", config.customer.name_fields) + self.assertEqual(config.customer.manager_field, "kehu_manage_person") + self.assertEqual(config.contact.name_fields[0], "f_2zpg91gtmwp") + self.assertEqual(config.user.label_fields[0], "nickname") + self.assertNotIn("id", config.user.label_fields) + self.assertEqual(config.followup.content_field, "f_19iplxz0nds") + self.assertEqual(config.followup.location_field, "f_b5c4hfi5og6") + self.assertEqual(config.project.name_fields[0], "f_project_name") + self.assertEqual(config.project.customer_relation_field, "f_projects_client") + + def test_load_config_reads_label_and_detail_field_overrides(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / "crm.json" + config_path.write_text( + json.dumps( + { + "users": {"labelFields": ["email"]}, + "collections": { + "customers": { + "nameFields": ["company_name"], + "managerField": "account_manager", + "detailFields": {"address": "billing_address"}, + }, + "contacts": {"nameFields": ["full_name"], "phoneField": "mobile"}, + "followups": {"contentField": "notes", "locationField": "venue"}, + }, + } + ), + encoding="utf-8", + ) + config = load_config(config_path) + + self.assertEqual(config.customer.name_fields, ["company_name"]) + self.assertEqual(config.customer.manager_field, "account_manager") + self.assertEqual(config.customer.detail_fields, {"address": "billing_address"}) + self.assertEqual(config.contact.name_fields, ["full_name"]) + self.assertEqual(config.contact.phone_field, "mobile") + self.assertEqual(config.followup.content_field, "notes") + self.assertEqual(config.followup.location_field, "venue") + self.assertEqual(config.user.label_fields, ["email"]) + + def test_project_name_fields_follow_a_renamed_name_field(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / "crm.json" + config_path.write_text( + json.dumps({"projects": {"nameField": "project_title"}}), + encoding="utf-8", + ) + config = load_config(config_path) + + self.assertEqual(config.project.name_fields[0], "project_title") def test_load_config_merges_file_and_environment_overrides(self) -> None: with tempfile.TemporaryDirectory() as tmp: @@ -162,6 +221,22 @@ class FilterBuilderTests(unittest.TestCase): }, ) + def test_build_relation_id_in_condition_supports_team_members(self) -> None: + self.assertEqual( + build_relation_id_in_condition("kehu_followup_person", [3, 7]), + {"kehu_followup_person": {"id": {"$in": [3, 7]}}}, + ) + self.assertEqual( + build_relation_id_in_condition(["f_projects_client", "kehu_followup_person", "id"], [3, 7]), + { + "f_projects_client": { + "kehu_followup_person": { + "id": {"$in": [3, 7]}, + } + } + }, + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_crm_service.py b/tests/test_crm_service.py index 96824bf..28d431a 100644 --- a/tests/test_crm_service.py +++ b/tests/test_crm_service.py @@ -1,10 +1,41 @@ import unittest import json +import re import tempfile from pathlib import Path from nocobase_crm_mcp.config import load_config -from nocobase_crm_mcp.crm import CrmService +from nocobase_crm_mcp.crm import PROJECTION_FALLBACK_WARNING, CrmService, _prune +from nocobase_crm_mcp.nb_client import NbCommandError + + +def project_row(row, fields): + """Shape a canned row the way NocoBase shapes a projected response. + + The fake client used to ignore `fields` entirely, which meant a missing + relation projection was invisible to the tests -- the canned rows already + looked projected. Honouring `fields` here is what makes assertions like + "phone never reaches the response" mean anything. + """ + if not fields: + return dict(row) + projected = {} + for path in fields: + head, _, leaf = path.partition(".") + if head not in row: + continue + value = row[head] + if not leaf: + projected[head] = value + continue + if isinstance(value, list): + nested = projected.setdefault(head, [{} for _ in value]) + for slot, item in zip(nested, value): + if isinstance(item, dict) and leaf in item: + slot[leaf] = item[leaf] + elif isinstance(value, dict) and leaf in value: + projected.setdefault(head, {})[leaf] = value[leaf] + return projected class FakeNbClient: @@ -21,6 +52,12 @@ class FakeNbClient: def list_records(self, resource, **kwargs): self.list_calls.append((resource, kwargs)) + response = self._rows(resource) + fields = kwargs.get("fields") + response["data"] = [project_row(row, fields) for row in response["data"]] + return response + + def _rows(self, resource): if resource == "users": return { "data": [ @@ -40,9 +77,13 @@ class FakeNbClient: { "id": 2, "name": "Alice Contact", + "f_2zpg91gtmwp": "Alice Contact", "phone": "+1 555 0100", + "khlxr_phone_number": "+1 555 0100", "email": "alice.contact@example.com", + "khlxr_email": "alice.contact@example.com", "title": "Buyer", + "f_tn8zdm2f938": "Buyer", "customer": [{"id": 1, "name": "Acme"}], "kehulianxiren_client": [{"id": 1, "f_q2q60q7bqbg": "Acme China"}], "createdAt": "2026-07-01T00:00:00.000Z", @@ -52,14 +93,21 @@ class FakeNbClient: "meta": {"count": 1}, } if resource == "followups": + # Carries both the default-config key names and the production field + # codes so the same fixture serves tests running under either config. return { "data": [ { "id": 1, + "followedAt": "2026-07-08T09:00:00.000Z", "f_srysmjkwfq1": "2026-07-08T09:00:00.000Z", + "content": "Called customer about sample status and confirmed next procurement meeting.", "f_19iplxz0nds": "Called customer about sample status and confirmed next procurement meeting.", + "customer": [{"id": 7, "f_q2q60q7bqbg": "Acme China"}], "gjjl_client": [{"id": 7, "f_q2q60q7bqbg": "Acme China"}], + "contact": [{"id": 8, "f_2zpg91gtmwp": "Alice Contact"}], "gjjl_client_contacts": [{"id": 8, "f_2zpg91gtmwp": "Alice Contact"}], + "owner": [{"id": 54, "nickname": "Alice Wang"}], "gjjl_followup_person": [{"id": 54, "nickname": "Alice Wang"}], "createdBy": {"id": 54, "nickname": "Alice Wang"}, "createdAt": "2026-07-08T09:10:00.000Z", @@ -94,7 +142,19 @@ class FakeNbClient: def get_record(self, resource, filter_by_tk, **kwargs): self.get_calls.append((resource, filter_by_tk, kwargs)) - return {"data": {"id": filter_by_tk, "name": "Acme", "f_q2q60q7bqbg": "Acme China"}} + row = { + "id": filter_by_tk, + "name": "Acme", + "f_q2q60q7bqbg": "Acme China", + "f_kh_english_name": "Acme", + "kh_progress": "kh_p_inprogress", + "f_y8iyf2jhana": "88 Nanjing Road", + "f_spgoie0arjq": "Connector supplier.", + "kehu_followup_person": [{"id": 54, "nickname": "Alice Wang"}], + "khlxr_phone_number": "+1 555 0100", + "khlxr_email": "alice.contact@example.com", + } + return {"data": project_row(row, kwargs.get("fields"))} def query_records(self, resource, **kwargs): self.query_calls.append((resource, kwargs)) @@ -174,16 +234,20 @@ class CrmServiceTests(unittest.TestCase): result = service.list_customers(query="Acme", status="active", owner="Alice", limit=10, offset=20) self.assertEqual(result["results"][0]["id"], 1) - self.assertEqual(result["results"][0]["name"], "Acme China") - self.assertEqual(result["results"][0]["status_display"], "进行中") - self.assertEqual(result["results"][0]["owner_names"], ["Alice Wang"]) + self.assertEqual(result["results"][0]["name"], "Acme") + self.assertEqual(result["results"][0]["status"], "进行中") + self.assertEqual(result["results"][0]["owners"], [{"id": 54, "name": "Alice Wang"}]) self.assertNotIn("kehu_contacts", result["results"][0]) self.assertNotIn("raw", result["results"][0]) self.assertEqual(result["total"], 1) self.assertEqual(client.list_calls[0][0], "users") resource, kwargs = client.list_calls[1] self.assertEqual(resource, "customers") - self.assertEqual(kwargs["fields"], load_config(None).customer.fields) + # The owner relation is fetched as a dotted projection, never bare -- a bare + # relation makes NocoBase return the whole related row. + self.assertIn("owner.id", kwargs["fields"]) + self.assertIn("owner.nickname", kwargs["fields"]) + self.assertNotIn("owner", kwargs["fields"]) self.assertEqual(kwargs["page_size"], 10) self.assertEqual(kwargs["page"], 3) self.assertEqual( @@ -210,11 +274,18 @@ class CrmServiceTests(unittest.TestCase): self.assertEqual(result["customer"]["id"], 7) self.assertEqual(result["customer"]["name"], "Acme") + self.assertEqual(result["timezone"], "Asia/Shanghai") + # The customer is compacted, not handed back as the raw NocoBase row. + self.assertNotIn("f_q2q60q7bqbg", result["customer"]) + self.assertNotIn("raw", result["customer"]) self.assertEqual(result["contacts"][0]["id"], 2) - self.assertEqual(result["contacts"][0]["customer_names"], ["Acme"]) + self.assertEqual(result["contacts"][0]["customers"], [{"id": 1, "name": "Acme"}]) + # Personal contact details do not belong in a customer overview. + self.assertNotIn("phone", result["contacts"][0]) + self.assertNotIn("email", result["contacts"][0]) self.assertEqual(result["recent_followups"][0]["id"], 1) self.assertEqual( - result["recent_followups"][0]["content_preview"], + result["recent_followups"][0]["content"], "Called customer about sample status and confirmed next procurement meeting.", ) self.assertNotIn("f_19iplxz0nds", result["recent_followups"][0]) @@ -233,8 +304,8 @@ class CrmServiceTests(unittest.TestCase): self.assertEqual(result["totals"]["customers"], 3) self.assertEqual(result["totals"]["contacts"], 3) self.assertEqual(result["totals"]["followups"], 3) - self.assertEqual(result["customer_status"][0]["display"], "已下单") - self.assertEqual(result["recent_followups"][0]["content_preview"], "Called customer about sample status and confirmed next procurement meeting.") + self.assertEqual(result["customer_status"][0]["label"], "已下单") + self.assertEqual(result["recent_followups"][0]["content"], "Called customer about sample status and confirmed next procurement meeting.") self.assertEqual(result["customer_owner"][0]["owner_id"], 54) self.assertGreaterEqual(len(client.query_calls), 3) self.assertEqual(client.query_calls[0][0], "customers") @@ -248,7 +319,7 @@ class CrmServiceTests(unittest.TestCase): self.assertEqual( [call[0] for call in client.field_calls], - ["customers", "contacts", "followups"], + ["customers", "contacts", "followups", "t_team_management", "t_projects", "t_qxeepjvi39s"], ) self.assertEqual(client.field_calls[0][1].get("fields"), None) self.assertEqual(schema["metadata"]["fields"]["customers"][1]["name"], "f_q2q60q7bqbg") @@ -297,7 +368,7 @@ class CrmServiceTests(unittest.TestCase): {"$and": [{"owner": {"id": {"$eq": 54}}}]}, ) - def test_list_followups_filters_owner_by_participant_or_creator(self) -> None: + def test_list_followups_filters_owner_by_participant_only(self) -> None: client = FakeNbClient() with tempfile.TemporaryDirectory() as tmp: config_path = Path(tmp) / "crm.json" @@ -306,6 +377,7 @@ class CrmServiceTests(unittest.TestCase): { "collections": { "followups": { + "ownerField": "gjjl_followup_person", "ownerFields": ["gjjl_followup_person", "createdBy"], } } @@ -320,18 +392,190 @@ class CrmServiceTests(unittest.TestCase): self.assertEqual(client.list_calls[0][0], "users") self.assertEqual( client.list_calls[1][1]["filter"], - { - "$and": [ - { - "$or": [ - {"gjjl_followup_person": {"id": {"$eq": 54}}}, - {"createdBy": {"id": {"$eq": 54}}}, - ] - } - ] - }, + {"$and": [{"gjjl_followup_person": {"id": {"$eq": 54}}}]}, ) + def test_customer_without_a_chinese_name_shows_its_english_name_not_its_id(self) -> None: + """Regression: customer 4249 surfaced as the string "4249". + + Its Chinese-name field was empty and its real name lived in + f_kh_english_name, which was missing from the name candidates, so label + resolution fell all the way through to the id. + """ + + class ZomatoClient(FakeNbClient): + def _rows(self, resource): + if resource == "followups": + return { + "data": [ + { + "id": 1, + "followedAt": "2026-07-08T09:00:00.000Z", + "content": "Dinner with the procurement team.", + "customer": [ + { + "id": 4249, + "f_q2q60q7bqbg": None, + "f_kh_english_name": "Zomato(Eternal)", + } + ], + } + ], + "meta": {"count": 1}, + } + return super()._rows(resource) + + row = CrmService(load_config(None), ZomatoClient()).list_followups(limit=1)["results"][0] + + self.assertEqual(row["customers"], [{"id": 4249, "name": "Zomato(Eternal)"}]) + + def test_customer_with_no_name_at_all_is_marked_as_an_id_not_passed_off_as_a_name(self) -> None: + class NamelessClient(FakeNbClient): + def _rows(self, resource): + if resource == "followups": + return { + "data": [ + { + "id": 1, + "followedAt": "2026-07-08T09:00:00.000Z", + "customer": [{"id": 4249}], + } + ], + "meta": {"count": 1}, + } + return super()._rows(resource) + + row = CrmService(load_config(None), NamelessClient()).list_followups(limit=1)["results"][0] + + self.assertEqual(row["customers"], [{"id": 4249, "name": "#4249"}]) + self.assertNotEqual(row["customers"][0]["name"], "4249") + + def test_get_customer_renames_detail_fields_and_leaks_no_field_codes(self) -> None: + """The whole point of the rewrite: readable keys, no NocoBase codes.""" + client = FakeNbClient() + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / "crm.json" + config_path.write_text( + json.dumps( + { + "collections": { + "customers": { + "fields": [ + "id", + "f_q2q60q7bqbg", + "f_kh_english_name", + "kh_progress", + "f_y8iyf2jhana", + "f_spgoie0arjq", + "kehu_followup_person", + ], + "nameFields": ["f_q2q60q7bqbg", "f_kh_english_name"], + "statusField": "kh_progress", + "ownerField": "kehu_followup_person", + "detailFields": {"address": "f_y8iyf2jhana", "intro": "f_spgoie0arjq"}, + } + } + } + ), + encoding="utf-8", + ) + service = CrmService(load_config(config_path), client) + + result = service.get_customer(7, include_contacts=False, include_recent_followups=False) + + self.assertEqual(result["customer"]["name"], "Acme China") + self.assertEqual(result["customer"]["address"], "88 Nanjing Road") + self.assertEqual(result["customer"]["intro"], "Connector supplier.") + self.assertEqual(result["customer"]["status"], "进行中") + self.assertEqual(result["customer"]["owners"], [{"id": 54, "name": "Alice Wang"}]) + + def field_codes(value): + if isinstance(value, dict): + for key, nested in value.items(): + if re.fullmatch(r"f_[a-z0-9_]+", str(key)): + yield key + yield from field_codes(nested) + elif isinstance(value, list): + for item in value: + yield from field_codes(item) + + self.assertEqual(list(field_codes(result)), []) + + def test_get_customer_never_returns_internal_nocobase_field_codes(self) -> None: + result = CrmService(load_config(None), FakeNbClient()).get_customer(7) + + def field_codes(value): + if isinstance(value, dict): + for key, nested in value.items(): + if re.fullmatch(r"f_[a-z0-9_]+", str(key)): + yield key + yield from field_codes(nested) + elif isinstance(value, list): + for item in value: + yield from field_codes(item) + + self.assertEqual(list(field_codes(result)), []) + + def test_get_customer_reports_a_structured_error_for_an_unknown_id(self) -> None: + class MissingClient(FakeNbClient): + def get_record(self, resource, filter_by_tk, **kwargs): + self.get_calls.append((resource, filter_by_tk, kwargs)) + return {"data": None} + + result = CrmService(load_config(None), MissingClient()).get_customer(999) + + self.assertEqual(result["error"]["code"], "customer_not_found") + + def test_requested_fields_never_ask_for_a_relation_both_bare_and_projected(self) -> None: + client = FakeNbClient() + service = CrmService(load_config(None), client) + + service.list_customers() + service.list_contacts() + service.list_followups() + service.list_stale_customers() + + for resource, kwargs in client.list_calls: + fields = kwargs.get("fields") or [] + roots = {path.split(".", 1)[0] for path in fields if "." in path} + overlap = roots.intersection(fields) + self.assertEqual(overlap, set(), f"{resource} requested {overlap} bare and projected") + + def test_projections_always_lead_with_the_id_field(self) -> None: + client = FakeNbClient() + service = CrmService(load_config(None), client) + + service.list_followups() + + fields = client.list_calls[0][1]["fields"] + for root in {path.split(".", 1)[0] for path in fields if "." in path}: + projected = [path for path in fields if path.startswith(f"{root}.")] + self.assertEqual(projected[0], f"{root}.id") + + def test_stale_customer_scan_projects_the_customer_relation_down_to_its_id(self) -> None: + client = FakeNbClient() + + CrmService(load_config(None), client).list_stale_customers() + + followup_call = next(kwargs for resource, kwargs in client.list_calls if resource == "followups") + self.assertIn("customer.id", followup_call["fields"]) + self.assertNotIn("customer", followup_call["fields"]) + + def test_rejected_projection_falls_back_to_relation_roots_with_a_warning(self) -> None: + class PickyClient(FakeNbClient): + def list_records(self, resource, **kwargs): + if resource == "followups" and any("." in path for path in kwargs.get("fields") or []): + raise NbCommandError("Unknown field gjjl_client.f_bogus") + return super().list_records(resource, **kwargs) + + result = CrmService(load_config(None), PickyClient()).list_followups(limit=1) + + self.assertEqual(result["results"][0]["id"], 1) + self.assertIn(PROJECTION_FALLBACK_WARNING, result["data_quality_warnings"]) + + def test_empty_values_are_omitted_but_ids_are_always_kept(self) -> None: + self.assertEqual(_prune({"id": None, "name": "", "tags": [], "status": "OK"}), {"id": None, "status": "OK"}) + def test_owner_resolution_returns_structured_errors_for_no_or_ambiguous_matches(self) -> None: class NoUserClient(FakeNbClient): def list_records(self, resource, **kwargs): @@ -382,8 +626,9 @@ class CrmServiceTests(unittest.TestCase): result = CrmService(load_config(None), FakeNbClient()).list_customers(limit=1, include_raw=True) row = result["results"][0] - self.assertEqual(row["name"], "Acme China") - self.assertEqual(row["raw"]["kehu_contacts"][0]["id"], 99) + self.assertEqual(row["name"], "Acme") + # raw keeps the relation expanded the way NocoBase returned it. + self.assertEqual(row["raw"]["owner"][0]["nickname"], "Alice Wang") def test_list_tools_clamp_limit_and_offset_for_remote_agents(self) -> None: client = FakeNbClient() @@ -400,22 +645,176 @@ class CrmServiceTests(unittest.TestCase): result = CrmService(load_config(None), FakeNbClient()).list_followups(limit=1) row = result["results"][0] + # Pinning the exact key set is the regression guard against the row + # quietly growing duplicate people/status/timestamp fields again. + self.assertEqual( + set(row), + {"id", "followedAt", "customers", "contacts", "participants", "content"}, + ) self.assertEqual(row["id"], 1) - self.assertEqual(row["content_preview"], "Called customer about sample status and confirmed next procurement meeting.") - self.assertEqual(row["followedAt"], "2026-07-08T09:00:00.000Z") - self.assertEqual(row["customer_names"], ["Acme China"]) - self.assertEqual(row["owner_names"], ["Alice Wang"]) + self.assertEqual(row["followedAt"], "2026-07-08T17:00:00+08:00") + self.assertEqual(row["customers"], [{"id": 7, "name": "Acme China"}]) + self.assertEqual(row["contacts"], [{"id": 8, "name": "Alice Contact"}]) + self.assertEqual(row["participants"], [{"id": 54, "name": "Alice Wang"}]) + self.assertEqual( + row["content"], + "Called customer about sample status and confirmed next procurement meeting.", + ) + # The submitter is also a participant, so naming them twice adds nothing. + self.assertNotIn("creator", row) self.assertNotIn("f_19iplxz0nds", row) - self.assertNotIn("summary", row) def test_list_followups_can_opt_into_raw_rows(self) -> None: result = CrmService(load_config(None), FakeNbClient()).list_followups(limit=1, include_raw=True) row = result["results"][0] - self.assertEqual(row["content_preview"], "Called customer about sample status and confirmed next procurement meeting.") - self.assertEqual(row["raw"]["f_19iplxz0nds"], "Called customer about sample status and confirmed next procurement meeting.") - self.assertEqual(row["raw"]["gjjl_client"][0]["f_q2q60q7bqbg"], "Acme China") + self.assertEqual( + row["content"], + "Called customer about sample status and confirmed next procurement meeting.", + ) + self.assertEqual(row["raw"]["customer"][0]["f_q2q60q7bqbg"], "Acme China") + def test_arbitrary_offset_fetches_two_pages_and_slices_exactly(self) -> None: + class PagedClient(FakeNbClient): + def list_records(self, resource, **kwargs): + self.list_calls.append((resource, kwargs)) + if resource != "customers": + return super().list_records(resource, **kwargs) + page = kwargs["page"] + page_size = kwargs["page_size"] + start = (page - 1) * page_size + return { + "data": [ + {"id": index, "name": f"Customer {index}"} + for index in range(start + 1, min(start + page_size, 50) + 1) + ], + "meta": {"count": 50}, + } + + client = PagedClient() + result = CrmService(load_config(None), client).list_customers(limit=10, offset=15) + + self.assertEqual([row["id"] for row in result["results"]], list(range(16, 26))) + customer_calls = [kwargs for resource, kwargs in client.list_calls if resource == "customers"] + self.assertEqual([call["page"] for call in customer_calls], [2, 3]) + self.assertEqual(result["next_offset"], 25) + + def test_team_filter_uses_formal_team_members_and_customer_owners(self) -> None: + class TeamClient(FakeNbClient): + def list_records(self, resource, **kwargs): + self.list_calls.append((resource, kwargs)) + if resource == "t_team_management": + return { + "data": [ + { + "id": 9, + "f_team_name": "华东团队", + "tdgl_manager": [{"id": 54, "nickname": "Alice Wang"}], + "tdgl_team_members": [{"id": 54, "nickname": "Alice Wang"}], + } + ], + "meta": {"count": 1}, + } + if resource == "users": + return { + "data": [ + { + "id": 54, + "username": "alice", + "nickname": "Alice Wang", + "yh_status": "nkluuumb1r1", + } + ], + "meta": {"count": 1}, + } + return super().list_records(resource, **kwargs) + + client = TeamClient() + service = CrmService(load_config(None), client) + + resolved = service.resolve_team("华东团队") + result = service.list_customers(team="华东团队") + + self.assertEqual(resolved["results"][0]["name"], "华东团队") + customer_call = next(kwargs for resource, kwargs in reversed(client.list_calls) if resource == "customers") + self.assertEqual( + customer_call["filter"], + {"$and": [{"owner": {"id": {"$in": [54]}}}]}, + ) + self.assertEqual(result["results"][0]["owners"], [{"id": 54, "name": "Alice Wang"}]) + + def test_project_query_and_timeline_use_crm_project_relations(self) -> None: + class ProjectClient(FakeNbClient): + project = { + "id": 41, + "f_project_name": "EV inverter", + "client_ID": 7, + "f_projects_client": {"id": 7, "f_q2q60q7bqbg": "Acme China"}, + "f_projects_progress": "k4w2vcz819w", + "createdAt": "2026-07-01T00:00:00.000Z", + "updatedAt": "2026-07-08T00:00:00.000Z", + } + + def list_records(self, resource, **kwargs): + self.list_calls.append((resource, kwargs)) + if resource == "t_projects": + return {"data": [dict(self.project)], "meta": {"count": 1}} + if resource == "t_qxeepjvi39s": + return { + "data": [ + { + "id": 5, + "f_6g9fw5oqef9": "2026-07-09T00:00:00.000Z", + "f_ei16g0ihwwd": "报价已提交", + "createdBy": {"id": 54, "nickname": "Alice Wang"}, + } + ], + "meta": {"count": 1}, + } + return super().list_records(resource, **kwargs) + + def get_record(self, resource, filter_by_tk, **kwargs): + self.get_calls.append((resource, filter_by_tk, kwargs)) + if resource == "t_projects": + return {"data": dict(self.project)} + return super().get_record(resource, filter_by_tk, **kwargs) + + client = ProjectClient() + service = CrmService(load_config(None), client) + + listed = service.list_projects(stage="已报价") + timeline = service.get_project_timeline(41) + + self.assertEqual(listed["results"][0]["stage"], "已报价") + self.assertEqual(listed["results"][0]["customers"], [{"id": 7, "name": "Acme China"}]) + # Relations are projected, so the CLI is never asked to append whole rows. + project_call = next(kwargs for resource, kwargs in client.list_calls if resource == "t_projects") + self.assertNotIn("appends", project_call) + self.assertIn("f_projects_client.f_q2q60q7bqbg", project_call["fields"]) + self.assertEqual( + {event["event_type"] for event in timeline["events"]}, + {"followup", "project_update"}, + ) + followup_event = next(e for e in timeline["events"] if e["event_type"] == "followup") + self.assertEqual(followup_event["actors"], ["Alice Wang"]) + update_event = next(e for e in timeline["events"] if e["event_type"] == "project_update") + self.assertEqual(update_event["actors"], ["Alice Wang"]) + + def test_stale_customers_uses_last_valid_followup_date(self) -> None: + class StaleClient(FakeNbClient): + def list_records(self, resource, **kwargs): + response = super().list_records(resource, **kwargs) + if resource == "customers": + response["data"][0]["id"] = 7 + return response + + result = CrmService(load_config(None), StaleClient()).list_stale_customers( + inactive_days=30, + as_of="2026-08-10", + ) + + self.assertEqual(result["results"][0]["customer"]["id"], 7) + self.assertEqual(result["results"][0]["days_since_last_followup"], 33) if __name__ == "__main__": unittest.main() diff --git a/tests/test_nb_client.py b/tests/test_nb_client.py index e84eaf6..ea424f3 100644 --- a/tests/test_nb_client.py +++ b/tests/test_nb_client.py @@ -297,6 +297,26 @@ class NbClientTests(unittest.TestCase): with self.assertRaises(NbCommandError): client.list_records("customers") + def test_json_output_parser_tolerates_one_surrounding_log_message(self) -> None: + def runner(cmd, **kwargs): + return subprocess.CompletedProcess( + cmd, + 0, + stdout='startup log\n{"data":[{"id":1}],"meta":{"count":1}}\nfinished', + stderr="", + ) + + result = NbClient(env_name="nb-mcp", runner=runner).list_records("customers") + + self.assertEqual(result["data"], [{"id": 1}]) + + def test_json_output_parser_rejects_multiple_top_level_objects(self) -> None: + def runner(cmd, **kwargs): + return subprocess.CompletedProcess(cmd, 0, stdout='{"log":1}\n{"data":[]}', stderr="") + + with self.assertRaises(NbCommandError): + NbClient(env_name="nb-mcp", runner=runner).list_records("customers") + def test_environment_overrides_can_change_nb_home_without_shell(self) -> None: captured_env = {} diff --git a/tests/test_server.py b/tests/test_server.py index f8ac7bb..1c7f681 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -156,10 +156,18 @@ class ServerTests(unittest.TestCase): "crm_overview", "crm_filter_options", "resolve_owner", + "list_teams", + "resolve_team", + "get_team", + "team_crm_overview", "list_customers", "get_customer", "list_contacts", "list_followups", + "list_projects", + "get_project", + "get_project_timeline", + "list_stale_customers", }, ) @@ -188,7 +196,8 @@ class ServerTests(unittest.TestCase): self.assertIn("include_raw", tools["list_customers"].inputSchema["properties"]) self.assertIn("include_raw", tools["list_contacts"].inputSchema["properties"]) self.assertIn("include_raw", tools["list_followups"].inputSchema["properties"]) - self.assertIn("include_raw_followups", tools["get_customer"].inputSchema["properties"]) + self.assertIn("include_raw", tools["get_customer"].inputSchema["properties"]) + self.assertIn("include_details", tools["list_contacts"].inputSchema["properties"]) def test_tools_return_structured_error_for_nb_failures(self) -> None: if FastMCP is None: