feat: add team-aware CRM queries and project tracking
This commit is contained in:
+5
-16
@@ -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
|
||||
|
||||
@@ -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
@@ -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 天,允许调用方覆盖。
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user