feat: add team-aware CRM queries and project tracking

This commit is contained in:
nac
2026-08-14 11:00:13 +00:00
parent 4b098eb4dd
commit 76e4cbf58a
13 changed files with 2761 additions and 263 deletions
+5 -16
View File
@@ -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
+66 -4
View File
@@ -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:
+43 -3
View File
@@ -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"]
}
}
@@ -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 天,允许调用方覆盖。
+129
View File
@@ -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")),
)
+1330 -195
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -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
+39 -3
View File
@@ -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],
+187 -9
View File
@@ -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()
+75
View File
@@ -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()
+431 -32
View File
@@ -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()
+20
View File
@@ -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 = {}
+10 -1
View File
@@ -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: