feat: expose readable submission choices
This commit is contained in:
@@ -20,7 +20,7 @@ Agent → 远程 Streamable HTTP MCP / OAuth 统一网关
|
||||
- `approval_process_batch`:一次最多处理 20 条;整批引用预检通过后才逐项执行,并在写后复核。
|
||||
- `approval_list_submission_templates`:实时列出当前用户的提单候选模板。
|
||||
- `approval_get_submission_schema`:返回结构化可填写字段,不传输巨大原始 Schema。
|
||||
- `approval_get_submission_options`:实时查询当前用户可用的薪福通部门、城市和费用类型;费用类型面向用户只展示名称,内部映射正式接口编码。
|
||||
- `approval_get_submission_options`:实时查询当前用户可用的薪福通部门、城市和费用类型;所有选项面向用户只展示业务名称,内部映射正式接口值。
|
||||
- `approval_prepare_submission`:校验内容,自动查重本人近 30 天跨钉钉/薪福通的发票,并在安全时生成完整预览及短期确认引用;不创建单据。
|
||||
- `approval_submit`:用户确认预览后,使用相同内容正式发起审批。
|
||||
- `approval_revoke_submission`:使用提单成功后返回的 `revokeId` 撤销本人刚通过 MCP 发起的钉钉或薪福通审批。
|
||||
@@ -46,7 +46,9 @@ Agent 不传 `userId`、员工号或单据所有者。网关从 OAuth Token 确
|
||||
|
||||
发起审批固定为“列模板 → 取 Schema → 自动查重/预览 → 用户确认 → 正式提交”。准备阶段从新表单提取发票代码与号码、数电票唯一编号、票据号码、附件哈希;没有唯一编号时,金额、日期与商户组合只作为疑似提示。网关实时刷新当前用户本人近 30 天的钉钉和薪福通已发起审批:原单待审批、已通过或状态未知时默认阻止,原单拒绝、撤回、作废或弱匹配时警告。任一来源查询不完整也不会当成“没有重复”。只有用户看过匹配单号、状态和覆盖情况并明确要求继续后,Agent 才能以 `allowDuplicateInvoices=true` 重新准备。
|
||||
|
||||
费用类型不要求用户理解薪福通内部编码。实时 Schema 会把费用申请、差旅预计费用及报销明细中的费用类型字段标记为 `expense_type` 选项;用户未主动提供时,Agent 查询企业实时费用目录并直接展示“交通费、住宿费”等名称,选定后仅在内部写入对应编码。无法取得目录时安全停止,不退化为让用户填写编码。
|
||||
用户不需要理解薪福通的任何内部选择值。实时 Schema 会把部门、城市、费用类型、出行方式、往返、时间范围、币种和酒店星级等字段标记为名称优先的选择项;Agent 只展示“深圳市”“交通费”“飞机”“往返”“人民币”等业务名称,选定后仅在内部写入正式接口值。动态下拉框也会标准化成 `label/code`,表格子字段同样递归处理。无法取得可靠名称映射时安全停止或允许跳过可选项,不退化为向用户索取工号、城市码、组织码、档案编号、数字舱位值或其他内部 ID。
|
||||
|
||||
人员、企业自定义档案、关联前置单据、分摊及文档未给出可靠名称映射的舱位字段目前标记为 `client_only`。必填时不生成提交预览并引导薪福通客户端;可选时不会作为普通问题打扰用户。单据公司、部门、字段引用和行程序号等服务器管理或内部派生值也不会作为用户输入项展示。
|
||||
|
||||
服务端临时保留不可逆的用户/发票 HMAC 指纹。首次使用时只为最近 30 天尚未索引的审批读取详情;以后准备提单会刷新精简历史状态,并只为新增审批补建指纹,不会重复逐张下载已索引发票。指纹和最小审批索引最多保留 35 天,过期后自动级联删除;10 分钟提单预留到期即删除。
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
Agent 面向统一审批语义,不直接理解钉钉 `processInstanceId/taskId`、薪福通员工号、上游响应或完整 Schema。网关在内部完成来源路由、字段抽取、动作调用和写后复核,只返回审批判断所需的精简字段与统一状态。
|
||||
|
||||
薪福通费用类型同样按领域选项抽象。网关先读取当前模板字段中的实时选项;模板未内嵌时,再从企业多级数据源中只定位费用类型目录并返回精简的 `label/code` 映射。Agent 只把 `label` 展示给用户,`code` 仅用于构造正式接口的 `expenseNumber`、`expenseTypeNumber` 或 `expTypeCode`。响应不包含完整数据源、内部身份或其他企业档案,且不持久化费用目录。
|
||||
薪福通所有选择字段按领域选项抽象。部门、城市和费用类型由选项工具返回精简 `label/code` 映射;模板内嵌下拉框被标准化成同样结构,固定枚举通过 `enumLabels` 提供中文名称,表格子字段递归应用相同规则。Agent 只把业务名称展示给用户,正式值仅用于内部构造请求。无法取得可靠名称映射的人员、档案、关联单据、分摊和部分舱位字段标记为 `client_only`,必填时安全停止、可选时允许省略,绝不转而索取工号或编码。响应不包含完整数据源、内部身份或其他企业档案,选项目录也不持久化。
|
||||
|
||||
所有工具以普通 `content.text` 中的精简 JSON 作为跨宿主可见结果,同时保留相同的精简 `structuredContent` 供支持结构化结果的宿主使用;两处都不携带诊断字段或上游原始响应。`raw` 仍是受过滤、分页和硬限额保护的按需诊断视图,不是默认数据通道。
|
||||
|
||||
@@ -106,4 +106,4 @@ Agent 面向统一审批语义,不直接理解钉钉 `processInstanceId/taskId
|
||||
|
||||
钉钉企业 OpenAPI 与 DWS 的个人接口不是同一能力面,但两者都支持无副作用的流程预测。网关在准备提单时调用企业接口 `POST /v1.0/workflow/processes/forecast`,发起人身份仍由服务器从已验证 OAuth 用户派生;只有预测成功并补齐全部自选处理人后才签发提交确认。预测请求不会创建审批实例,也不会持久化表单正文。包含 `DDBizSuite` 的考勤/费控模板不会把套件子字段伪装成普通字段提交。`业务招待申请_北极大`虽由普通字段和明细表组成,但真实测试中新版企业创建接口返回系统异常、旧版企业接口拒绝组件格式。当前 6 个白名单钉钉模板因此全部标记为 `client_only`,必须在客户端发起。
|
||||
|
||||
薪福通 Schema 会组合正式创建接口字段与企业实时自定义字段。准备阶段递归校验报销金额、费用明细、表格自定义字段、企业必填字段、当前用户成员映射与员工号。人员字段使用花名册编号数组,部门字段使用组织编码数组,表格字段使用带 `sequence`、`columnValues` 和 `titleCode` 的 JSON 行结构。薪福通没有无副作用的流程预测接口,因此所有开放的创建接口都在写后实时核验结果;申请类流程若按企业配置即时完成,会明确返回 `completed`,而不是误报异常。
|
||||
薪福通 Schema 会组合正式创建接口字段与企业实时自定义字段。准备阶段递归校验报销金额、费用明细、表格自定义字段、企业必填字段和当前用户身份映射。内部仍按正式接口需要使用组织值、选项值及表格 `titleCode`,但这些只存在于 Agent 与网关的工具请求中;用户只选择名称。人员等没有安全名称查询能力的字段不会接受调用方直接塞入内部值。薪福通没有无副作用的流程预测接口,因此所有开放的创建接口都在写后实时核验结果;申请类流程若按企业配置即时完成,会明确返回 `completed`,而不是误报异常。
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
| 钉钉 OA | 差旅费用报销_捷康 | 仅客户端 | 钉钉费控报销套件含行程、报销明细、发票、收款账户和企业主体;另有关联出差申请和项目字段 |
|
||||
| 钉钉 OA | 业务招待费用报销_捷康 | 仅客户端 | 钉钉费控报销套件含报销明细、发票、收款账户和付款主体 |
|
||||
|
||||
薪福通自定义字段统一使用 `customFieldValues[].fieldNumber/fieldValue`。日期字段使用 `yyyy-MM-dd HH:mm`,人员字段值是花名册编号 JSON 数组,部门字段值是组织编码 JSON 数组,表格字段值是包含 `sequence`、`columnValues`、`titleCode` 的 JSON 数组字符串,附件字段值是先通过薪福通附件能力取得的附件编号。`approval_get_submission_schema` 会返回实时 `valueFormat`;Agent 不应按字段名称猜编码或结构。
|
||||
薪福通自定义字段在工具内部统一使用 `customFieldValues[].fieldNumber/fieldValue`,但用户不应看到或提供这些引用。日期字段使用 `yyyy-MM-dd HH:mm`;部门由可读名称选择后内部映射;下拉框与表格子字段使用实时名称映射。人员、附件及其他没有安全名称查询或上传能力的字段标记为 `client_only`,不会要求用户填写花名册编号或附件编号。`approval_get_submission_schema` 会返回实时 `valueFormat` 和 `selectionCapability`;Agent 不得按字段名称猜编码或结构。
|
||||
|
||||
薪福通模板要求承担部门、城市或费用类型时,使用同一 `templateId` 调用 `approval_get_submission_options`。`kind=department` 只会返回当前登录用户本人档案中的可选组织;`kind=city` 可用 `query` 按城市名称搜索;`kind=expense_type` 先读取模板内嵌选项,再按需读取企业多级费用类型数据源。费用申请的 `expenseNumber`、差旅预计费用的 `expenseTypeNumber` 和报销明细的 `expTypeCode` 都在 Schema 中标记这一选项源。Agent 只向用户显示 `label` 并在内部使用 `code`,不得向用户索取内部编码。城市全量接口较慢,网关给予独立的 45 秒超时并在进程内缓存字典 24 小时,不写入磁盘;费用类型保持实时且不落盘。响应不会暴露员工号、完整数据源或原始成员档案。
|
||||
薪福通模板要求承担部门、城市或费用类型时,使用同一 `templateId` 调用 `approval_get_submission_options`。`kind=department` 只会返回当前登录用户本人档案中的可选组织;`kind=city` 可用 `query` 按城市名称搜索;`kind=expense_type` 先读取模板内嵌选项,再按需读取企业多级费用类型数据源。费用申请、差旅预计费用及报销明细均标记对应选项源。固定枚举提供 `enumLabels`,例如出行方式、单程/往返、上午/下午、人民币和酒店星级;动态 `SELECT` 的 `options` 统一为 `{label, code}`,表格列也递归标准化。Agent 只向用户显示名称并在内部使用正式值,不得向用户索取内部编码。城市全量接口较慢,网关给予独立的 45 秒超时并在进程内缓存字典 24 小时,不写入磁盘;费用类型保持实时且不落盘。响应不会暴露员工号、完整数据源或原始成员档案。
|
||||
|
||||
薪福通的自定义字段接口可能同时列出 `billCompanyCode`、`billDepartmentCode` 等标准单据抬头字段,也可能用动态字段号返回名为“承担部门/单据部门”的 `ADV_DEPARTMENT` 控件,但创建接口禁止把它们放进 `customFieldValues`。MCP 会剔除这些服务器管理控件;公司和部门都省略时,薪福通根据当前登录申请人的档案默认填写。若通过标准抬头字段显式指定,则公司与部门必须成对填写。
|
||||
薪福通的自定义字段接口可能同时列出标准单据抬头控件,也可能用动态字段号返回名为“承担部门/单据部门”的控件,但创建接口禁止把这些标准控件放进 `customFieldValues`。MCP 会剔除服务器管理控件并拒绝调用方显式指定公司或部门;两者省略时由薪福通根据当前登录申请人的档案默认填写。真正的业务部门选择字段则只展示当前用户可用的部门名称并在内部映射。
|
||||
|
||||
任一来源模板的 `submissionCapability.status=client_only` 时,`approval_prepare_submission` 会再次拒绝,不能通过修改本地 Skill 或桥绕过。薪福通白名单模板使用正式创建接口,并在写后实时核验:`billSts=1` 为待审批,`billSts=2` 为企业流程规则导致的即时完成。当前列出的 6 个钉钉模板均为 `client_only`;对于以后可由 API 构造的钉钉模板,网关会先调用企业接口 `POST /v1.0/workflow/processes/forecast`,预测成功并补齐自选处理人后才生成提交确认。
|
||||
|
||||
@@ -5,10 +5,10 @@ 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.
|
||||
For an XFT department, city, or expense type, call `approval_get_submission_options` with the same `templateId`; use only a returned `code`. Department results are restricted to the current signed-in user's member record. If the user has not provided an expense type, call it with `kind=expense_type`, present only the returned `label` values as user-readable choices, and map the selected label to its `code` internally. Never show, explain, or ask the user for `expenseNumber`, `expenseTypeNumber`, `expTypeCode`, or any other internal expense code. If the tool cannot return expense types, stop and offer retry or native-client completion instead of asking for a code.
|
||||
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.
|
||||
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 optional fields with short explanations and ask whether the user wants to add any. The user may choose to skip all optional fields; record that choice instead of silently omitting them.
|
||||
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.
|
||||
@@ -18,6 +18,6 @@ If the user explicitly asks to undo a DingTalk or XFT approval just created thro
|
||||
|
||||
For an XFT submission or recall result that needs reconciliation, call `approval_get_submission_status` with the `businessId` returned by `approval_submit`. It is read-only and revalidates that the bill belongs to the signed-in user's initiated bills. Report `statusName` and the compact flow history. `completed` means the workflow finished immediately under the enterprise's approval rules (for example, no other effective approver); do not retry it. A fresh short-lived `revokeId` is returned only while the live bill status is pending approval; it still requires the user's explicit authorization before one recall attempt. Never treat status lookup alone as permission to repeat a write, and never retry `completed`, `upstream_state_mismatch`, or `outcome_uncertain`.
|
||||
|
||||
Never ask for or pass an applicant user ID, employee number, owner, or approval status. The gateway derives them from the authenticated user. For XFT custom fields, use the live `fieldNumber` and its declared `valueFormat`; people use roster-number arrays, departments use organization-code arrays, and tables use `sequence`/`columnValues`/`titleCode`. If a required field type is unsupported or XFT reports a missing employee profile or permission, explain the requirement and stop.
|
||||
Never ask for or pass an applicant user ID, employee number, owner, or approval status. The gateway derives them from the authenticated user. For XFT custom fields, keep live `fieldNumber`, option codes, table `titleCode`, and serialization details internal to the tool call. Departments must be selected by returned department names; person fields currently require the native client because the MCP has no safe name lookup. If a required field type is unsupported or XFT reports a missing employee profile or permission, explain the business requirement without exposing internal identifiers and stop.
|
||||
|
||||
Local file paths and file bytes are not valid attachment field values. When the live Schema requires an upstream attachment number but the MCP has no attachment-upload tool, preserve any local draft and direct the user to complete that form in the native client. Never pass a local path, base64 content, or an invented attachment number to `approval_prepare_submission`.
|
||||
|
||||
@@ -66,7 +66,7 @@ To resume:
|
||||
4. For each attachment, verify the regular file still exists and recompute SHA-256. If it is missing or its hash changed, show the attachment display name and require the user to select or confirm the file again. A changed modification time with the same hash may update metadata without changing the business content.
|
||||
5. Continue the required and optional field collection from `create-approval.md`.
|
||||
|
||||
Treat a stored expense-type code as an internal form value. On resume, query `approval_get_submission_options(kind=expense_type)` again, verify that the stored code still maps to a live label, and use only that label in the conversation. If the mapping changed or disappeared, show the current labels and ask the user to choose again; never expose the stored code.
|
||||
Treat every stored selection value as an internal form value. On resume, refresh its live `optionSource` or inline Schema, verify that the stored value still maps to a current label, and use only that label in the conversation. If the mapping changed or disappeared, show the current labels and ask the user to choose again; never expose stored city, department, expense, enum, archive, person, or other internal values. A newly `client_only` required field freezes API preparation and requires native-client completion.
|
||||
|
||||
Delete only the selected local file. Confirm deletion in business terms. Deleting a local draft never calls an upstream revoke or approval API.
|
||||
|
||||
|
||||
+3
-3
@@ -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,短期提交引用不得写入草稿。薪福通费用类型未提供时调用approval_get_submission_options(kind=expense_type),只向用户展示label并在内部使用code,绝不索取或展示内部费用编码。",
|
||||
instructions: "首次使用或登录链接过期时调用approval_login;每次调用会检查登录并在需要时生成有效的新链接,绝不因链接过期而重新安装。工具不会通过其他消息通道自动发送链接,必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。审批引用、调用容量和内部分批不得向用户解释;查看时只回答业务信息,用户明确授权后在授权范围内自动处理。写前校验失败时零写入,任何不确定结果都不得自动重试。提单草稿仅可保存在 Agent 所在机器的当前用户私有目录,不得上传网关;恢复草稿后必须重新读取实时模板 Schema,短期提交引用不得写入草稿。薪福通所有选择型字段只向用户展示业务名称:optionSource 选项展示label,内嵌枚举展示enumLabels;内部值仅供提交。不得向用户索取或展示城市码、组织码、工号、档案编号、数字舱位值或其他内部ID;selectionCapability=client_only时停止构造该必填项或允许跳过该可选项。",
|
||||
});
|
||||
server.registerTool("approval_login", {
|
||||
title: "登录审批服务", description: "检查登录;未登录或旧链接已过期时生成新的钉钉授权链接。同一身份同时用于钉钉OA和薪福通,无需重新安装。", inputSchema: {},
|
||||
@@ -441,12 +441,12 @@ 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 构造能力。", inputSchema: {
|
||||
title: "获取发起审批表单结构", description: "实时读取模板字段、必填项、可读选择名称、复杂字段格式和 API 构造能力;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,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 },
|
||||
|
||||
+3
-3
@@ -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。薪福通部门、城市或费用类型选项必须用 approval_get_submission_options 实时查询,不得猜测。用户未提供费用类型时,必须用 kind=expense_type 获取可选项,只向用户展示并询问 label(如交通费、住宿费),用户选定后在内部使用对应 code;绝不向用户展示、解释或索取 expenseNumber、expenseTypeNumber、expTypeCode 等字段的内部编码。必须先检查 submissionCapability;client_only 时停止 API 构造并引导钉钉客户端。读取 Schema 后,逐项核对用户已提供的信息;必填字段缺失或含糊时必须继续询问且不得猜测,直到全部完整。必须列出尚未填写的非必填字段并询问用户是否补充,用户可以明确跳过。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 或中文名称;用户选定后由 Agent 在内部映射正式值。绝不向用户展示、解释或索取字段编码、城市码、组织码、工号、档案编号、数字舱位值或其他内部 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实时读取结构化字段、必填状态和填写约束。Agent必须持续询问直至必填字段齐全,并主动提醒可选字段;不会返回超大的上游原始Schema。",
|
||||
description: "使用模板列表返回的templateId实时读取结构化字段、必填状态、可读选项和填写约束。选择项只向用户展示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实时查询当前登录用户本人的承担部门,或按名称搜索城市和费用类型。费用类型未提供时必须用kind=expense_type并只向用户展示label,内部使用code,禁止向用户索取或展示内部编码。只返回可填写的code/label,不返回成员档案、员工号或原始上游数据。",
|
||||
description: "使用薪福通templateId实时查询当前登录用户本人的承担部门,或按名称搜索城市和费用类型。所有结果只向用户展示label,Agent内部使用code提交;禁止向用户索取或展示内部编码。只返回可填写的code/label,不返回成员档案、员工号或原始上游数据。",
|
||||
inputSchema: {
|
||||
templateId: z.string().min(20).max(4096),
|
||||
kind: z.enum(["department", "city", "expense_type"]),
|
||||
|
||||
+73
-18
@@ -389,11 +389,16 @@ export class UnifiedApprovalService {
|
||||
const template = this.decodeSubmissionTemplate(user, templateId);
|
||||
if (template.source === "xft") {
|
||||
if (!this.xft || !template.xftTemplate) throw new ApprovalSafetyError("薪福通提单源未启用");
|
||||
const capability = xftSubmissionCapability(template.xftTemplate.kind);
|
||||
const schema = await this.xft.getSubmissionSchema(user, template.xftTemplate);
|
||||
const requiredClientOnly = schema.fields.filter(requiredXftFieldNeedsNativeClient);
|
||||
const capability = requiredClientOnly.length ? {
|
||||
status: "client_only",
|
||||
reason: `包含当前 MCP 无法按业务名称安全填写的必填项:${requiredClientOnly.map((field) => field.name).join("、")}`,
|
||||
} : xftSubmissionCapability(template.xftTemplate.kind);
|
||||
return {
|
||||
templateId,
|
||||
source: "xft",
|
||||
...(await this.xft.getSubmissionSchema(user, template.xftTemplate)),
|
||||
...schema,
|
||||
submissionCapability: capability,
|
||||
};
|
||||
}
|
||||
@@ -2022,25 +2027,34 @@ function validateXftSubmissionValues(
|
||||
common_reimburse: ["reimAmount", "reimAmountCurrencyCode", "expenseList"],
|
||||
travel_reimburse: ["reimAmount", "reimAmountCurrencyCode", "businessTrip", "expenseList"],
|
||||
};
|
||||
assertNoClientOnlyStandardValues(values);
|
||||
const requiredUnsupported = customFields.filter(requiredXftFieldNeedsNativeClient);
|
||||
if (requiredUnsupported.length) {
|
||||
throw new ApprovalSafetyError(`该薪福通模板包含当前 MCP 无法安全填写的必填项:${requiredUnsupported.map((field) => field.name).join("、")},请在薪福通客户端发起`);
|
||||
}
|
||||
const missingBusiness = required[kind].filter((key) => isBlankSubmissionValue(values[key]));
|
||||
const missingCustom = missingRequiredXftCustomFields(customFields, values.customFieldValues);
|
||||
if (missingBusiness.length || missingCustom.length) {
|
||||
const businessLabels: Record<string, string> = {
|
||||
businessTrip: "差旅行程", applyExpenses: "事前申请费用", entertainmentApplyApiCreateDTO: "招待信息",
|
||||
useCarInfo: "用车信息", reimAmount: "报销总金额", reimAmountCurrencyCode: "报销币种", expenseList: "费用明细",
|
||||
};
|
||||
const parts = [
|
||||
...(missingBusiness.length ? [`业务字段:${missingBusiness.join("、")}`] : []),
|
||||
...(missingCustom.length ? [`自定义字段:${missingCustom.map((field) => `${field.name}(${field.fieldNumber})`).join("、")}`] : []),
|
||||
...(missingBusiness.length ? [`业务字段:${missingBusiness.map((key) => businessLabels[key] ?? key).join("、")}`] : []),
|
||||
...(missingCustom.length ? [`自定义字段:${missingCustom.map((field) => field.name).join("、")}`] : []),
|
||||
];
|
||||
throw new ApprovalSafetyError(`薪福通提单缺少必填${parts.join(";")}`);
|
||||
}
|
||||
const hasBillCompany = !isBlankSubmissionValue(values.billCompanyCode);
|
||||
const hasBillDepartment = !isBlankSubmissionValue(values.billDepartmentCode);
|
||||
if (hasBillCompany !== hasBillDepartment) {
|
||||
throw new ApprovalSafetyError("薪福通单据公司 billCompanyCode 和单据部门 billDepartmentCode 必须同时填写或同时省略");
|
||||
if (hasBillCompany || hasBillDepartment) {
|
||||
throw new ApprovalSafetyError("单据公司和部门由薪福通按当前登录用户填写,不应向用户索取或由 Agent 指定");
|
||||
}
|
||||
validateRequiredXftCustomFields(customFields, values.customFieldValues);
|
||||
validateXftCustomFieldValues(customFields, values.customFieldValues);
|
||||
if (kind === "common_reimburse" || kind === "travel_reimburse") {
|
||||
requirePositiveDecimal(values.reimAmount, "报销总金额 reimAmount");
|
||||
if (values.reimAmountCurrencyCode !== "CNY") throw new ApprovalSafetyError("报销币种 reimAmountCurrencyCode 目前只支持 CNY");
|
||||
if (values.reimAmountCurrencyCode !== "CNY") throw new ApprovalSafetyError("报销币种目前只支持人民币");
|
||||
validateXftExpenses(values.expenseList);
|
||||
}
|
||||
if (kind === "travel_apply" || kind === "travel_reimburse") validateXftBusinessTrip(values.businessTrip);
|
||||
@@ -2048,6 +2062,40 @@ function validateXftSubmissionValues(
|
||||
if (kind === "entertainment_apply") validateXftEntertainment(values.entertainmentApplyApiCreateDTO);
|
||||
}
|
||||
|
||||
function requiredXftFieldNeedsNativeClient(field: XftSubmissionField): boolean {
|
||||
if (!field.required) return false;
|
||||
if (field.selectionCapability?.status === "client_only") return true;
|
||||
return (field.itemFields ?? []).some((item) => item.required === true
|
||||
&& isSubmissionRecord(item.selectionCapability)
|
||||
&& item.selectionCapability.status === "client_only");
|
||||
}
|
||||
|
||||
function assertNoClientOnlyStandardValues(values: Record<string, unknown>): void {
|
||||
const reject = (record: Record<string, unknown>, key: string, label: string) => {
|
||||
if (!isBlankSubmissionValue(record[key])) {
|
||||
throw new ApprovalSafetyError(`${label}当前无法通过 MCP 按业务名称安全填写;不要提供内部编码或 ID,请省略该可选项或改用薪福通客户端`);
|
||||
}
|
||||
};
|
||||
reject(values, "relatedBillId", "关联申请单");
|
||||
reject(values, "peerEmpNumbers", "同行人员");
|
||||
reject(values, "billCollectionInfo", "收款信息");
|
||||
reject(values, "billLendPayRelList", "核销借款");
|
||||
if (isSubmissionRecord(values.businessTrip) && Array.isArray(values.businessTrip.businessTripDetails)) {
|
||||
values.businessTrip.businessTripDetails.filter(isSubmissionRecord).forEach((trip) => {
|
||||
reject(trip, "customArchiveOptionNumbers", "行程自定义档案");
|
||||
if (Array.isArray(trip.estimateExpenses)) trip.estimateExpenses.filter(isSubmissionRecord)
|
||||
.forEach((expense) => reject(expense, "spreadInfo", "预计费用分摊"));
|
||||
});
|
||||
}
|
||||
if (Array.isArray(values.expenseList)) values.expenseList.filter(isSubmissionRecord).forEach((expense) => {
|
||||
reject(expense, "participants", "费用参与人员");
|
||||
reject(expense, "aircraftCabin", "飞机舱位");
|
||||
reject(expense, "trainCabin", "火车席别");
|
||||
reject(expense, "shipCabin", "轮船舱位");
|
||||
reject(expense, "spreadInfo", "费用分摊");
|
||||
});
|
||||
}
|
||||
|
||||
function validateXftExpenseApplication(value: unknown): void {
|
||||
if (!Array.isArray(value) || !value.length) throw new ApprovalSafetyError("事前申请费用 applyExpenses 至少需要 1 条");
|
||||
value.forEach((item, index) => {
|
||||
@@ -2080,7 +2128,7 @@ function validateRequiredXftCustomFields(
|
||||
): void {
|
||||
const missing = missingRequiredXftCustomFields(fields, value);
|
||||
if (missing.length) {
|
||||
throw new ApprovalSafetyError(`薪福通提单缺少必填自定义字段:${missing.map((field) => `${field.name}(${field.fieldNumber})`).join("、")}`);
|
||||
throw new ApprovalSafetyError(`薪福通提单缺少必填自定义字段:${missing.map((field) => field.name).join("、")}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2106,17 +2154,20 @@ function validateXftCustomFieldValues(fields: XftSubmissionField[], value: unkno
|
||||
if (!field) throw new ApprovalSafetyError(`薪福通自定义字段编码“${fieldNumber}”不在当前实时表单中`);
|
||||
if (seen.has(fieldNumber)) throw new ApprovalSafetyError(`薪福通自定义字段“${field.name}(${fieldNumber})”重复`);
|
||||
seen.add(fieldNumber);
|
||||
if (field.selectionCapability?.status === "client_only") {
|
||||
throw new ApprovalSafetyError(`“${field.name}”当前无法通过 MCP 按业务名称安全填写,请在薪福通客户端完成`);
|
||||
}
|
||||
if (typeof raw.fieldValue !== "string") throw new ApprovalSafetyError(`薪福通自定义字段“${field.name}”的 fieldValue 必须是字符串;数组和表格需传 JSON 字符串`);
|
||||
const type = field.type.toUpperCase();
|
||||
if (type.includes("TABLE")) validateXftTableField(field, raw.fieldValue);
|
||||
if (type === "DATE" && !/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(raw.fieldValue)) {
|
||||
throw new ApprovalSafetyError(`薪福通自定义字段“${field.name}”必须使用 yyyy-MM-dd HH:mm 日期时间格式`);
|
||||
}
|
||||
if (type.includes("PERSON") || type.includes("EMP") || type.includes("DEPARTMENT") || type.includes("ORG")) {
|
||||
if (type.includes("DEPARTMENT") || type.includes("ORG")) {
|
||||
let parsed: unknown;
|
||||
try { parsed = JSON.parse(raw.fieldValue); } catch { throw new ApprovalSafetyError(`薪福通自定义字段“${field.name}”必须是 JSON 数组字符串`); }
|
||||
try { parsed = JSON.parse(raw.fieldValue); } catch { throw new ApprovalSafetyError(`薪福通字段“${field.name}”的部门选择格式无效,请重新查询并选择部门名称`); }
|
||||
if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== "string" || !item.trim())) {
|
||||
throw new ApprovalSafetyError(`薪福通自定义字段“${field.name}”必须是非空编码组成的 JSON 数组字符串`);
|
||||
throw new ApprovalSafetyError(`薪福通字段“${field.name}”的部门选择格式无效,请重新查询并选择部门名称`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -2134,6 +2185,10 @@ function validateXftTableField(field: XftSubmissionField, value: string): void {
|
||||
if (!isSubmissionRecord(column) || isBlankSubmissionValue(column.titleCode) || isBlankSubmissionValue(column.value)) {
|
||||
throw new ApprovalSafetyError(`薪福通表格字段“${field.name}”第 ${index + 1} 行的列必须包含 titleCode 和 value`);
|
||||
}
|
||||
const itemField = (field.itemFields ?? []).find((item) => String(item.titleCode ?? "") === String(column.titleCode ?? ""));
|
||||
if (itemField && isSubmissionRecord(itemField.selectionCapability) && itemField.selectionCapability.status === "client_only") {
|
||||
throw new ApprovalSafetyError(`薪福通表格字段“${field.name}”中的“${String(itemField.label ?? "选项")}”当前无法通过 MCP 安全填写,请在薪福通客户端完成`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2145,7 +2200,7 @@ function validateXftExpenses(value: unknown): void {
|
||||
if (isBlankSubmissionValue(item.expTypeCode)) throw new ApprovalSafetyError(`第 ${index + 1} 笔费用缺少费用类型,请先查询并选择可用名称`);
|
||||
if (isBlankSubmissionValue(item.currencyCode)) throw new ApprovalSafetyError(`第 ${index + 1} 笔费用缺少费用币种`);
|
||||
if (isBlankSubmissionValue(item.originalCurrencyReimAmt)) throw new ApprovalSafetyError(`第 ${index + 1} 笔费用缺少费用金额`);
|
||||
if (item.currencyCode !== "CNY") throw new ApprovalSafetyError(`第 ${index + 1} 笔费用币种目前只支持 CNY`);
|
||||
if (item.currencyCode !== "CNY") throw new ApprovalSafetyError(`第 ${index + 1} 笔费用币种目前只支持人民币`);
|
||||
requirePositiveDecimal(item.originalCurrencyReimAmt, `第 ${index + 1} 笔费用金额 originalCurrencyReimAmt`);
|
||||
if (item.wrTim !== undefined) requireDateString(item.wrTim, `第 ${index + 1} 笔消费日期 wrTim`, /^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
@@ -2157,21 +2212,21 @@ function validateXftBusinessTrip(value: unknown): void {
|
||||
}
|
||||
value.businessTripDetails.forEach((item, index) => {
|
||||
if (!isSubmissionRecord(item)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程必须是对象`);
|
||||
for (const key of ["departCityCode", "destinationCityCode", "endTime"] as const) {
|
||||
if (isBlankSubmissionValue(item[key])) throw new ApprovalSafetyError(`第 ${index + 1} 段行程缺少 ${key}`);
|
||||
}
|
||||
if (isBlankSubmissionValue(item.departCityCode)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程缺少出发城市`);
|
||||
if (isBlankSubmissionValue(item.destinationCityCode)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程缺少目的城市`);
|
||||
if (isBlankSubmissionValue(item.endTime)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程缺少结束时间`);
|
||||
const beginTime = item.beginTime ?? item.startTime;
|
||||
if (isBlankSubmissionValue(beginTime)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程缺少 beginTime(官方示例别名 startTime)`);
|
||||
if (isBlankSubmissionValue(beginTime)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程缺少开始时间`);
|
||||
requireDateString(beginTime, `第 ${index + 1} 段出发时间`, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/);
|
||||
requireDateString(item.endTime, `第 ${index + 1} 段结束时间`, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/);
|
||||
if (String(beginTime) > String(item.endTime)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程结束时间不能早于出发时间`);
|
||||
if (item.roundTrip !== undefined && !["N", "Y"].includes(String(item.roundTrip))) {
|
||||
throw new ApprovalSafetyError(`第 ${index + 1} 段行程 roundTrip 只能是 N 或 Y`);
|
||||
throw new ApprovalSafetyError(`第 ${index + 1} 段行程的往返选择无效,请重新选择“单程”或“往返”`);
|
||||
}
|
||||
if (item.travelMode !== undefined) {
|
||||
const modes = String(item.travelMode).split(",").map((mode) => mode.trim()).filter(Boolean);
|
||||
const allowed = new Set(["Train", "Bus", "Ship", "Hotel", "RideHailing", "AirPlane", "Other"]);
|
||||
if (!modes.length || modes.some((mode) => !allowed.has(mode))) throw new ApprovalSafetyError(`第 ${index + 1} 段行程 travelMode 含不支持的出行方式`);
|
||||
if (!modes.length || modes.some((mode) => !allowed.has(mode))) throw new ApprovalSafetyError(`第 ${index + 1} 段行程含不支持的出行方式,请从 Schema 的中文名称中重新选择`);
|
||||
}
|
||||
if (item.estimateExpenses !== undefined) {
|
||||
if (!Array.isArray(item.estimateExpenses)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程 estimateExpenses 必须是数组`);
|
||||
|
||||
+188
-69
@@ -1515,6 +1515,11 @@ function normalizeSubmissionField(value: JsonObject): XftSubmissionField[] {
|
||||
const name = stringAt(value, ["name"]);
|
||||
const type = stringAt(value, ["type"]);
|
||||
if (!fieldNumber || !name || !type || value.isDisplay === false) return [];
|
||||
const options = Array.isArray(value.options) ? normalizeSubmissionOptions(value.options) : [];
|
||||
const normalizedType = type.toUpperCase();
|
||||
const optionSource = isExpenseTypeField(fieldNumber, name) ? expenseTypeOptionSource()
|
||||
: normalizedType.includes("DEPARTMENT") || normalizedType.includes("ORG") ? departmentOptionSource()
|
||||
: /城市|city/i.test(`${fieldNumber} ${name}`) ? cityOptionSource() : undefined;
|
||||
return [{
|
||||
fieldNumber,
|
||||
name,
|
||||
@@ -1522,10 +1527,10 @@ function normalizeSubmissionField(value: JsonObject): XftSubmissionField[] {
|
||||
required: value.required === true,
|
||||
...(typeof value.description === "string" && value.description.trim() ? { description: value.description.trim() } : {}),
|
||||
...(typeof value.multiple === "boolean" ? { multiple: value.multiple } : {}),
|
||||
...(Array.isArray(value.options) ? { options: value.options.map((option) => sanitizeOption(option)).filter((option) => option !== undefined) } : {}),
|
||||
...(options.length ? { options } : {}),
|
||||
...(typeof value.moduleType === "string" && value.moduleType ? { moduleType: value.moduleType } : {}),
|
||||
...(isExpenseTypeField(fieldNumber, name) ? { optionSource: expenseTypeOptionSource() } : {}),
|
||||
...submissionFieldShape(value, type),
|
||||
...(optionSource ? { optionSource } : {}),
|
||||
...submissionFieldShape(value, type, options, optionSource),
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -1537,32 +1542,89 @@ function expenseTypeOptionSource() {
|
||||
return { tool: "approval_get_submission_options" as const, kind: "expense_type" as const };
|
||||
}
|
||||
|
||||
function submissionFieldShape(value: JsonObject, type: string): Pick<XftSubmissionField, "valueFormat" | "itemFields"> {
|
||||
function cityOptionSource() {
|
||||
return { tool: "approval_get_submission_options" as const, kind: "city" as const };
|
||||
}
|
||||
|
||||
function departmentOptionSource() {
|
||||
return { tool: "approval_get_submission_options" as const, kind: "department" as const };
|
||||
}
|
||||
|
||||
const LABEL_ONLY_SELECTION = {
|
||||
status: "supported" as const,
|
||||
userInput: "label" as const,
|
||||
submitValue: "code" as const,
|
||||
exposeInternalValue: false as const,
|
||||
};
|
||||
|
||||
function clientOnlySelection(reason: string) {
|
||||
return { status: "client_only" as const, reason };
|
||||
}
|
||||
|
||||
function submissionFieldShape(
|
||||
value: JsonObject,
|
||||
type: string,
|
||||
options: XftSubmissionOption[],
|
||||
optionSource?: ReturnType<typeof expenseTypeOptionSource> | ReturnType<typeof cityOptionSource> | ReturnType<typeof departmentOptionSource>,
|
||||
): Pick<XftSubmissionField, "valueFormat" | "itemFields" | "selectionCapability"> {
|
||||
const normalized = type.toUpperCase();
|
||||
if (normalized.includes("TABLE")) {
|
||||
const candidates = [value.columns, value.children, value.fieldList, value.tableColumns]
|
||||
.find(Array.isArray) as unknown[] | undefined;
|
||||
const itemFields = (candidates ?? []).filter(isObject).flatMap((column) => {
|
||||
const titleCode = stringAt(column, ["titleCode", "fieldNumber", "fieldCode", "code"]);
|
||||
const label = stringAt(column, ["name", "title", "label"]);
|
||||
const columnType = stringAt(column, ["type", "fieldType"]);
|
||||
if (!titleCode) return [];
|
||||
return [{ titleCode, ...(label ? { label } : {}), ...(columnType ? { type: columnType } : {}), required: column.required === true }];
|
||||
});
|
||||
const itemFields = (candidates ?? []).filter(isObject).flatMap(normalizeSubmissionTableColumn);
|
||||
return {
|
||||
valueFormat: "JSON array: [{sequence:1,columnValues:[{titleCode,value}]}]",
|
||||
...(itemFields.length ? { itemFields } : {}),
|
||||
};
|
||||
}
|
||||
if (normalized.includes("ATTACH") || normalized.includes("ACCESSOR")) return { valueFormat: "附件编号字符串(须先通过薪福通附件接口取得)" };
|
||||
if (normalized.includes("PERSON") || normalized.includes("EMP")) return { valueFormat: "薪福通花名册编号 JSON 数组" };
|
||||
if (normalized.includes("DEPARTMENT") || normalized.includes("ORG")) return { valueFormat: "薪福通组织编码 JSON 数组" };
|
||||
if (normalized.includes("ATTACH") || normalized.includes("ACCESSOR")) return {
|
||||
valueFormat: "当前 MCP 无法安全上传附件,请在薪福通客户端补充",
|
||||
selectionCapability: clientOnlySelection("当前 MCP 没有薪福通附件上传能力"),
|
||||
};
|
||||
if (normalized.includes("PERSON") || normalized.includes("EMP")) return {
|
||||
valueFormat: "当前 MCP 无法按姓名安全选择人员,请在薪福通客户端补充",
|
||||
selectionCapability: clientOnlySelection("正式接口需要内部花名册值,但当前没有按姓名查询人员的安全接口"),
|
||||
};
|
||||
if (normalized.includes("DEPARTMENT") || normalized.includes("ORG")) return {
|
||||
valueFormat: "向用户展示部门名称,Agent 内部使用所选项",
|
||||
selectionCapability: LABEL_ONLY_SELECTION,
|
||||
};
|
||||
if (normalized === "DATE") return { valueFormat: "yyyy-MM-dd HH:mm" };
|
||||
if (normalized.includes("SELECT")) return { valueFormat: "使用 options 中的编码" };
|
||||
if (normalized.includes("SELECT")) {
|
||||
if (options.length || optionSource) return {
|
||||
valueFormat: "向用户展示选项名称,Agent 内部使用所选项",
|
||||
selectionCapability: LABEL_ONLY_SELECTION,
|
||||
};
|
||||
return {
|
||||
valueFormat: "当前模板未返回可读选项,请在薪福通客户端补充",
|
||||
selectionCapability: clientOnlySelection("模板没有返回可验证的名称与内部值映射"),
|
||||
};
|
||||
}
|
||||
if (normalized.includes("INPUT")) return { valueFormat: "文本字符串" };
|
||||
return {};
|
||||
}
|
||||
|
||||
function normalizeSubmissionTableColumn(column: JsonObject): Array<Record<string, unknown>> {
|
||||
const titleCode = stringAt(column, ["titleCode", "fieldNumber", "fieldCode", "code"]);
|
||||
const label = stringAt(column, ["name", "title", "label"]);
|
||||
const type = stringAt(column, ["type", "fieldType"]);
|
||||
if (!titleCode) return [];
|
||||
const options = Array.isArray(column.options) ? normalizeSubmissionOptions(column.options) : [];
|
||||
const normalizedType = (type ?? "").toUpperCase();
|
||||
const optionSource = isExpenseTypeField(titleCode, label ?? "") ? expenseTypeOptionSource()
|
||||
: normalizedType.includes("DEPARTMENT") || normalizedType.includes("ORG") ? departmentOptionSource()
|
||||
: /城市|city/i.test(`${titleCode} ${label ?? ""}`) ? cityOptionSource() : undefined;
|
||||
return [{
|
||||
titleCode,
|
||||
...(label ? { label } : {}),
|
||||
...(type ? { type } : {}),
|
||||
required: column.required === true,
|
||||
...(options.length ? { options } : {}),
|
||||
...(optionSource ? { optionSource } : {}),
|
||||
...submissionFieldShape(column, type ?? "", options, optionSource),
|
||||
}];
|
||||
}
|
||||
|
||||
function deduplicateSubmissionFields(fields: XftSubmissionField[]): XftSubmissionField[] {
|
||||
const seen = new Set<string>();
|
||||
return fields.filter((field) => {
|
||||
@@ -1575,13 +1637,16 @@ function deduplicateSubmissionFields(fields: XftSubmissionField[]): XftSubmissio
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeOption(value: unknown): unknown {
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
|
||||
function sanitizeOption(value: unknown): XftSubmissionOption | undefined {
|
||||
if (!isObject(value)) return undefined;
|
||||
const label = stringAt(value, ["label", "name", "value", "optionName"]);
|
||||
const code = stringAt(value, ["code", "number", "optionCode", "key"]);
|
||||
if (!label && !code) return undefined;
|
||||
return { ...(label ? { label } : {}), ...(code ? { code } : {}) };
|
||||
const label = stringAt(value, ["label", "name", "optionName", "displayName", "value"]);
|
||||
const code = stringAt(value, ["code", "number", "optionCode", "key", "value"]);
|
||||
if (!label || !code) return undefined;
|
||||
return { label, code };
|
||||
}
|
||||
|
||||
function normalizeSubmissionOptions(values: unknown[]): XftSubmissionOption[] {
|
||||
return uniqueOptions(values.map(sanitizeOption).filter((item): item is XftSubmissionOption => Boolean(item)));
|
||||
}
|
||||
|
||||
function submissionInputContract(kind: XftSubmissionKind): Record<string, unknown> {
|
||||
@@ -1632,13 +1697,67 @@ function submissionInputContractSummary(kind: XftSubmissionKind): Record<string,
|
||||
return values[kind];
|
||||
}
|
||||
|
||||
function cityField(key: string, label: string, required: boolean): Record<string, unknown> {
|
||||
return {
|
||||
key, label, required, type: "string",
|
||||
description: "向用户展示城市名称,Agent 内部使用所选项",
|
||||
optionSource: cityOptionSource(),
|
||||
selectionCapability: LABEL_ONLY_SELECTION,
|
||||
};
|
||||
}
|
||||
|
||||
function expenseTypeField(key: string, required: boolean): Record<string, unknown> {
|
||||
return {
|
||||
key, label: "费用类型", required, type: "string",
|
||||
description: "向用户展示费用名称,Agent 内部使用所选项",
|
||||
optionSource: expenseTypeOptionSource(),
|
||||
selectionCapability: LABEL_ONLY_SELECTION,
|
||||
};
|
||||
}
|
||||
|
||||
function readableEnumField(
|
||||
key: string,
|
||||
label: string,
|
||||
required: boolean,
|
||||
labels: Record<string, string>,
|
||||
extra: Record<string, unknown> = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
key, label, required, type: "string", enum: Object.keys(labels), enumLabels: labels,
|
||||
description: "只向用户展示中文选项名称,Agent 内部使用对应值",
|
||||
selectionCapability: LABEL_ONLY_SELECTION,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function clientOnlyField(
|
||||
key: string,
|
||||
label: string,
|
||||
required: boolean,
|
||||
reason: string,
|
||||
extra: Record<string, unknown> = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
key, label, required, ...extra,
|
||||
selectionCapability: clientOnlySelection(reason),
|
||||
description: `${reason};不要向用户索取内部编码或 ID,如需填写请在薪福通客户端完成`,
|
||||
};
|
||||
}
|
||||
|
||||
const TRAVEL_MODE_LABELS = {
|
||||
Train: "火车", Bus: "汽车", Ship: "轮船", Hotel: "住宿", RideHailing: "网约车", AirPlane: "飞机", Other: "其他",
|
||||
} as const;
|
||||
const ROUND_TRIP_LABELS = { N: "单程", Y: "往返" } as const;
|
||||
const HALF_DAY_LABELS = { AM: "上午", PM: "下午" } as const;
|
||||
const CURRENCY_LABELS = { CNY: "人民币" } as const;
|
||||
|
||||
function expenseApplicationFields(): Array<Record<string, unknown>> {
|
||||
return [{
|
||||
key: "applyExpenses", label: "事前申请费用列表", required: true, type: "array", minItems: 1, itemFields: [
|
||||
{ key: "expenseNumber", label: "费用类型", required: true, type: "string", description: "向用户展示可读名称,内部填写所选项的 code", optionSource: expenseTypeOptionSource() },
|
||||
expenseTypeField("expenseNumber", true),
|
||||
{ key: "amount", label: "申请金额", required: true, type: "decimal", precision: 2 },
|
||||
{ key: "occurrenceDate", label: "费用发生时间", required: false, type: "datetime", description: "ISO 8601 时间" },
|
||||
{ key: "payCityCode", label: "消费城市编码", required: false, type: "string" },
|
||||
cityField("payCityCode", "消费城市", false),
|
||||
{ key: "participantNum", label: "参与人数", required: false, type: "integer" },
|
||||
{ key: "merchantName", label: "商户名称", required: false, type: "string" },
|
||||
{ key: "remark", label: "备注", required: false, type: "string" },
|
||||
@@ -1650,7 +1769,7 @@ function expenseApplicationFields(): Array<Record<string, unknown>> {
|
||||
function entertainmentApplicationFields(): Array<Record<string, unknown>> {
|
||||
return [{
|
||||
key: "entertainmentApplyApiCreateDTO", label: "招待信息", required: true, type: "object", fields: [
|
||||
{ key: "entertainmentCityCode", label: "招待城市编码", required: true, type: "string", description: "薪福通城市 cityCode" },
|
||||
cityField("entertainmentCityCode", "招待城市", true),
|
||||
{ key: "entertainmentPersonCount", label: "招待人数", required: true, type: "integer", minimum: 1 },
|
||||
{ key: "entertainmentStartTime", label: "招待开始日期", required: true, type: "date", format: "yyyy-MM-dd" },
|
||||
{ key: "entertainmentEndTime", label: "招待结束日期", required: true, type: "date", format: "yyyy-MM-dd" },
|
||||
@@ -1663,44 +1782,44 @@ function travelApplicationFields(): Array<Record<string, unknown>> {
|
||||
key: "businessTrip", label: "差旅行程", required: true, type: "object", fields: [{
|
||||
key: "businessTripDetails", label: "行程明细", required: true, type: "array", minItems: 1,
|
||||
itemFields: [
|
||||
{ key: "departCityCode", label: "出发城市编码", required: true, type: "string" },
|
||||
{ key: "destinationCityCode", label: "目的城市编码", required: true, type: "string" },
|
||||
cityField("departCityCode", "出发城市", true),
|
||||
cityField("destinationCityCode", "目的城市", true),
|
||||
{ key: "beginTime", label: "开始时间", required: true, type: "datetime", format: "yyyy-MM-dd HH:mm" },
|
||||
{ key: "endTime", label: "结束时间", required: true, type: "datetime", format: "yyyy-MM-dd HH:mm" },
|
||||
{ key: "travelMode", label: "出行方式", required: false, type: "comma_separated_enum", enum: ["Train", "Bus", "Ship", "Hotel", "RideHailing", "AirPlane", "Other"] },
|
||||
{ key: "roundTrip", label: "是否往返", required: false, type: "string", enum: ["N", "Y"] },
|
||||
{ key: "beginTimePrecision", label: "开始时间半天精度", required: false, type: "string", enum: ["AM", "PM"] },
|
||||
{ key: "endTimePrecision", label: "结束时间半天精度", required: false, type: "string", enum: ["AM", "PM"] },
|
||||
{ key: "customArchiveOptionNumbers", label: "行程自定义档案选项", required: false, type: "array" },
|
||||
readableEnumField("travelMode", "出行方式", false, TRAVEL_MODE_LABELS, { type: "comma_separated_enum", multiple: true }),
|
||||
readableEnumField("roundTrip", "是否往返", false, ROUND_TRIP_LABELS),
|
||||
readableEnumField("beginTimePrecision", "开始时间范围", false, HALF_DAY_LABELS),
|
||||
readableEnumField("endTimePrecision", "结束时间范围", false, HALF_DAY_LABELS),
|
||||
clientOnlyField("customArchiveOptionNumbers", "行程自定义档案", false, "当前 MCP 没有按名称查询企业自定义档案的可靠能力", { type: "array" }),
|
||||
{ key: "estimateExpenses", label: "预计费用", required: false, type: "array", itemFields: [
|
||||
{ key: "expenseTypeNumber", label: "费用类型", required: true, type: "string", description: "向用户展示可读名称,内部填写所选项的 code", optionSource: expenseTypeOptionSource() },
|
||||
expenseTypeField("expenseTypeNumber", true),
|
||||
{ key: "applyAmount", label: "申请金额", required: true, type: "decimal" },
|
||||
{ key: "remark", label: "备注", required: false, type: "string" },
|
||||
{ key: "spreadInfo", label: "分摊信息", required: false, type: "object" },
|
||||
clientOnlyField("spreadInfo", "分摊信息", false, "当前 MCP 无法把企业分摊名称安全映射为正式接口值", { type: "object" }),
|
||||
] },
|
||||
],
|
||||
}] },
|
||||
{ key: "relatedBillId", label: "关联单据 ID", required: false, type: "string" },
|
||||
{ key: "peerEmpNumbers", label: "同行人员工号", required: false, type: "array" },
|
||||
{ key: "billCompanyCode", label: "单据公司编码", required: false, type: "string" },
|
||||
{ key: "billDepartmentCode", label: "单据部门编码", required: false, type: "string" },
|
||||
clientOnlyField("relatedBillId", "关联申请单", false, "当前 MCP 没有按业务名称安全选择前置单据的能力", { type: "string" }),
|
||||
clientOnlyField("peerEmpNumbers", "同行人员", false, "当前 MCP 没有按姓名查询薪福通人员的安全接口", { type: "array" }),
|
||||
{ key: "billCompanyCode", label: "单据公司", required: false, type: "string", serverManaged: true, userVisible: false, description: "省略后由薪福通按当前登录用户填写" },
|
||||
{ key: "billDepartmentCode", label: "单据部门", required: false, type: "string", serverManaged: true, userVisible: false, description: "省略后由薪福通按当前登录用户填写" },
|
||||
];
|
||||
}
|
||||
|
||||
function reimbursementHeaderFields(): Array<Record<string, unknown>> {
|
||||
return [
|
||||
{ key: "reimAmount", label: "报销总金额", required: true, type: "decimal", precision: 2 },
|
||||
{ key: "reimAmountCurrencyCode", label: "报销币种", required: true, type: "string", enum: ["CNY"], description: "正式接口目前仅支持人民币" },
|
||||
{ key: "outRelId", label: "外部关联 ID", required: false, type: "string", maxLength: 100 },
|
||||
{ key: "billDepartmentCode", label: "单据部门编码", required: false, type: "string", maxLength: 100 },
|
||||
{ key: "billCompanyCode", label: "单据公司编码", required: false, type: "string", maxLength: 50 },
|
||||
{ key: "customFieldValues", label: "单据自定义字段", required: false, type: "array", itemFields: customFieldValueFields() },
|
||||
{ key: "billCollectionInfo", label: "收款信息", required: false, type: "object", description: "需要付款时填写;包含收款人、银行、账号、金额和币种" },
|
||||
{ key: "billLendPayRelList", label: "核销借款信息", required: false, type: "array", itemFields: [
|
||||
readableEnumField("reimAmountCurrencyCode", "报销币种", true, CURRENCY_LABELS, { default: "CNY" }),
|
||||
{ key: "outRelId", label: "外部关联标识", required: false, type: "string", maxLength: 100, userVisible: false, description: "仅供系统集成使用,不应询问普通用户" },
|
||||
{ key: "billDepartmentCode", label: "单据部门", required: false, type: "string", maxLength: 100, serverManaged: true, userVisible: false, description: "省略后由薪福通按当前登录用户填写" },
|
||||
{ key: "billCompanyCode", label: "单据公司", required: false, type: "string", maxLength: 50, serverManaged: true, userVisible: false, description: "省略后由薪福通按当前登录用户填写" },
|
||||
{ key: "customFieldValues", label: "单据自定义字段", required: false, type: "array", userVisible: false, description: "由 Agent 根据实时 fields 组装,不向用户展示字段编码", itemFields: customFieldValueFields() },
|
||||
clientOnlyField("billCollectionInfo", "收款信息", false, "当前 MCP 无法完整验证收款账户的正式接口引用", { type: "object" }),
|
||||
clientOnlyField("billLendPayRelList", "核销借款", false, "当前 MCP 没有按可读单号安全选择本人借款单的能力", { type: "array", itemFields: [
|
||||
{ key: "lendBillId", label: "借款单号", required: true, type: "string" },
|
||||
{ key: "currencyCode", label: "核销币种", required: true, type: "string", enum: ["CNY"] },
|
||||
readableEnumField("currencyCode", "核销币种", true, CURRENCY_LABELS, { default: "CNY" }),
|
||||
{ key: "originalPayAmount", label: "核销金额", required: true, type: "decimal", precision: 2 },
|
||||
] },
|
||||
] }),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1712,21 +1831,21 @@ function travelReimbursementFields(): Array<Record<string, unknown>> {
|
||||
return [
|
||||
...reimbursementHeaderFields(),
|
||||
expenseListField(true),
|
||||
{ key: "peerEmpNumbers", label: "同行人员工号", required: false, type: "array", maxItems: 50 },
|
||||
clientOnlyField("peerEmpNumbers", "同行人员", false, "当前 MCP 没有按姓名查询薪福通人员的安全接口", { type: "array", maxItems: 50 }),
|
||||
{
|
||||
key: "businessTrip", label: "差旅行程", required: true, type: "object", fields: [{
|
||||
key: "businessTripDetails", label: "行程明细", required: true, type: "array", minItems: 1,
|
||||
itemFields: [
|
||||
{ key: "departCityCode", label: "出发城市编码", required: true, type: "string", description: "薪福通城市 cityCode" },
|
||||
{ key: "destinationCityCode", label: "目的城市编码", required: true, type: "string", description: "薪福通城市 cityCode" },
|
||||
cityField("departCityCode", "出发城市", true),
|
||||
cityField("destinationCityCode", "目的城市", true),
|
||||
{ key: "beginTime", aliases: ["startTime"], label: "出发时间", required: true, type: "datetime", format: "yyyy-MM-dd HH:mm" },
|
||||
{ key: "endTime", label: "结束时间", required: true, type: "datetime", format: "yyyy-MM-dd HH:mm" },
|
||||
{ key: "travelMode", label: "出行方式", required: false, type: "comma_separated_enum", enum: ["Train", "Bus", "Ship", "Hotel", "RideHailing", "AirPlane", "Other"] },
|
||||
{ key: "roundTrip", label: "是否往返", required: false, type: "string", enum: ["N", "Y"], default: "N" },
|
||||
{ key: "beginTimePrecision", label: "开始时间半天精度", required: false, type: "string", enum: ["AM", "PM"], description: "模板时间精度为半天时必填" },
|
||||
{ key: "endTimePrecision", label: "结束时间半天精度", required: false, type: "string", enum: ["AM", "PM"], description: "模板时间精度为半天时必填" },
|
||||
readableEnumField("travelMode", "出行方式", false, TRAVEL_MODE_LABELS, { type: "comma_separated_enum", multiple: true }),
|
||||
readableEnumField("roundTrip", "是否往返", false, ROUND_TRIP_LABELS, { default: "N" }),
|
||||
readableEnumField("beginTimePrecision", "开始时间范围", false, HALF_DAY_LABELS, { condition: "模板时间精度为半天时必填" }),
|
||||
readableEnumField("endTimePrecision", "结束时间范围", false, HALF_DAY_LABELS, { condition: "模板时间精度为半天时必填" }),
|
||||
{ key: "tripReason", label: "出差事由", required: false, type: "string", maxLength: 200, description: "模板将行程事由设为必填时必须填写" },
|
||||
{ key: "customArchiveOptionNumbers", label: "行程自定义档案选项", required: false, type: "array" },
|
||||
clientOnlyField("customArchiveOptionNumbers", "行程自定义档案", false, "当前 MCP 没有按名称查询企业自定义档案的可靠能力", { type: "array" }),
|
||||
],
|
||||
}],
|
||||
},
|
||||
@@ -1735,25 +1854,25 @@ function travelReimbursementFields(): Array<Record<string, unknown>> {
|
||||
|
||||
function expenseListField(travel: boolean): Record<string, unknown> {
|
||||
const itemFields: Array<Record<string, unknown>> = [
|
||||
{ key: "expTypeCode", label: "费用类型", required: true, type: "string", maxLength: 100, description: "向用户展示可读名称,内部填写所选项的 code", optionSource: expenseTypeOptionSource() },
|
||||
{ key: "currencyCode", label: "费用币种", required: true, type: "string", enum: ["CNY"] },
|
||||
{ ...expenseTypeField("expTypeCode", true), maxLength: 100 },
|
||||
readableEnumField("currencyCode", "费用币种", true, CURRENCY_LABELS, { default: "CNY" }),
|
||||
{ key: "originalCurrencyReimAmt", label: "费用金额", required: true, type: "decimal", precision: 2 },
|
||||
{ key: "wrTim", label: "消费日期", required: false, type: "date", format: "yyyy-MM-dd" },
|
||||
{ key: "remark", label: "费用备注", required: false, type: "string", maxLength: 500 },
|
||||
{ key: "merchantName", label: "商户名称", required: false, type: "string", maxLength: 200 },
|
||||
{ key: "participants", label: "费用参与人员工号", required: false, type: "array", maxItems: 50 },
|
||||
clientOnlyField("participants", "费用参与人员", false, "当前 MCP 没有按姓名查询薪福通人员的安全接口", { type: "array", maxItems: 50 }),
|
||||
{ key: "partiNbr", label: "参与人数", required: false, type: "integer" },
|
||||
{ key: "taxRate", label: "税率", required: false, type: "decimal", precision: 2 },
|
||||
{ key: "taxFreeAmount", label: "不含税金额", required: false, type: "decimal", precision: 2 },
|
||||
{ key: "departureCode", label: "费用出发城市编码", required: false, type: "string" },
|
||||
{ key: "destinationCode", label: "费用目的城市编码", required: false, type: "string" },
|
||||
cityField("departureCode", "费用出发城市", false),
|
||||
cityField("destinationCode", "费用目的城市", false),
|
||||
{ key: "bgnTim", label: "交通出发日期", required: false, type: "date", format: "yyyy-MM-dd" },
|
||||
{ key: "endTim", label: "交通到达日期", required: false, type: "date", format: "yyyy-MM-dd" },
|
||||
{ key: "checkInDate", label: "酒店入住日期", required: false, type: "date", format: "yyyy-MM-dd" },
|
||||
{ key: "checkOutDate", label: "酒店离店日期", required: false, type: "date", format: "yyyy-MM-dd" },
|
||||
{ key: "payCityCode", label: "消费城市编码", required: false, type: "string" },
|
||||
{ key: "hotelStar", label: "酒店星级", required: false, type: "string", enum: ["1", "2", "3", "4"], enumLabels: { "1": "五星", "2": "四星", "3": "三星", "4": "其他" } },
|
||||
{ key: "aircraftCabin", label: "飞机舱位", required: false, type: "string", enum: ["1", "2", "3", "4"] },
|
||||
cityField("payCityCode", "消费城市", false),
|
||||
readableEnumField("hotelStar", "酒店星级", false, { "1": "五星", "2": "四星", "3": "三星", "4": "其他" }),
|
||||
clientOnlyField("aircraftCabin", "飞机舱位", false, "正式接口文档未提供可验证的舱位名称映射", { type: "string" }),
|
||||
{ key: "airlineNo", label: "航班号", required: false, type: "string" },
|
||||
{ key: "airlineCom", label: "航空公司", required: false, type: "string" },
|
||||
{ key: "flightETicketNo", label: "电子客票号", required: false, type: "string" },
|
||||
@@ -1761,20 +1880,20 @@ function expenseListField(travel: boolean): Record<string, unknown> {
|
||||
{ key: "civilAviationFund", label: "民航发展基金", required: false, type: "decimal", precision: 2 },
|
||||
{ key: "discount", label: "机票折扣", required: false, type: "decimal", precision: 2 },
|
||||
{ key: "trainNo", label: "列车号", required: false, type: "string" },
|
||||
{ key: "trainCabin", label: "火车席别", required: false, type: "string", description: "使用官方席别编码 1-15" },
|
||||
clientOnlyField("trainCabin", "火车席别", false, "正式接口文档未提供可验证的席别名称映射", { type: "string" }),
|
||||
{ key: "trainETicketNo", label: "火车票票号", required: false, type: "string" },
|
||||
{ key: "shipCabin", label: "轮船舱位", required: false, type: "string", enum: ["1", "2", "3"] },
|
||||
{ key: "customFieldValues", label: "费用自定义字段", required: false, type: "array", itemFields: customFieldValueFields() },
|
||||
{ key: "spreadInfo", label: "费用分摊", required: false, type: "object", description: "按企业分摊方式填写" },
|
||||
clientOnlyField("shipCabin", "轮船舱位", false, "正式接口文档未提供可验证的舱位名称映射", { type: "string" }),
|
||||
{ key: "customFieldValues", label: "费用自定义字段", required: false, type: "array", userVisible: false, description: "由 Agent 根据实时 fields 组装,不向用户展示字段编码", itemFields: customFieldValueFields() },
|
||||
clientOnlyField("spreadInfo", "费用分摊", false, "当前 MCP 无法把企业分摊名称安全映射为正式接口值", { type: "object" }),
|
||||
];
|
||||
if (travel) itemFields.push({ key: "dstntIndex", label: "归属行程序号", required: false, type: "string", description: "从 0 开始;不填则不归组到行程" });
|
||||
if (travel) itemFields.push({ key: "dstntIndex", label: "归属行程", required: false, type: "string", userVisible: false, internallyDerived: true, description: "由 Agent 根据用户选择的行程位置内部换算,不向用户展示序号" });
|
||||
return { key: "expenseList", label: "费用明细", required: true, type: "array", minItems: 1, itemFields };
|
||||
}
|
||||
|
||||
function customFieldValueFields(): Array<Record<string, unknown>> {
|
||||
return [
|
||||
{ key: "fieldNumber", label: "自定义字段编码", required: true, type: "string" },
|
||||
{ key: "fieldValue", label: "自定义字段值", required: true, type: "string", description: "复杂值按字段类型序列化为 JSON 字符串" },
|
||||
{ key: "fieldNumber", label: "字段引用", required: true, type: "string", userVisible: false, description: "由 Agent 从实时 Schema 内部复制,不向用户展示或索取" },
|
||||
{ key: "fieldValue", label: "字段值", required: true, type: "string", userVisible: false, description: "由 Agent 根据字段类型内部序列化" },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+15
-1
@@ -77,7 +77,8 @@ export interface XftSubmissionField {
|
||||
required: boolean;
|
||||
description?: string;
|
||||
multiple?: boolean;
|
||||
options?: unknown[];
|
||||
options?: XftSubmissionOption[];
|
||||
enumLabels?: Record<string, string>;
|
||||
moduleType?: string;
|
||||
valueFormat?: string;
|
||||
itemFields?: Array<Record<string, unknown>>;
|
||||
@@ -85,6 +86,7 @@ export interface XftSubmissionField {
|
||||
tool: "approval_get_submission_options";
|
||||
kind: XftSubmissionOptionKind;
|
||||
};
|
||||
selectionCapability?: XftSubmissionSelectionCapability;
|
||||
}
|
||||
|
||||
export type XftSubmissionOptionKind = "department" | "city" | "expense_type";
|
||||
@@ -94,6 +96,18 @@ export interface XftSubmissionOption {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type XftSubmissionSelectionCapability =
|
||||
| {
|
||||
status: "supported";
|
||||
userInput: "label";
|
||||
submitValue: "code";
|
||||
exposeInternalValue: false;
|
||||
}
|
||||
| {
|
||||
status: "client_only";
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export interface XftApprovalProvider {
|
||||
authStatus(user: AuthenticatedDingTalkUser): Promise<{
|
||||
authenticated: true;
|
||||
|
||||
+2
-2
@@ -248,7 +248,7 @@ test("MCP approval_login returns both portable text and a standard resource link
|
||||
assert.match(instructions, /登录链接过期.*approval_login.*绝不.*重新安装/);
|
||||
assert.match(instructions, /审批引用.*调用容量.*内部分批.*不得向用户解释/);
|
||||
assert.match(instructions, /提单草稿仅可保存在 Agent 所在机器.*不得上传网关.*实时模板 Schema/s);
|
||||
assert.match(instructions, /kind=expense_type.*只向用户展示label.*内部使用code.*绝不索取或展示内部费用编码/s);
|
||||
assert.match(instructions, /所有选择型字段只向用户展示业务名称.*optionSource.*label.*enumLabels.*城市码.*组织码.*工号.*client_only/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 +258,7 @@ 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.*code仅供内部提交/s);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
|
||||
@@ -105,9 +105,10 @@ test("customer distribution prefers remote MCP OAuth and keeps a generic compati
|
||||
assert.match(localDrafts, /never on the approval gateway/);
|
||||
assert.match(localDrafts, /local attachment path.*never a valid upstream attachment field value.*native client/s);
|
||||
assert.match(createApproval, /Local file paths.*not valid attachment field values.*native client/s);
|
||||
assert.match(createApproval, /kind=expense_type.*returned `label` values.*selected label.*`code` internally/s);
|
||||
assert.match(createApproval, /Never show, explain, or ask the user for `expenseNumber`.*`expenseTypeNumber`.*`expTypeCode`/s);
|
||||
assert.match(localDrafts, /expense-type code.*internal form value.*only that label.*never expose the stored code/s);
|
||||
assert.match(createApproval, /every XFT selection.*label-only.*optionSource.*returned `label` values.*`code` only inside/s);
|
||||
assert.match(createApproval, /Never show, explain, or ask.*city code.*organization code.*employee number.*numeric cabin value.*`expenseNumber`.*`expTypeCode`/s);
|
||||
assert.match(createApproval, /selectionCapability\.status=client_only.*required field stops.*optional field may be skipped/s);
|
||||
assert.match(localDrafts, /every stored selection value.*internal form value.*only that label.*never expose stored city.*department.*expense.*internal values/s);
|
||||
|
||||
const builder = readFileSync("scripts/build-release.mjs", "utf8");
|
||||
assert.match(builder, /schemaVersion: 3,\s+version,/s);
|
||||
|
||||
@@ -52,8 +52,9 @@ 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() ?? "", /kind=expense_type.*只向用户展示.*label.*内部使用.*code/s);
|
||||
assert.match(client.getInstructions() ?? "", /绝不向用户展示、解释或索取.*expenseNumber.*expenseTypeNumber.*expTypeCode/s);
|
||||
assert.match(client.getInstructions() ?? "", /所有选择型字段只向用户展示业务名称.*optionSource.*options\/enumLabels.*内部映射正式值/s);
|
||||
assert.match(client.getInstructions() ?? "", /绝不向用户展示、解释或索取.*城市码.*组织码.*工号.*档案编号.*数字舱位值.*内部 ID/s);
|
||||
assert.match(client.getInstructions() ?? "", /selectionCapability\.status=client_only.*必填时停止.*可选时.*跳过/s);
|
||||
|
||||
const tools = await client.listTools();
|
||||
assert.deepEqual(tools.tools.map((tool) => tool.name), [
|
||||
|
||||
+62
-2
@@ -5,7 +5,7 @@ import { deflateRawSync } from "node:zlib";
|
||||
import type { AppConfig } from "../src/config.js";
|
||||
import { UnifiedApprovalService } from "../src/unified-approval.js";
|
||||
import type { ApprovalService } from "../src/approval-service.js";
|
||||
import type { XftApprovalProvider, XftSubmissionTemplate } from "../src/xft.js";
|
||||
import type { XftApprovalProvider, XftSubmissionField, XftSubmissionTemplate } from "../src/xft.js";
|
||||
|
||||
function config(): AppConfig {
|
||||
return {
|
||||
@@ -1131,10 +1131,70 @@ test("Xft preparation reports standard and live custom required fields together"
|
||||
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
|
||||
await assert.rejects(
|
||||
service.prepareSubmission(user, { templateId, values: {} }),
|
||||
/业务字段:applyExpenses;自定义字段:招待事由(C1)、招待日期(C2)/,
|
||||
/业务字段:事前申请费用;自定义字段:招待事由、招待日期/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Xft preparation blocks fields whose user-readable choices cannot be resolved", async () => {
|
||||
const template: XftSubmissionTemplate = {
|
||||
eventSeqId: "1", eventNumber: "XFT-E", eventName: "费用申请单",
|
||||
eventType: "EXPENSE_APPLY", kind: "expense_apply",
|
||||
};
|
||||
let fields: XftSubmissionField[] = [{
|
||||
fieldNumber: "P1", name: "参与人员", type: "PERSON", required: true,
|
||||
selectionCapability: { status: "client_only", reason: "没有按姓名查询人员的安全接口" },
|
||||
}];
|
||||
const xft = {
|
||||
async listSubmissionTemplates() { return [template]; },
|
||||
async getSubmissionSchema() { return { template, fields, inputContract: {}, 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 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: { applyExpenses: [{ expenseNumber: "MEAL", amount: 10 }] },
|
||||
}), /无法安全填写的必填项:参与人员.*薪福通客户端/);
|
||||
|
||||
fields = [{
|
||||
fieldNumber: "P1", name: "参与人员", type: "PERSON", required: false,
|
||||
selectionCapability: { status: "client_only", reason: "没有按姓名查询人员的安全接口" },
|
||||
}];
|
||||
await assert.rejects(service.prepareSubmission(user, {
|
||||
templateId,
|
||||
values: {
|
||||
applyExpenses: [{ expenseNumber: "MEAL", amount: 10 }],
|
||||
customFieldValues: [{ fieldNumber: "P1", fieldValue: "[\"EMP-1\"]" }],
|
||||
},
|
||||
}), /参与人员.*无法通过 MCP.*薪福通客户端/);
|
||||
});
|
||||
|
||||
test("Xft preparation refuses optional internal-value fields instead of asking the user for codes", async () => {
|
||||
const template: XftSubmissionTemplate = {
|
||||
eventSeqId: "1", eventNumber: "XFT-T", eventName: "出差申请单",
|
||||
eventType: "TRAVEL_APPLY", kind: "travel_apply",
|
||||
};
|
||||
const xft = {
|
||||
async listSubmissionTemplates() { return [template]; },
|
||||
async getSubmissionSchema() { return { template, fields: [], inputContract: {}, 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: {
|
||||
businessTrip: { businessTripDetails: [{
|
||||
departCityCode: "1", destinationCityCode: "2", beginTime: "2026-08-12 09:00", endTime: "2026-08-12 18:00",
|
||||
}] },
|
||||
peerEmpNumbers: ["EMP-1"],
|
||||
},
|
||||
}), /同行人员.*不要提供内部编码或 ID/);
|
||||
});
|
||||
|
||||
test("submission rejects caller-supplied applicant identity", async () => {
|
||||
const ding = {
|
||||
async listSubmissionTemplates() { return [{ processCode: "PROC-1", name: "出差申请单_北极大", dirName: "北极大_人事假勤" }]; },
|
||||
|
||||
@@ -62,6 +62,13 @@ function findRecordByKey(value: unknown, key: string): Record<string, unknown> |
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findRecordsByKey(value: unknown, key: string): Array<Record<string, unknown>> {
|
||||
if (Array.isArray(value)) return value.flatMap((item) => findRecordsByKey(item, key));
|
||||
if (!value || typeof value !== "object") return [];
|
||||
const record = value as Record<string, unknown>;
|
||||
return [...(record.key === key ? [record] : []), ...Object.values(record).flatMap((item) => findRecordsByKey(item, key))];
|
||||
}
|
||||
|
||||
test("Xft OpenAPI maps the authenticated DingTalk user and signs encrypted requests", async () => {
|
||||
const calls: Array<{ path: string; body: Record<string, unknown>; headers: Headers; url: URL }> = [];
|
||||
const fakeFetch: typeof fetch = async (input, init) => {
|
||||
@@ -477,10 +484,91 @@ test("all formal Xft expense fields declare a user-readable expense type option
|
||||
const field = findRecordByKey(schema.inputContract, fieldKey);
|
||||
assert.equal(field?.label, "费用类型");
|
||||
assert.deepEqual(field?.optionSource, { tool: "approval_get_submission_options", kind: "expense_type" });
|
||||
assert.deepEqual(field?.selectionCapability, {
|
||||
status: "supported", userInput: "label", submitValue: "code", exposeInternalValue: false,
|
||||
});
|
||||
assert.doesNotMatch(String(field?.description ?? ""), /要求用户.*编码/);
|
||||
}
|
||||
});
|
||||
|
||||
test("all formal Xft city and enum choices expose readable labels while unresolved references fail closed", async () => {
|
||||
const records = [
|
||||
{ eventSeqId: 21, eventNumber: "EXP-21", eventName: "费用申请", eventStatus: "1", eventType: "EXPENSE_APPLY" },
|
||||
{ eventSeqId: 22, eventNumber: "ENT-22", eventName: "招待申请", eventStatus: "1", eventType: "ENTERTAINMENT_APPLY" },
|
||||
{ eventSeqId: 23, eventNumber: "TRIP-23", eventName: "差旅申请", eventStatus: "1", eventType: "TRAVEL_APPLY" },
|
||||
{ eventSeqId: 24, eventNumber: "REIM-24", eventName: "费用报销", eventStatus: "1", eventType: "COMMON_REIM" },
|
||||
{ eventSeqId: 25, eventNumber: "TR-25", eventName: "差旅报销", eventStatus: "1", eventType: "TRAVEL_REIM" },
|
||||
];
|
||||
const client = new XftOpenApiClient(config(), async (input) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
|
||||
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: records } });
|
||||
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [] });
|
||||
throw new Error(`unexpected ${path}`);
|
||||
});
|
||||
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 key of ["payCityCode", "entertainmentCityCode", "departCityCode", "destinationCityCode", "departureCode", "destinationCode"]) {
|
||||
const matches = findRecordsByKey(combined, key);
|
||||
assert.ok(matches.length > 0, `${key} should exist`);
|
||||
for (const field of matches) {
|
||||
assert.deepEqual(field.optionSource, { tool: "approval_get_submission_options", kind: "city" });
|
||||
assert.equal((field.selectionCapability as Record<string, unknown>)?.exposeInternalValue, false);
|
||||
assert.doesNotMatch(String(field.label), /编码/);
|
||||
}
|
||||
}
|
||||
const travelMode = findRecordByKey(combined, "travelMode");
|
||||
assert.equal((travelMode?.enumLabels as Record<string, string>)?.AirPlane, "飞机");
|
||||
assert.equal((travelMode?.enumLabels as Record<string, string>)?.RideHailing, "网约车");
|
||||
assert.equal((findRecordByKey(combined, "roundTrip")?.enumLabels as Record<string, string>)?.Y, "往返");
|
||||
assert.equal((findRecordByKey(combined, "beginTimePrecision")?.enumLabels as Record<string, string>)?.AM, "上午");
|
||||
assert.equal((findRecordByKey(combined, "currencyCode")?.enumLabels as Record<string, string>)?.CNY, "人民币");
|
||||
assert.equal((findRecordByKey(combined, "hotelStar")?.enumLabels as Record<string, string>)?.["1"], "五星");
|
||||
for (const key of ["peerEmpNumbers", "participants", "customArchiveOptionNumbers", "aircraftCabin", "trainCabin", "shipCabin", "relatedBillId"]) {
|
||||
const field = findRecordByKey(combined, key);
|
||||
assert.equal((field?.selectionCapability as Record<string, unknown>)?.status, "client_only", key);
|
||||
assert.doesNotMatch(String(field?.label ?? ""), /工号|编码| ID/);
|
||||
}
|
||||
});
|
||||
|
||||
test("live Xft custom choices normalize labels recursively and unsupported selectors are explicit", async () => {
|
||||
const templateRecord = {
|
||||
eventSeqId: 31, eventNumber: "CUSTOM-31", eventName: "费用申请", eventStatus: "1", eventType: "EXPENSE_APPLY",
|
||||
};
|
||||
const client = new XftOpenApiClient(config(), async (input) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
|
||||
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: [templateRecord] } });
|
||||
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [
|
||||
{ fieldNumber: "S1", name: "餐别", type: "SELECT", required: true, options: [
|
||||
{ optionCode: "LUNCH", optionName: "午餐" }, { optionCode: "DINNER", optionName: "晚餐" },
|
||||
] },
|
||||
{ fieldNumber: "S2", name: "无名称映射", type: "SELECT", required: false, options: ["1", "2"] },
|
||||
{ fieldNumber: "P1", name: "参与人员", type: "PERSON", required: true },
|
||||
{ fieldNumber: "D1", name: "成本部门", type: "ADV_DEPARTMENT", required: true },
|
||||
{ fieldNumber: "T1", name: "明细", type: "TABLE", required: true, columns: [
|
||||
{ titleCode: "TS", name: "用餐类型", type: "SELECT", required: true, options: [{ code: "TEAM", label: "团队餐" }] },
|
||||
{ titleCode: "TP", name: "同行人", type: "PERSON", required: false },
|
||||
] },
|
||||
] });
|
||||
throw new Error(`unexpected ${path}`);
|
||||
});
|
||||
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
|
||||
const fields = (await client.getSubmissionSchema({ userId: "ding-u1" }, template)).fields;
|
||||
const select = fields.find((field) => field.fieldNumber === "S1")!;
|
||||
assert.deepEqual(select.options, [{ code: "LUNCH", label: "午餐" }, { code: "DINNER", label: "晚餐" }]);
|
||||
assert.equal(select.selectionCapability?.status, "supported");
|
||||
assert.equal(fields.find((field) => field.fieldNumber === "S2")?.selectionCapability?.status, "client_only");
|
||||
assert.equal(fields.find((field) => field.fieldNumber === "P1")?.selectionCapability?.status, "client_only");
|
||||
assert.deepEqual(fields.find((field) => field.fieldNumber === "D1")?.optionSource, {
|
||||
tool: "approval_get_submission_options", kind: "department",
|
||||
});
|
||||
const tableFields = fields.find((field) => field.fieldNumber === "T1")?.itemFields ?? [];
|
||||
assert.deepEqual(tableFields.find((field) => field.titleCode === "TS")?.options, [{ code: "TEAM", label: "团队餐" }]);
|
||||
assert.equal((tableFields.find((field) => field.titleCode === "TP")?.selectionCapability as Record<string, unknown>)?.status, "client_only");
|
||||
});
|
||||
|
||||
test("Xft expense types prefer user-readable live template options and fail closed when unavailable", async () => {
|
||||
const templateRecord = {
|
||||
eventSeqId: 8, eventNumber: "EXP-8", eventName: "招待费用申请单", eventStatus: "1", eventType: "EXPENSE_APPLY",
|
||||
|
||||
Reference in New Issue
Block a user