fix: keep submission previews user-readable

This commit is contained in:
2026-08-20 01:13:36 +00:00
parent ecb2f570e0
commit 7fbd13bf20
12 changed files with 485 additions and 23 deletions
+2 -2
View File
@@ -41,12 +41,12 @@ Agent 不传 `userId`、员工号或单据所有者。网关从 OAuth Token 确
模板出现在列表中不等于所有控件都能由开放 API 构造。`approval_get_submission_schema` 会返回 `submissionCapability`,准备阶段也会再次安全校验:
- 薪福通:白名单中的申请和报销模板均通过正式创建 API 发起,并在创建后实时核验状态。结果可能进入待审批,也可能因企业流程中没有其他有效审批人等规则即时完成;后者返回 `completed`,不会错误地当成接口失败,也不会签发不可用的撤回引用。
- 薪福通:可安全构造的申请和报销模板通过正式创建 API 发起,并在创建后实时核验状态。结果可能进入待审批,也可能因企业流程中没有其他有效审批人等规则即时完成;后者返回 `completed`,不会错误地当成接口失败,也不会签发不可用的撤回引用。用车申请当前明确标记为 `client_only`:正式接口要求 `useCarInfo` 对象,但实时模板与现有文档不足以验证其业务结构,不能让 Agent 猜字段。
- 钉钉:当前 6 个候选模板均返回 `client_only`,必须在钉钉客户端发起。其中 5 个包含企业 OpenAPI 无法可靠构造的 `DDBizSuite` 考勤或费控套件;`业务招待申请_北极大`虽是普通明细表,但真实创建时新版企业接口返回系统异常、旧版接口拒绝组件格式,因此同样安全降级。
发起审批固定为“列模板 → 取 Schema → 自动查重/预览 → 用户确认 → 正式提交”。准备阶段从新表单提取发票代码与号码、数电票唯一编号、票据号码、附件哈希;没有唯一编号时,金额、日期与商户组合只作为疑似提示。网关实时刷新当前用户本人近 30 天的钉钉和薪福通已发起审批:原单待审批、已通过或状态未知时默认阻止,原单拒绝、撤回、作废或弱匹配时警告。任一来源查询不完整也不会当成“没有重复”。只有用户看过匹配单号、状态和覆盖情况并明确要求继续后,Agent 才能以 `allowDuplicateInvoices=true` 重新准备。
用户不需要理解薪福通的任何内部选择值。实时 Schema 会把部门、城市、费用类型、出行方式、往返、时间范围、币种和酒店星级等字段标记为名称优先的选择项;Agent 只展示“深圳市”“交通费”“飞机”“往返”“人民币”等业务名称,选定后仅在内部写入正式接口值。动态下拉框也会标准化成 `label/code`,表格子字段同样递归处理。无法取得可靠名称映射时安全停止或允许跳过可选项,不退化为向用户索取工号、城市码、组织码、档案编号、数字舱位值或其他内部 ID。
用户不需要理解薪福通的任何内部选择值。实时 Schema 会把部门、城市、费用类型、出行方式、往返、时间范围、币种和酒店星级等字段标记为名称优先的选择项;Agent 只展示“深圳市”“交通费”“飞机”“往返”“人民币”等业务名称,选定后仅在内部写入正式接口值。动态下拉框也会标准化成 `label/code`,表格子字段同样递归处理。准备阶段的最终确认预览会再次按实时选项反向解析,只返回业务字段名和业务名称,不回显城市码、费用编码、字段号或表格列号;任何值无法可靠反向解析时,不生成 `confirmationId`。无法取得可靠名称映射时安全停止或允许跳过可选项,不退化为向用户索取工号、城市码、组织码、档案编号、数字舱位值或其他内部 ID。
人员、企业自定义档案、关联前置单据、分摊及文档未给出可靠名称映射的舱位字段目前标记为 `client_only`。必填时不生成提交预览并引导薪福通客户端;可选时不会作为普通问题打扰用户。单据公司、部门、字段引用和行程序号等服务器管理或内部派生值也不会作为用户输入项展示。
+1 -1
View File
@@ -24,7 +24,7 @@
Agent 面向统一审批语义,不直接理解钉钉 `processInstanceId/taskId`、薪福通员工号、上游响应或完整 Schema。网关在内部完成来源路由、字段抽取、动作调用和写后复核,只返回审批判断所需的精简字段与统一状态。
薪福通所有选择字段按领域选项抽象。部门、城市和费用类型由选项工具返回精简 `label/code` 映射;模板内嵌下拉框被标准化成同样结构,固定枚举通过 `enumLabels` 提供中文名称,表格子字段递归应用相同规则。Agent 只把业务名称展示给用户,正式值仅用于内部构造请求。无法取得可靠名称映射的人员、档案、关联单据、分摊和部分舱位字段标记为 `client_only`,必填时安全停止、可选时允许省略,绝不转而索取工号或编码。响应不包含完整数据源、内部身份或其他企业档案,选项目录也不持久化。
薪福通所有选择字段按领域选项抽象。部门、城市和费用类型由选项工具返回精简 `label/code` 映射;模板内嵌下拉框被标准化成同样结构,固定枚举通过 `enumLabels` 提供中文名称,表格子字段递归应用相同规则。Agent 只把业务名称展示给用户,正式值仅用于内部构造请求。准备确认时,网关再用实时 Schema 把正式值反向解析为业务名称;预览不包含城市码、费用编码、自定义字段号或表格列号,任一选择无法反向解析时会在签发确认引用前安全失败。无法取得可靠名称映射的人员、档案、关联单据、分摊和部分舱位字段标记为 `client_only`,必填时安全停止、可选时允许省略,绝不转而索取工号或编码。用车申请的正式 `useCarInfo` 结构目前也无法从实时模板可靠推导,因此整体标记为 `client_only`。响应不包含完整数据源、内部身份或其他企业档案,选项目录也不持久化。
所有工具以普通 `content.text` 中的精简 JSON 作为跨宿主可见结果,同时保留相同的精简 `structuredContent` 供支持结构化结果的宿主使用;两处都不携带诊断字段或上游原始响应。`raw` 仍是受过滤、分页和硬限额保护的按需诊断视图,不是默认数据通道。
+4 -3
View File
@@ -8,6 +8,7 @@
| 薪福通 | 费用报销单 | 支持,有约束 | 报销总额、币种、费用明细;当前模板要求承担部门和费用说明 |
| 薪福通 | 招待费用申请单 | 支持,有约束 | 企业模板类型为 `EXPENSE_APPLY`,使用费用申请创建接口的 `applyExpenses`(费用类型、金额等),而不是按模板名称误用招待申请专用 DTO;费用类型名称实时查询、编码内部映射;写后实时核验状态 |
| 薪福通 | 出差费用申请单 | 支持,有约束 | 行程、城市和预计费用递归校验;实测仅有发起节点时流程即时完成并返回 `completed` |
| 薪福通 | 用车申请单(若企业开放) | 仅客户端 | 正式创建接口要求 `useCarInfo`,但现有文档和实时模板未提供可验证的业务字段结构;MCP 不猜测对象结构 |
| 钉钉 OA | 业务招待申请_北极大 | 仅客户端 | 普通字段和明细表可读取,但真实测试中新版企业创建接口返回系统异常、旧版企业接口拒绝组件格式 |
| 钉钉 OA | 出差申请单_北极大 | 仅客户端 | `DDBizSuite` 出差套件包含日程、天数、同行人及考勤联动,企业 OpenAPI 没有可靠构造协议 |
| 钉钉 OA | 外出_北极大 | 仅客户端 | `DDBizSuite` 外出套件包含类型、起止时间、时长和考勤联动 |
@@ -15,10 +16,10 @@
| 钉钉 OA | 差旅费用报销_捷康 | 仅客户端 | 钉钉费控报销套件含行程、报销明细、发票、收款账户和企业主体;另有关联出差申请和项目字段 |
| 钉钉 OA | 业务招待费用报销_捷康 | 仅客户端 | 钉钉费控报销套件含报销明细、发票、收款账户和付款主体 |
薪福通自定义字段在工具内部统一使用 `customFieldValues[].fieldNumber/fieldValue`,但用户不应看到或提供这些引用。日期字段使用 `yyyy-MM-dd HH:mm`;部门由可读名称选择后内部映射;下拉框与表格子字段使用实时名称映射。人员、附件及其他没有安全名称查询或上传能力的字段标记为 `client_only`,不会要求用户填写花名册编号或附件编号。`approval_get_submission_schema` 会返回实时 `valueFormat` 和 `selectionCapability`;Agent 不得按字段名称猜编码或结构。
薪福通自定义字段在工具内部统一使用 `customFieldValues[].fieldNumber/fieldValue`,但用户不应看到或提供这些引用。日期字段使用 `yyyy-MM-dd HH:mm`;部门由可读名称选择后内部映射;下拉框与表格子字段使用实时名称映射。人员、附件及其他没有安全名称查询或上传能力的字段标记为 `client_only`,不会要求用户填写花名册编号或附件编号。`approval_get_submission_schema` 会返回实时 `valueFormat`、`selectionCapability` 和该模板的精简 `minimalExample`;后者只说明 `values` 形状,所有尖括号占位符都必须由用户业务回答或实时选项替换,不能原样提交。Agent 不得按字段名称猜编码或结构。
薪福通模板要求承担部门、城市或费用类型时,使用同一 `templateId` 调用 `approval_get_submission_options`。`kind=department` 只会返回当前登录用户本人档案中的可选组织;`kind=city` 可用 `query` 按城市名称搜索;`kind=expense_type` 先读取模板内嵌选项,再按需读取企业多级费用类型数据源。费用申请、差旅预计费用及报销明细均标记对应选项源。固定枚举提供 `enumLabels`,例如出行方式、单程/往返、上午/下午、人民币和酒店星级;动态 `SELECT` 的 `options` 统一为 `{label, code}`,表格列也递归标准化。Agent 只向用户显示名称并在内部使用正式值,不得向用户索取内部编码。城市全量接口较慢,网关给予独立的 45 秒超时并在进程内缓存字典 24 小时,不写入磁盘;费用类型保持实时且不落盘。响应不会暴露员工号、完整数据源或原始成员档案。
薪福通模板要求承担部门、城市或费用类型时,使用同一 `templateId` 调用 `approval_get_submission_options`。`kind=department` 只会返回当前登录用户本人档案中的可选组织;`kind=city` 可用 `query` 按城市名称搜索;`kind=expense_type` 先读取模板内嵌选项,再按需读取企业多级费用类型数据源。费用申请、差旅预计费用及报销明细均标记对应选项源。固定枚举提供 `enumLabels`,例如出行方式、单程/往返、上午/下午、人民币和酒店星级;动态 `SELECT` 的 `options` 统一为 `{label, code}`,表格列也递归标准化。Agent 只向用户显示名称并在内部使用正式值,不得向用户索取内部编码。`approval_prepare_submission` 会对标准字段、自定义下拉和表格列做第二次反向解析,确认预览只保留业务名称;无法映射时不签发确认引用。城市全量接口较慢,网关给予独立的 45 秒超时并在进程内缓存字典 24 小时,不写入磁盘;费用类型保持实时且不落盘。响应不会暴露员工号、完整数据源或原始成员档案。
薪福通的自定义字段接口可能同时列出标准单据抬头控件,也可能用动态字段号返回名为“承担部门/单据部门”的控件,但创建接口禁止把这些标准控件放进 `customFieldValues`。MCP 会剔除服务器管理控件并拒绝调用方显式指定公司或部门;两者省略时由薪福通根据当前登录申请人的档案默认填写。真正的业务部门选择字段则只展示当前用户可用的部门名称并在内部映射。
任一来源模板的 `submissionCapability.status=client_only` 时,`approval_prepare_submission` 会再次拒绝,不能通过修改本地 Skill 或桥绕过。薪福通白名单模板使用正式创建接口,并在写后实时核验:`billSts=1` 为待审批,`billSts=2` 为企业流程规则导致的即时完成。当前列出的 6 个钉钉模板均为 `client_only`;对于以后可由 API 构造的钉钉模板,网关会先调用企业接口 `POST /v1.0/workflow/processes/forecast`,预测成功并补齐自选处理人后才生成提交确认。
任一来源模板的 `submissionCapability.status=client_only` 时,`approval_prepare_submission` 会再次拒绝,不能通过修改本地 Skill 或桥绕过。可安全构造的薪福通模板使用正式创建接口,并在写后实时核验:`billSts=1` 为待审批,`billSts=2` 为企业流程规则导致的即时完成;用车申请当前为 `client_only`。当前列出的 6 个钉钉模板也均为 `client_only`;对于以后可由 API 构造的钉钉模板,网关会先调用企业接口 `POST /v1.0/workflow/processes/forecast`,预测成功并补齐自选处理人后才生成提交确认。
@@ -4,14 +4,15 @@ Create only XFT approvals and these DingTalk templates: `出差申请单_北极
1. Call `approval_list_submission_templates` and use only its returned `templateId`.
2. Call `approval_get_submission_schema`. Compare every required field in `fields` and `inputContract` with information the user has already supplied. Never guess a missing value.
If `submissionCapability.status` is `client_only`, stop API construction and tell the user to start that template in its native client. All six current DingTalk candidates are client-only: five contain unsupported business suites, while live enterprise-API tests rejected the ordinary table form in `业务招待申请_北极大`. XFT whitelist templates use their formal create APIs and are verified against live status after submission.
Treat every XFT selection as a label-only user interaction. For a field with `optionSource`, call `approval_get_submission_options` with the same `templateId`, show only returned `label` values, and retain its `code` only inside the tool request. Department results are restricted to the current signed-in user's member record. For an inline `options` field show only `options[].label`; for an inline enum show only `enumLabels` values. Never show, explain, or ask the user for a field code, city code, organization code, employee number, archive number, numeric cabin value, `expenseNumber`, `expenseTypeNumber`, `expTypeCode`, or other internal ID. If `selectionCapability.status=client_only`, do not ask for the internal value or guess it: a required field stops MCP creation and requires the native client; an optional field may be skipped, or the user may choose native-client completion. If a required option source cannot return readable labels, stop and offer retry or native-client completion.
Use `inputContract.values.minimalExample` only as the exact JSON shape for `values`. Replace every angle-bracket placeholder from the user's business answer or the selected live option; never submit a placeholder literally. Continue to enforce the recursive `standardFields` constraints even when the example omits optional fields.
If `submissionCapability.status` is `client_only`, stop API construction and tell the user to start that template in its native client. All six current DingTalk candidates are client-only: five contain unsupported business suites, while live enterprise-API tests rejected the ordinary table form in `业务招待申请_北极大`. Supported XFT templates use their formal create APIs and are verified against live status after submission; XFT use-car applications remain client-only because the formal endpoint's `useCarInfo` structure cannot yet be derived safely from the live form.
Treat every XFT selection as a label-only user interaction. For a field with `optionSource`, call `approval_get_submission_options` with the same `templateId` and show only returned `label` values. After the user chooses a label, copy that exact item's `code` into the corresponding `values` field for prepare/submit; never put the label itself into a code-valued field. Keep its `code` only inside tool arguments and never repeat it in conversation. Apply the same rule to inline `options`; for an inline enum show only `enumLabels` values and internally use the matching enum key. Department results are restricted to the current signed-in user's member record. Never show, explain, or ask the user for a field code, city code, organization code, employee number, archive number, numeric cabin value, `expenseNumber`, `expenseTypeNumber`, `expTypeCode`, or other internal ID. If `selectionCapability.status=client_only`, do not ask for the internal value or guess it: a required field stops MCP creation and requires the native client; an optional field may be skipped, or the user may choose native-client completion. If a required option source cannot return readable labels, stop and offer retry or native-client completion.
For DingTalk, do not ask for or fill applicant and top-level department controls marked `serverManaged`; keep `deptId=-1` unless the user selected a department that the gateway can verify belongs to them. The gateway derives the default department from the authenticated user's live directory record.
3. While any required field is missing or ambiguous, ask the user for the missing information and repeat the check after each answer. Prefer one concise question at a time, grouping fields only when they naturally belong together. Do not call `approval_prepare_submission` until all required fields are complete.
4. Explicitly show the remaining user-visible, API-supported optional business fields with short explanations and ask whether the user wants to add any. Do not present fields marked `userVisible=false`, `serverManaged`, `internallyDerived`, or `selectionCapability.status=client_only` as ordinary questions. Briefly mention client-only optional capabilities only when they are relevant to the user's request. The user may choose to skip all optional fields; record that choice instead of silently omitting them.
5. Call `approval_prepare_submission` with `allowDuplicateInvoices=false`. It automatically checks invoice identifiers and expense signatures against the signed-in user's submitted DingTalk and XFT history for the most recent 30 days. If it reports another missing, invalid, unsupported, or selection-required field, return to the collection loop and ask until validation succeeds. Never work around validation.
6. If preparation returns `duplicate_confirmation_required` or `duplicate_check_unavailable`, no submit-capable `confirmationId` was created. Show the duplicate check's masked invoice evidence, matching source/business number/status, and coverage warning. Ask whether the user still wants to submit. Never infer consent from the original creation request. Only after the user explicitly accepts that exact risk, call prepare again with the same content and `allowDuplicateInvoices=true`. A rejected, revoked, or void prior bill is still a warning; a pending, approved, or unknown prior bill is blocking by default. If another active preparation reserved the same invoice, wait for or finish that preparation rather than overriding it.
7. Show the successful returned preview, including `duplicateCheck`, and obtain explicit final confirmation to create the approval. Do not treat answers to field questions, the choice to skip optional fields, or the duplicate-risk acknowledgement as this final submission confirmation.
7. Show the successful returned preview, including `duplicateCheck`, and obtain explicit final confirmation to create the approval. The preview is already converted back to business labels; repeat those labels exactly and never replace them with values retained from Schema construction. If the gateway cannot map any selection back to a current label, it will refuse to create a `confirmationId`; refresh the live options instead of exposing the unresolved value. Do not treat answers to field questions, the choice to skip optional fields, or the duplicate-risk acknowledgement as this final submission confirmation.
8. After final confirmation, call `approval_submit` once with the same `templateId`, `values`, department, selections, `allowDuplicateInvoices`, and returned `confirmationId`. Never modify the content or retry automatically.
If the user explicitly asks to undo a DingTalk or XFT approval just created through this MCP, call `approval_revoke_submission` once with the returned `revokeId`. Never use revoke to reject another person's todo, never accept a raw user ID, employee ID, or bill ID, and never automatically retry `failed` or `outcome_uncertain`. XFT recall is experimental in this release candidate: the current enterprise has returned `XFTSYS9999` for a real pending bill and no successful XFT recall has been observed. State this limitation when relevant and never report a recall as successful unless the tool returns `revoked` after live reconciliation.
+5 -5
View File
@@ -390,7 +390,7 @@ function parseEndpoint(argv: string[]): string {
export function createBridgeMcpServer(bridge: RemoteBridge): McpServer {
const server = new McpServer({ name: "dingtalk-approval-stdio-bridge", version: APPLICATION_VERSION }, {
instructions: "首次使用或登录链接过期时调用approval_login;每次调用会检查登录并在需要时生成有效的新链接,绝不因链接过期而重新安装。工具不会通过其他消息通道自动发送链接,必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。审批引用、调用容量和内部分批不得向用户解释;查看时只回答业务信息,用户明确授权后在授权范围内自动处理。写前校验失败时零写入,任何不确定结果都不得自动重试。提单草稿仅可保存在 Agent 所在机器的当前用户私有目录,不得上传网关;恢复草稿后必须重新读取实时模板 Schema,短期提交引用不得写入草稿。薪福通所有选择型字段只向用户展示业务名称:optionSource 选项展示label,内嵌枚举展示enumLabels;内部值仅供提交。不得向用户索取或展示城市码、组织码、工号、档案编号、数字舱位值或其他内部ID;selectionCapability=client_only时停止构造该必填项或允许跳过该可选项。",
instructions: "首次使用或登录链接过期时调用approval_login;每次调用会检查登录并在需要时生成有效的新链接,绝不因链接过期而重新安装。工具不会通过其他消息通道自动发送链接,必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。审批引用、调用容量和内部分批不得向用户解释;查看时只回答业务信息,用户明确授权后在授权范围内自动处理。写前校验失败时零写入,任何不确定结果都不得自动重试。提单草稿仅可保存在 Agent 所在机器的当前用户私有目录,不得上传网关;恢复草稿后必须重新读取实时模板 Schema,短期提交引用不得写入草稿。薪福通所有选择型字段只向用户展示业务名称:optionSource 选项展示label,内嵌枚举展示enumLabels;用户选定label后,values中必须填同一项的code而不能填label,code只存在于工具调用内部。不得向用户索取或展示城市码、组织码、工号、档案编号、数字舱位值或其他内部ID;selectionCapability=client_only时停止构造该必填项或允许跳过该可选项。",
});
server.registerTool("approval_login", {
title: "登录审批服务", description: "检查登录;未登录或旧链接已过期时生成新的钉钉授权链接。同一身份同时用于钉钉OA和薪福通,无需重新安装。", inputSchema: {},
@@ -441,19 +441,19 @@ export function createBridgeMcpServer(bridge: RemoteBridge): McpServer {
}, annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_list_submission_templates", args));
server.registerTool("approval_get_submission_schema", {
title: "获取发起审批表单结构", description: "实时读取模板字段、必填项、可读选择名称、复杂字段格式和 API 构造能力;client_only字段不得让用户提供内部值。", inputSchema: {
title: "获取发起审批表单结构", description: "实时读取模板字段、必填项、可读选择名称、minimalExample、复杂字段格式和 API 构造能力;minimalExample的尖括号必须替换,client_only字段不得让用户提供内部值。", inputSchema: {
templateId: z.string().min(20).max(4096),
}, annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_get_submission_schema", args));
server.registerTool("approval_get_submission_options", {
title: "查询发起审批可用选项", description: "实时查询当前登录用户本人的薪福通承担部门,或按名称搜索城市和费用类型。所有选项只向用户展示label,code仅供内部提交。", inputSchema: {
title: "查询发起审批可用选项", description: "实时查询当前登录用户本人的薪福通承担部门,或按名称搜索城市和费用类型。所有选项只向用户展示label;values必须填所选项的code而不能填label,code仅供工具内部提交。", inputSchema: {
templateId: z.string().min(20).max(4096), kind: z.enum(["department", "city", "expense_type"]),
query: z.string().max(100).default(""), limit: z.number().int().min(1).max(100).default(50),
}, annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_get_submission_options", args));
const submissionInputSchema = {
templateId: z.string().min(20).max(4096),
values: z.record(z.string().min(1).max(200), z.unknown()),
values: z.record(z.string().min(1).max(200), z.unknown()).describe("选择项必须使用所选label对应的code而不是label;code不得展示给用户"),
deptId: z.number().int().default(-1).describe("钉钉发起部门;默认-1,由网关按当前登录用户实时解析并验证"),
targetSelectActioners: z.array(z.object({
actionerKey: z.string().min(1).max(500),
@@ -462,7 +462,7 @@ export function createBridgeMcpServer(bridge: RemoteBridge): McpServer {
allowDuplicateInvoices: z.boolean().default(false).describe("仅在网关已提示重复风险或查重不完整、且用户明确确认仍要提交后设为 true"),
};
server.registerTool("approval_prepare_submission", {
title: "校验并预览新审批", description: "无副作用校验全部字段并自动查重本人近30天钉钉/薪福通发票;有重复风险或查重不完整时要求用户明确确认,不会直接生成提交引用。", inputSchema: submissionInputSchema,
title: "校验并预览新审批", description: "无副作用校验全部字段并自动查重本人近30天钉钉/薪福通发票;选择项在values中填所选label对应的code,成功预览只显示业务名称;有重复风险或查重不完整时要求用户明确确认,不会直接生成提交引用。", inputSchema: submissionInputSchema,
annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_prepare_submission", args));
server.registerTool("approval_submit", {
+5 -5
View File
@@ -41,7 +41,7 @@ export const APPROVAL_MCP_INSTRUCTIONS = [
"只有用户明确要求处理审批时,才调用 approval_process_batch;写操作不会二次确认。开始写入前会先校验整批 approvalId 和 targetId;任一引用无效则整批零写入。重新拉取待办后必须重新确认目标,禁止自动补偿或自动重试。",
"钉钉开放接口无法预检部分模板的签字等业务规则;要求手写签名时返回客户端处理链接。处理结果中 failed 表示操作被拒绝且未生效;除非用户看过原因后再次明确要求,否则不要重试。succeeded 表示节点提交成功;completed 才表示无需当前用户继续审批。uncertain/outcome_uncertain 表示已经提交但未确认,禁止自动重试,应稍后重新列出待办。followUpRequired 大于 0 时应重新列出待办并告知用户。",
"出现 authChallenge 时,工具不会通过其他消息通道自动发送内容。必须把 directUrl 原样写入当前面向用户的回复;有二维码图片时直接作为当前回复附件,禁止转成 ASCII,也不得只声称已生成或已推送。",
"发起审批范围仅包括薪福通已支持模板,以及 6 个钉钉候选模板:北极大的出差申请、外出、业务招待申请,捷康的差旅费用报销、业务招待费用报销、业务招待申请。禁止绕过范围限制。固定使用 approval_list_submission_templates → approval_get_submission_schema → approval_prepare_submission。所有选择型字段只向用户展示业务名称:optionSource 指向的部门、城市或费用类型必须用 approval_get_submission_options 实时查询;内嵌 options/enumLabels 只展示 label 或中文名称;用户选定后由 Agent 在内部映射正式值。绝不向用户展示、解释或索取字段编码、城市码、组织码、工号、档案编号、数字舱位值或其他内部 ID。selectionCapability.status=client_only 的字段不得猜测或构造;必填时停止并引导薪福通客户端,可选时只说明客户端可补充且允许用户跳过。必须先检查 submissionCapability;client_only 时停止 API 构造并引导原生客户端。读取 Schema 后,逐项核对用户已提供的信息;必填字段缺失或含糊时必须继续询问且不得猜测,直到全部完整。只列出 userVisible!=false 且非 client_only 的尚未填写非必填字段,使用业务名称说明并询问用户是否补充;用户可以明确跳过。prepare 若返回缺失、无效或需自选处理人的字段,继续询问后重新 prepare。prepare 成功后必须向用户展示完整预览并另行取得明确确认;确认后才用完全相同的内容调用 approval_submit。不要让用户或 Agent 提供发起人 userId、薪福通员工号或审批状态。",
"发起审批范围仅包括薪福通已支持模板,以及 6 个钉钉候选模板:北极大的出差申请、外出、业务招待申请,捷康的差旅费用报销、业务招待费用报销、业务招待申请。禁止绕过范围限制。固定使用 approval_list_submission_templates → approval_get_submission_schema → approval_prepare_submission。所有选择型字段只向用户展示业务名称:optionSource 指向的部门、城市或费用类型必须用 approval_get_submission_options 实时查询;内嵌 options/enumLabels 只展示 label 或中文名称。用户选择 label 后,Agent 必须在 values 对应字段中填入同一选项的 code,不能把 label 当作正式值;code 只存在于工具调用内部,绝不出现在用户对话。绝不向用户展示、解释或索取字段编码、城市码、组织码、工号、档案编号、数字舱位值或其他内部 ID。selectionCapability.status=client_only 的字段不得猜测或构造;必填时停止并引导薪福通客户端,可选时只说明客户端可补充且允许用户跳过。必须先检查 submissionCapability;client_only 时停止 API 构造并引导原生客户端。读取 Schema 后,逐项核对用户已提供的信息;必填字段缺失或含糊时必须继续询问且不得猜测,直到全部完整。只列出 userVisible!=false 且非 client_only 的尚未填写非必填字段,使用业务名称说明并询问用户是否补充;用户可以明确跳过。prepare 若返回缺失、无效或需自选处理人的字段,继续询问后重新 prepare。prepare 成功后必须向用户展示完整预览并另行取得明确确认;确认后才用完全相同的内容调用 approval_submit。不要让用户或 Agent 提供发起人 userId、薪福通员工号或审批状态。",
"用户明确要求起草、保存、继续、列出或删除提单草稿时,草稿只能由 Agent 保存在运行机器的当前 OS 用户私有数据目录:Windows %LOCALAPPDATA%\\DingTalkApproval\\drafts、macOS ~/Library/Application Support/DingTalkApproval/drafts、Linux ${XDG_DATA_HOME:-~/.local/share}/dingtalk-approval/drafts;POSIX 目录/文件权限为 0700/0600并用同目录临时文件原子替换。不得上传网关、写入项目仓库或声称已在钉钉/薪福通创建草稿。宿主没有本地文件能力时明确说明无法持久保存。每个 UUID.json 使用 schemaVersion=1、kind=dingtalk-approval-local-draft、draftId、title、source、templateName、status(editing或submit_uncertain)、values、optionalFieldDecision、attachments、createdAt、updatedAt;只保存用户填写值和附件本地路径/大小/mtime/SHA-256,不得保存 Token、身份 ID、短期 templateId/confirmationId/revokeId 或完整上游 Schema。恢复时必须重新列模板并实时读取 Schema,附件必须在正式准备前重新校验;本地路径不能当作上游附件值。每次草稿操作自动清理超过 90 天未更新的普通草稿。正式提交成功或即时完成后删除;明确失败时保留;outcome_uncertain 时冻结且禁止自动重试,先实时核验本人发起记录。",
"approval_prepare_submission 会自动检查当前用户近30天本人发起的钉钉与薪福通审批是否使用了相同发票。若返回 duplicate_confirmation_required 或 duplicate_check_unavailable,本次没有生成 confirmationId;必须展示匹配单号、状态和查重覆盖情况并询问用户。只有用户明确表示知晓重复或查重不完整仍要提交后,才可将 allowDuplicateInvoices=true 原样重新 prepare。不得自行代替用户确认。",
"approval_submit 发起后会返回实时核验结果;仍在审批中的单据会带 revokeId,按企业规则即时完成的薪福通流程返回 completed 且不可撤回。只有用户明确要求撤销该本人审批时才调用 approval_revoke_submission;禁止用撤销代替拒绝别人的待办,禁止自动重试 completed、failed 或 outcome_uncertain。薪福通创建或撤回需要复核时使用只读 approval_get_submission_status,它会实时校验本人已发起单据归属,但不会再次执行写操作。",
@@ -172,7 +172,7 @@ export function createApprovalMcpServer(
server.registerTool("approval_get_submission_schema", {
title: "获取发起审批表单结构",
description: "使用模板列表返回的templateId实时读取结构化字段、必填状态、可读选项和填写约束。选择项只向用户展示label/enumLabels;client_only字段不得索取内部值。Agent必须持续询问直至必填字段齐全,并主动提醒可选字段中能安全填写的业务项;不会返回超大的上游原始Schema。",
description: "使用模板列表返回的templateId实时读取结构化字段、必填状态、可读选项、填写约束和minimalExample。minimalExample只说明values形状,尖括号必须替换;选择项只向用户展示label/enumLabels;client_only字段不得索取内部值。Agent必须持续询问直至必填字段齐全,并主动提醒可选字段中能安全填写的业务项;不会返回超大的上游原始Schema。",
inputSchema: {
templateId: z.string().min(20).max(4096).describe("必须原样使用 approval_list_submission_templates 返回的短期签名引用"),
},
@@ -184,7 +184,7 @@ export function createApprovalMcpServer(
server.registerTool("approval_get_submission_options", {
title: "查询发起审批可用选项",
description: "使用薪福通templateId实时查询当前登录用户本人的承担部门,或按名称搜索城市和费用类型。所有结果只向用户展示label,Agent内部使用code提交;禁止向用户索取或展示内部编码。只返回可填写的code/label,不返回成员档案、员工号或原始上游数据。",
description: "使用薪福通templateId实时查询当前登录用户本人的承担部门,或按名称搜索城市和费用类型。所有结果只向用户展示label;用户选择后,values对应字段必须填同一项的code,不能填label。code仅留在工具调用内部,禁止向用户索取或展示。只返回可填写的code/label,不返回成员档案、员工号或原始上游数据。",
inputSchema: {
templateId: z.string().min(20).max(4096),
kind: z.enum(["department", "city", "expense_type"]),
@@ -199,7 +199,7 @@ export function createApprovalMcpServer(
const submissionInputSchema = {
templateId: z.string().min(20).max(4096).describe("模板列表返回的短期签名引用"),
values: z.record(z.string().min(1).max(200), z.unknown()).describe("按模板Schema填写的表单业务字段;不得包含发起人身份、员工号或审批状态"),
values: z.record(z.string().min(1).max(200), z.unknown()).describe("按模板Schema填写的表单业务字段;选择项必须使用所选label对应的code而不是label,code不得展示给用户;不得包含发起人身份、员工号或审批状态"),
deptId: z.number().int().default(-1).describe("仅钉钉OA使用;默认传-1,由服务器实时解析并验证当前登录用户所属部门"),
targetSelectActioners: z.array(z.object({
actionerKey: z.string().min(1).max(500),
@@ -210,7 +210,7 @@ export function createApprovalMcpServer(
server.registerTool("approval_prepare_submission", {
title: "校验并预览新审批",
description: "仅在用户已补齐所有必填字段并选择是否填写可选字段后调用。实时复核模板和字段构造能力,并自动查重本人近30天钉钉/薪福通发票。若返回缺失或无效字段,Agent必须继续询问;若为 client_only 则停止 API 构造。重复风险或查重不完整时不生成confirmationId,必须展示结果并询问用户;只有用户明确接受后才以allowDuplicateInvoices=true重试准备。成功预览仍需另行获得最终提交确认。",
description: "仅在用户已补齐所有必填字段并选择是否填写可选字段后调用。选择项在values中使用所选label对应的code,不能直接填label;code不得展示给用户。实时复核模板和字段构造能力,并自动查重本人近30天钉钉/薪福通发票。若返回缺失或无效字段,Agent必须继续询问;若为 client_only 则停止 API 构造。重复风险或查重不完整时不生成confirmationId,必须展示结果并询问用户;只有用户明确接受后才以allowDuplicateInvoices=true重试准备。成功预览会反向显示业务名称,仍需另行获得最终提交确认。",
inputSchema: submissionInputSchema,
annotations: { readOnlyHint: true, destructiveHint: false },
}, async ({ templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices }) => {
+241 -1
View File
@@ -19,6 +19,11 @@ const DINGTALK_SUBMISSION_TEMPLATES = {
} as const;
const DINGTALK_SUBMISSION_CATEGORIES = Object.keys(DINGTALK_SUBMISSION_TEMPLATES);
function xftSubmissionCapability(kind: XftSubmissionKind): Record<string, unknown> {
if (kind === "use_car_apply") return {
status: "client_only",
reason: "正式创建接口要求用车信息对象,但当前文档与实时模板未提供可验证的业务字段结构",
kind,
};
return {
status: "api_supported_with_constraints",
finalValidation: "薪福通创建接口会在正式提交时完成预算、权限和流程业务校验;创建后网关实时核验结果,流程可能进入待审批,也可能按企业规则即时完成",
@@ -467,7 +472,7 @@ export class UnifiedApprovalService {
applicant: "当前登录用户(由服务器映射,不能由 Agent 指定)",
identityValidation,
submissionStatus: "APPRV_PEND",
formSummary: summarizeValues(input.values),
formSummary: await readableXftSubmissionSummary(this.xft, user, schema.template, schema.fields, schema.inputContract, input.values),
processForecast: { supported: false, message: "薪福通正式接口不提供无副作用的流程预测;提交时由上游完成最终业务校验" },
};
fingerprint = hashJson({ template: schema.template, fields: schema.fields });
@@ -2021,6 +2026,9 @@ function validateXftSubmissionValues(
values: Record<string, unknown>,
customFields: XftSubmissionField[],
): void {
if (kind === "use_car_apply") {
throw new ApprovalSafetyError("用车申请的正式接口字段结构当前无法可靠构造,请在薪福通客户端发起");
}
const required: Record<XftSubmissionKind, string[]> = {
travel_apply: ["businessTrip"], expense_apply: ["applyExpenses"],
entertainment_apply: ["entertainmentApplyApiCreateDTO"], common_apply: [], use_car_apply: ["useCarInfo"],
@@ -2414,6 +2422,238 @@ function summarizeValue(value: unknown): unknown {
return String(value);
}
interface ReadableXftSummaryContext {
provider: XftApprovalProvider;
user: AuthenticatedDingTalkUser;
template: XftSubmissionTemplate;
customFields: XftSubmissionField[];
optionLabels: Map<string, string>;
}
async function readableXftSubmissionSummary(
provider: XftApprovalProvider,
user: AuthenticatedDingTalkUser,
template: XftSubmissionTemplate,
customFields: XftSubmissionField[],
inputContract: Record<string, unknown>,
values: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const valueContract = isSubmissionRecord(inputContract.values) ? inputContract.values : undefined;
const standardFields = Array.isArray(valueContract?.standardFields)
? valueContract.standardFields.filter(isSubmissionRecord)
: [];
const context: ReadableXftSummaryContext = {
provider, user, template, customFields, optionLabels: new Map(),
};
if (!standardFields.length) {
const compatibilitySummary: Record<string, unknown> = {
"预览说明": "当前实时模板未提供可读字段定义;已完成结构校验,但内部表单值不会在预览中展示",
"已填写业务区块数": Object.keys(values).length,
};
if (customFields.length && Object.hasOwn(values, "customFieldValues")) {
const custom = await readableXftCustomFields(values.customFieldValues, context, "表单自定义字段");
if (custom.length) compatibilitySummary["表单自定义字段"] = custom;
}
// Some third-party/test providers still implement the older contract. Never
// fall back to echoing their opaque values, because those may be codes or IDs.
return compatibilitySummary;
}
const output = await readableXftRecord(values, standardFields, context, "表单", true);
const known = new Set(standardFields.flatMap((field) => [
String(field.key ?? ""),
...(Array.isArray(field.aliases) ? field.aliases.map(String) : []),
]).filter(Boolean));
const standardHasCustomFields = standardFields.some((field) => String(field.key ?? "") === "customFieldValues");
if (!standardHasCustomFields && Object.hasOwn(values, "customFieldValues")) {
const custom = await readableXftCustomFields(values.customFieldValues, context, "表单自定义字段");
if (custom.length) output["表单自定义字段"] = custom;
}
known.add("customFieldValues");
const supplementary = Object.keys(values).filter((key) => !known.has(key));
if (supplementary.length) {
const invoiceOnly = supplementary.every((key) => /invoice|发票|票据/i.test(key));
if (!invoiceOnly) {
throw new ApprovalSafetyError("当前实时模板无法为部分已填写内容生成不含内部值的完整预览,请刷新模板后重新填写");
}
output["发票查重资料"] = "已提供;唯一标识仅用于查重,并在确认预览中脱敏展示";
}
return output;
}
async function readableXftRecord(
record: Record<string, unknown>,
descriptors: Array<Record<string, unknown>>,
context: ReadableXftSummaryContext,
path: string,
allowUnknownKeys = false,
): Promise<Record<string, unknown>> {
const output: Record<string, unknown> = {};
const knownKeys = new Set<string>();
for (const descriptor of descriptors) {
const key = String(descriptor.key ?? descriptor.fieldNumber ?? descriptor.titleCode ?? "");
if (!key) continue;
const aliases = Array.isArray(descriptor.aliases) ? descriptor.aliases.map(String) : [];
[key, ...aliases].forEach((candidate) => knownKeys.add(candidate));
const matchedKey = [key, ...aliases].find((candidate) => Object.hasOwn(record, candidate));
if (!matchedKey || isBlankSubmissionValue(record[matchedKey])) continue;
if (key === "customFieldValues") {
const custom = await readableXftCustomFields(record[matchedKey], context, `${path}自定义字段`);
if (custom.length) output["自定义字段"] = custom;
continue;
}
if (descriptor.userVisible === false || descriptor.serverManaged === true || descriptor.internallyDerived === true) continue;
const label = String(descriptor.label ?? descriptor.name ?? "业务字段");
output[label] = await readableXftValue(record[matchedKey], descriptor, context, `${path}“${label}”`);
}
if (!allowUnknownKeys && Object.keys(record).some((key) => !knownKeys.has(key))) {
throw new ApprovalSafetyError(`${path}包含当前实时模板未定义的内容,无法生成不含内部值的完整预览`);
}
return output;
}
async function readableXftValue(
value: unknown,
descriptor: Record<string, unknown>,
context: ReadableXftSummaryContext,
path: string,
): Promise<unknown> {
const selection = isSubmissionRecord(descriptor.selectionCapability) ? descriptor.selectionCapability : undefined;
if (selection?.status === "client_only") {
throw new ApprovalSafetyError(`${path}当前无法通过 MCP 按业务名称安全预览,请省略该可选项或在薪福通客户端完成`);
}
if (String(descriptor.key ?? "") === "customFieldValues") {
return readableXftCustomFields(value, context, path);
}
const type = String(descriptor.type ?? "").toUpperCase();
if (type.includes("TABLE")) return readableXftTable(value, descriptor, context, path);
const enumLabels = isSubmissionRecord(descriptor.enumLabels) ? descriptor.enumLabels : undefined;
const inlineOptions = Array.isArray(descriptor.options) ? descriptor.options.filter(isSubmissionRecord) : [];
const optionSource = isSubmissionRecord(descriptor.optionSource) ? descriptor.optionSource : undefined;
if (enumLabels || inlineOptions.length || optionSource) {
const selected = type === "COMMA_SEPARATED_ENUM" && typeof value === "string"
? value.split(",").map((item) => item.trim()).filter(Boolean)
: normalizeXftSelectedValues(value, type);
const labels = await Promise.all(selected.map((item) => readableXftOptionLabel(
item, descriptor, enumLabels, inlineOptions, optionSource, context, path,
)));
return labels.length === 1 && descriptor.multiple !== true && type !== "COMMA_SEPARATED_ENUM" ? labels[0] : labels;
}
const objectFields = Array.isArray(descriptor.fields) ? descriptor.fields.filter(isSubmissionRecord) : [];
if (objectFields.length) {
if (!isSubmissionRecord(value)) throw new ApprovalSafetyError(`${path}无法生成安全的业务预览,请重新填写`);
return readableXftRecord(value, objectFields, context, path);
}
const itemFields = Array.isArray(descriptor.itemFields) ? descriptor.itemFields.filter(isSubmissionRecord) : [];
if (type === "ARRAY" || Array.isArray(value)) {
if (!Array.isArray(value)) throw new ApprovalSafetyError(`${path}无法生成安全的业务预览,请重新填写`);
if (!itemFields.length) {
if (value.some((item) => item !== null && typeof item === "object")) {
throw new ApprovalSafetyError(`${path}缺少可读字段定义,无法安全展示`);
}
return value.map((item) => summarizeValue(item));
}
return Promise.all(value.map(async (item, index) => {
if (!isSubmissionRecord(item)) throw new ApprovalSafetyError(`${path}第 ${index + 1} 项无法生成安全的业务预览`);
return readableXftRecord(item, itemFields, context, `${path}第 ${index + 1} 项`);
}));
}
if (isSubmissionRecord(value)) {
throw new ApprovalSafetyError(`${path}缺少可读字段定义,无法安全展示`);
}
return summarizeValue(value);
}
function normalizeXftSelectedValues(value: unknown, type: string): string[] {
if (Array.isArray(value)) return value.map(String);
if (typeof value !== "string") return [String(value)];
if (type.includes("DEPARTMENT") || type.includes("ORG") || value.trim().startsWith("[")) {
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) return parsed.map(String);
} catch {
// A non-JSON option is handled as one selected value below.
}
}
return [value];
}
async function readableXftOptionLabel(
code: string,
descriptor: Record<string, unknown>,
enumLabels: Record<string, unknown> | undefined,
inlineOptions: Array<Record<string, unknown>>,
optionSource: Record<string, unknown> | undefined,
context: ReadableXftSummaryContext,
path: string,
): Promise<string> {
const fixed = typeof enumLabels?.[code] === "string" ? String(enumLabels[code]) : undefined;
if (fixed) return fixed;
const inline = inlineOptions.find((item) => String(item.code ?? "") === code);
if (inline && typeof inline.label === "string" && inline.label.trim()) return inline.label.trim();
const kind = optionSource?.kind;
if (kind === "department" || kind === "city" || kind === "expense_type") {
const cacheKey = `${kind}\u0000${code}`;
const cached = context.optionLabels.get(cacheKey);
if (cached) return cached;
const queried = await context.provider.getSubmissionOptions(context.user, context.template, kind, code, 200);
const matched = queried.items.find((item) => item.code === code && item.label.trim());
if (matched) {
context.optionLabels.set(cacheKey, matched.label.trim());
return matched.label.trim();
}
}
throw new ApprovalSafetyError(`${path}的当前选择已失效或无法映射为业务名称,请重新查询可用名称后选择`);
}
async function readableXftCustomFields(
value: unknown,
context: ReadableXftSummaryContext,
path: string,
): Promise<Array<Record<string, unknown>>> {
if (value === undefined) return [];
if (!Array.isArray(value)) throw new ApprovalSafetyError(`${path}无法生成安全的业务预览`);
const known = new Map(context.customFields.map((field) => [field.fieldNumber, field]));
return Promise.all(value.map(async (item, index) => {
if (!isSubmissionRecord(item)) throw new ApprovalSafetyError(`${path}第 ${index + 1} 项无法生成安全的业务预览`);
const field = known.get(String(item.fieldNumber ?? ""));
if (!field) throw new ApprovalSafetyError(`${path}包含当前实时模板无法识别的字段,请刷新模板后重试`);
const descriptor: Record<string, unknown> = {
...field,
key: field.fieldNumber,
label: field.name,
};
return { [field.name]: await readableXftValue(item.fieldValue, descriptor, context, `${path}“${field.name}”`) };
}));
}
async function readableXftTable(
value: unknown,
descriptor: Record<string, unknown>,
context: ReadableXftSummaryContext,
path: string,
): Promise<Array<Record<string, unknown>>> {
let rows = value;
if (typeof rows === "string") {
try { rows = JSON.parse(rows); } catch { throw new ApprovalSafetyError(`${path}无法生成安全的业务预览`); }
}
if (!Array.isArray(rows)) throw new ApprovalSafetyError(`${path}无法生成安全的业务预览`);
const itemFields = Array.isArray(descriptor.itemFields) ? descriptor.itemFields.filter(isSubmissionRecord) : [];
return Promise.all(rows.map(async (row, index) => {
if (!isSubmissionRecord(row) || !Array.isArray(row.columnValues)) {
throw new ApprovalSafetyError(`${path}第 ${index + 1} 行无法生成安全的业务预览`);
}
const output: Record<string, unknown> = {};
for (const column of row.columnValues) {
if (!isSubmissionRecord(column)) throw new ApprovalSafetyError(`${path}第 ${index + 1} 行无法生成安全的业务预览`);
const columnDescriptor = itemFields.find((item) => String(item.titleCode ?? "") === String(column.titleCode ?? ""));
if (!columnDescriptor) throw new ApprovalSafetyError(`${path}包含当前实时模板无法识别的列,请刷新模板后重试`);
const label = String(columnDescriptor.label ?? "业务字段");
output[label] = await readableXftValue(column.value, columnDescriptor, context, `${path}第 ${index + 1} 行“${label}”`);
}
return output;
}));
}
function submissionStringValue(value: unknown): string {
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
+48
View File
@@ -1663,18 +1663,66 @@ function submissionInputContract(kind: XftSubmissionKind): Record<string, unknow
values: {
...core[kind],
standardFields: submissionStandardFields(kind),
minimalExample: submissionMinimalExample(kind),
},
customFieldValues: "使用 fields[].fieldNumber 与 fieldValue 组装;具体值格式以 fields[].type/options 为准",
serverManagedFields: [...XFT_MANAGED_SUBMISSION_FIELDS],
submissionStatus: "APPRV_PEND",
notes: [
"申请人身份、表单编码、权限校验和审批状态由网关填写,Agent 不得传入",
"minimalExample 只说明 values 的 JSON 形状;尖括号占位符必须替换为用户业务回答或实时选项对应值,绝不能原样提交",
"表单配置中的 billCompanyCode/billDepartmentCode 属于标准抬头字段,不放入 customFieldValues;两者均省略时由薪福通按当前申请人默认公司和部门填写",
"薪福通不提供无副作用的流程预测接口,准备阶段只做本地结构校验,最终业务校验在提交时完成",
],
};
}
function submissionMinimalExample(kind: XftSubmissionKind): Record<string, unknown> {
const city = "<由城市选项中用户所选名称对应的code替换>";
const expense = "<由费用类型选项中用户所选名称对应的code替换>";
const currency = "<人民币对应的内部枚举值>";
if (kind === "travel_apply") return {
businessTrip: { businessTripDetails: [{
departCityCode: city, destinationCityCode: city,
beginTime: "<yyyy-MM-dd HH:mm>", endTime: "<yyyy-MM-dd HH:mm>",
}] },
};
if (kind === "expense_apply") return {
applyExpenses: [{ expenseNumber: expense, amount: "<正数金额,最多两位小数>" }],
};
if (kind === "entertainment_apply") return {
entertainmentApplyApiCreateDTO: {
entertainmentCityCode: city, entertainmentPersonCount: "<正整数>",
entertainmentStartTime: "<yyyy-MM-dd>", entertainmentEndTime: "<yyyy-MM-dd>",
},
};
if (kind === "common_apply") return {
customFieldValues: [{
fieldNumber: "<从实时fields中复制当前字段的fieldNumber,仅工具内部使用>",
fieldValue: "<按该field的type/options序列化用户业务回答>",
}],
};
if (kind === "common_reimburse") return {
reimAmount: "<报销总金额>", reimAmountCurrencyCode: currency,
expenseList: [{
expTypeCode: expense, currencyCode: currency,
originalCurrencyReimAmt: "<本笔费用金额>",
}],
};
if (kind === "travel_reimburse") return {
reimAmount: "<报销总金额>", reimAmountCurrencyCode: currency,
expenseList: [{
expTypeCode: expense, currencyCode: currency,
originalCurrencyReimAmt: "<本笔费用金额>",
}],
businessTrip: { businessTripDetails: [{
departCityCode: city, destinationCityCode: city,
beginTime: "<yyyy-MM-dd HH:mm>", endTime: "<yyyy-MM-dd HH:mm>",
}] },
};
return { clientOnly: "用车信息对象当前必须在薪福通客户端填写" };
}
function submissionStandardFields(kind: XftSubmissionKind): Array<Record<string, unknown>> {
if (kind === "travel_reimburse") return travelReimbursementFields();
if (kind === "common_reimburse") return commonReimbursementFields();
+4 -1
View File
@@ -249,6 +249,7 @@ test("MCP approval_login returns both portable text and a standard resource link
assert.match(instructions, /审批引用.*调用容量.*内部分批.*不得向用户解释/);
assert.match(instructions, /提单草稿仅可保存在 Agent 所在机器.*不得上传网关.*实时模板 Schema/s);
assert.match(instructions, /所有选择型字段只向用户展示业务名称.*optionSource.*label.*enumLabels.*城市码.*组织码.*工号.*client_only/s);
assert.match(instructions, /用户选定label.*values.*code.*不能填label.*工具调用内部/s);
assert.doesNotMatch(instructions, /最多.*20|selectionId|approvalId|targetId/);
const history = tools.tools.find((tool) => tool.name === "approval_list_history")!;
assert.equal((history.inputSchema as any).properties.startDate.default, "");
@@ -258,7 +259,9 @@ test("MCP approval_login returns both portable text and a standard resource link
assert.match(batch.description ?? "", /Schema容量.*不向用户解释.*任一无效则零写入/s);
const options = tools.tools.find((tool) => tool.name === "approval_get_submission_options")!;
assert.deepEqual((options.inputSchema as any).properties.kind.enum, ["department", "city", "expense_type"]);
assert.match(options.description ?? "", /所有选项只向用户展示label.*code仅供内部提交/s);
assert.match(options.description ?? "", /所有选项只向用户展示label.*values.*code.*不能填label.*工具内部/s);
const submissionSchema = tools.tools.find((tool) => tool.name === "approval_get_submission_schema")!;
assert.match(submissionSchema.description ?? "", /minimalExample.*尖括号必须替换/);
} finally {
await client.close();
await server.close();
+5 -2
View File
@@ -52,7 +52,8 @@ test("MCP initialization teaches the workflow while tools/list stays compact", a
assert.match(client.getInstructions() ?? "", /恢复时.*实时读取 Schema/s);
assert.match(client.getInstructions() ?? "", /自动清理超过 90 天/s);
assert.match(client.getInstructions() ?? "", /outcome_uncertain.*冻结.*禁止自动重试/s);
assert.match(client.getInstructions() ?? "", /所有选择型字段只向用户展示业务名称.*optionSource.*options\/enumLabels.*内部映射正式值/s);
assert.match(client.getInstructions() ?? "", /所有选择型字段只向用户展示业务名称.*optionSource.*options\/enumLabels.*values.*正式值/s);
assert.match(client.getInstructions() ?? "", /用户选择 label.*values.*同一选项的 code.*不能把 label.*code 只存在于工具调用内部/s);
assert.match(client.getInstructions() ?? "", /绝不向用户展示、解释或索取.*城市码.*组织码.*工号.*档案编号.*数字舱位值.*内部 ID/s);
assert.match(client.getInstructions() ?? "", /selectionCapability\.status=client_only.*必填时停止.*可选时.*跳过/s);
@@ -86,11 +87,13 @@ test("MCP initialization teaches the workflow while tools/list stays compact", a
assert.match(descriptions.approval_process_batch ?? "", /Schema容量.*不得转述给用户.*任一无效.*零写入/s);
assert.match(descriptions.approval_prepare_submission ?? "", /明确确认|确认/);
assert.match(descriptions.approval_get_submission_schema ?? "", /必填字段齐全.*可选字段/);
assert.match(descriptions.approval_get_submission_options ?? "", /承担部门.*城市.*费用类型.*只向用户展示label.*内部使用code/s);
assert.match(descriptions.approval_get_submission_schema ?? "", /minimalExample.*values形状.*尖括号必须替换/);
assert.match(descriptions.approval_get_submission_options ?? "", /承担部门.*城市.*费用类型.*只向用户展示label.*values.*code.*不能填label/s);
const optionSchema = tools.tools.find((tool) => tool.name === "approval_get_submission_options")?.inputSchema as any;
assert.deepEqual(optionSchema.properties.kind.enum, ["department", "city", "expense_type"]);
assert.match(descriptions.approval_prepare_submission ?? "", /继续询问/);
assert.match(descriptions.approval_prepare_submission ?? "", /查重.*allowDuplicateInvoices/s);
assert.match(descriptions.approval_prepare_submission ?? "", /values.*code.*不能直接填label.*预览.*业务名称/s);
const prepareSchema = tools.tools.find((tool) => tool.name === "approval_prepare_submission")?.inputSchema as any;
assert.equal(prepareSchema.properties.allowDuplicateInvoices.default, false);
assert.match(descriptions.approval_list_submission_templates ?? "", /出差申请.*外出.*差旅费用报销.*业务招待费用报销/);
+158
View File
@@ -1108,6 +1108,164 @@ test("Xft application templates support API submission with live post-write stat
assert.equal(travelPrepared.status, "confirmation_required");
});
test("Xft preparation preview resolves every selection to user-readable business names", async () => {
const template: XftSubmissionTemplate = {
eventSeqId: "event-travel", eventNumber: "XFT-TRAVEL", eventName: "差旅报销单",
eventType: "TRAVEL_REIM", kind: "travel_reimburse",
};
const fields: XftSubmissionField[] = [
{
fieldNumber: "C-TOPIC", name: "申请事项", type: "SELECT", required: true,
options: [{ code: "PROCUREMENT", label: "采购事项" }],
selectionCapability: { status: "supported", userInput: "label", submitValue: "code", exposeInternalValue: false },
},
{
fieldNumber: "C-TABLE", name: "补充明细", type: "TABLE", required: true,
itemFields: [{
titleCode: "T-TYPE", label: "明细类型", type: "SELECT", required: true,
options: [{ code: "MARKETING", label: "市场活动" }],
selectionCapability: { status: "supported", userInput: "label", submitValue: "code", exposeInternalValue: false },
}],
},
];
const standardFields = [
{ key: "reimAmount", label: "报销总金额", type: "decimal" },
{ key: "reimAmountCurrencyCode", label: "报销币种", type: "string", enumLabels: { CNY: "人民币" } },
{
key: "expenseList", label: "费用明细", type: "array", itemFields: [
{ key: "expTypeCode", label: "费用类型", type: "string", optionSource: { kind: "expense_type" } },
{ key: "currencyCode", label: "费用币种", type: "string", enumLabels: { CNY: "人民币" } },
{ key: "originalCurrencyReimAmt", label: "费用金额", type: "decimal" },
{ key: "payCityCode", label: "消费城市", type: "string", optionSource: { kind: "city" } },
],
},
{
key: "businessTrip", label: "差旅行程", type: "object", fields: [{
key: "businessTripDetails", label: "行程明细", type: "array", itemFields: [
{ key: "departCityCode", label: "出发城市", type: "string", optionSource: { kind: "city" } },
{ key: "destinationCityCode", label: "目的城市", type: "string", optionSource: { kind: "city" } },
{ key: "beginTime", label: "出发时间", type: "datetime" },
{ key: "endTime", label: "结束时间", type: "datetime" },
{ key: "travelMode", label: "出行方式", type: "comma_separated_enum", multiple: true, enumLabels: { AirPlane: "飞机", Train: "火车" } },
{ key: "roundTrip", label: "是否往返", type: "string", enumLabels: { Y: "往返", N: "单程" } },
],
}],
},
{ key: "customFieldValues", userVisible: false, type: "array" },
];
const optionCalls: Array<{ kind: string; query: string }> = [];
const xft = {
async listSubmissionTemplates() { return [template]; },
async getSubmissionSchema() {
return { template, fields, inputContract: { values: { standardFields } }, diagnostics: {} };
},
async getSubmissionOptions(_user: unknown, _template: unknown, kind: string, query: string) {
optionCalls.push({ kind, query });
const labels: Record<string, string> = {
"440300": "深圳市", "310100": "上海市", MEAL: "餐饮费",
};
return {
kind,
items: labels[query] ? [{ code: query, label: labels[query] }] : [],
diagnostics: {},
};
},
async validateSubmissionIdentity() { return { validated: true, diagnostics: {} }; },
};
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
const user = { userId: "user-1" };
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
const prepared = await service.prepareSubmission(user, {
templateId,
values: {
reimAmount: "100.00",
reimAmountCurrencyCode: "CNY",
expenseList: [{
expTypeCode: "MEAL", currencyCode: "CNY", originalCurrencyReimAmt: "100.00", payCityCode: "440300",
}],
businessTrip: { businessTripDetails: [{
departCityCode: "440300", destinationCityCode: "310100",
beginTime: "2026-08-20 09:00", endTime: "2026-08-21 18:00",
travelMode: "AirPlane,Train", roundTrip: "Y",
}] },
customFieldValues: [
{ fieldNumber: "C-TOPIC", fieldValue: "PROCUREMENT" },
{ fieldNumber: "C-TABLE", fieldValue: JSON.stringify([{ sequence: 1, columnValues: [{ titleCode: "T-TYPE", value: "MARKETING" }] }]) },
],
},
});
assert.equal(prepared.status, "confirmation_required");
const summary = JSON.stringify(prepared.preview.formSummary);
for (const label of ["深圳市", "上海市", "餐饮费", "人民币", "飞机", "火车", "往返", "采购事项", "市场活动"]) {
assert.match(summary, new RegExp(label));
}
for (const internal of ["440300", "310100", "MEAL", "C-TOPIC", "PROCUREMENT", "CNY", "T-TYPE", "MARKETING"]) {
assert.doesNotMatch(summary, new RegExp(internal));
}
assert.deepEqual(optionCalls, [
{ kind: "expense_type", query: "MEAL" },
{ kind: "city", query: "440300" },
{ kind: "city", query: "310100" },
]);
});
test("Xft preparation fails before confirmation when a selected code has no readable mapping", async () => {
const template: XftSubmissionTemplate = {
eventSeqId: "event-expense", eventNumber: "XFT-EXPENSE", eventName: "费用申请单",
eventType: "EXPENSE_APPLY", kind: "expense_apply",
};
const xft = {
async listSubmissionTemplates() { return [template]; },
async getSubmissionSchema() {
return {
template,
fields: [],
inputContract: { values: { standardFields: [{
key: "applyExpenses", label: "事前申请费用列表", type: "array", itemFields: [
{ key: "expenseNumber", label: "费用类型", type: "string", optionSource: { kind: "expense_type" } },
{ key: "amount", label: "申请金额", type: "decimal" },
],
}] } },
diagnostics: {},
};
},
async getSubmissionOptions() { return { kind: "expense_type", items: [], diagnostics: {} }; },
async validateSubmissionIdentity() { return { validated: true, diagnostics: {} }; },
};
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
const user = { userId: "user-1" };
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
await assert.rejects(
service.prepareSubmission(user, {
templateId, values: { applyExpenses: [{ expenseNumber: "UNKNOWN", amount: "10.00" }] },
}),
/无法映射为业务名称.*重新查询可用名称/,
);
});
test("Xft use-car application is explicitly client-only and cannot be prepared", async () => {
const template: XftSubmissionTemplate = {
eventSeqId: "event-car", eventNumber: "XFT-CAR", eventName: "用车申请单",
eventType: "USE_CAR_APPLY", kind: "use_car_apply",
};
const xft = {
async listSubmissionTemplates() { return [template]; },
async getSubmissionSchema() {
return { template, fields: [], inputContract: { values: { standardFields: [] } }, diagnostics: {} };
},
};
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
const user = { userId: "user-1" };
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
const schema = await service.getSubmissionSchema(user, templateId);
assert.equal(schema.submissionCapability.status, "client_only");
assert.match(String(schema.submissionCapability.reason), /用车信息对象.*未提供可验证/);
await assert.rejects(
service.prepareSubmission(user, { templateId, values: { useCarInfo: { purpose: "客户拜访" } } }),
/用车申请.*薪福通客户端/,
);
});
test("Xft preparation reports standard and live custom required fields together", async () => {
const template: XftSubmissionTemplate = {
eventSeqId: "1", eventNumber: "XFT-E", eventName: "招待费用申请单",
+8
View File
@@ -368,6 +368,9 @@ test("Xft submission discovers live templates and derives protected create field
assert.equal(schema.fields[0]?.fieldNumber, "C1");
assert.equal(schema.fields.length, 1);
const standardFields = (schema.inputContract.values as Record<string, unknown>).standardFields as Array<Record<string, unknown>>;
const minimalExample = (schema.inputContract.values as Record<string, unknown>).minimalExample as Record<string, unknown>;
assert.deepEqual(Object.keys(minimalExample), ["reimAmount", "reimAmountCurrencyCode", "expenseList"]);
assert.match(JSON.stringify(minimalExample), /报销总金额.*费用类型选项.*本笔费用金额/);
const expenseList = standardFields.find((field) => field.key === "expenseList");
const expenseItemFields = expenseList?.itemFields as Array<Record<string, unknown>>;
assert.deepEqual(expenseItemFields.filter((field) => field.required).map((field) => field.key), [
@@ -509,6 +512,11 @@ test("all formal Xft city and enum choices expose readable labels while unresolv
const templates = await client.listSubmissionTemplates({ userId: "ding-u1" }, 20);
const schemas = await Promise.all(templates.map((template) => client.getSubmissionSchema({ userId: "ding-u1" }, template)));
const combined = schemas.map((schema) => schema.inputContract);
for (const schema of schemas) {
const values = schema.inputContract.values as Record<string, unknown>;
assert.ok(values.minimalExample && typeof values.minimalExample === "object");
assert.match(JSON.stringify(values.minimalExample), /</);
}
for (const key of ["payCityCode", "entertainmentCityCode", "departCityCode", "destinationCityCode", "departureCode", "destinationCode"]) {
const matches = findRecordsByKey(combined, key);
assert.ok(matches.length > 0, `${key} should exist`);