feat: expose user-readable expense types
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,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` 且不提供撤回引用。
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
|
||||
Agent 面向统一审批语义,不直接理解钉钉 `processInstanceId/taskId`、薪福通员工号、上游响应或完整 Schema。网关在内部完成来源路由、字段抽取、动作调用和写后复核,只返回审批判断所需的精简字段与统一状态。
|
||||
|
||||
薪福通费用类型同样按领域选项抽象。网关先读取当前模板字段中的实时选项;模板未内嵌时,再从企业多级数据源中只定位费用类型目录并返回精简的 `label/code` 映射。Agent 只把 `label` 展示给用户,`code` 仅用于构造正式接口的 `expenseNumber`、`expenseTypeNumber` 或 `expTypeCode`。响应不包含完整数据源、内部身份或其他企业档案,且不持久化费用目录。
|
||||
|
||||
所有工具以普通 `content.text` 中的精简 JSON 作为跨宿主可见结果,同时保留相同的精简 `structuredContent` 供支持结构化结果的宿主使用;两处都不携带诊断字段或上游原始响应。`raw` 仍是受过滤、分页和硬限额保护的按需诊断视图,不是默认数据通道。
|
||||
|
||||
## 本机提单草稿
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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 会剔除这些服务器管理控件;公司和部门都省略时,薪福通根据当前登录申请人的档案默认填写。若通过标准抬头字段显式指定,则公司与部门必须成对填写。
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+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,短期提交引用不得写入草稿。",
|
||||
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));
|
||||
|
||||
+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 实时查询,不得猜测。必须先检查 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),
|
||||
},
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
|
||||
+119
-6
@@ -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<XftSubmissionOption[]> {
|
||||
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<XftSubmissionField, "valueFormat" | "itemFields"> {
|
||||
const normalized = type.toUpperCase();
|
||||
if (normalized.includes("TABLE")) {
|
||||
@@ -1522,7 +1635,7 @@ function submissionInputContractSummary(kind: XftSubmissionKind): Record<string,
|
||||
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: "企业费用管理中定义的费用类型编码" },
|
||||
{ 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<Record<string, unknown>> {
|
||||
{ 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<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: "企业费用管理中定义的费用类型编码" },
|
||||
{ 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" },
|
||||
|
||||
+7
-1
@@ -81,9 +81,13 @@ export interface XftSubmissionField {
|
||||
moduleType?: string;
|
||||
valueFormat?: string;
|
||||
itemFields?: Array<Record<string, unknown>>;
|
||||
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<string, unknown>;
|
||||
}>;
|
||||
validateSubmissionIdentity(user: AuthenticatedDingTalkUser): Promise<{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
+132
-2
@@ -44,6 +44,24 @@ function decodedBody(init?: RequestInit): Record<string, unknown> {
|
||||
return JSON.parse(String(sm4.decrypt(envelope.secretMsg, privateKey.slice(0, 32)))) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function findRecordByKey(value: unknown, key: string): Record<string, unknown> | 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<string, unknown>;
|
||||
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<string, unknown>; 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<string, unknown>).standardFields as Array<Record<string, unknown>>;
|
||||
const expenseList = standardFields.find((field) => field.key === "expenseList");
|
||||
assert.deepEqual((expenseList?.itemFields as Array<Record<string, unknown>>).filter((field) => field.required).map((field) => field.key), [
|
||||
const expenseItemFields = expenseList?.itemFields as Array<Record<string, unknown>>;
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user