diff --git a/README.md b/README.md index 0948d7e..bb425b1 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Agent → 远程 Streamable HTTP MCP / OAuth 统一网关 ## 工具 - `approval_login`:检查登录或返回唯一授权链接。 -- `approval_list_todos`:实时列出本人待办,签发短期 `approvalId`,并为可安全整组处理的结果签发 `selectionId`。 +- `approval_list_todos`:实时列出本人待办;薪福通不按单据类型过滤。签发短期 `approvalId`,并仅为可安全整组处理的结果签发 `selectionId`。 - `approval_list_history`:实时列出本人已经处理过或本人发起的审批;默认最近 30 天,可用 `startDate/endDate` 指定最长 120 天的时间窗口,并签发只能读取详情的短期 `approvalId`。 - `approval_get_detail`:返回摘要、结构化费用明细或受限 raw 分页。 - `approval_get_actions`:检查同意、拒绝与退回能力。 @@ -28,6 +28,8 @@ Agent → 远程 Streamable HTTP MCP / OAuth 统一网关 待办、历史和处理结果使用统一、精简的领域字段,不向 Agent 暴露钉钉任务 ID、薪福通员工号或上游原始响应。历史查询分为 `handled`(本人已经处理过)和 `submitted`(本人发起),结果没有 `selectionId` 和可执行动作。常见 `approvalId` 使用紧凑的 v4 无状态加密格式:薪福通通常约 60 字符、钉钉 OA 通常约 100 字符,并绑定当前用户和 30 分钟有效期;历史引用还绑定查询语义,只能读取详情。旧版未过期的 v2/v3 引用仍可读取。 +薪福通消息中心返回且带稳定任务标识的本人待办都会进入列表。原有费用单据以及能被正式单据接口实时识别的新类型继续支持详情、同意、拒绝和退回;尚未确认接口兼容性的项目返回空 `availableActions` 与 `manualAction.reason=unsupported_xft_task_type`,只允许引导到薪福通客户端查看和处理。此类引用读取详情不会请求未验证的上游接口,动作查询全部返回禁用,批处理在预检阶段以 `provider_not_supported`、`writesStarted=false` 整批零写入;列表包含此类项目时不签发 `selectionId`。新增的能力诊断只给出数量,不额外携带任务 ID、单号、人员或正文。 + 一次写调用最多处理 20 条。整组同意/拒绝可直接使用 `selectionId`;超过 20 条时,Agent 可从同一次原始列表中按 `approvalId` 分组,不需要 `batchPlan`,也不能重新拉取后把新待办混入原授权。服务器在首个写入前校验整批 `approvalId` 和 `targetId`;任一引用无效时返回 `preflight_failed`、`writesStarted=false`,整批零写入且不自动补偿或重试。普通文本与结构化结果保持同一份精简数据,以兼容不渲染 `structuredContent` 的宿主。 Agent 不传 `userId`、员工号或单据所有者。网关从 OAuth Token 确定用户,再分别映射钉钉 OA 与薪福通身份。钉钉提单中的申请人和部门也由网关通过当前登录用户的通讯录档案实时填写;即使用户修改本地桥,也不能冒用他人身份或部门。 diff --git a/docs/architecture.md b/docs/architecture.md index f64d5ee..ea4b2ef 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -24,6 +24,8 @@ Agent 面向统一审批语义,不直接理解钉钉 `processInstanceId/taskId`、薪福通员工号、上游响应或完整 Schema。网关在内部完成来源路由、字段抽取、动作调用和写后复核,只返回审批判断所需的精简字段与统一状态。 +薪福通当前待办以消息中心的本人任务为可见性边界,不再用应用码或单据类型白名单过滤。对旧费用类型以及能被正式 `bill-list` 实时识别的未知应用任务,网关标记为 `bill_api` 并沿用详情和审批链路;其他带稳定任务标识的项目标记为 `list_only`,只返回安全列表字段、空动作集合和客户端处理提示。`list_only` 的上游任务标识只进入用户绑定、来源绑定且短期有效的 v4 加密引用,不作为业务编号展示;详情和动作查询不会触发未经确认的上游接口,写请求在整批预检阶段返回 `provider_not_supported` 且零写入。包含此类项目的结果不签发 `selectionId`,诊断仅汇总 `billApiCount/clientOnlyCount`。本次能力分级只作用于当前待办,已处理历史仍只读取正式单据接口可验证的记录。 + 薪福通所有选择字段按领域选项抽象。部门、城市和费用类型由选项工具返回精简 `label/code` 映射;模板内嵌下拉框被标准化成同样结构,固定枚举通过 `enumLabels` 提供中文名称,表格子字段递归应用相同规则。Agent 只把业务名称展示给用户,正式值仅用于内部构造请求。准备确认时,网关再用实时 Schema 把正式值反向解析为业务名称;预览不包含城市码、费用编码、自定义字段号或表格列号,任一选择无法反向解析时会在签发确认引用前安全失败。无法取得可靠名称映射的人员、档案、关联单据、分摊和部分舱位字段标记为 `client_only`,必填时安全停止、可选时允许省略,绝不转而索取工号或编码。用车申请已从 MCP 发起范围移除,实时模板列表会过滤,旧模板引用也会被网关拒绝。响应不包含完整数据源、内部身份或其他企业档案,选项目录也不持久化。 所有工具以普通 `content.text` 中的精简 JSON 作为跨宿主可见结果,同时保留相同的精简 `structuredContent` 供支持结构化结果的宿主使用;两处都不携带诊断字段或上游原始响应。`raw` 仍是受过滤、分页和硬限额保护的按需诊断视图,不是默认数据通道。 diff --git a/src/bridge.ts b/src/bridge.ts index 1d5ff09..258a661 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -433,7 +433,7 @@ function parseEndpoint(argv: string[]): string { export function createBridgeMcpServer(bridge: RemoteBridge): McpServer { const server = new McpServer({ name: "dingtalk-approval-stdio-bridge", version: APPLICATION_VERSION }, { - instructions: "首次使用或登录链接过期时调用approval_login;每次调用会检查登录并在需要时生成有效的新链接,绝不因链接过期而重新安装。工具不会通过其他消息通道自动发送链接,必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。审批引用、调用容量和内部分批不得向用户解释;查看时只回答业务信息,用户明确授权后在授权范围内自动处理。写前校验失败时零写入,任何不确定结果都不得自动重试。提单草稿仅可保存在 Agent 所在机器的当前用户私有目录,不得上传网关;恢复草稿后必须重新读取实时模板 Schema,短期提交引用不得写入草稿。薪福通所有选择型字段只向用户展示业务名称:optionSource 选项展示label,内嵌枚举展示enumLabels;用户选定label后,values中必须填同一项的code而不能填label,code只存在于工具调用内部。不得向用户索取或展示城市码、组织码、工号、档案编号、数字舱位值或其他内部ID;selectionCapability=client_only时停止构造该必填项或允许跳过该可选项。", + instructions: "首次使用或登录链接过期时调用approval_login;每次调用会检查登录并在需要时生成有效的新链接,绝不因链接过期而重新安装。工具不会通过其他消息通道自动发送链接,必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。审批引用、调用容量和内部分批不得向用户解释;查看时只回答业务信息,用户明确授权后在授权范围内自动处理。薪福通待办不按单据类型过滤;availableActions=[]且manualAction.reason=unsupported_xft_task_type的项目仍要展示,但只能引导薪福通客户端查看处理,禁止尝试MCP写入。写前校验失败时零写入,任何不确定结果都不得自动重试。提单草稿仅可保存在 Agent 所在机器的当前用户私有目录,不得上传网关;恢复草稿后必须重新读取实时模板 Schema,短期提交引用不得写入草稿。薪福通所有选择型字段只向用户展示业务名称:optionSource 选项展示label,内嵌枚举展示enumLabels;用户选定label后,values中必须填同一项的code而不能填label,code只存在于工具调用内部。不得向用户索取或展示城市码、组织码、工号、档案编号、数字舱位值或其他内部ID;selectionCapability=client_only时停止构造该必填项或允许跳过该可选项。", }); server.registerTool("approval_login", { title: "登录审批服务", description: "检查登录;未登录或旧链接已过期时生成新的钉钉授权链接。同一身份同时用于钉钉OA和薪福通,无需重新安装。", inputSchema: {}, @@ -443,7 +443,7 @@ export function createBridgeMcpServer(bridge: RemoteBridge): McpServer { return bridgeAuthToolResult(result); }); server.registerTool("approval_list_todos", { - title: "列出我的审批待办", description: "实时列出当前登录用户本人的钉钉OA和薪福通待办。", inputSchema: { + title: "列出我的审批待办", description: "实时列出当前登录用户本人的钉钉OA和薪福通待办;薪福通不按单据类型过滤,未适配项目会明确标记为仅可在薪福通客户端查看处理。", inputSchema: { source: z.enum(["all", "dingtalk", "xft"]).default("all"), query: z.string().max(200).default(""), limit: z.number().int().min(1).max(200).default(50), }, annotations: { readOnlyHint: true, destructiveHint: false }, diff --git a/src/server.ts b/src/server.ts index 9ea3052..2d11d54 100644 --- a/src/server.ts +++ b/src/server.ts @@ -33,7 +33,7 @@ const MCP_SESSION_MAX_PER_USER = 8; const MCP_SESSION_MAX_TOTAL = 1000; export const APPROVAL_MCP_INSTRUCTIONS = [ - "先调用 approval_list_todos 获取当前待办。用户只是在查看时,仅回答标题、申请人、金额、时间等业务信息,不主动讨论审批接口、批处理方式或索取写操作授权。用户明确要求处理后,在其授权范围内内部选择整组引用或自动分批;技术分批不得造成重复确认。", + "先调用 approval_list_todos 获取当前待办。薪福通待办不按单据类型过滤;availableActions=[] 且 manualAction.reason=unsupported_xft_task_type 的项目仍要展示给用户,但只能引导到薪福通客户端查看和处理,禁止尝试 MCP 写入。用户只是在查看时,仅回答标题、申请人、金额、时间等业务信息,不主动讨论审批接口、批处理方式或索取写操作授权。用户明确要求处理后,在其授权范围内内部选择整组引用或自动分批;技术分批不得造成重复确认。", "approvalId、selectionId、targetId、单次条数限制、分批方式以及内部引用是否返回均属于 Agent 与 MCP 的内部调用细节,绝不向用户展示、解释或要求用户操作。面向用户只说明目标审批、业务动作、成功结果和需要用户决策的业务失败;不要说引用是否签发、是否失效、是否存在一键全选或如何分组。", "查询本人处理过或本人发起的审批时调用 approval_list_history;默认最近30天,可用 startDate/endDate 指定最长120天的时间窗口。用其只读 approvalId 获取详情;历史引用不能用于 approval_get_actions 或 approval_process_batch,也不会返回 selectionId。", "费用复核优先调用 approval_get_detail(view=expense);summary 用于一般详情,raw 仅用于分页补充字段。", @@ -67,7 +67,7 @@ export function createApprovalMcpServer( server.registerTool("approval_list_todos", { title: "列出我的审批待办", - description: "审批操作的入口。通过同一登录身份实时列出当前用户在钉钉OA和薪福通中的待办,并取得后续接口必须使用的approvalId;列表尽可能直接包含金额和带时区的提交时间。", + description: "审批操作的入口。通过同一登录身份实时列出当前用户在钉钉OA和薪福通中的待办;薪福通不按单据类型静默过滤。已确认正式接口兼容的项目取得可用approvalId,未适配项目也会显示但以availableActions=[]和manualAction明确要求到薪福通客户端查看处理。列表尽可能直接包含金额和带时区的提交时间。", inputSchema: { source: z.enum(["all", "dingtalk", "xft"]).default("all"), query: z.string().max(200).default(""), diff --git a/src/unified-approval.ts b/src/unified-approval.ts index e29ce83..e520ed2 100644 --- a/src/unified-approval.ts +++ b/src/unified-approval.ts @@ -7,6 +7,7 @@ import { ApprovalService, type ApprovalHistoryItem, type ProcessApprovalItem, ty import type { ApprovalHistoryRange, ApprovalHistoryScope, AuthenticatedDingTalkUser, XftApprovalProvider, XftHistoryItem, XftSubmissionField, XftSubmissionKind, XftSubmissionOptionKind, XftSubmissionTemplate, XftTodoItem } from "./xft.js"; import { browseRawDetail, compactApprovalDetail, type ApprovalDetailOptions, type ApprovalDetailView } from "./approval-detail.js"; import { sensitiveLabel } from "./approval-metadata.js"; +import { isRecord } from "./json.js"; import type { InvoiceDeduplicationService } from "./invoice-dedup.js"; export type UnifiedSource = "dingtalk" | "xft"; @@ -235,7 +236,12 @@ export class UnifiedApprovalService { if (!this.xft) sourceStatus.xft = { status: "disabled" }; else tasks.push(this.xft.listMyTodos(user, limit, query).then((result) => { xftItems = result.items.map((item) => this.normalizeXftTodo(user, item)); - sourceStatus.xft = { status: "ok", count: result.items.length }; + sourceStatus.xft = { + status: "ok", + count: result.items.length, + ...(typeof result.diagnostics?.billApiCount === "number" ? { billApiCount: result.diagnostics.billApiCount } : {}), + ...(typeof result.diagnostics?.clientOnlyCount === "number" ? { clientOnlyCount: result.diagnostics.clientOnlyCount } : {}), + }; }).catch(async (error) => { if (error instanceof XftSessionExpiredError && this.xftChallenges) { try { sourceStatus.xft = { status: "auth_required", authChallenge: await this.xftChallenges.getOrStart(user) }; } @@ -251,10 +257,15 @@ export class UnifiedApprovalService { : interleave(dingTalkItems, xftItems); const returnedItems = items.slice(0, limit); const sourcesHealthy = requestedSourcesHealthy(source, sourceStatus); + const containsClientOnly = returnedItems.some((item) => isRecord(item) + && item.source === "xft" + && isRecord(item.manualAction) + && item.manualAction.reason === "unsupported_xft_task_type"); const selectionUnavailableReason = !issueSelection ? "selection_not_requested" : returnedItems.length === 0 ? "empty_result" : !sourcesHealthy ? "requested_source_unhealthy" : returnedItems.length > APPROVAL_BATCH_LIMIT ? "result_exceeds_batch_limit" + : containsClientOnly ? "contains_client_only_item" : undefined; const selectionAvailable = selectionUnavailableReason === undefined; return { @@ -710,6 +721,20 @@ export class UnifiedApprovalService { const ref = this.decodeApproval(user, approvalId); const view = options.view ?? "summary"; if (ref.provider === "xft") { + if (ref.clientOnly) { + return { + approvalId, + source: "xft" as const, + sourceType: "unadapted_xft_task", + view, + status: "client_required", + detailCapability: { + status: "client_only", + message: "该薪福通待办已在列表中显示,但尚未确认正式详情接口兼容性,请在薪福通客户端查看详情", + }, + diagnostics: { realtime: true, persistedBusinessData: false, upstreamDetailRequested: false }, + }; + } if (!this.xft || !ref.billId) throw new ApprovalSafetyError("薪福通审批源未启用"); try { const raw = redactSensitive(ref.historyScope @@ -772,6 +797,18 @@ export class UnifiedApprovalService { }; } if (ref.provider === "official_oa") return this.getOfficialOaActions(user, approvalId, ref); + if (ref.clientOnly) { + const message = "该薪福通待办已列出,但尚未确认正式审批接口兼容性,请在薪福通客户端处理"; + return { + approvalId, + actions: [ + { action: "approve" as const, allowed: false, clientRequired: true, unavailableReason: "provider_not_supported", message }, + { action: "reject" as const, allowed: false, clientRequired: true, unavailableReason: "provider_not_supported", message }, + { action: "return" as const, allowed: false, clientRequired: true, unavailableReason: "provider_not_supported", message }, + ], + manualAction: { required: true, reason: "unsupported_xft_task_type", message }, + }; + } if (!this.xft || !ref.billId) throw new ApprovalSafetyError("薪福通审批源未启用"); let returned; try { returned = await this.xft.listReturnNodes(user, ref.billId); } @@ -1060,6 +1097,12 @@ export class UnifiedApprovalService { if (ref.historyScope) { throw new ApprovalReferenceValidationError("history_reference_read_only", "历史审批引用仅用于读取,不能提交审批动作"); } + if (ref.provider === "xft" && ref.clientOnly) { + throw new ApprovalReferenceValidationError( + "provider_not_supported", + "该薪福通待办尚未确认正式审批接口兼容性,不能通过 MCP 处理,请在薪福通客户端完成", + ); + } if (ref.provider === "xft" && action === "approve" && ref.allowApprove === false) { throw new ApprovalReferenceValidationError("action_not_allowed", "该薪福通待办当前不允许同意"); } @@ -1194,19 +1237,24 @@ export class UnifiedApprovalService { } private normalizeXftTodo(user: AuthenticatedDingTalkUser, item: XftTodoItem) { + const billApiSupported = item.capability !== "list_only" && Boolean(item.billId); + const referenceId = billApiSupported ? item.billId : item.taskId; + if (!referenceId) throw new ApprovalSafetyError("薪福通待办缺少安全引用标识"); const ref: ApprovalReference = { kind: "approval", userId: user.userId, provider: "xft", - billId: item.billId, - ...(item.allowPass !== undefined ? { allowApprove: item.allowPass } : {}), - ...(item.allowReject !== undefined ? { allowReject: item.allowReject } : {}), + billId: billApiSupported ? item.billId : `task:${referenceId}`, + ...(billApiSupported ? {} : { clientOnly: true }), + ...(billApiSupported && item.allowPass !== undefined ? { allowApprove: item.allowPass } : { allowApprove: false }), + ...(billApiSupported && item.allowReject !== undefined ? { allowReject: item.allowReject } : { allowReject: false }), exp: expiresInMinutes(30), }; + const clientOnlyMessage = "该薪福通待办已列出,但尚未确认正式详情和审批接口兼容性,请在薪福通客户端查看和处理"; return { approvalId: this.signPortableApprovalReference(ref), source: "xft", - businessNo: item.billId, + ...(billApiSupported ? { businessNo: item.billId } : {}), title: item.title ?? "薪福通审批", applicant: { ...(item.applicantName ? { name: item.applicantName } : {}), @@ -1215,11 +1263,18 @@ export class UnifiedApprovalService { ...(item.applyTime ? { submittedAt: localTime(item.applyTime) } : {}), ...(item.amount !== undefined ? { amount: { value: item.amount, currency: item.currency ?? "CNY" } } : {}), ...(item.reason ? { summary: item.reason } : {}), - availableActions: [ + availableActions: billApiSupported ? [ ...(item.allowPass === false ? [] : ["approve"]), ...(item.allowReject === false ? [] : ["reject"]), "return", - ], + ] : [], + ...(billApiSupported ? {} : { + manualAction: { + required: true, + reason: "unsupported_xft_task_type", + message: clientOnlyMessage, + }, + }), }; } diff --git a/src/xft-open-api.ts b/src/xft-open-api.ts index fc861f3..711d8d0 100644 --- a/src/xft-open-api.ts +++ b/src/xft-open-api.ts @@ -124,8 +124,9 @@ export class XftOpenApiClient implements XftApprovalProvider { const scanLimit = hasQuery ? 200 : Math.min(200, Math.max(limit, 1)); const { items, totalCount, truncated } = await this.loadCurrentTodos(identity, scanLimit); const filtered = items.filter((item) => matchesXftTodoQuery(item, search)); + const returnedItems = filtered.slice(0, limit); return { - items: filtered.slice(0, limit).map(stripRaw), + items: returnedItems.map(stripRaw), totalCount: hasQuery ? filtered.length : totalCount, identity: { enterpriseUserId: identity.enterpriseUserId, @@ -135,6 +136,8 @@ export class XftOpenApiClient implements XftApprovalProvider { source: "xft_official_openapi", realtime: true, identityMapping: this.api.dingTalkAppCode, + billApiCount: returnedItems.filter((item) => item.capability !== "list_only").length, + clientOnlyCount: returnedItems.filter((item) => item.capability === "list_only").length, ...(truncated ? { truncated: true, scanLimit } : {}), persistedBusinessData: false, }, @@ -818,8 +821,9 @@ export class XftOpenApiClient implements XftApprovalProvider { for (let currentPage = 1; currentPage <= 100 && items.length < limit; currentPage += 1) { const page = await this.loadTodoPage(identity, currentPage, pageSize, billId); for (const item of page.items) { - if (seen.has(item.billId)) continue; - seen.add(item.billId); + const identity = item.billId ? `bill:${item.billId}` : item.taskId ? `task:${item.taskId}` : undefined; + if (!identity || seen.has(identity)) continue; + seen.add(identity); items.push(item); if (items.length >= limit) break; } @@ -836,6 +840,7 @@ export class XftOpenApiClient implements XftApprovalProvider { for (let currentPage = 1; currentPage <= 100 && items.length < limit; currentPage += 1) { const page = await this.loadTaskPage(identity, currentPage, pageSize, "done"); for (const item of page.items) { + if (!item.billId || item.capability === "list_only") continue; if (seen.has(item.billId)) continue; seen.add(item.billId); items.push(toHistoryItem(item, "handled")); @@ -982,28 +987,51 @@ export class XftOpenApiClient implements XftApprovalProvider { const detailed = objectRecords(detailResult); const byTaskId = new Map(detailed.map((record) => [stringAt(record, ["todoTaskId"]) ?? "", record])); const todoRecords = pageRecords.map((record) => ({ ...record, ...(byTaskId.get(stringAt(record, ["todoTaskId"]) ?? "") ?? {}) })); - const relevant = todoRecords.filter((record) => isSmartExpenseTodo(record)); - const byBillTodo = new Map(); - for (const record of relevant) { - const id = billIdFromTodo(record); - if (id) byBillTodo.set(id, record); + const selectedRecords = billId + ? todoRecords.filter((record) => billIdFromTodo(record) === billId) + : todoRecords; + const knownBillIds = uniqueStrings(selectedRecords + .filter((record) => isSmartExpenseTodo(record)) + .flatMap((record) => billIdFromTodo(record) ?? [])); + const probeBillIds = uniqueStrings(selectedRecords + .filter((record) => !isSmartExpenseTodo(record)) + .flatMap((record) => billIdFromTodo(record) ?? [])); + const knownSummaries = knownBillIds.length ? await this.loadBillSummaries(identity, knownBillIds) : []; + let probedSummaries: JsonObject[] = []; + let billProbeFailed = false; + if (probeBillIds.length) { + try { probedSummaries = await this.loadBillSummaries(identity, probeBillIds); } + catch { billProbeFailed = true; } } - const selectedIds = billId ? [...byBillTodo.keys()].filter((id) => id === billId) : [...byBillTodo.keys()]; - if (selectedIds.length === 0) return { items: [] as CurrentTodo[], pageRecordCount: pageRecords.length }; - const summaries = await this.request( - "/itrip/xft-api/v1/bills/bill-list", - { billIds: selectedIds.map(businessIdValue), start: 0, limit: selectedIds.length }, - "POST", - identity, - ); - const byId = new Map(findObjectArrays(summaries).flat().flatMap((item) => { + const byId = new Map([...knownSummaries, ...probedSummaries].flatMap((item) => { const id = stringAt(item, ["billId", "businessId", "id"]); return id ? [[id, item] as const] : []; })); - const items = selectedIds.map((id) => normalizeTodo(id, { ...(byBillTodo.get(id) ?? {}), ...(byId.get(id) ?? {}) })); + const items = selectedRecords.flatMap((record) => { + const id = billIdFromTodo(record); + const summary = id ? byId.get(id) : undefined; + if (id && (isSmartExpenseTodo(record) || summary)) { + return [normalizeTodo(id, { ...record, ...(summary ?? {}) })]; + } + if (billId) return []; + const unsupported = normalizeListOnlyTodo(record, id + ? billProbeFailed ? "bill_lookup_unavailable" : "unsupported_task_type" + : "missing_bill_identifier"); + return unsupported ? [unsupported] : []; + }); return { items, pageRecordCount: pageRecords.length }; } + private async loadBillSummaries(identity: XftIdentity, billIds: string[]): Promise { + const summaries = await this.request( + "/itrip/xft-api/v1/bills/bill-list", + { billIds: billIds.map(businessIdValue), start: 0, limit: billIds.length }, + "POST", + identity, + ); + return findObjectArrays(summaries).flat(); + } + private async findCurrentTodo(identity: XftIdentity, billId: string): Promise { const pageSize = 1000; for (let currentPage = 1; currentPage <= 100; currentPage += 1) { @@ -1026,7 +1054,9 @@ export class XftOpenApiClient implements XftApprovalProvider { const pageSize = 1000; for (let currentPage = 1; currentPage <= 100; currentPage += 1) { const page = await this.loadTodoPage(identity, currentPage, pageSize); - for (const item of page.items) if (billIds.has(item.billId)) found.add(item.billId); + for (const item of page.items) { + if (item.billId && billIds.has(item.billId)) found.add(item.billId); + } if (found.size === billIds.size || page.pageRecordCount < pageSize) return found; } throw new ApprovalSafetyError("薪福通待办超过安全分页上限,无法确认单据归属"); @@ -1258,6 +1288,7 @@ function normalizeTodo(billId: string, raw: JsonObject): CurrentTodo { return { source: "xft", billId, + capability: "bill_api", taskId: stringAt(raw, ["processTaskId", "todoTaskId"]), eventType: stringAt(raw, ["eventType", "billType", "eventCode"]), title: stringAt(raw, ["billName", "title", "eventName", "eventNam", "billTypeName"]) ?? parsedContent.title, @@ -1273,11 +1304,35 @@ function normalizeTodo(billId: string, raw: JsonObject): CurrentTodo { }; } +function normalizeListOnlyTodo( + raw: JsonObject, + unavailableReason: NonNullable, +): CurrentTodo | undefined { + const taskId = stringAt(raw, ["todoTaskId", "businessId", "groupId", "REFNBR", "referenceNumber"]); + if (!taskId) return undefined; + const parsedContent = parseTodoContent(stringAt(raw, ["content"])); + return { + source: "xft", + taskId, + capability: "list_only", + unavailableReason, + title: stringAt(raw, ["billName", "title", "eventName", "eventNam", "billTypeName"]) ?? parsedContent.title ?? "薪福通待办", + applicantName: stringAt(raw, ["applyUserName", "applicantName", "aplNam", "empName", "applyEmpName"]) ?? parsedContent.applicantName, + applicantDepartment: stringAt(raw, ["departmentName", "deptName", "aplDepNam", "applyDeptName"]) ?? parsedContent.department, + applyTime: stringAt(raw, ["applyTime", "aplTime", "submitTime", "createTime"]), + allowPass: false, + allowReject: false, + raw, + }; +} + function objectRecords(value: unknown): JsonObject[] { if (Array.isArray(value)) return value.filter(isObject); return findObjectArrays(value).flat(); } +function uniqueStrings(values: string[]): string[] { return [...new Set(values)]; } + function isSmartExpenseTodo(record: JsonObject): boolean { const appCode = stringAt(record, ["appCode", "APPCOD"])?.toLowerCase() ?? ""; const businessCode = stringAt(record, ["businessCode", "BUSTYP"])?.toUpperCase() ?? ""; @@ -1317,12 +1372,21 @@ function stripRaw(item: CurrentTodo): XftTodoItem { } function toHistoryItem(item: CurrentTodo, historyScope: ApprovalHistoryScope): CurrentHistory { - const { allowPass: _allowPass, allowReject: _allowReject, ...rest } = item; + const { + allowPass: _allowPass, + allowReject: _allowReject, + capability: _capability, + unavailableReason: _unavailableReason, + billId, + ...rest + } = item; + if (!billId) throw new ApprovalSafetyError("薪福通历史任务缺少正式单据标识"); const statuses = collectStatusValues(item.raw); const status = statuses.billStatus ?? statuses.billSts ?? statuses.status; const completedAt = normalizedHistoryTime(item.raw, ["finishTime", "finishedAt", "completedAt", "dealTime", "approveTime", "endTime"]); return { ...rest, + billId, historyScope, ...(status ? { status, statusName: xftBillStatusName(status) } : {}), ...(completedAt ? { completedAt } : {}), diff --git a/src/xft.ts b/src/xft.ts index b4489c7..f7de4c8 100644 --- a/src/xft.ts +++ b/src/xft.ts @@ -6,8 +6,10 @@ export interface AuthenticatedDingTalkUser { export interface XftTodoItem { source: "xft"; - billId: string; + billId?: string; taskId?: string; + capability?: "bill_api" | "list_only"; + unavailableReason?: "unsupported_task_type" | "missing_bill_identifier" | "bill_lookup_unavailable"; eventSeqId?: string; title?: string; applicantName?: string; @@ -28,7 +30,8 @@ export interface ApprovalHistoryRange { endTime: number; } -export interface XftHistoryItem extends Omit { +export interface XftHistoryItem extends Omit { + billId: string; historyScope: ApprovalHistoryScope; status?: string; statusName?: string; diff --git a/test/unified.test.ts b/test/unified.test.ts index 8b54e4f..915d69c 100644 --- a/test/unified.test.ts +++ b/test/unified.test.ts @@ -388,6 +388,65 @@ test("XFT action restrictions are enforced during zero-write preflight", async ( assert.equal(writes, 0); }); +test("unadapted XFT todos stay visible but detail and every write action remain client-only", async () => { + let detailCalls = 0; + let writes = 0; + const ding = { + async listMyTodos() { + return { items: [{ + source: "official_oa", processInstanceId: "mixed-instance", taskId: "mixed-task", title: "钉钉审批", raw: {}, + }] }; + }, + } as unknown as ApprovalService; + const xft = { + async listMyTodos() { + return { + items: [{ + source: "xft", taskId: "provider-task-secret", capability: "list_only", unavailableReason: "unsupported_task_type", + title: "薪福通其他审批", allowPass: false, allowReject: false, + }], + diagnostics: { billApiCount: 0, clientOnlyCount: 1 }, + }; + }, + async getTodoDetail() { detailCalls += 1; return {}; }, + async listReturnNodes() { detailCalls += 1; return []; }, + async processApprovalBatch() { writes += 1; return { results: [{ success: true }] }; }, + }; + const service = new UnifiedApprovalService(config(), ding, xft as never); + const user = { userId: "user-1" }; + + const listed = await service.listTodos(user, "all", 10); + assert.equal(listed.returned, 2); + assert.equal("selectionId" in listed, false, "a mixed result containing client-only XFT tasks cannot become a batch selection"); + assert.equal(listed.sourceStatus.xft?.billApiCount, 0); + assert.equal(listed.sourceStatus.xft?.clientOnlyCount, 1); + const item = listed.items.find((candidate) => candidate.title === "薪福通其他审批")!; + assert.equal("businessNo" in item, false); + assert.deepEqual(item.availableActions, []); + assert.equal((item.manualAction as Record).reason, "unsupported_xft_task_type"); + const approvalId = String(item.approvalId); + assert.match(approvalId, /^v4\./); + assert.doesNotMatch(approvalId, /provider-task-secret/); + + const detail = await service.getDetail(user, approvalId) as unknown as Record; + assert.equal(detail.status, "client_required"); + assert.deepEqual(detail.diagnostics, { realtime: true, persistedBusinessData: false, upstreamDetailRequested: false }); + assert.equal(detailCalls, 0, "client-only detail must not call an unverified upstream detail API"); + + const actions = await service.getActions(user, approvalId); + assert.deepEqual(actions.actions.map((action) => [action.action, action.allowed]), [ + ["approve", false], ["reject", false], ["return", false], + ]); + assert.equal((actions.manualAction as Record).reason, "unsupported_xft_task_type"); + assert.equal(detailCalls, 0, "client-only action discovery must not call an unverified upstream action API"); + + const processed = await service.processBatch(user, "approve", "同意", [{ approvalId }]); + assert.equal(processed.status, "preflight_failed"); + assert.equal(processed.writesStarted, false); + assert.equal(processed.invalidItems?.[0]?.code, "provider_not_supported"); + assert.equal(writes, 0); +}); + test("approval reference expiry is checked once before the batch starts", async () => { const originalNow = Date.now; let now = originalNow(); diff --git a/test/xft-open-api.test.ts b/test/xft-open-api.test.ts index dfd81ce..b58b93a 100644 --- a/test/xft-open-api.test.ts +++ b/test/xft-open-api.test.ts @@ -186,30 +186,83 @@ test("Xft OpenAPI does not turn missing or blank amounts into zero", async () => } }); -test("Xft OpenAPI continues past an irrelevant full first todo page", async () => { +test("Xft OpenAPI keeps unadapted personal todos visible instead of filtering them", async () => { const pages: number[] = []; - const irrelevant = Array.from({ length: 100 }, (_, index) => ({ - todoTaskId: `OTHER-${index}`, groupId: `OTHER-${index}`, appCode: "other-app", - })); 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" }] } }); if (path.endsWith("todo-task/page/by-user")) { const page = Number(decodedBody(init).currentPage); pages.push(page); - return encrypted({ code: "0", data: { records: page === 1 ? irrelevant : [{ - todoTaskId: "TD-TARGET", groupId: "2026081400000001", appCode: "xft-itrip", - }] } }); + return encrypted({ code: "0", data: { records: [{ + todoTaskId: "OTHER-1", groupId: "OTHER-1", appCode: "other-app", title: "其他审批", + }], totalSize: 1 } }); } if (path.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [] }); + throw new Error(`unexpected ${path}`); + }); + const result = await client.listMyTodos({ userId: "ding-u1" }, 10); + assert.equal(result.items[0]?.title, "其他审批"); + assert.equal(result.items[0]?.capability, "list_only"); + assert.equal(result.items[0]?.unavailableReason, "missing_bill_identifier"); + assert.equal(result.diagnostics.billApiCount, 0); + assert.equal(result.diagnostics.clientOnlyCount, 1); + assert.deepEqual(pages, [1]); +}); + +test("Xft OpenAPI upgrades an unknown app todo when the official bill API recognizes it", async () => { + const targetBillId = "2026081400000001"; + 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("todo-task/page/by-user")) return encrypted({ code: "0", data: { records: [{ + todoTaskId: "TD-INVITATION", groupId: targetBillId, appCode: "new-approval-app", + }], totalSize: 1 } }); + if (path.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [] }); if (path.endsWith("bill-list")) return encrypted({ code: "0", data: { list: [{ - billId: "2026081400000001", eventType: "COMMON_REIM", title: "目标费用单", + billId: targetBillId, eventType: "INVITATION_APPLY", title: "招待费用申请单", }] } }); throw new Error(`unexpected ${path}`); }); - const result = await client.listMyTodos({ userId: "ding-u1" }, 1); - assert.equal(result.items[0]?.billId, "2026081400000001"); - assert.deepEqual(pages, [1, 2]); + + const result = await client.listMyTodos({ userId: "ding-u1" }, 10); + assert.equal(result.items[0]?.billId, targetBillId); + assert.equal(result.items[0]?.title, "招待费用申请单"); + assert.equal(result.items[0]?.capability, "bill_api"); + assert.equal(result.diagnostics.billApiCount, 1); + assert.equal(result.diagnostics.clientOnlyCount, 0); +}); + +test("Xft OpenAPI degrades a failed unknown bill probe without hiding known expense todos", async () => { + const knownBillId = "2026081400000001"; + const unknownBillId = "2026081400000002"; + 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" }] } }); + if (path.endsWith("todo-task/page/by-user")) return encrypted({ code: "0", data: { records: [{ + todoTaskId: "TD-KNOWN", groupId: knownBillId, appCode: "xft-itrip", + }, { + todoTaskId: "TD-UNKNOWN", groupId: unknownBillId, appCode: "new-approval-app", title: "新类型审批", + }], totalSize: 2 } }); + if (path.endsWith("todo-task/detail/by-ids")) return encrypted({ code: "0", data: [] }); + if (path.endsWith("bill-list")) { + const ids = (decodedBody(init).billIds as Array).map(String); + if (ids.includes(unknownBillId)) return encrypted({ code: "BIZ_LOOKUP_FAILED", message: "lookup unavailable" }); + return encrypted({ code: "0", data: { list: [{ billId: knownBillId, eventType: "COMMON_REIM", title: "费用报销单" }] } }); + } + throw new Error(`unexpected ${path}`); + }); + + const result = await client.listMyTodos({ userId: "ding-u1" }, 10); + assert.equal(result.items.length, 2); + assert.equal(result.items[0]?.billId, knownBillId); + assert.equal(result.items[0]?.capability, "bill_api"); + assert.equal(result.items[1]?.title, "新类型审批"); + assert.equal(result.items[1]?.billId, undefined); + assert.equal(result.items[1]?.capability, "list_only"); + assert.equal(result.items[1]?.unavailableReason, "bill_lookup_unavailable"); + assert.equal(result.diagnostics.billApiCount, 1); + assert.equal(result.diagnostics.clientOnlyCount, 1); }); test("Xft write transport failures are uncertain while deterministic rejections stay failed", async () => {