From 76a4e0592cce89ababf41fb40e6050ee2c1730b2 Mon Sep 17 00:00:00 2001 From: Donald Date: Wed, 26 Aug 2026 05:05:59 +0000 Subject: [PATCH] fix: repair XFT expense type diagnostics --- README.md | 2 + docs/submission-templates.md | 2 +- src/xft-open-api.ts | 174 ++++++++++++++++++++++++---- test/xft-open-api.test.ts | 213 ++++++++++++++++++++++++++++++----- 4 files changed, 339 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index f226d96..0948d7e 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ Agent 不传 `userId`、员工号或单据所有者。网关从 OAuth Token 确 用户不需要理解薪福通的任何内部选择值。实时 Schema 会把部门、城市、费用类型、出行方式、往返、时间范围、币种和酒店星级等字段标记为名称优先的选择项;Agent 只展示“深圳市”“交通费”“飞机”“往返”“人民币”等业务名称,选定后仅在内部写入正式接口值。动态下拉框也会标准化成 `label/code`,表格子字段同样递归处理。准备阶段的最终确认预览会再次按实时选项反向解析,只返回业务字段名和业务名称,不回显城市码、费用编码、字段号或表格列号;任何值无法可靠反向解析时,不生成 `confirmationId`。无法取得可靠名称映射时安全停止或允许跳过可选项,不退化为向用户索取工号、城市码、组织码、档案编号、数字舱位值或其他内部 ID。 +薪福通正式 OpenAPI 当前没有独立的费用类型目录查询。费用类型会先从模板实时字段读取,再检查企业自定义多级数据源;两处都没有可用选项时,MCP 返回具体的实时检查结果并引导在费用管理页面确认或使用薪福通客户端,不会把空目录误报为网络 `fetch` 故障。 + 人员、企业自定义档案、关联前置单据、分摊及文档未给出可靠名称映射的舱位字段目前标记为 `client_only`。必填时不生成提交预览并引导薪福通客户端;可选时不会作为普通问题打扰用户。单据公司、部门、字段引用和行程序号等服务器管理或内部派生值也不会作为用户输入项展示。 服务端临时保留不可逆的用户/发票 HMAC 指纹。首次使用时只为最近 30 天尚未索引的审批读取详情;以后准备提单会刷新精简历史状态,并只为新增审批补建指纹,不会重复逐张下载已索引发票。指纹和最小审批索引最多保留 35 天,过期后自动级联删除;10 分钟提单预留到期即删除。 diff --git a/docs/submission-templates.md b/docs/submission-templates.md index 7cc2722..68248b1 100644 --- a/docs/submission-templates.md +++ b/docs/submission-templates.md @@ -17,7 +17,7 @@ 薪福通自定义字段在工具内部统一使用 `customFieldValues[].fieldNumber/fieldValue`,但用户不应看到或提供这些引用。日期字段使用 `yyyy-MM-dd HH:mm`;部门由可读名称选择后内部映射;下拉框与表格子字段使用实时名称映射。人员、附件及其他没有安全名称查询或上传能力的字段标记为 `client_only`,不会要求用户填写花名册编号或附件编号。`approval_get_submission_schema` 会返回实时 `valueFormat`、`selectionCapability` 和该模板的精简 `minimalExample`;后者只说明 `values` 形状,所有尖括号占位符都必须由用户业务回答或实时选项替换,不能原样提交。Agent 不得按字段名称猜编码或结构。 -薪福通模板要求承担部门、城市或费用类型时,使用同一 `templateId` 调用 `approval_get_submission_options`。`kind=department` 只会返回当前登录用户本人档案中的可选组织;`kind=city` 可用 `query` 按城市名称搜索;`kind=expense_type` 先读取模板内嵌选项,再按需读取企业多级费用类型数据源。费用申请、差旅预计费用及报销明细均标记对应选项源。固定枚举提供 `enumLabels`,例如出行方式、单程/往返、上午/下午、人民币和酒店星级;动态 `SELECT` 的 `options` 统一为 `{label, code}`,表格列也递归标准化。Agent 只向用户显示名称并在内部使用正式值,不得向用户索取内部编码。`approval_prepare_submission` 会对标准字段、自定义下拉和表格列做第二次反向解析,确认预览只保留业务名称;无法映射时不签发确认引用。城市全量接口较慢,网关给予独立的 45 秒超时并在进程内缓存字典 24 小时,不写入磁盘;费用类型保持实时且不落盘。响应不会暴露员工号、完整数据源或原始成员档案。 +薪福通模板要求承担部门、城市或费用类型时,使用同一 `templateId` 调用 `approval_get_submission_options`。`kind=department` 只会返回当前登录用户本人档案中的可选组织;`kind=city` 可用 `query` 按城市名称搜索;`kind=expense_type` 先读取模板内嵌选项,再按需读取企业多级费用类型数据源。多级数据源分页接口按正式契约把 `start/limit` 放在 query,并用列表的 `seqId` 读取详情中的 `optionSaveDtoList`;该接口只覆盖企业自定义多级数据源,薪福通正式 OpenAPI 当前没有独立的费用类型目录查询。模板没有内嵌选项且企业多级数据源为空或不含费用类型时,工具会明确返回这两项实时检查结果并引导在费用管理页面确认或使用客户端,不再包装成笼统的获取失败。费用申请、差旅预计费用及报销明细均标记对应选项源。固定枚举提供 `enumLabels`,例如出行方式、单程/往返、上午/下午、人民币和酒店星级;动态 `SELECT` 的 `options` 统一为 `{label, code}`,表格列也递归标准化。Agent 只向用户显示名称并在内部使用正式值,不得向用户索取内部编码。`approval_prepare_submission` 会对标准字段、自定义下拉和表格列做第二次反向解析,确认预览只保留业务名称;无法映射时不签发确认引用。城市全量接口较慢,网关给予独立的 45 秒超时并在进程内缓存字典 24 小时,不写入磁盘;费用类型保持实时且不落盘。响应不会暴露员工号、完整数据源或原始成员档案。 薪福通的自定义字段接口可能同时列出标准单据抬头控件,也可能用动态字段号返回名为“承担部门/单据部门”的控件,但创建接口禁止把这些标准控件放进 `customFieldValues`。MCP 会剔除服务器管理控件并拒绝调用方显式指定公司或部门;两者省略时由薪福通根据当前登录申请人的档案默认填写。真正的业务部门选择字段则只展示当前用户可用的部门名称并在内部映射。 diff --git a/src/xft-open-api.ts b/src/xft-open-api.ts index 9466dfc..fc861f3 100644 --- a/src/xft-open-api.ts +++ b/src/xft-open-api.ts @@ -456,6 +456,7 @@ export class XftOpenApiClient implements XftApprovalProvider { identity: XftIdentity, template: XftSubmissionTemplate, ): Promise { + let customFieldError: unknown; try { const rawFields = await this.request( `/itrip/xft-api/v1/form/customFields/eventSeqId/${encodeURIComponent(template.eventSeqId)}`, @@ -465,34 +466,46 @@ export class XftOpenApiClient implements XftApprovalProvider { ); const embedded = extractExpenseTypeOptions(rawFields); if (embedded.length) return embedded; - } catch { + } catch (error) { + customFieldError = error; // 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 }> = []; + let sourceCount = 0; 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 start = page * EXPENSE_TYPE_SOURCE_PAGE_SIZE; + const response = await this.request( + `/itrip/xft-api/v1/dataSource/findMultilevelSourcePage?start=${start}&limit=${EXPENSE_TYPE_SOURCE_PAGE_SIZE}`, + {}, + "POST", + identity, + ); const records = pagedRecords(response); + sourceCount += records.length; for (const record of records) { - const name = stringAt(record, ["sourceName", "dataSourceName", "multilevelSourceName", "displayName", "name", "title"]); - const id = stringAt(record, ["sourceSeqId", "dataSourceSeqId", "multilevelSourceSeqId", "seqId", "id"]); + const name = stringAt(record, [ + "selectSourceName", "sourceName", "dataSourceName", "multilevelSourceName", "displayName", "name", "title", + ]); + const id = stringAt(record, ["seqId", "sourceSeqId", "dataSourceSeqId", "multilevelSourceSeqId"]); if (id && name && EXPENSE_TYPE_HINT.test(name)) candidates.push({ id, raw: record }); } if (records.length < EXPENSE_TYPE_SOURCE_PAGE_SIZE) break; } + if (!candidates.length) { + if (customFieldError) throw customFieldError; + const sourceResult = sourceCount === 0 + ? "企业多级数据源列表为空" + : `企业多级数据源共返回 ${sourceCount} 项,但没有费用类型数据源`; + throw new ApprovalSafetyError( + `薪福通费用类型查询没有返回可用选项:提单实时字段未提供内嵌选项,且${sourceResult}。` + + "薪福通正式 OpenAPI 未提供独立的费用类型目录查询,请在费用管理页面确认配置或使用薪福通客户端完成", + ); + } const output: XftSubmissionOption[] = []; + const detailErrors: unknown[] = []; for (const candidate of candidates.slice(0, 10)) { output.push(...extractExpenseTypeOptions(candidate.raw, true)); try { @@ -503,10 +516,17 @@ export class XftOpenApiClient implements XftApprovalProvider { identity, ); output.push(...extractExpenseTypeOptions(detail, true)); - } catch { + } catch (error) { + detailErrors.push(error); // Continue with other matching sources. Empty output fails closed below. } } + if (!output.length && detailErrors.length) throw detailErrors[0]; + if (!output.length) { + throw new ApprovalSafetyError( + `薪福通费用类型查询没有返回可用选项:已找到 ${candidates.length} 个费用类型数据源,但详情中没有可选择的名称与编码`, + ); + } return uniqueOptions(output); } @@ -1078,30 +1098,31 @@ export class XftOpenApiClient implements XftApprovalProvider { signal: AbortSignal.timeout(timeoutMs), }); } catch (error) { - throw new XftApiError(`请求薪福通正式接口失败:${errorMessage(error)}`, 0, "TRANSPORT_ERROR"); + throw xftTransportError(error, pathname, "request", timeoutMs); } let text: string; try { text = await response.text(); } catch (error) { - throw new XftApiError(`读取薪福通响应失败:${errorMessage(error)}`, 0, "RESPONSE_STREAM_ERROR"); + throw xftTransportError(error, pathname, "response", timeoutMs); } + const operation = xftOperationLabel(pathname); let parsed: unknown; try { const plaintext = this.api.encrypted && /^[0-9a-fA-F]+$/.test(text) && text.length % 2 === 0 ? String(sm4.decrypt(text, this.api.authoritySecret.slice(0, 32))) : text; parsed = plaintext ? JSON.parse(plaintext) : {}; - } catch { throw new XftApiError(`薪福通正式接口返回解密或 JSON 解析失败(HTTP ${response.status})`, response.status); } - parsed = this.decryptEnvelope(parsed); - if (!response.ok) throw new XftApiError(apiMessage(parsed) ?? `薪福通接口 HTTP ${response.status}`, response.status, apiCode(parsed)); + } catch { throw new XftApiError(`薪福通${operation}返回解密或 JSON 解析失败(HTTP ${response.status})`, response.status); } + parsed = this.decryptEnvelope(parsed, operation); + if (!response.ok) throw xftResponseError(parsed, response.status, operation); const code = apiCode(parsed); if (!isXftSuccessCode(code)) { - throw new XftApiError(apiMessage(parsed) ?? `薪福通接口错误 ${code}`, response.status, code); + throw xftResponseError(parsed, response.status, operation); } return unwrapData(parsed); } - private decryptEnvelope(value: unknown): unknown { + private decryptEnvelope(value: unknown, operation: string): unknown { if (!this.api.encrypted || !isObject(value)) return value; const encrypted = typeof value.secretMsg === "string" ? value.secretMsg : isObject(value.data) && typeof value.data.secretMsg === "string" ? value.data.secretMsg @@ -1111,11 +1132,114 @@ export class XftOpenApiClient implements XftApprovalProvider { const plaintext = sm4.decrypt(encrypted, this.api.authoritySecret.slice(0, 32)); return JSON.parse(String(plaintext)); } catch { - throw new XftApiError("薪福通响应解密失败,请核对应用加密配置和 AuthoritySecret", 502, "DECRYPT_FAILED"); + throw new XftApiError(`薪福通${operation}响应解密失败,请核对应用加密配置和 AuthoritySecret`, 502, "DECRYPT_FAILED"); } } } +type XftTransportPhase = "request" | "response"; + +interface XftTransportDiagnostic { + operation: string; + phase: XftTransportPhase; + category: string; + causeName?: string; + causeCode?: string; + timeoutMs: number; +} + +function xftTransportError(error: unknown, path: string, phase: XftTransportPhase, timeoutMs: number): XftApiError { + const operation = xftOperationLabel(path); + const diagnostic = xftTransportDiagnostic(error, operation, phase, timeoutMs); + // Keep logs useful without recording URLs, query parameters, identities, request bodies, or upstream payloads. + console.error("[dingtalk-approval-mcp] XFT transport failure", diagnostic); + const technical = [ + diagnostic.causeCode ? `底层错误码 ${diagnostic.causeCode}` : undefined, + diagnostic.causeName && diagnostic.causeName !== "TypeError" ? `错误类型 ${diagnostic.causeName}` : undefined, + `超时上限 ${timeoutMs}ms`, + ].filter(Boolean).join(";"); + const prefix = phase === "request" ? "请求" : "读取响应"; + const code = phase === "request" ? "TRANSPORT_ERROR" : "RESPONSE_STREAM_ERROR"; + return new XftApiError(`薪福通${operation}${prefix}失败:${diagnostic.category}(${technical})`, 0, code); +} + +function xftTransportDiagnostic( + error: unknown, + operation: string, + phase: XftTransportPhase, + timeoutMs: number, +): XftTransportDiagnostic { + const chain = errorDiagnosticChain(error); + const causeCode = chain.map((item) => safeDiagnosticCode(item.code)).find(Boolean); + const names = chain.map((item) => safeDiagnosticName(item.name)).filter((name): name is string => Boolean(name)); + const causeName = names.find((name) => name !== "Error" && name !== "TypeError") + ?? names.find((name) => name !== "Error"); + const markers = `${causeCode ?? ""} ${causeName ?? ""}`.toUpperCase(); + let category = phase === "response" ? "响应流在读取过程中中断" : "网络传输失败"; + if (/TIMEOUT|ETIMEDOUT/.test(markers)) category = phase === "response" ? "读取响应超时" : "连接或等待响应超时"; + else if (/ENOTFOUND|EAI_AGAIN|DNS/.test(markers)) category = "DNS 解析失败"; + else if (/ECONNREFUSED/.test(markers)) category = "连接被拒绝"; + else if (/ECONNRESET|UND_ERR_SOCKET|EPIPE/.test(markers)) category = "网络连接被重置或中断"; + else if (/ENETUNREACH|EHOSTUNREACH/.test(markers)) category = "目标网络或主机不可达"; + else if (/CERT|TLS|SSL|UNABLE_TO_VERIFY|SELF_SIGNED/.test(markers)) category = "TLS 或证书校验失败"; + else if (/ABORT/.test(markers)) category = "请求被取消或超过等待时间"; + return { operation, phase, category, ...(causeName ? { causeName } : {}), ...(causeCode ? { causeCode } : {}), timeoutMs }; +} + +function errorDiagnosticChain(error: unknown): Array> { + const output: Array> = []; + const pending: unknown[] = [error]; + const seen = new Set(); + while (pending.length && output.length < 8) { + const current = pending.shift(); + if ((!current || typeof current !== "object") && typeof current !== "function") continue; + if (seen.has(current as object)) continue; + seen.add(current as object); + const record = current as Record; + output.push(record); + if (record.cause) pending.push(record.cause); + if (Array.isArray(record.errors)) pending.push(...record.errors); + } + return output; +} + +function safeDiagnosticCode(value: unknown): string | undefined { + return typeof value === "string" && /^[A-Z][A-Z0-9_]{1,63}$/.test(value) ? value : undefined; +} + +function safeDiagnosticName(value: unknown): string | undefined { + return typeof value === "string" && /^[A-Za-z][A-Za-z0-9]{0,63}(?:Error|Exception)$/.test(value) ? value : undefined; +} + +function xftResponseError(value: unknown, status: number, operation: string): XftApiError { + const code = apiCode(value); + const visibleCode = safeXftApiCode(code); + const metadata = [`操作:${operation}`, `HTTP ${status}`, ...(visibleCode ? [`薪福通错误码 ${visibleCode}`] : [])].join(";"); + return new XftApiError(`${apiMessage(value) ?? "薪福通接口返回业务错误"}(${metadata})`, status, code); +} + +function safeXftApiCode(value: string | undefined): string | undefined { + return value && /^[A-Za-z0-9_.:-]{1,64}$/.test(value) ? value : undefined; +} + +function xftOperationLabel(path: string): string { + if (path.includes("user-mapping")) return "用户身份映射"; + if (path.includes("member/get/by-id")) return "当前用户成员档案"; + if (path.includes("todo-task/page/by-user")) return "待办或已处理列表"; + if (path.includes("todo-task/detail/by-ids")) return "待办任务详情"; + if (path.endsWith("/bill-list")) return "单据摘要"; + if (path.includes("/form/query-event-list")) return "提单模板列表"; + if (path.includes("/form/customFields/eventSeqId/")) return "提单实时字段"; + if (path.includes("/dataSource/findMultilevelSourcePage")) return "费用类型数据源列表"; + if (path.includes("/dataSource/getMultiLevelDetail/")) return "费用类型数据源详情"; + if (path.includes("/common/city/getAllCity")) return "城市选项"; + if (path.includes("/approve/")) return "审批写操作"; + if (path.includes("/create")) return "审批发起"; + if (path.includes("/detail") || path.includes("/taskHistory/")) return "审批详情"; + if (path.includes("/queryBillByCondition")) return "本人发起记录"; + return "业务接口"; +} + function isUncertainXftWriteError(error: unknown): boolean { return !(error instanceof ApprovalSafetyError) && (!(error instanceof XftApiError) @@ -1394,11 +1518,11 @@ function extractExpenseTypeOptions(value: unknown, trustedExpenseSource = false) ]); const code = explicitCode ?? (path.length > 0 ? stringAt(record, [ "optionCode", "optionNumber", "itemCode", "itemNumber", "nodeCode", "nodeNumber", "dataCode", "dataNumber", - "sourceOptionCode", "sourceOptionNumber", "code", "number", "value", "key", + "sourceOptionCode", "sourceOptionNumber", "code", "number", "value", "key", "seqId", ]) : undefined); const label = stringAt(record, [ "expenseName", "expenseTypeName", "expTypeName", "optionName", "itemName", "nodeName", "dataName", - "sourceOptionName", "label", "text", "displayName", "name", "title", + "sourceOptionName", "selectOptionName", "label", "text", "displayName", "name", "title", ]); if (code && label) candidates.push({ code, label }); } diff --git a/test/xft-open-api.test.ts b/test/xft-open-api.test.ts index 749cace..dfd81ce 100644 --- a/test/xft-open-api.test.ts +++ b/test/xft-open-api.test.ts @@ -273,10 +273,37 @@ test("Xft OpenAPI rejects non-exact, missing-identity, and ambiguous user mappin }); test("Xft OpenAPI surfaces XFT framework business errors", async () => { - const client = new XftOpenApiClient(config(), async () => encrypted({ - SYCOMRETZ: [{ ERRCOD: "SYOPA16", ERRMSG: "应用状态错误" }], - })); - await assert.rejects(client.authStatus({ userId: "ding-u1" }), /应用状态错误/); + const client = new XftOpenApiClient(config(), async () => new Response( + String(sm4.encrypt(JSON.stringify({ + SYCOMRETZ: [{ ERRCOD: "SYOPA16", ERRMSG: "应用状态错误" }], + }), privateKey.slice(0, 32))), + { status: 403 }, + )); + await assert.rejects( + client.authStatus({ userId: "ding-u1" }), + /应用状态错误(操作:用户身份映射;HTTP 403;薪福通错误码 SYOPA16)/, + ); +}); + +test("Xft OpenAPI exposes safe transport diagnostics without leaking the native error message", async () => { + for (const [cause, expected] of [ + [Object.assign(new Error("connect ECONNRESET https://user:password@private.invalid/?token=secret"), { code: "ECONNRESET" }), + /用户身份映射请求失败:网络连接被重置或中断(底层错误码 ECONNRESET;超时上限 2000ms)/], + [Object.assign(new Error("getaddrinfo ENOTFOUND private.invalid"), { code: "ENOTFOUND" }), + /用户身份映射请求失败:DNS 解析失败(底层错误码 ENOTFOUND;超时上限 2000ms)/], + [Object.assign(new Error("connect timed out"), { code: "UND_ERR_CONNECT_TIMEOUT", name: "ConnectTimeoutError" }), + /用户身份映射请求失败:连接或等待响应超时(底层错误码 UND_ERR_CONNECT_TIMEOUT;错误类型 ConnectTimeoutError;超时上限 2000ms)/], + ] as const) { + const client = new XftOpenApiClient(config(), async () => { + throw new TypeError("fetch failed", { cause }); + }); + await assert.rejects(client.authStatus({ userId: "ding-u1" }), (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, expected); + assert.doesNotMatch(error.message, /password|private\.invalid|token=secret|fetch failed/); + return true; + }); + } }); test("Xft OpenAPI preserves official GET endpoint query parameters in the signed target", async () => { @@ -454,7 +481,8 @@ test("Xft submission options expose only departments, cities, and the matching e let expenseSourceRequests = 0; let unrelatedSourceRequests = 0; const client = new XftOpenApiClient(config(), async (input, init) => { - const path = new URL(String(input)).pathname; + const url = new URL(String(input)); + const path = url.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.endsWith("member/get/by-id")) { @@ -476,27 +504,32 @@ test("Xft submission options expose only departments, cities, and the matching e } 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: "项目档案" }, - ] } }); + assert.equal(init?.method, "POST"); + assert.equal(url.searchParams.get("start"), "0"); + assert.equal(url.searchParams.get("limit"), "100"); + assert.deepEqual(decodedBody(init), {}); + return encrypted({ success: true, message: "调用成功!", totalCount: 2, list: [ + { seqId: 150, id: "DS-EXP", selectSourceName: "企业费用类型" }, + { seqId: 151, id: "DS-PROJECT", selectSourceName: "项目档案" }, + ] }); } - if (path.endsWith("dataSource/getMultiLevelDetail/DS-EXP")) { + if (path.endsWith("dataSource/getMultiLevelDetail/150")) { expenseSourceRequests += 1; - return encrypted({ code: "0", data: { - sourceSeqId: "DS-EXP", - sourceName: "企业费用类型", - levels: [{ nodes: [ - { optionNumber: "TRAFFIC", optionName: "交通费" }, - { optionNumber: "HOTEL", optionName: "住宿费" }, - { optionNumber: "HIDDEN", optionName: "停用费用", selectable: false }, - ] }], + return encrypted({ success: true, message: "调用成功!", data: { + id: "DS-EXP", + selectSourceName: "企业费用类型", + optionSaveDtoList: [ + { seqId: 601, selectOptionName: "交通费", optionSaveDtoList: null }, + { seqId: 602, selectOptionName: "住宿费", optionSaveDtoList: null }, + { seqId: 603, selectOptionName: "停用费用", optionSaveDtoList: null, selectable: false }, + ], } }); } - if (path.endsWith("dataSource/getMultiLevelDetail/DS-PROJECT")) { + if (path.endsWith("dataSource/getMultiLevelDetail/151")) { unrelatedSourceRequests += 1; - return encrypted({ code: "0", data: { nodes: [{ optionNumber: "P1", optionName: "项目一" }] } }); + return encrypted({ success: true, message: "调用成功!", data: { + optionSaveDtoList: [{ seqId: 701, selectOptionName: "项目一", optionSaveDtoList: null }], + } }); } throw new Error(`unexpected ${path}`); }); @@ -513,7 +546,7 @@ test("Xft submission options expose only departments, cities, and the matching e 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.deepEqual(expenseTypes.items, [{ code: "602", label: "住宿费" }]); assert.equal(expenseTypes.total, 1); assert.equal(expenseTypes.hasMore, false); assert.equal(expenseSourceRequests, 1); @@ -669,15 +702,143 @@ test("Xft expense types prefer user-readable live template options and fail clos 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: "项目档案" }, - ] } }); + if (path.endsWith("dataSource/findMultilevelSourcePage")) return encrypted({ + success: true, message: "调用成功!", totalCount: 1, list: [ + { seqId: 100, id: "DS-PROJECT", selectSourceName: "项目档案" }, + ], + }); 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"), - /暂时无法实时获取可选费用类型/, + /提单实时字段未提供内嵌选项,且企业多级数据源共返回 1 项,但没有费用类型数据源.*正式 OpenAPI 未提供独立的费用类型目录查询/, + ); +}); + +test("Xft expense type lookup explains a successful empty official data-source response", async () => { + const templateRecord = { + eventSeqId: 16, eventNumber: "EXP-16", 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: [templateRecord] } }); + if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [] }); + if (path.endsWith("dataSource/findMultilevelSourcePage")) return encrypted({ + success: true, message: "调用成功!", totalCount: 0, list: [], + }); + throw new Error(`unexpected ${path}`); + }); + const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!; + await assert.rejects( + client.getSubmissionOptions({ userId: "ding-u1" }, template, "expense_type"), + /提单实时字段未提供内嵌选项,且企业多级数据源列表为空.*请在费用管理页面确认配置或使用薪福通客户端完成/, + ); +}); + +test("Xft expense type lookup follows the official data-source paging and detail contract", async () => { + const templateRecord = { + eventSeqId: 17, eventNumber: "EXP-17", eventName: "费用报销单", eventStatus: "1", eventType: "COMMON_REIM", + }; + let listRequests = 0; + let detailRequests = 0; + const client = new XftOpenApiClient(config(), async (input, init) => { + const url = new URL(String(input)); + const path = url.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")) { + listRequests += 1; + assert.equal(init?.method, "POST"); + assert.equal(url.searchParams.get("start"), "0"); + assert.equal(url.searchParams.get("limit"), "100"); + assert.deepEqual(decodedBody(init), {}); + return encrypted({ + success: true, message: "调用成功!", totalCount: 1, list: [ + { seqId: 150, id: "SOUR01", selectSourceName: "企业费用类型" }, + ], + }); + } + if (path.endsWith("dataSource/getMultiLevelDetail/150")) { + detailRequests += 1; + assert.equal(init?.method, "GET"); + assert.equal(init?.body, undefined); + return encrypted({ + success: true, + message: "调用成功!", + data: { + id: "SOUR01", + selectSourceName: "企业费用类型", + optionSaveDtoList: [{ + seqId: 1214876, + selectOptionName: "交通费", + optionSaveDtoList: [{ seqId: 1214877, selectOptionName: "出租车费", optionSaveDtoList: null }], + }], + }, + }); + } + throw new Error(`unexpected ${path}`); + }); + const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!; + const result = await client.getSubmissionOptions({ userId: "ding-u1" }, template, "expense_type"); + assert.equal(listRequests, 1); + assert.equal(detailRequests, 1); + assert.deepEqual(result.items, [ + { code: "1214876", label: "交通费" }, + { code: "1214877", label: "出租车费" }, + ]); +}); + +test("Xft expense type lookup preserves data-source API errors instead of replacing them with an empty result", async () => { + const templateRecord = { + eventSeqId: 18, eventNumber: "EXP-18", 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: [templateRecord] } }); + if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [] }); + if (path.endsWith("dataSource/findMultilevelSourcePage")) { + return new Response(String(sm4.encrypt(JSON.stringify({ + code: "NO_PERMISSION", message: "未开通多级数据源查询权限", + }), privateKey.slice(0, 32))), { status: 403 }); + } + throw new Error(`unexpected ${path}`); + }); + const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!; + await assert.rejects( + client.getSubmissionOptions({ userId: "ding-u1" }, template, "expense_type"), + /未开通多级数据源查询权限(操作:费用类型数据源列表;HTTP 403;薪福通错误码 NO_PERMISSION)/, + ); +}); + +test("Xft expense type lookup preserves a failed matching data-source detail", async () => { + const templateRecord = { + eventSeqId: 19, eventNumber: "EXP-19", 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: [templateRecord] } }); + if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [] }); + if (path.endsWith("dataSource/findMultilevelSourcePage")) return encrypted({ + success: true, message: "调用成功!", totalCount: 1, list: [ + { seqId: 151, id: "SOUR02", selectSourceName: "企业费用类型" }, + ], + }); + if (path.endsWith("dataSource/getMultiLevelDetail/151")) { + throw new TypeError("fetch failed", { + cause: Object.assign(new Error("socket closed with internal address"), { code: "UND_ERR_SOCKET" }), + }); + } + throw new Error(`unexpected ${path}`); + }); + const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!; + await assert.rejects( + client.getSubmissionOptions({ userId: "ding-u1" }, template, "expense_type"), + /费用类型数据源详情请求失败:网络连接被重置或中断(底层错误码 UND_ERR_SOCKET;超时上限 2000ms)/, ); });