fix: list unadapted XFT approval tasks safely

This commit is contained in:
2026-08-29 18:09:29 +00:00
parent b00c22d74e
commit f301cef3e8
9 changed files with 283 additions and 45 deletions
+62 -7
View File
@@ -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,
},
}),
};
}