From 34c3c03a5f9592235847458ccf2bfd4b29118264 Mon Sep 17 00:00:00 2001 From: Donald Date: Thu, 20 Aug 2026 00:11:20 +0000 Subject: [PATCH] feat: expose user-readable expense types --- README.md | 4 +- docs/architecture.md | 2 + docs/deployment.md | 2 + docs/submission-templates.md | 4 +- .../references/create-approval.md | 2 +- .../references/local-drafts.md | 2 + src/bridge.ts | 6 +- src/server.ts | 6 +- src/unified-approval.ts | 10 +- src/xft-open-api.ts | 125 +++++++++++++++- src/xft.ts | 8 +- test/bridge.test.ts | 4 + test/distribution.test.ts | 3 + test/server-instructions.test.ts | 6 +- test/xft-open-api.test.ts | 134 +++++++++++++++++- 15 files changed, 293 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 09c07fe..453fffa 100644 --- a/README.md +++ b/README.md @@ -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,6 +46,8 @@ Agent 不传 `userId`、员工号或单据所有者。网关从 OAuth Token 确 发起审批固定为“列模板 → 取 Schema → 自动查重/预览 → 用户确认 → 正式提交”。准备阶段从新表单提取发票代码与号码、数电票唯一编号、票据号码、附件哈希;没有唯一编号时,金额、日期与商户组合只作为疑似提示。网关实时刷新当前用户本人近 30 天的钉钉和薪福通已发起审批:原单待审批、已通过或状态未知时默认阻止,原单拒绝、撤回、作废或弱匹配时警告。任一来源查询不完整也不会当成“没有重复”。只有用户看过匹配单号、状态和覆盖情况并明确要求继续后,Agent 才能以 `allowDuplicateInvoices=true` 重新准备。 +费用类型不要求用户理解薪福通内部编码。实时 Schema 会把费用申请、差旅预计费用及报销明细中的费用类型字段标记为 `expense_type` 选项;用户未主动提供时,Agent 查询企业实时费用目录并直接展示“交通费、住宿费”等名称,选定后仅在内部写入对应编码。无法取得目录时安全停止,不退化为让用户填写编码。 + 服务端临时保留不可逆的用户/发票 HMAC 指纹。首次使用时只为最近 30 天尚未索引的审批读取详情;以后准备提单会刷新精简历史状态,并只为新增审批补建指纹,不会重复逐张下载已索引发票。指纹和最小审批索引最多保留 35 天,过期后自动级联删除;10 分钟提单预留到期即删除。 安全准备后,确认引用绑定登录用户、模板版本、完整表单内容、重复风险决定和发票预留,10 分钟有效且只能使用一次;相同发票在另一个有效确认中时不会再签发确认,避免并发重复创建。正文和发票原号不落库。钉钉企业 OpenAPI 支持通过 `POST /v1.0/workflow/processes/forecast` 进行无副作用的流程预测;网关只在预测成功并补齐所有自选处理人后才生成提交确认。薪福通在正式提交时完成最终业务校验,并由网关写后读取 `billSts`:状态 `1(待审批)` 返回 `submitted` 和 `revokeId`,状态 `2(审批通过)` 返回 `completed` 且不提供撤回引用。 diff --git a/docs/architecture.md b/docs/architecture.md index 3c58072..47896fd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -24,6 +24,8 @@ Agent 面向统一审批语义,不直接理解钉钉 `processInstanceId/taskId`、薪福通员工号、上游响应或完整 Schema。网关在内部完成来源路由、字段抽取、动作调用和写后复核,只返回审批判断所需的精简字段与统一状态。 +薪福通费用类型同样按领域选项抽象。网关先读取当前模板字段中的实时选项;模板未内嵌时,再从企业多级数据源中只定位费用类型目录并返回精简的 `label/code` 映射。Agent 只把 `label` 展示给用户,`code` 仅用于构造正式接口的 `expenseNumber`、`expenseTypeNumber` 或 `expTypeCode`。响应不包含完整数据源、内部身份或其他企业档案,且不持久化费用目录。 + 所有工具以普通 `content.text` 中的精简 JSON 作为跨宿主可见结果,同时保留相同的精简 `structuredContent` 供支持结构化结果的宿主使用;两处都不携带诊断字段或上游原始响应。`raw` 仍是受过滤、分页和硬限额保护的按需诊断视图,不是默认数据通道。 ## 本机提单草稿 diff --git a/docs/deployment.md b/docs/deployment.md index 23f9fb6..0f7628c 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -24,6 +24,8 @@ https://approval.justconn.top/oauth/dingtalk/callback - `xft-member/openapi/xft-member/member/get/by-id`(查询企业成员详情,用于由当前登录身份安全取得本人花名册员工号); - `itrip-gateway/v1/form/query-event-list`; - `itrip-gateway/v1/form/customFields/eventSeqId/{eventSeqId}`; +- `itrip-gateway/v1/dataSource/findMultilevelSourcePage`(查询企业费用类型数据源); +- `itrip-gateway/v1/dataSource/getMultiLevelDetail/{sourceSeqId}`(读取费用类型名称与正式提交编码); - `itrip-gateway/v1/approve/recall`(单据撤回); - `itrip-gateway/v1/bills/apply/travel/create`; - `itrip-gateway/v1/bills/apply/expense/create`; diff --git a/docs/submission-templates.md b/docs/submission-templates.md index 87f4abe..c9cce33 100644 --- a/docs/submission-templates.md +++ b/docs/submission-templates.md @@ -6,7 +6,7 @@ |---|---|---|---| | 薪福通 | 差旅报销单 | 支持,有约束 | 报销总额、币种、费用明细、差旅行程;费用和行程逐项递归校验,企业模板还要求承担部门 | | 薪福通 | 费用报销单 | 支持,有约束 | 报销总额、币种、费用明细;当前模板要求承担部门和费用说明 | -| 薪福通 | 招待费用申请单 | 支持,有约束 | 企业模板类型为 `EXPENSE_APPLY`,使用费用申请创建接口的 `applyExpenses`(费用类型编码、金额等),而不是按模板名称误用招待申请专用 DTO;写后实时核验状态 | +| 薪福通 | 招待费用申请单 | 支持,有约束 | 企业模板类型为 `EXPENSE_APPLY`,使用费用申请创建接口的 `applyExpenses`(费用类型、金额等),而不是按模板名称误用招待申请专用 DTO;费用类型名称实时查询、编码内部映射;写后实时核验状态 | | 薪福通 | 出差费用申请单 | 支持,有约束 | 行程、城市和预计费用递归校验;实测仅有发起节点时流程即时完成并返回 `completed` | | 钉钉 OA | 业务招待申请_北极大 | 仅客户端 | 普通字段和明细表可读取,但真实测试中新版企业创建接口返回系统异常、旧版企业接口拒绝组件格式 | | 钉钉 OA | 出差申请单_北极大 | 仅客户端 | `DDBizSuite` 出差套件包含日程、天数、同行人及考勤联动,企业 OpenAPI 没有可靠构造协议 | @@ -17,7 +17,7 @@ 薪福通自定义字段统一使用 `customFieldValues[].fieldNumber/fieldValue`。日期字段使用 `yyyy-MM-dd HH:mm`,人员字段值是花名册编号 JSON 数组,部门字段值是组织编码 JSON 数组,表格字段值是包含 `sequence`、`columnValues`、`titleCode` 的 JSON 数组字符串,附件字段值是先通过薪福通附件能力取得的附件编号。`approval_get_submission_schema` 会返回实时 `valueFormat`;Agent 不应按字段名称猜编码或结构。 -薪福通模板要求承担部门或城市编码时,使用同一 `templateId` 调用 `approval_get_submission_options`。`kind=department` 只会返回当前登录用户本人档案中的可选组织;`kind=city` 可用 `query` 按城市名称搜索。城市全量接口较慢,网关给予独立的 45 秒超时并在进程内缓存字典 24 小时,不写入磁盘。响应仅包含可填写的 `code` 和 `label`,不会暴露员工号或原始成员档案。 +薪福通模板要求承担部门、城市或费用类型时,使用同一 `templateId` 调用 `approval_get_submission_options`。`kind=department` 只会返回当前登录用户本人档案中的可选组织;`kind=city` 可用 `query` 按城市名称搜索;`kind=expense_type` 先读取模板内嵌选项,再按需读取企业多级费用类型数据源。费用申请的 `expenseNumber`、差旅预计费用的 `expenseTypeNumber` 和报销明细的 `expTypeCode` 都在 Schema 中标记这一选项源。Agent 只向用户显示 `label` 并在内部使用 `code`,不得向用户索取内部编码。城市全量接口较慢,网关给予独立的 45 秒超时并在进程内缓存字典 24 小时,不写入磁盘;费用类型保持实时且不落盘。响应不会暴露员工号、完整数据源或原始成员档案。 薪福通的自定义字段接口可能同时列出 `billCompanyCode`、`billDepartmentCode` 等标准单据抬头字段,也可能用动态字段号返回名为“承担部门/单据部门”的 `ADV_DEPARTMENT` 控件,但创建接口禁止把它们放进 `customFieldValues`。MCP 会剔除这些服务器管理控件;公司和部门都省略时,薪福通根据当前登录申请人的档案默认填写。若通过标准抬头字段显式指定,则公司与部门必须成对填写。 diff --git a/skills/dingtalk-approval/references/create-approval.md b/skills/dingtalk-approval/references/create-approval.md index ab557c1..2c543bc 100644 --- a/skills/dingtalk-approval/references/create-approval.md +++ b/skills/dingtalk-approval/references/create-approval.md @@ -5,7 +5,7 @@ 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 or city code, 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. Never invent or request an internal code when the tool can resolve it. + 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. 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. diff --git a/skills/dingtalk-approval/references/local-drafts.md b/skills/dingtalk-approval/references/local-drafts.md index 327b82c..c00c109 100644 --- a/skills/dingtalk-approval/references/local-drafts.md +++ b/skills/dingtalk-approval/references/local-drafts.md @@ -66,6 +66,8 @@ 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. + Delete only the selected local file. Confirm deletion in business terms. Deleting a local draft never calls an upstream revoke or approval API. ## Prepare and submit diff --git a/src/bridge.ts b/src/bridge.ts index eab1e02..4187ac7 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -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,短期提交引用不得写入草稿。", + instructions: "首次使用或登录链接过期时调用approval_login;每次调用会检查登录并在需要时生成有效的新链接,绝不因链接过期而重新安装。工具不会通过其他消息通道自动发送链接,必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。审批引用、调用容量和内部分批不得向用户解释;查看时只回答业务信息,用户明确授权后在授权范围内自动处理。写前校验失败时零写入,任何不确定结果都不得自动重试。提单草稿仅可保存在 Agent 所在机器的当前用户私有目录,不得上传网关;恢复草稿后必须重新读取实时模板 Schema,短期提交引用不得写入草稿。薪福通费用类型未提供时调用approval_get_submission_options(kind=expense_type),只向用户展示label并在内部使用code,绝不索取或展示内部费用编码。", }); server.registerTool("approval_login", { title: "登录审批服务", description: "检查登录;未登录或旧链接已过期时生成新的钉钉授权链接。同一身份同时用于钉钉OA和薪福通,无需重新安装。", inputSchema: {}, @@ -446,8 +446,8 @@ export function createBridgeMcpServer(bridge: RemoteBridge): McpServer { }, annotations: { readOnlyHint: true, destructiveHint: false }, }, async (args) => bridge.call("approval_get_submission_schema", args)); server.registerTool("approval_get_submission_options", { - title: "查询发起审批可用选项", description: "实时查询当前登录用户本人的薪福通承担部门,或搜索城市编码。", inputSchema: { - templateId: z.string().min(20).max(4096), kind: z.enum(["department", "city"]), + 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 }, }, async (args) => bridge.call("approval_get_submission_options", args)); diff --git a/src/server.ts b/src/server.ts index c4ad872..6b0ab1b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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 实时查询,不得猜测。必须先检查 submissionCapability;client_only 时停止 API 构造并引导钉钉客户端。读取 Schema 后,逐项核对用户已提供的信息;必填字段缺失或含糊时必须继续询问且不得猜测,直到全部完整。必须列出尚未填写的非必填字段并询问用户是否补充,用户可以明确跳过。prepare 若返回缺失、无效或需自选处理人的字段,继续询问后重新 prepare。prepare 成功后必须向用户展示完整预览并另行取得明确确认;确认后才用完全相同的内容调用 approval_submit。不要让用户或 Agent 提供发起人 userId、薪福通员工号或审批状态。", + "发起审批范围仅包括薪福通已支持模板,以及 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、薪福通员工号或审批状态。", "用户明确要求起草、保存、继续、列出或删除提单草稿时,草稿只能由 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,它会实时校验本人已发起单据归属,但不会再次执行写操作。", @@ -184,10 +184,10 @@ export function createApprovalMcpServer( server.registerTool("approval_get_submission_options", { title: "查询发起审批可用选项", - description: "使用薪福通templateId实时查询当前登录用户本人的承担部门,或按名称搜索城市编码。只返回可填写的code/label,不返回成员档案、员工号或原始上游数据。", + description: "使用薪福通templateId实时查询当前登录用户本人的承担部门,或按名称搜索城市和费用类型。费用类型未提供时必须用kind=expense_type并只向用户展示label,内部使用code,禁止向用户索取或展示内部编码。只返回可填写的code/label,不返回成员档案、员工号或原始上游数据。", inputSchema: { templateId: z.string().min(20).max(4096), - kind: z.enum(["department", "city"]), + kind: z.enum(["department", "city", "expense_type"]), query: z.string().max(100).default(""), limit: z.number().int().min(1).max(100).default(50), }, diff --git a/src/unified-approval.ts b/src/unified-approval.ts index 860b5c6..0365967 100644 --- a/src/unified-approval.ts +++ b/src/unified-approval.ts @@ -2052,7 +2052,7 @@ function validateXftExpenseApplication(value: unknown): void { if (!Array.isArray(value) || !value.length) throw new ApprovalSafetyError("事前申请费用 applyExpenses 至少需要 1 条"); value.forEach((item, index) => { if (!isSubmissionRecord(item)) throw new ApprovalSafetyError(`事前申请费用第 ${index + 1} 条必须是对象`); - if (isBlankSubmissionValue(item.expenseNumber)) throw new ApprovalSafetyError(`事前申请费用第 ${index + 1} 条缺少费用类型编码 expenseNumber`); + if (isBlankSubmissionValue(item.expenseNumber)) throw new ApprovalSafetyError(`事前申请费用第 ${index + 1} 条缺少费用类型,请先查询并选择可用名称`); requirePositiveDecimal(item.amount, `事前申请费用第 ${index + 1} 条申请金额 amount`); if (!isBlankSubmissionValue(item.participantNum) && (!Number.isSafeInteger(Number(item.participantNum)) || Number(item.participantNum) <= 0)) { @@ -2142,9 +2142,9 @@ function validateXftExpenses(value: unknown): void { if (!Array.isArray(value) || value.length === 0) throw new ApprovalSafetyError("费用明细 expenseList 至少需要一笔"); value.forEach((item, index) => { if (!isSubmissionRecord(item)) throw new ApprovalSafetyError(`第 ${index + 1} 笔费用必须是对象`); - for (const key of ["expTypeCode", "currencyCode", "originalCurrencyReimAmt"] as const) { - if (isBlankSubmissionValue(item[key])) throw new ApprovalSafetyError(`第 ${index + 1} 笔费用缺少 ${key}`); - } + 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`); requirePositiveDecimal(item.originalCurrencyReimAmt, `第 ${index + 1} 笔费用金额 originalCurrencyReimAmt`); if (item.wrTim !== undefined) requireDateString(item.wrTim, `第 ${index + 1} 笔消费日期 wrTim`, /^\d{4}-\d{2}-\d{2}$/); @@ -2177,7 +2177,7 @@ function validateXftBusinessTrip(value: unknown): void { if (!Array.isArray(item.estimateExpenses)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程 estimateExpenses 必须是数组`); item.estimateExpenses.forEach((expense, expenseIndex) => { if (!isSubmissionRecord(expense) || isBlankSubmissionValue(expense.expenseTypeNumber) || isBlankSubmissionValue(expense.applyAmount)) { - throw new ApprovalSafetyError(`第 ${index + 1} 段行程第 ${expenseIndex + 1} 笔预计费用缺少 expenseTypeNumber 或 applyAmount`); + throw new ApprovalSafetyError(`第 ${index + 1} 段行程第 ${expenseIndex + 1} 笔预计费用缺少费用类型或申请金额`); } requirePositiveDecimal(expense.applyAmount, `第 ${index + 1} 段行程第 ${expenseIndex + 1} 笔预计费用 applyAmount`); }); diff --git a/src/xft-open-api.ts b/src/xft-open-api.ts index 4df6482..fe7a397 100644 --- a/src/xft-open-api.ts +++ b/src/xft-open-api.ts @@ -80,6 +80,9 @@ const XFT_MANAGED_SUBMISSION_FIELDS = new Set([ const XFT_STANDARD_FORM_FIELD_NUMBERS = new Set(["billCompanyCode", "billDepartmentCode"]); const CITY_OPTIONS_TIMEOUT_MS = 45_000; const CITY_OPTIONS_CACHE_MS = 24 * 60 * 60 * 1000; +const EXPENSE_TYPE_SOURCE_PAGE_SIZE = 100; +const EXPENSE_TYPE_SOURCE_MAX_PAGES = 5; +const EXPENSE_TYPE_HINT = /费用\s*(?:类型|类别|项目)|(?:类型|类别|项目)\s*费用|费目|expense[\s_-]*type/i; /** Official XFT OpenAPI adapter. It never accepts an empId from an Agent. */ export class XftOpenApiClient implements XftApprovalProvider { @@ -369,13 +372,13 @@ export class XftOpenApiClient implements XftApprovalProvider { limit = 50, ) { const identity = await this.resolveIdentity(user); - await this.requireSubmissionTemplate(identity, template); + const current = await this.requireSubmissionTemplate(identity, template); let options: XftSubmissionOption[]; let cacheHit = false; if (kind === "department") { const member = await this.requireCurrentMember(identity); options = extractDepartmentOptions(member); - } else { + } else if (kind === "city") { if (this.cityOptionsCache && Date.now() - this.cityOptionsCache.fetchedAt < CITY_OPTIONS_CACHE_MS) { options = this.cityOptionsCache.items; cacheHit = true; @@ -390,13 +393,21 @@ export class XftOpenApiClient implements XftApprovalProvider { options = extractCityOptions(cities); this.cityOptionsCache = { fetchedAt: Date.now(), items: options }; } + } else { + options = await this.loadExpenseTypeOptions(identity, current); + if (!options.length) { + throw new ApprovalSafetyError("暂时无法实时获取可选费用类型,请稍后重试或在薪福通客户端完成"); + } } const filtered = query.trim() ? options.filter((item) => `${item.label}\n${item.code}`.toLowerCase().includes(query.trim().toLowerCase())) : options; + const boundedLimit = Math.min(100, Math.max(1, limit)); return { kind, - items: filtered.slice(0, Math.min(100, Math.max(1, limit))), + items: filtered.slice(0, boundedLimit), + total: filtered.length, + hasMore: filtered.length > boundedLimit, diagnostics: { source: "xft_official_openapi", realtime: !cacheHit, @@ -407,6 +418,64 @@ export class XftOpenApiClient implements XftApprovalProvider { }; } + private async loadExpenseTypeOptions( + identity: XftIdentity, + template: XftSubmissionTemplate, + ): Promise { + try { + const rawFields = await this.request( + `/itrip/xft-api/v1/form/customFields/eventSeqId/${encodeURIComponent(template.eventSeqId)}`, + undefined, + "GET", + identity, + ); + const embedded = extractExpenseTypeOptions(rawFields); + if (embedded.length) return embedded; + } catch { + // Some templates do not expose standard expense dictionaries in custom fields. + // Fall through to the enterprise multilevel data-source APIs. + } + + const candidates: Array<{ id: string; raw: JsonObject }> = []; + for (let page = 0; page < EXPENSE_TYPE_SOURCE_MAX_PAGES; page += 1) { + let response: unknown; + try { + response = await this.request( + "/itrip/xft-api/v1/dataSource/findMultilevelSourcePage", + { start: page * EXPENSE_TYPE_SOURCE_PAGE_SIZE, limit: EXPENSE_TYPE_SOURCE_PAGE_SIZE }, + "POST", + identity, + ); + } catch { + return []; + } + const records = pagedRecords(response); + for (const record of records) { + const name = stringAt(record, ["sourceName", "dataSourceName", "multilevelSourceName", "displayName", "name", "title"]); + const id = stringAt(record, ["sourceSeqId", "dataSourceSeqId", "multilevelSourceSeqId", "seqId", "id"]); + if (id && name && EXPENSE_TYPE_HINT.test(name)) candidates.push({ id, raw: record }); + } + if (records.length < EXPENSE_TYPE_SOURCE_PAGE_SIZE) break; + } + + const output: XftSubmissionOption[] = []; + for (const candidate of candidates.slice(0, 10)) { + output.push(...extractExpenseTypeOptions(candidate.raw, true)); + try { + const detail = await this.request( + `/itrip/xft-api/v1/dataSource/getMultiLevelDetail/${encodeURIComponent(candidate.id)}`, + undefined, + "GET", + identity, + ); + output.push(...extractExpenseTypeOptions(detail, true)); + } catch { + // Continue with other matching sources. Empty output fails closed below. + } + } + return uniqueOptions(output); + } + async submitApproval( user: AuthenticatedDingTalkUser, template: XftSubmissionTemplate, @@ -1258,6 +1327,41 @@ function extractCityOptions(value: unknown): XftSubmissionOption[] { return uniqueOptions(candidates); } +function extractExpenseTypeOptions(value: unknown, trustedExpenseSource = false): XftSubmissionOption[] { + const candidates: XftSubmissionOption[] = []; + const visit = (current: unknown, path: string[], inheritedHint: boolean, depth: number): void => { + if (depth > 12) return; + if (Array.isArray(current)) { + for (const item of current) visit(item, path, inheritedHint, depth + 1); + return; + } + if (!isObject(current)) return; + const record = current; + const recordHint = EXPENSE_TYPE_HINT.test(stringAt(record, [ + "fieldName", "name", "label", "title", "sourceName", "dataSourceName", "multilevelSourceName", + ]) ?? ""); + const pathHint = path.some((part) => EXPENSE_TYPE_HINT.test(part)); + const inExpenseContext = trustedExpenseSource || inheritedHint || recordHint || pathHint; + if (inExpenseContext && record.selectable !== false && record.canSelect !== false && record.disabled !== true) { + const explicitCode = stringAt(record, [ + "expenseNumber", "expenseTypeNumber", "expTypeCode", "expenseTypeCode", "expenseTypeNo", "expTypeNo", + ]); + const code = explicitCode ?? (path.length > 0 ? stringAt(record, [ + "optionCode", "optionNumber", "itemCode", "itemNumber", "nodeCode", "nodeNumber", "dataCode", "dataNumber", + "sourceOptionCode", "sourceOptionNumber", "code", "number", "value", "key", + ]) : undefined); + const label = stringAt(record, [ + "expenseName", "expenseTypeName", "expTypeName", "optionName", "itemName", "nodeName", "dataName", + "sourceOptionName", "label", "text", "displayName", "name", "title", + ]); + if (code && label) candidates.push({ code, label }); + } + for (const [key, child] of Object.entries(record)) visit(child, [...path, key], inExpenseContext, depth + 1); + }; + visit(value, [], trustedExpenseSource, 0); + return uniqueOptions(candidates); +} + function visitObjects( value: unknown, visitor: (record: JsonObject, path: string[]) => void, @@ -1420,10 +1524,19 @@ function normalizeSubmissionField(value: JsonObject): XftSubmissionField[] { ...(typeof value.multiple === "boolean" ? { multiple: value.multiple } : {}), ...(Array.isArray(value.options) ? { options: value.options.map((option) => sanitizeOption(option)).filter((option) => option !== undefined) } : {}), ...(typeof value.moduleType === "string" && value.moduleType ? { moduleType: value.moduleType } : {}), + ...(isExpenseTypeField(fieldNumber, name) ? { optionSource: expenseTypeOptionSource() } : {}), ...submissionFieldShape(value, type), }]; } +function isExpenseTypeField(fieldNumber: string, name: string): boolean { + return EXPENSE_TYPE_HINT.test(name) || /^(?:expenseNumber|expenseTypeNumber|expTypeCode)$/i.test(fieldNumber); +} + +function expenseTypeOptionSource() { + return { tool: "approval_get_submission_options" as const, kind: "expense_type" as const }; +} + function submissionFieldShape(value: JsonObject, type: string): Pick { const normalized = type.toUpperCase(); if (normalized.includes("TABLE")) { @@ -1522,7 +1635,7 @@ function submissionInputContractSummary(kind: XftSubmissionKind): Record> { return [{ key: "applyExpenses", label: "事前申请费用列表", required: true, type: "array", minItems: 1, itemFields: [ - { key: "expenseNumber", label: "费用类型编码", required: true, type: "string", description: "企业费用管理中定义的费用类型编码" }, + { key: "expenseNumber", label: "费用类型", required: true, type: "string", description: "向用户展示可读名称,内部填写所选项的 code", optionSource: expenseTypeOptionSource() }, { 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" }, @@ -1560,7 +1673,7 @@ function travelApplicationFields(): Array> { { key: "endTimePrecision", label: "结束时间半天精度", required: false, type: "string", enum: ["AM", "PM"] }, { key: "customArchiveOptionNumbers", label: "行程自定义档案选项", required: false, type: "array" }, { key: "estimateExpenses", label: "预计费用", required: false, type: "array", itemFields: [ - { key: "expenseTypeNumber", label: "费用类型编码", required: true, type: "string" }, + { key: "expenseTypeNumber", label: "费用类型", required: true, type: "string", description: "向用户展示可读名称,内部填写所选项的 code", optionSource: expenseTypeOptionSource() }, { key: "applyAmount", label: "申请金额", required: true, type: "decimal" }, { key: "remark", label: "备注", required: false, type: "string" }, { key: "spreadInfo", label: "分摊信息", required: false, type: "object" }, @@ -1622,7 +1735,7 @@ function travelReimbursementFields(): Array> { function expenseListField(travel: boolean): Record { const itemFields: Array> = [ - { key: "expTypeCode", label: "费用类型编码", required: true, type: "string", maxLength: 100, description: "企业费用管理中定义的费用类型编码" }, + { key: "expTypeCode", label: "费用类型", required: true, type: "string", maxLength: 100, description: "向用户展示可读名称,内部填写所选项的 code", optionSource: expenseTypeOptionSource() }, { key: "currencyCode", label: "费用币种", required: true, type: "string", enum: ["CNY"] }, { key: "originalCurrencyReimAmt", label: "费用金额", required: true, type: "decimal", precision: 2 }, { key: "wrTim", label: "消费日期", required: false, type: "date", format: "yyyy-MM-dd" }, diff --git a/src/xft.ts b/src/xft.ts index eedc820..1a7583f 100644 --- a/src/xft.ts +++ b/src/xft.ts @@ -81,9 +81,13 @@ export interface XftSubmissionField { moduleType?: string; valueFormat?: string; itemFields?: Array>; + optionSource?: { + tool: "approval_get_submission_options"; + kind: XftSubmissionOptionKind; + }; } -export type XftSubmissionOptionKind = "department" | "city"; +export type XftSubmissionOptionKind = "department" | "city" | "expense_type"; export interface XftSubmissionOption { code: string; @@ -159,6 +163,8 @@ export interface XftApprovalProvider { ): Promise<{ kind: XftSubmissionOptionKind; items: XftSubmissionOption[]; + total?: number; + hasMore?: boolean; diagnostics: Record; }>; validateSubmissionIdentity(user: AuthenticatedDingTalkUser): Promise<{ diff --git a/test/bridge.test.ts b/test/bridge.test.ts index 93fd74a..c982953 100644 --- a/test/bridge.test.ts +++ b/test/bridge.test.ts @@ -248,6 +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.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, ""); @@ -255,6 +256,9 @@ test("MCP approval_login returns both portable text and a standard resource link const batch = tools.tools.find((tool) => tool.name === "approval_process_batch")!; assert.equal((batch.inputSchema as any).properties.items.maxItems, 20); 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); } finally { await client.close(); await server.close(); diff --git a/test/distribution.test.ts b/test/distribution.test.ts index a03d0cf..10602ec 100644 --- a/test/distribution.test.ts +++ b/test/distribution.test.ts @@ -105,6 +105,9 @@ 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); const builder = readFileSync("scripts/build-release.mjs", "utf8"); assert.match(builder, /schemaVersion: 3,\s+version,/s); diff --git a/test/server-instructions.test.ts b/test/server-instructions.test.ts index d927dde..ec2bab0 100644 --- a/test/server-instructions.test.ts +++ b/test/server-instructions.test.ts @@ -52,6 +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() ?? "", /kind=expense_type.*只向用户展示.*label.*内部使用.*code/s); + assert.match(client.getInstructions() ?? "", /绝不向用户展示、解释或索取.*expenseNumber.*expenseTypeNumber.*expTypeCode/s); const tools = await client.listTools(); assert.deepEqual(tools.tools.map((tool) => tool.name), [ @@ -83,7 +85,9 @@ 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 ?? "", /承担部门.*城市编码/); + assert.match(descriptions.approval_get_submission_options ?? "", /承担部门.*城市.*费用类型.*只向用户展示label.*内部使用code/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); const prepareSchema = tools.tools.find((tool) => tool.name === "approval_prepare_submission")?.inputSchema as any; diff --git a/test/xft-open-api.test.ts b/test/xft-open-api.test.ts index c1efdba..c04e0dd 100644 --- a/test/xft-open-api.test.ts +++ b/test/xft-open-api.test.ts @@ -44,6 +44,24 @@ function decodedBody(init?: RequestInit): Record { return JSON.parse(String(sm4.decrypt(envelope.secretMsg, privateKey.slice(0, 32)))) as Record; } +function findRecordByKey(value: unknown, key: string): Record | undefined { + if (Array.isArray(value)) { + for (const item of value) { + const match = findRecordByKey(item, key); + if (match) return match; + } + return undefined; + } + if (!value || typeof value !== "object") return undefined; + const record = value as Record; + if (record.key === key) return record; + for (const child of Object.values(record)) { + const match = findRecordByKey(child, key); + if (match) return match; + } + return undefined; +} + test("Xft OpenAPI maps the authenticated DingTalk user and signs encrypted requests", async () => { const calls: Array<{ path: string; body: Record; headers: Headers; url: URL }> = []; const fakeFetch: typeof fetch = async (input, init) => { @@ -344,9 +362,14 @@ test("Xft submission discovers live templates and derives protected create field assert.equal(schema.fields.length, 1); const standardFields = (schema.inputContract.values as Record).standardFields as Array>; const expenseList = standardFields.find((field) => field.key === "expenseList"); - assert.deepEqual((expenseList?.itemFields as Array>).filter((field) => field.required).map((field) => field.key), [ + const expenseItemFields = expenseList?.itemFields as Array>; + assert.deepEqual(expenseItemFields.filter((field) => field.required).map((field) => field.key), [ "expTypeCode", "currencyCode", "originalCurrencyReimAmt", ]); + assert.deepEqual(expenseItemFields.find((field) => field.key === "expTypeCode")?.optionSource, { + tool: "approval_get_submission_options", kind: "expense_type", + }); + assert.equal(expenseItemFields.find((field) => field.key === "expTypeCode")?.label, "费用类型"); const identityCheck = await client.validateSubmissionIdentity({ userId: "ding-u1" }); assert.equal(identityCheck.validated, true); assert.doesNotMatch(JSON.stringify(identityCheck), /E001|EMP-1/); @@ -360,11 +383,13 @@ test("Xft submission discovers live templates and derives protected create field assert.equal(createBodies[0]?.billApplyAuthCheckType, "Y"); }); -test("Xft submission options expose only the current user's departments and searchable city codes", async () => { +test("Xft submission options expose only departments, cities, and the matching expense data source", async () => { const templateRecord = { eventSeqId: 1, eventNumber: "TRAVEL-1", eventName: "出差费用申请单", eventStatus: "1", eventType: "TRAVEL_APPLY", }; let cityRequests = 0; + let expenseSourceRequests = 0; + let unrelatedSourceRequests = 0; const client = new XftOpenApiClient(config(), async (input, init) => { const path = new URL(String(input)).pathname; if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } }); @@ -386,6 +411,30 @@ test("Xft submission options expose only the current user's departments and sear regions: [{ cityCode: "440300", cityName: "深圳市" }, { cityCode: "441900", cityName: "东莞市" }], } }); } + if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [] }); + if (path.endsWith("dataSource/findMultilevelSourcePage")) { + assert.deepEqual(decodedBody(init), { start: 0, limit: 100 }); + return encrypted({ code: "0", data: { records: [ + { sourceSeqId: "DS-EXP", sourceName: "企业费用类型" }, + { sourceSeqId: "DS-PROJECT", sourceName: "项目档案" }, + ] } }); + } + if (path.endsWith("dataSource/getMultiLevelDetail/DS-EXP")) { + expenseSourceRequests += 1; + return encrypted({ code: "0", data: { + sourceSeqId: "DS-EXP", + sourceName: "企业费用类型", + levels: [{ nodes: [ + { optionNumber: "TRAFFIC", optionName: "交通费" }, + { optionNumber: "HOTEL", optionName: "住宿费" }, + { optionNumber: "HIDDEN", optionName: "停用费用", selectable: false }, + ] }], + } }); + } + if (path.endsWith("dataSource/getMultiLevelDetail/DS-PROJECT")) { + unrelatedSourceRequests += 1; + return encrypted({ code: "0", data: { nodes: [{ optionNumber: "P1", optionName: "项目一" }] } }); + } throw new Error(`unexpected ${path}`); }); const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!; @@ -400,6 +449,87 @@ test("Xft submission options expose only the current user's departments and sear const cachedCities = await client.getSubmissionOptions({ userId: "ding-u1" }, template, "city", "东莞", 10); assert.deepEqual(cachedCities.items, [{ code: "441900", label: "东莞市" }]); assert.equal(cityRequests, 1); + const expenseTypes = await client.getSubmissionOptions({ userId: "ding-u1" }, template, "expense_type", "住宿", 10); + assert.deepEqual(expenseTypes.items, [{ code: "HOTEL", label: "住宿费" }]); + assert.equal(expenseTypes.total, 1); + assert.equal(expenseTypes.hasMore, false); + assert.equal(expenseSourceRequests, 1); + assert.equal(unrelatedSourceRequests, 0); +}); + +test("all formal Xft expense fields declare a user-readable expense type option source", async () => { + const records = [ + { eventSeqId: 11, eventNumber: "EXP-11", eventName: "费用申请", eventStatus: "1", eventType: "EXPENSE_APPLY" }, + { eventSeqId: 12, eventNumber: "TRIP-12", eventName: "出差费用申请", eventStatus: "1", eventType: "TRAVEL_APPLY" }, + { eventSeqId: 13, eventNumber: "REIM-13", eventName: "费用报销", eventStatus: "1", eventType: "COMMON_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" }, 10); + const expected = ["expenseNumber", "expenseTypeNumber", "expTypeCode"]; + for (const [index, fieldKey] of expected.entries()) { + const schema = await client.getSubmissionSchema({ userId: "ding-u1" }, templates[index]!); + const field = findRecordByKey(schema.inputContract, fieldKey); + assert.equal(field?.label, "费用类型"); + assert.deepEqual(field?.optionSource, { tool: "approval_get_submission_options", kind: "expense_type" }); + assert.doesNotMatch(String(field?.description ?? ""), /要求用户.*编码/); + } +}); + +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", + }; + let dataSourceRequests = 0; + 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: "expenseType", name: "费用类型", type: "SELECT", required: true, + options: [ + { optionCode: "MEAL", optionName: "客户招待费" }, + { optionCode: "CAR", optionName: "市内交通费" }, + { optionCode: "其他费", optionName: "其他费" }, + ], + }] }); + if (path.includes("dataSource/")) { + dataSourceRequests += 1; + throw new Error("data source must not be called"); + } + throw new Error(`unexpected ${path}`); + }); + const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!; + const schema = await client.getSubmissionSchema({ userId: "ding-u1" }, template); + assert.deepEqual(schema.fields[0]?.optionSource, { + tool: "approval_get_submission_options", kind: "expense_type", + }); + const result = await client.getSubmissionOptions({ userId: "ding-u1" }, template, "expense_type", "招待", 10); + assert.deepEqual(result.items, [{ code: "MEAL", label: "客户招待费" }]); + const sameCodeAndLabel = await client.getSubmissionOptions({ userId: "ding-u1" }, template, "expense_type", "其他", 10); + assert.deepEqual(sameCodeAndLabel.items, [{ code: "其他费", label: "其他费" }]); + assert.equal(dataSourceRequests, 0); + + const unavailable = 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: [] }); + if (path.endsWith("dataSource/findMultilevelSourcePage")) return encrypted({ code: "0", data: { records: [ + { sourceSeqId: "DS-PROJECT", sourceName: "项目档案" }, + ] } }); + throw new Error(`unexpected ${path}`); + }); + const unavailableTemplate = (await unavailable.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!; + await assert.rejects( + unavailable.getSubmissionOptions({ userId: "ding-u1" }, unavailableTemplate, "expense_type"), + /暂时无法实时获取可选费用类型/, + ); }); test("Xft recall derives empId from the authenticated user and validates the upstream success key", async () => {