CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s
2877 lines
139 KiB
TypeScript
2877 lines
139 KiB
TypeScript
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "node:crypto";
|
||
import { APPROVAL_BATCH_LIMIT } from "./approval-contract.js";
|
||
import type { AppConfig } from "./config.js";
|
||
import { ApprovalSafetyError, XftSessionExpiredError, errorMessage } from "./errors.js";
|
||
import { ApprovalService, type ApprovalHistoryItem, type ProcessApprovalItem, type TodoItem } from "./approval-service.js";
|
||
import {
|
||
XFT_ATTACHMENT_MAX_FILE_BYTES,
|
||
XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION,
|
||
XFT_ATTACHMENT_MAX_TOTAL_BYTES,
|
||
type ApprovalHistoryRange,
|
||
type ApprovalHistoryScope,
|
||
type AuthenticatedDingTalkUser,
|
||
type XftApprovalProvider,
|
||
type XftHistoryItem,
|
||
type XftResolvedSubmissionAttachment,
|
||
type XftSubmissionAttachment,
|
||
type XftSubmissionField,
|
||
type XftSubmissionKind,
|
||
type XftSubmissionOptionKind,
|
||
type XftSubmissionTemplate,
|
||
type 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";
|
||
import { SignedReferenceCodec } from "./signed-reference.js";
|
||
|
||
export type UnifiedSource = "dingtalk" | "xft";
|
||
export type UnifiedAction = "approve" | "reject" | "return";
|
||
export type SubmissionSource = "dingtalk" | "xft";
|
||
|
||
const DINGTALK_SUBMISSION_TEMPLATES = {
|
||
"北极大_人事假勤": ["出差申请单_北极大", "外出_北极大", "业务招待申请_北极大"],
|
||
"捷康_费用报销": ["差旅费用报销_捷康", "业务招待费用报销_捷康", "业务招待申请_捷康"],
|
||
} as const;
|
||
const DINGTALK_SUBMISSION_CATEGORIES = Object.keys(DINGTALK_SUBMISSION_TEMPLATES);
|
||
const ALLOWED_XFT_SUBMISSION_KINDS = new Set<XftSubmissionKind>([
|
||
"travel_apply", "expense_apply", "entertainment_apply", "common_apply",
|
||
"common_reimburse", "travel_reimburse",
|
||
]);
|
||
|
||
function assertXftSubmissionTemplateAllowed(template: XftSubmissionTemplate): void {
|
||
if (!ALLOWED_XFT_SUBMISSION_KINDS.has(template.kind)) {
|
||
throw new ApprovalSafetyError("该薪福通模板不在 MCP 支持的发起范围内,请重新列出可发起模板");
|
||
}
|
||
}
|
||
|
||
function xftSubmissionCapability(kind: XftSubmissionKind): Record<string, unknown> {
|
||
return {
|
||
status: "api_supported_with_constraints",
|
||
finalValidation: "薪福通创建接口会在正式提交时完成预算、权限和流程业务校验;创建后网关实时核验结果,流程可能进入待审批,也可能按企业规则即时完成",
|
||
kind,
|
||
};
|
||
}
|
||
|
||
interface SubmissionTemplateReference {
|
||
kind: "submission_template";
|
||
userId: string;
|
||
source: SubmissionSource;
|
||
exp: number;
|
||
processCode?: string;
|
||
xftTemplate?: XftSubmissionTemplate;
|
||
}
|
||
|
||
interface SubmissionConfirmationReference {
|
||
kind: "submission_confirmation";
|
||
userId: string;
|
||
source: SubmissionSource;
|
||
templateFingerprint: string;
|
||
requestHash: string;
|
||
nonce: string;
|
||
serviceEpoch: string;
|
||
invoiceReservationId?: string;
|
||
attachmentManifest?: Array<{ fieldName: string; fileName: string; size: number; sha256: string }>;
|
||
exp: number;
|
||
processCode?: string;
|
||
xftTemplate?: XftSubmissionTemplate;
|
||
}
|
||
|
||
interface SubmissionRevokeReference {
|
||
kind: "submission_revoke";
|
||
userId: string;
|
||
source: SubmissionSource;
|
||
processInstanceId?: string;
|
||
billId?: string;
|
||
exp: number;
|
||
}
|
||
|
||
interface ApprovalReference {
|
||
kind: "approval";
|
||
userId: string;
|
||
provider: "official_oa" | "yida" | "xft";
|
||
historyScope?: ApprovalHistoryScope;
|
||
exp: number;
|
||
processInstanceId?: string;
|
||
taskId?: string;
|
||
appType?: string;
|
||
processCode?: string;
|
||
clientOnly?: boolean;
|
||
clientUrl?: string;
|
||
billId?: string;
|
||
allowApprove?: boolean;
|
||
allowReject?: boolean;
|
||
title?: string;
|
||
applicantName?: string;
|
||
applicantDepartment?: string;
|
||
submittedAt?: string;
|
||
amount?: number;
|
||
currency?: string;
|
||
summary?: string;
|
||
}
|
||
|
||
interface ApprovalSelectionReference {
|
||
kind: "approval_selection";
|
||
userId: string;
|
||
source: UnifiedSource | "all";
|
||
query: string;
|
||
limit: number;
|
||
count: number;
|
||
digest: string;
|
||
exp: number;
|
||
}
|
||
|
||
interface PreflightApprovalItem {
|
||
approvalId: string;
|
||
targetId?: string;
|
||
ref: ApprovalReference;
|
||
target?: TargetReference;
|
||
}
|
||
|
||
interface XftTargetReference {
|
||
kind: "target";
|
||
userId: string;
|
||
provider: "xft";
|
||
billId: string;
|
||
nodeId: string;
|
||
exp: number;
|
||
}
|
||
|
||
interface OfficialOaTargetReference {
|
||
kind: "target";
|
||
userId: string;
|
||
provider: "official_oa";
|
||
processInstanceId: string;
|
||
taskId: string;
|
||
activityId: string;
|
||
revertAction: "REVERT_FOR_RESUBMIT" | "REVERT_FOR_APPROVAL";
|
||
exp: number;
|
||
}
|
||
|
||
type TargetReference = XftTargetReference | OfficialOaTargetReference;
|
||
|
||
interface UnifiedActionCapability {
|
||
action: UnifiedAction;
|
||
allowed: boolean;
|
||
submissionSupported?: boolean;
|
||
guaranteed?: boolean;
|
||
remarkRequired?: boolean;
|
||
[key: string]: unknown;
|
||
}
|
||
|
||
interface UnifiedActionsResult {
|
||
approvalId: string;
|
||
actions: UnifiedActionCapability[];
|
||
[key: string]: unknown;
|
||
}
|
||
|
||
export interface SubmissionInput {
|
||
templateId: string;
|
||
values: Record<string, unknown>;
|
||
deptId?: number;
|
||
targetSelectActioners?: Array<{ actionerKey: string; actionerStaffIds: string[] }>;
|
||
allowDuplicateInvoices?: boolean;
|
||
attachments?: XftSubmissionAttachment[];
|
||
}
|
||
|
||
export interface OfficialOaReturnClient {
|
||
supportsUser(userId: string): boolean;
|
||
listRevertActivities(userId: string, taskId: string): Promise<Array<{
|
||
activityId: string;
|
||
activityName?: string;
|
||
revertAction: "REVERT_FOR_RESUBMIT" | "REVERT_FOR_APPROVAL";
|
||
approverNames: string[];
|
||
}>>;
|
||
revertTask(userId: string, request: {
|
||
processInstanceId: string;
|
||
taskId: string;
|
||
targetActivityId: string;
|
||
revertAction: "REVERT_FOR_RESUBMIT" | "REVERT_FOR_APPROVAL";
|
||
remark?: string;
|
||
}): Promise<void>;
|
||
}
|
||
|
||
export interface AuthChallenge {
|
||
provider: "xft";
|
||
type: "qr";
|
||
challengeId: string;
|
||
qrImageUrl: string;
|
||
directUrl?: string;
|
||
expiresAt: string;
|
||
pollAfterSeconds: number;
|
||
}
|
||
|
||
export interface XftChallengeProvider {
|
||
getOrStart(user: AuthenticatedDingTalkUser): Promise<AuthChallenge>;
|
||
getImage?(challengeId: string): Buffer | undefined;
|
||
}
|
||
|
||
export class UnifiedApprovalService {
|
||
private readonly signer: SignedReferenceCodec;
|
||
private readonly approvalSigner: CompactApprovalReferenceCodec;
|
||
private readonly submissionServiceEpoch = randomBytes(18).toString("base64url");
|
||
private readonly consumedSubmissionNonces = new Map<string, number>();
|
||
|
||
constructor(
|
||
private readonly config: AppConfig,
|
||
private readonly dingTalk: ApprovalService,
|
||
private readonly xft?: XftApprovalProvider,
|
||
private readonly xftChallenges?: XftChallengeProvider,
|
||
private readonly officialOaReturns?: OfficialOaReturnClient,
|
||
private readonly invoiceDedup?: InvoiceDeduplicationService,
|
||
) {
|
||
this.signer = new SignedReferenceCodec(config.approvalRefSecret);
|
||
this.approvalSigner = new CompactApprovalReferenceCodec(config.approvalRefSecret);
|
||
}
|
||
|
||
async listTodos(
|
||
user: AuthenticatedDingTalkUser,
|
||
source: UnifiedSource | "all",
|
||
limit: number,
|
||
query = "",
|
||
issueSelection = true,
|
||
) {
|
||
let dingTalkItems: Array<Record<string, unknown>> = [];
|
||
let xftItems: Array<Record<string, unknown>> = [];
|
||
const sourceStatus: Record<string, Record<string, unknown>> = {};
|
||
const tasks: Promise<void>[] = [];
|
||
if (source !== "xft") {
|
||
const dingTalkScanLimit = query.trim() ? 200 : limit;
|
||
tasks.push(this.dingTalk.listMyTodos(user.userId, "all", dingTalkScanLimit).then((result) => {
|
||
const normalized = result.items.map((item) => this.normalizeDingTalkTodo(user, item));
|
||
dingTalkItems = normalized.filter((item) => matchesTodoQuery(item, query));
|
||
sourceStatus.dingtalk = {
|
||
status: "ok",
|
||
count: normalized.length,
|
||
enrichmentFailures: result.diagnostics?.officialEnrichmentFailures ?? 0,
|
||
};
|
||
}).catch((error) => {
|
||
sourceStatus.dingtalk = { status: "unavailable", message: errorMessage(error) };
|
||
}));
|
||
}
|
||
if (source !== "dingtalk") {
|
||
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,
|
||
...(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) }; }
|
||
catch (challengeError) { sourceStatus.xft = { status: "unavailable", message: errorMessage(challengeError) }; }
|
||
} else {
|
||
sourceStatus.xft = { status: "unavailable", message: errorMessage(error) };
|
||
}
|
||
}));
|
||
}
|
||
await Promise.all(tasks);
|
||
const items = source === "dingtalk" ? dingTalkItems
|
||
: source === "xft" ? xftItems
|
||
: 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 {
|
||
items: returnedItems,
|
||
returned: returnedItems.length,
|
||
...(selectionAvailable ? {
|
||
selectionId: this.signer.sign({
|
||
kind: "approval_selection",
|
||
userId: user.userId,
|
||
source,
|
||
query,
|
||
limit,
|
||
count: returnedItems.length,
|
||
digest: this.approvalSelectionDigest(user, returnedItems),
|
||
exp: expiresInMinutes(15),
|
||
} satisfies ApprovalSelectionReference),
|
||
} : {}),
|
||
sourceStatus,
|
||
diagnostics: { realtime: true, persistedBusinessData: false },
|
||
};
|
||
}
|
||
|
||
async listHistory(
|
||
user: AuthenticatedDingTalkUser,
|
||
scope: ApprovalHistoryScope,
|
||
source: UnifiedSource | "all",
|
||
limit: number,
|
||
query = "",
|
||
startDate = "",
|
||
endDate = "",
|
||
) {
|
||
const range = resolveHistoryRange(startDate, endDate, this.config.officialLookbackDays);
|
||
let dingTalkItems: Array<Record<string, unknown>> = [];
|
||
let xftItems: Array<Record<string, unknown>> = [];
|
||
const sourceStatus: Record<string, Record<string, unknown>> = {};
|
||
const tasks: Promise<void>[] = [];
|
||
const scanLimit = query.trim() ? 200 : limit;
|
||
if (source !== "xft") {
|
||
tasks.push(this.dingTalk.listMyHistory(user.userId, scope, scanLimit, range).then((result) => {
|
||
const normalized = result.items.map((item) => this.normalizeDingTalkHistory(user, item));
|
||
dingTalkItems = normalized.filter((item) => matchesTodoQuery(item, query));
|
||
sourceStatus.dingtalk = {
|
||
status: "ok", count: normalized.length,
|
||
...(result.diagnostics?.truncated === true ? {
|
||
truncated: true,
|
||
...(typeof result.diagnostics.scanLimit === "number" ? { scanLimit: result.diagnostics.scanLimit } : {}),
|
||
} : {}),
|
||
};
|
||
}).catch((error) => {
|
||
sourceStatus.dingtalk = { status: "unavailable", message: errorMessage(error) };
|
||
}));
|
||
}
|
||
if (source !== "dingtalk") {
|
||
if (!this.xft) sourceStatus.xft = { status: "disabled" };
|
||
else tasks.push(this.xft.listMyHistory(user, scope, scanLimit, query, range).then((result) => {
|
||
xftItems = result.items.map((item) => this.normalizeXftHistory(user, item));
|
||
sourceStatus.xft = {
|
||
status: "ok", count: result.items.length,
|
||
...(result.diagnostics?.truncated === true ? {
|
||
truncated: true,
|
||
...(typeof result.diagnostics.scanLimit === "number" ? { scanLimit: result.diagnostics.scanLimit } : {}),
|
||
} : {}),
|
||
};
|
||
}).catch(async (error) => {
|
||
if (error instanceof XftSessionExpiredError && this.xftChallenges) {
|
||
try { sourceStatus.xft = { status: "auth_required", authChallenge: await this.xftChallenges.getOrStart(user) }; }
|
||
catch (challengeError) { sourceStatus.xft = { status: "unavailable", message: errorMessage(challengeError) }; }
|
||
} else {
|
||
sourceStatus.xft = { status: "unavailable", message: errorMessage(error) };
|
||
}
|
||
}));
|
||
}
|
||
await Promise.all(tasks);
|
||
const items = source === "dingtalk" ? dingTalkItems
|
||
: source === "xft" ? xftItems
|
||
: sortUnifiedHistory([...dingTalkItems, ...xftItems]);
|
||
return {
|
||
scope,
|
||
timeRange: {
|
||
startDate: new Date(range.startTime).toISOString(),
|
||
endDate: new Date(range.endTime).toISOString(),
|
||
timeZone: "Asia/Shanghai",
|
||
defaultedToRecent30Days: !startDate.trim() && !endDate.trim(),
|
||
},
|
||
items: items.slice(0, limit),
|
||
returned: Math.min(items.length, limit),
|
||
sourceStatus,
|
||
diagnostics: { realtime: true, persistedBusinessData: false, readOnlyHistory: true },
|
||
};
|
||
}
|
||
|
||
async listSubmissionTemplates(
|
||
user: AuthenticatedDingTalkUser,
|
||
source: SubmissionSource | "all",
|
||
limit: number,
|
||
query = "",
|
||
) {
|
||
let dingTalkItems: Array<Record<string, unknown>> = [];
|
||
let xftItems: Array<Record<string, unknown>> = [];
|
||
const sourceStatus: Record<string, Record<string, unknown>> = {};
|
||
const tasks: Promise<void>[] = [];
|
||
if (source !== "xft") tasks.push(this.dingTalk.listSubmissionTemplates(limit, query, DINGTALK_SUBMISSION_CATEGORIES).then((templates) => {
|
||
const supported = templates.filter(dingTalkSubmissionTemplateAllowed);
|
||
dingTalkItems = supported.map((template) => ({
|
||
templateId: this.signer.sign({
|
||
kind: "submission_template", userId: user.userId, source: "dingtalk",
|
||
processCode: template.processCode, exp: expiresInMinutes(30),
|
||
} satisfies SubmissionTemplateReference),
|
||
source: "dingtalk",
|
||
name: template.name ?? template.processCode,
|
||
category: template.dirName,
|
||
}));
|
||
sourceStatus.dingtalk = {
|
||
status: "ok",
|
||
count: supported.length,
|
||
allowedTemplates: Object.values(DINGTALK_SUBMISSION_TEMPLATES).flat(),
|
||
};
|
||
}).catch((error) => { sourceStatus.dingtalk = { status: "unavailable", message: errorMessage(error) }; }));
|
||
if (source !== "dingtalk") {
|
||
if (!this.xft) sourceStatus.xft = { status: "disabled" };
|
||
else tasks.push(this.xft.listSubmissionTemplates(user, limit, query).then((templates) => {
|
||
const supported = templates.filter((template) => ALLOWED_XFT_SUBMISSION_KINDS.has(template.kind));
|
||
xftItems = supported.map((template) => ({
|
||
templateId: this.signer.sign({
|
||
kind: "submission_template", userId: user.userId, source: "xft", xftTemplate: template,
|
||
exp: expiresInMinutes(30),
|
||
} satisfies SubmissionTemplateReference),
|
||
source: "xft",
|
||
name: template.eventName,
|
||
kind: template.kind,
|
||
eventType: template.eventType,
|
||
}));
|
||
sourceStatus.xft = { status: "ok", count: supported.length };
|
||
}).catch((error) => { sourceStatus.xft = { status: "unavailable", message: errorMessage(error) }; }));
|
||
}
|
||
await Promise.all(tasks);
|
||
const items = source === "dingtalk" ? dingTalkItems
|
||
: source === "xft" ? xftItems
|
||
: interleave(dingTalkItems, xftItems);
|
||
return { items: items.slice(0, limit), sourceStatus, diagnostics: { realtime: true, persistedBusinessData: false } };
|
||
}
|
||
|
||
async getSubmissionSchema(user: AuthenticatedDingTalkUser, templateId: string) {
|
||
const template = this.decodeSubmissionTemplate(user, templateId);
|
||
if (template.source === "xft") {
|
||
if (!this.xft || !template.xftTemplate) throw new ApprovalSafetyError("薪福通提单源未启用");
|
||
assertXftSubmissionTemplateAllowed(template.xftTemplate);
|
||
const schema = await this.xft.getSubmissionSchema(user, template.xftTemplate);
|
||
const requiredClientOnly = schema.fields.filter(requiredXftFieldNeedsNativeClient);
|
||
const capability = requiredClientOnly.length ? {
|
||
status: "client_only",
|
||
reason: `包含当前 MCP 无法按业务名称安全填写的必填项:${requiredClientOnly.map((field) => field.name).join("、")}`,
|
||
} : xftSubmissionCapability(template.xftTemplate.kind);
|
||
return {
|
||
templateId,
|
||
source: "xft",
|
||
...schema,
|
||
submissionCapability: capability,
|
||
};
|
||
}
|
||
if (!template.processCode) throw new ApprovalSafetyError("钉钉模板引用缺少 processCode");
|
||
const current = await this.dingTalk.getSubmissionSchema(template.processCode);
|
||
assertDingTalkSubmissionTemplateAllowed(current.template);
|
||
const fields = compactDingTalkSchema(current.schema);
|
||
const unsupportedSuites = fields.filter((field) => field.type === "DDBizSuite").map((field) => String(field.name));
|
||
const apiIncompatibility = dingTalkSubmissionApiIncompatibility(current.template);
|
||
return {
|
||
templateId,
|
||
source: "dingtalk",
|
||
template: { name: current.template.name, processCode: current.template.processCode },
|
||
fields,
|
||
submissionCapability: apiIncompatibility ? {
|
||
status: "client_only",
|
||
reason: apiIncompatibility,
|
||
} : unsupportedSuites.length ? {
|
||
status: "client_only",
|
||
reason: `包含官方发起 API 无法可靠构造的钉钉业务套件:${unsupportedSuites.join("、")}`,
|
||
} : { status: "api_supported_with_schema_constraints" },
|
||
inputContract: {
|
||
values: "对象;key 必须与 fields[].name 完全一致,value 按 fields[].type/constraints 填写",
|
||
deptId: "默认使用 -1;服务器会实时解析当前登录用户的所属部门,显式部门也必须属于本人",
|
||
targetSelectActioners: "只在准备结果要求自选审批人时填写,userId 必须来自可信通讯录查询",
|
||
},
|
||
diagnostics: { realtime: true, persistedBusinessData: false },
|
||
};
|
||
}
|
||
|
||
async getSubmissionOptions(
|
||
user: AuthenticatedDingTalkUser,
|
||
templateId: string,
|
||
kind: XftSubmissionOptionKind,
|
||
query = "",
|
||
limit = 50,
|
||
) {
|
||
const template = this.decodeSubmissionTemplate(user, templateId);
|
||
if (template.source !== "xft" || !this.xft || !template.xftTemplate) {
|
||
throw new ApprovalSafetyError("实时提单选项查询当前仅用于薪福通模板");
|
||
}
|
||
assertXftSubmissionTemplateAllowed(template.xftTemplate);
|
||
return {
|
||
templateId,
|
||
source: "xft" as const,
|
||
...(await this.xft.getSubmissionOptions(user, template.xftTemplate, kind, query, limit)),
|
||
};
|
||
}
|
||
|
||
async prepareSubmission(user: AuthenticatedDingTalkUser, input: SubmissionInput): Promise<{
|
||
status: string;
|
||
confirmationId?: string;
|
||
preview: Record<string, any>;
|
||
[key: string]: unknown;
|
||
}> {
|
||
const template = this.decodeSubmissionTemplate(user, input.templateId);
|
||
assertPlainSubmissionValues(input.values);
|
||
let preview: Record<string, unknown>;
|
||
let fingerprint: string;
|
||
if (template.source === "xft") {
|
||
if (!this.xft || !template.xftTemplate) throw new ApprovalSafetyError("薪福通提单源未启用");
|
||
assertXftSubmissionTemplateAllowed(template.xftTemplate);
|
||
const schema = await this.xft.getSubmissionSchema(user, template.xftTemplate);
|
||
const attachments = validateXftSubmissionAttachments(schema.fields, input.attachments ?? [], false);
|
||
validateXftSubmissionValues(template.xftTemplate.kind, input.values, schema.fields, attachments);
|
||
const identityValidation = await this.xft.validateSubmissionIdentity(user);
|
||
preview = {
|
||
source: "xft", template: schema.template,
|
||
applicant: "当前登录用户(由服务器映射,不能由 Agent 指定)",
|
||
identityValidation,
|
||
submissionStatus: "APPRV_PEND",
|
||
formSummary: await readableXftSubmissionSummary(this.xft, user, schema.template, schema.fields, schema.inputContract, input.values),
|
||
...(attachments.length ? { attachments: attachmentPreview(attachments) } : {}),
|
||
processForecast: { supported: false, message: "薪福通正式接口不提供无副作用的流程预测;提交时由上游完成最终业务校验" },
|
||
};
|
||
fingerprint = hashJson({ template: schema.template, fields: schema.fields });
|
||
} else {
|
||
if ((input.attachments ?? []).length) throw new ApprovalSafetyError("本地附件上传当前仅支持薪福通模板");
|
||
if (!template.processCode) throw new ApprovalSafetyError("钉钉模板引用缺少 processCode");
|
||
const current = await this.dingTalk.getSubmissionSchema(template.processCode);
|
||
assertDingTalkSubmissionTemplateAllowed(current.template);
|
||
const fields = compactDingTalkSchema(current.schema);
|
||
assertDingTalkApiConstructible(fields, current.template);
|
||
validateDingTalkValues(fields, input.values);
|
||
const departmentId = await this.dingTalk.resolveSubmissionDepartment(user.userId, input.deptId);
|
||
const request = dingTalkSubmissionRequest(template.processCode, input, fields, departmentId);
|
||
const forecast = await this.dingTalk.forecastSubmission(user.userId, request);
|
||
const selectionRequirements = forecastSelectionRequirements(forecast);
|
||
validateTargetSelections(selectionRequirements, input.targetSelectActioners ?? []);
|
||
preview = {
|
||
source: "dingtalk",
|
||
template: { name: current.template.name, processCode: current.template.processCode },
|
||
applicant: "当前登录用户(由服务器填写,不能由 Agent 指定)",
|
||
department: "当前登录用户的已验证所属部门(由服务器填写)",
|
||
formSummary: summarizeValues(input.values),
|
||
processForecast: compactForecast(forecast),
|
||
selectionRequirements,
|
||
};
|
||
fingerprint = hashJson({ template: current.template.processCode, fields });
|
||
}
|
||
const expiresAt = Date.now() + 10 * 60_000;
|
||
const invoiceEvidenceExpected = template.source === "xft"
|
||
&& (template.xftTemplate?.kind === "common_reimburse" || template.xftTemplate?.kind === "travel_reimburse");
|
||
const deduplicationValues = submissionValuesForInvoiceDeduplication(input);
|
||
const deduplication = this.invoiceDedup
|
||
? await this.invoiceDedup.prepare(
|
||
user, deduplicationValues, input.allowDuplicateInvoices === true, expiresAt, invoiceEvidenceExpected,
|
||
)
|
||
: undefined;
|
||
if (deduplication?.duplicateCheck.requiresUserConfirmation) {
|
||
return {
|
||
status: deduplication.duplicateCheck.status === "check_unavailable"
|
||
? "duplicate_check_unavailable"
|
||
: "duplicate_confirmation_required",
|
||
preview: { ...preview, duplicateCheck: deduplication.duplicateCheck },
|
||
message: "发票查重发现历史重复、疑似重复或覆盖不完整。本次没有生成提交确认;请向用户展示查重结果。只有用户明确表示仍要提交后,才能以 allowDuplicateInvoices=true 原样重新准备。",
|
||
diagnostics: { persistedBusinessData: false, confirmationCreated: false },
|
||
};
|
||
}
|
||
const nonce = randomBytes(18).toString("base64url");
|
||
const normalized = normalizedSubmissionInput(input);
|
||
const confirmationId = this.signer.sign({
|
||
kind: "submission_confirmation", userId: user.userId, source: template.source,
|
||
templateFingerprint: fingerprint, requestHash: hashJson(normalized), nonce,
|
||
serviceEpoch: this.submissionServiceEpoch,
|
||
...(deduplication?.reservationId ? { invoiceReservationId: deduplication.reservationId } : {}),
|
||
...((input.attachments ?? []).length ? { attachmentManifest: submissionAttachmentManifest(input) } : {}),
|
||
...(template.processCode ? { processCode: template.processCode } : {}),
|
||
...(template.xftTemplate ? { xftTemplate: template.xftTemplate } : {}),
|
||
exp: expiresAt,
|
||
} satisfies SubmissionConfirmationReference);
|
||
return {
|
||
status: "confirmation_required",
|
||
confirmationId,
|
||
expiresAt: new Date(expiresAt).toISOString(),
|
||
preview: { ...preview, ...(deduplication ? { duplicateCheck: deduplication.duplicateCheck } : {}) },
|
||
message: "请向用户展示完整预览;只有用户明确确认后,才能使用相同内容调用 approval_submit。",
|
||
diagnostics: { persistedBusinessData: false, confirmationBoundToContent: true, oneTimeConfirmation: true },
|
||
};
|
||
}
|
||
|
||
async submitApproval(user: AuthenticatedDingTalkUser, confirmationId: string, input: SubmissionInput) {
|
||
this.cleanupSubmissionNonces();
|
||
const confirmation = this.signer.verify<SubmissionConfirmationReference>(confirmationId);
|
||
if (confirmation.kind !== "submission_confirmation" || confirmation.userId !== user.userId || confirmation.exp < Date.now()) {
|
||
throw new ApprovalSafetyError("confirmationId 无效、已过期或不属于当前用户,请重新准备提单");
|
||
}
|
||
if (confirmation.serviceEpoch !== this.submissionServiceEpoch) {
|
||
throw new ApprovalSafetyError("confirmationId 来自先前的服务进程,为避免重启后重复提单,请重新准备并确认");
|
||
}
|
||
if (this.consumedSubmissionNonces.has(confirmation.nonce)) {
|
||
throw new ApprovalSafetyError("该 confirmationId 已使用;为避免重复提单,禁止再次提交");
|
||
}
|
||
if (hashJson(normalizedSubmissionInput(input)) !== confirmation.requestHash) {
|
||
throw new ApprovalSafetyError("提单内容与用户确认时不一致,请重新准备并确认");
|
||
}
|
||
const deduplicationValues = submissionValuesForInvoiceDeduplication(input);
|
||
if (confirmation.invoiceReservationId) this.invoiceDedup?.assertReservation(user, confirmation.invoiceReservationId);
|
||
this.consumedSubmissionNonces.set(confirmation.nonce, confirmation.exp);
|
||
try {
|
||
if (confirmation.source === "xft") {
|
||
if (!this.xft || !confirmation.xftTemplate) throw new ApprovalSafetyError("薪福通提单源未启用");
|
||
assertXftSubmissionTemplateAllowed(confirmation.xftTemplate);
|
||
const schema = await this.xft.getSubmissionSchema(user, confirmation.xftTemplate);
|
||
if (hashJson({ template: schema.template, fields: schema.fields }) !== confirmation.templateFingerprint) {
|
||
throw new ApprovalSafetyError("薪福通表单在确认后发生变化,请重新准备并确认");
|
||
}
|
||
const attachments = validateXftSubmissionAttachments(schema.fields, input.attachments ?? [], true);
|
||
validateXftSubmissionValues(confirmation.xftTemplate.kind, input.values, schema.fields, attachments);
|
||
const submitted = await this.xft.submitApproval(
|
||
user,
|
||
confirmation.xftTemplate,
|
||
input.values,
|
||
attachments,
|
||
confirmationId,
|
||
);
|
||
let liveStatus;
|
||
try {
|
||
liveStatus = await this.xft.getMySubmissionStatus(user, submitted.billId);
|
||
} catch {
|
||
const duplicateIndexUpdate = await this.invoiceDedup?.recordSubmission(
|
||
user, confirmation.invoiceReservationId, deduplicationValues, "xft", submitted.billId, "outcome_uncertain",
|
||
);
|
||
return {
|
||
source: "xft" as const,
|
||
status: "outcome_uncertain" as const,
|
||
billId: submitted.billId,
|
||
message: "薪福通已创建单据,但暂时无法确认其已进入待审批状态;禁止重试,请稍后用 approval_get_submission_status 核验",
|
||
diagnostics: { ...submitted.diagnostics, automaticRetryAllowed: false, postWriteVerified: false, duplicateIndexUpdate },
|
||
};
|
||
}
|
||
const completedImmediately = /^(?:2|APPRV)$/i.test(liveStatus.status);
|
||
if (completedImmediately) {
|
||
const duplicateIndexUpdate = await this.invoiceDedup?.recordSubmission(
|
||
user, confirmation.invoiceReservationId, deduplicationValues, "xft", submitted.billId, "approved",
|
||
);
|
||
return {
|
||
source: "xft" as const,
|
||
status: "completed" as const,
|
||
billId: submitted.billId,
|
||
observedStatus: liveStatus.status,
|
||
observedStatusName: liveStatus.statusName,
|
||
completionMode: "workflow_completed_immediately" as const,
|
||
message: `薪福通单据已创建,审批流程已即时完成并处于“${liveStatus.statusName}”;这通常由企业流程规则(例如没有其他有效审批人)导致,当前状态不可撤回`,
|
||
diagnostics: { ...submitted.diagnostics, automaticRetryAllowed: false, postWriteVerified: true, duplicateIndexUpdate },
|
||
};
|
||
}
|
||
if (!liveStatus.revokeAllowed) {
|
||
const duplicateIndexUpdate = await this.invoiceDedup?.recordSubmission(
|
||
user, confirmation.invoiceReservationId, deduplicationValues, "xft", submitted.billId, liveStatus.status,
|
||
);
|
||
return {
|
||
source: "xft" as const,
|
||
status: "upstream_state_mismatch" as const,
|
||
billId: submitted.billId,
|
||
observedStatus: liveStatus.status,
|
||
observedStatusName: liveStatus.statusName,
|
||
message: `薪福通已创建单据,但上游实际状态为“${liveStatus.statusName}”,并未进入可撤回的待审批流程;禁止重试,请在薪福通核对`,
|
||
diagnostics: { ...submitted.diagnostics, automaticRetryAllowed: false, postWriteVerified: true, duplicateIndexUpdate },
|
||
};
|
||
}
|
||
const revokeId = this.signer.sign({
|
||
kind: "submission_revoke", userId: user.userId, source: "xft", billId: submitted.billId,
|
||
exp: expiresInMinutes(7 * 24 * 60),
|
||
} satisfies SubmissionRevokeReference);
|
||
const duplicateIndexUpdate = await this.invoiceDedup?.recordSubmission(
|
||
user, confirmation.invoiceReservationId, deduplicationValues, "xft", submitted.billId, liveStatus.status,
|
||
);
|
||
return {
|
||
source: "xft", ...submitted, revokeId,
|
||
observedStatus: liveStatus.status,
|
||
observedStatusName: liveStatus.statusName,
|
||
diagnostics: { ...submitted.diagnostics, postWriteVerified: true, duplicateIndexUpdate },
|
||
};
|
||
}
|
||
if (!confirmation.processCode) throw new ApprovalSafetyError("钉钉提单确认缺少 processCode");
|
||
const current = await this.dingTalk.getSubmissionSchema(confirmation.processCode);
|
||
assertDingTalkSubmissionTemplateAllowed(current.template);
|
||
const fields = compactDingTalkSchema(current.schema);
|
||
assertDingTalkApiConstructible(fields, current.template);
|
||
if (hashJson({ template: current.template.processCode, fields }) !== confirmation.templateFingerprint) {
|
||
throw new ApprovalSafetyError("钉钉审批模板在确认后发生变化,请重新准备并确认");
|
||
}
|
||
validateDingTalkValues(fields, input.values);
|
||
const departmentId = await this.dingTalk.resolveSubmissionDepartment(user.userId, input.deptId);
|
||
const processInstanceId = await this.dingTalk.submitApproval(
|
||
user.userId,
|
||
dingTalkSubmissionRequest(confirmation.processCode, input, fields, departmentId),
|
||
);
|
||
const revokeId = this.signer.sign({
|
||
kind: "submission_revoke", userId: user.userId, source: "dingtalk", processInstanceId,
|
||
exp: expiresInMinutes(7 * 24 * 60),
|
||
} satisfies SubmissionRevokeReference);
|
||
const duplicateIndexUpdate = await this.invoiceDedup?.recordSubmission(
|
||
user, confirmation.invoiceReservationId, deduplicationValues, "dingtalk", processInstanceId, "pending",
|
||
);
|
||
return {
|
||
source: "dingtalk", status: "submitted", processInstanceId, revokeId,
|
||
diagnostics: { applicantIdentity: "server_derived", persistedBusinessData: false, duplicateIndexUpdate },
|
||
};
|
||
} catch (error) {
|
||
// A request may have reached the upstream. Never permit an automatic replay with the same confirmation.
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async revokeSubmission(user: AuthenticatedDingTalkUser, revokeId: string, remark = "") {
|
||
let reference: SubmissionRevokeReference;
|
||
try { reference = this.signer.verify<SubmissionRevokeReference>(revokeId); }
|
||
catch { throw new ApprovalSafetyError("revokeId 无效、已过期或来自先前的服务会话,请使用 approval_submit 最新返回的值"); }
|
||
if (
|
||
reference.kind !== "submission_revoke" || !["dingtalk", "xft"].includes(reference.source) ||
|
||
reference.userId !== user.userId || reference.exp < Date.now()
|
||
) {
|
||
throw new ApprovalSafetyError("revokeId 无效、已过期或不属于当前登录用户");
|
||
}
|
||
if (reference.source === "xft") {
|
||
if (!this.xft || !reference.billId) throw new ApprovalSafetyError("薪福通撤回引用缺少单据编号或服务未启用");
|
||
const recalled = await this.xft.recallSubmission(user, reference.billId, remark);
|
||
return {
|
||
source: "xft", ...recalled,
|
||
diagnostics: { ...recalled.diagnostics, referenceUserBound: true, automaticRetryAllowed: false },
|
||
};
|
||
}
|
||
if (!reference.processInstanceId) throw new ApprovalSafetyError("钉钉撤销引用缺少审批实例");
|
||
return {
|
||
source: "dingtalk", ...(await this.dingTalk.revokeSubmission(user.userId, reference.processInstanceId, remark)),
|
||
diagnostics: { ownershipRevalidated: true, persistedBusinessData: false, automaticRetryAllowed: false },
|
||
};
|
||
}
|
||
|
||
async getSubmissionStatus(user: AuthenticatedDingTalkUser, source: SubmissionSource, businessId: string) {
|
||
if (source !== "xft") throw new ApprovalSafetyError("已发起单据状态核验当前仅支持薪福通");
|
||
if (!this.xft) throw new ApprovalSafetyError("薪福通提单源未启用");
|
||
if (!/^\d{10,20}$/.test(businessId)) throw new ApprovalSafetyError("薪福通单据编号格式无效");
|
||
const status = await this.xft.getMySubmissionStatus(user, businessId);
|
||
const revokeId = status.revokeAllowed ? this.signer.sign({
|
||
kind: "submission_revoke", userId: user.userId, source: "xft", billId: status.billId,
|
||
exp: expiresInMinutes(30),
|
||
} satisfies SubmissionRevokeReference) : undefined;
|
||
return {
|
||
source: "xft" as const,
|
||
...status,
|
||
...(revokeId ? { revokeId, revokeIdExpiresAt: new Date(Date.now() + 30 * 60_000).toISOString() } : {}),
|
||
};
|
||
}
|
||
|
||
async getDetail(user: AuthenticatedDingTalkUser, approvalId: string, options: ApprovalDetailOptions = {}) {
|
||
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
|
||
? await this.xft.getHistoryDetail(user, ref.historyScope, ref.billId)
|
||
: await this.xft.getTodoDetail(user, ref.billId));
|
||
return this.presentDetail({
|
||
approvalId,
|
||
source: "xft",
|
||
sourceType: "smart_expense",
|
||
}, raw, view, options);
|
||
} catch (error) {
|
||
if (error instanceof XftSessionExpiredError && this.xftChallenges) {
|
||
return { approvalId, source: "xft", status: "auth_required", authChallenge: await this.xftChallenges.getOrStart(user) };
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
if (!ref.processInstanceId) throw new ApprovalSafetyError("审批引用缺少流程实例");
|
||
const raw = redactSensitive(ref.historyScope
|
||
? await this.dingTalk.getHistoryDetail(user.userId, ref.historyScope, ref.processInstanceId, ref.taskId)
|
||
: await this.dingTalk.getDetail(user.userId, {
|
||
source: ref.provider,
|
||
processInstanceId: ref.processInstanceId,
|
||
...(ref.appType ? { appType: ref.appType } : {}),
|
||
}));
|
||
return this.presentDetail({
|
||
approvalId,
|
||
source: "dingtalk",
|
||
sourceType: ref.provider,
|
||
}, view === "raw" ? raw : withReferenceMetadata(raw, ref), view, options);
|
||
}
|
||
|
||
private presentDetail(
|
||
identity: { approvalId: string; source: UnifiedSource; sourceType: string },
|
||
raw: unknown,
|
||
view: ApprovalDetailView,
|
||
options: ApprovalDetailOptions,
|
||
) {
|
||
if (view === "raw") {
|
||
return {
|
||
...identity,
|
||
view,
|
||
detail: browseRawDetail(raw, options.rawPath, options.rawCursor, options.rawLimit, options.rawQuery),
|
||
};
|
||
}
|
||
return { ...identity, view, ...compactApprovalDetail(raw, view) };
|
||
}
|
||
|
||
async getActions(user: AuthenticatedDingTalkUser, approvalId: string): Promise<UnifiedActionsResult> {
|
||
const ref = this.decodeApproval(user, approvalId);
|
||
if (ref.historyScope) throw new ApprovalSafetyError("历史审批引用仅用于读取详情,不能执行审批动作");
|
||
if (ref.provider === "yida") {
|
||
if (!ref.processInstanceId || !ref.appType) throw new ApprovalSafetyError("宜搭审批引用缺少任务信息");
|
||
await this.dingTalk.getDetail(user.userId, {
|
||
source: "yida", processInstanceId: ref.processInstanceId, appType: ref.appType,
|
||
});
|
||
return {
|
||
approvalId,
|
||
actions: [...APPROVE_REJECT_ACTIONS, { action: "return" as const, allowed: false, unavailableReason: "provider_not_supported", message: "宜搭审批当前不支持退回" }],
|
||
};
|
||
}
|
||
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); }
|
||
catch (error) {
|
||
if (error instanceof XftSessionExpiredError && this.xftChallenges) {
|
||
return { approvalId, status: "auth_required", authChallenge: await this.xftChallenges.getOrStart(user), actions: [] };
|
||
}
|
||
throw error;
|
||
}
|
||
return {
|
||
approvalId,
|
||
actions: [
|
||
...APPROVE_REJECT_ACTIONS.map((capability) => capability.action === "approve"
|
||
? { ...capability, allowed: ref.allowApprove !== false }
|
||
: { ...capability, allowed: ref.allowReject !== false }),
|
||
{
|
||
action: "return" as const,
|
||
allowed: returned.nodes.length > 0,
|
||
...(returned.nodes.length === 0 ? { unavailableReason: "no_revert_target", message: "当前节点没有可退回目标" } : {}),
|
||
remarkRequired: returned.requirements.commentRequired,
|
||
signatureRequired: returned.requirements.signatureRequired,
|
||
targets: returned.nodes.map((node) => ({
|
||
targetId: this.signer.sign({
|
||
kind: "target",
|
||
userId: user.userId,
|
||
provider: "xft",
|
||
billId: ref.billId!,
|
||
nodeId: node.nodeId,
|
||
exp: expiresInMinutes(30),
|
||
}),
|
||
label: node.nodeName ?? node.nodeId,
|
||
approverNames: node.approverNames,
|
||
})),
|
||
},
|
||
],
|
||
};
|
||
}
|
||
|
||
async processBatch(
|
||
user: AuthenticatedDingTalkUser,
|
||
action: UnifiedAction,
|
||
remark: string,
|
||
requestedItems: Array<{ approvalId: string; targetId?: string }>,
|
||
selectionId?: string,
|
||
) {
|
||
if (selectionId && requestedItems.length > 0) throw new ApprovalSafetyError("selectionId 与 items 只能选择一种目标方式");
|
||
if (selectionId && action === "return") throw new ApprovalSafetyError("整组 selectionId 不支持退回;退回必须逐张取得并提供 targetId");
|
||
let items = requestedItems;
|
||
if (selectionId) {
|
||
const selection = this.decodeApprovalSelection(user, selectionId);
|
||
const live = await this.listTodos(user, selection.source, selection.limit, selection.query, false);
|
||
if (
|
||
live.returned !== selection.count
|
||
|| !requestedSourcesHealthy(selection.source, live.sourceStatus)
|
||
|| this.approvalSelectionDigest(user, live.items) !== selection.digest
|
||
) {
|
||
throw new ApprovalSafetyError("待办列表在确认后发生变化;为避免处理错单,请重新列出并让用户重新确认");
|
||
}
|
||
items = live.items.map((item) => ({ approvalId: String(item.approvalId) }));
|
||
}
|
||
if (items.length === 0) throw new ApprovalSafetyError("审批列表不能为空");
|
||
if (items.length > APPROVAL_BATCH_LIMIT) throw new ApprovalSafetyError(`一次最多处理 ${APPROVAL_BATCH_LIMIT} 张审批单`);
|
||
const preflight = this.preflightBatchReferences(user, action, items);
|
||
if (preflight.invalidItems.length > 0) {
|
||
return {
|
||
action,
|
||
status: "preflight_failed",
|
||
writesStarted: false,
|
||
total: items.length,
|
||
succeeded: 0,
|
||
uncertain: 0,
|
||
failed: 0,
|
||
notAttempted: items.length,
|
||
results: [],
|
||
invalidItems: preflight.invalidItems,
|
||
requiresRelist: true,
|
||
retryable: false,
|
||
diagnostics: { transactional: false, realtimeValidation: false, persistedBusinessData: false },
|
||
};
|
||
}
|
||
const results: Array<Record<string, unknown>> = [];
|
||
let officialClientTodos: Promise<TodoItem[]> | undefined;
|
||
const batchClientUrl = async (ref: ApprovalReference): Promise<string | undefined> => {
|
||
if (!ref.processInstanceId || !ref.taskId) return undefined;
|
||
officialClientTodos ??= this.dingTalk.listMyTodos(user.userId, "official_oa", 200)
|
||
.then((listed) => listed.items)
|
||
.catch(() => []);
|
||
return (await officialClientTodos).find((todo) => todo.source === "official_oa"
|
||
&& todo.processInstanceId === ref.processInstanceId
|
||
&& todo.taskId === ref.taskId)?.clientUrl;
|
||
};
|
||
const xftOutcomes = new Map<string, Record<string, unknown>>();
|
||
const xftItems = preflight.items.filter((item) => item.ref.provider === "xft");
|
||
if (xftItems.length > 0) {
|
||
if (!this.xft) {
|
||
for (const item of xftItems) xftOutcomes.set(item.ref.billId ?? "", {
|
||
source: "xft", status: "failed", success: false, failureConfirmed: true,
|
||
retryable: false, message: "薪福通审批源未启用",
|
||
});
|
||
} else {
|
||
try {
|
||
const processed = await this.xft.processApprovalBatch(
|
||
user,
|
||
action === "approve" ? "agree" : action,
|
||
remark,
|
||
xftItems.map((item) => ({
|
||
billId: item.ref.billId!,
|
||
...(item.target?.provider === "xft" ? { targetNodeId: item.target.nodeId } : {}),
|
||
})),
|
||
);
|
||
for (const result of processed.results) {
|
||
xftOutcomes.set(result.billId, result.status === "succeeded" ? {
|
||
source: "xft", status: "processed", success: true,
|
||
} : result.status === "outcome_uncertain" ? {
|
||
source: "xft", status: "outcome_uncertain", outcomeUncertain: true,
|
||
success: false, message: result.error,
|
||
} : {
|
||
source: "xft", status: "failed", success: false, failureConfirmed: true,
|
||
retryable: false, message: result.error,
|
||
});
|
||
}
|
||
} catch (error) {
|
||
if (error instanceof XftSessionExpiredError && this.xftChallenges) {
|
||
const authChallenge = await this.xftChallenges.getOrStart(user);
|
||
for (const item of xftItems) xftOutcomes.set(item.ref.billId ?? "", {
|
||
source: "xft", status: "auth_required", success: false, authChallenge,
|
||
});
|
||
} else {
|
||
for (const item of xftItems) xftOutcomes.set(item.ref.billId ?? "", {
|
||
source: "xft", status: "failed", success: false, failureConfirmed: true,
|
||
retryable: false, message: errorMessage(error),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
for (const item of preflight.items) {
|
||
const ref = item.ref;
|
||
try {
|
||
if (ref.provider === "official_oa" && ref.clientOnly && action !== "return") {
|
||
const clientUrl = await batchClientUrl(ref);
|
||
results.push({
|
||
approvalId: item.approvalId,
|
||
source: "dingtalk",
|
||
status: "manual_action_required",
|
||
success: false,
|
||
message: "该审批模板要求在钉钉客户端完成手写签名,不能通过开放接口处理",
|
||
manualAction: {
|
||
required: true,
|
||
reason: "signature_required",
|
||
...(clientUrl ? { url: clientUrl } : {}),
|
||
},
|
||
});
|
||
continue;
|
||
}
|
||
if (ref.provider === "xft") {
|
||
if (!ref.billId) throw new ApprovalSafetyError("薪福通审批引用缺少单据编号");
|
||
results.push({ approvalId: item.approvalId, ...(xftOutcomes.get(ref.billId) ?? {
|
||
source: "xft", status: "failed", success: false, failureConfirmed: true,
|
||
retryable: false, message: "薪福通未返回该单据的处理结果",
|
||
}) });
|
||
continue;
|
||
}
|
||
if (action === "return") {
|
||
if (ref.provider !== "official_oa") throw new ApprovalSafetyError("宜搭审批当前不支持退回");
|
||
if (item.target?.provider !== "official_oa") throw new ApprovalSafetyError("钉钉 OA 退回目标无效");
|
||
const returned = await this.processOfficialOaReturn(user, ref, item.target, remark);
|
||
if (returned.status === "outcome_uncertain") {
|
||
results.push({
|
||
approvalId: item.approvalId,
|
||
source: "dingtalk",
|
||
status: "outcome_uncertain",
|
||
outcomeUncertain: true,
|
||
success: false,
|
||
message: returned.message,
|
||
});
|
||
continue;
|
||
}
|
||
results.push({ approvalId: item.approvalId, source: "dingtalk", status: "processed", success: true });
|
||
continue;
|
||
}
|
||
const processItem = dingTalkProcessItem(ref);
|
||
const processed = await this.dingTalk.processBatch(
|
||
user.userId,
|
||
action === "approve" ? "agree" : "refuse",
|
||
remark,
|
||
[processItem],
|
||
);
|
||
const result = processed.results[0];
|
||
if (result?.outcomeUncertain === true || result?.status === "outcome_uncertain") {
|
||
results.push({
|
||
approvalId: item.approvalId,
|
||
source: "dingtalk",
|
||
status: "outcome_uncertain",
|
||
outcomeUncertain: true,
|
||
success: false,
|
||
message: String(result.message ?? "审批操作已经提交,但最终状态暂时无法确认;禁止自动重试,请稍后重新列出待办。"),
|
||
});
|
||
continue;
|
||
}
|
||
if (!result?.success) {
|
||
const failureMessage = String(result?.error ?? "钉钉审批处理失败");
|
||
if (ref.provider === "official_oa" && /签字|签名|signature|handwrite/i.test(failureMessage)) {
|
||
const clientUrl = await batchClientUrl(ref);
|
||
results.push({
|
||
approvalId: item.approvalId, source: "dingtalk", status: "manual_action_required", success: false,
|
||
message: "该审批在提交时被确认需要钉钉客户端签字,请在客户端完成",
|
||
manualAction: { required: true, reason: "signature_required", ...(clientUrl ? { url: clientUrl } : {}) },
|
||
});
|
||
continue;
|
||
}
|
||
results.push({
|
||
approvalId: item.approvalId,
|
||
source: "dingtalk",
|
||
status: "failed",
|
||
success: false,
|
||
failureConfirmed: result?.failureConfirmed !== false,
|
||
retryable: false,
|
||
message: failureMessage,
|
||
...(result?.supportReference ? { supportReference: result.supportReference } : {}),
|
||
});
|
||
continue;
|
||
}
|
||
if (result.followUpRequired === true) {
|
||
results.push({
|
||
approvalId: item.approvalId,
|
||
source: "dingtalk",
|
||
status: "follow_up_required",
|
||
success: true,
|
||
completed: false,
|
||
message: String(result.message ?? "当前节点已处理,但同一审批实例仍有新的待审批节点"),
|
||
});
|
||
} else {
|
||
results.push({ approvalId: item.approvalId, source: "dingtalk", status: "processed", success: true, completed: true });
|
||
}
|
||
} catch (error) {
|
||
const message = errorMessage(error);
|
||
if (ref.provider === "official_oa" && /签字|签名|signature|handwrite/i.test(message)) {
|
||
const clientUrl = await batchClientUrl(ref);
|
||
results.push({
|
||
approvalId: item.approvalId, source: "dingtalk", status: "manual_action_required", success: false,
|
||
message: "该审批在提交时被确认需要钉钉客户端签字,请在客户端完成",
|
||
manualAction: { required: true, reason: "signature_required", ...(clientUrl ? { url: clientUrl } : {}) },
|
||
});
|
||
} else {
|
||
results.push({ approvalId: item.approvalId, status: "failed", success: false, message });
|
||
}
|
||
}
|
||
}
|
||
const visibleResults: Array<Record<string, any>> = results.map((result, index) => {
|
||
const ref = preflight.items[index]?.ref;
|
||
return {
|
||
index: index + 1,
|
||
...(ref?.provider === "xft" && ref.billId ? { businessNo: ref.billId } : {}),
|
||
...(ref?.provider !== "xft" && ref?.processInstanceId ? { businessNo: ref.processInstanceId } : {}),
|
||
...(ref?.title ? { title: ref.title } : {}),
|
||
...(ref?.applicantName ? { applicant: ref.applicantName } : {}),
|
||
...(ref?.amount !== undefined ? { amount: { value: ref.amount, currency: ref.currency ?? "CNY" } } : {}),
|
||
...result,
|
||
};
|
||
});
|
||
return {
|
||
action,
|
||
total: visibleResults.length,
|
||
succeeded: visibleResults.filter((item) => item.success).length,
|
||
uncertain: visibleResults.filter((item) => item.outcomeUncertain === true).length,
|
||
failed: visibleResults.filter((item) => !item.success && item.outcomeUncertain !== true).length,
|
||
completed: visibleResults.filter((item) => item.success && item.completed !== false).length,
|
||
followUpRequired: visibleResults.filter((item) => item.status === "follow_up_required").length,
|
||
results: visibleResults,
|
||
diagnostics: { transactional: false, realtimeValidation: true, persistedBusinessData: false },
|
||
};
|
||
}
|
||
|
||
private preflightBatchReferences(
|
||
user: AuthenticatedDingTalkUser,
|
||
action: UnifiedAction,
|
||
items: Array<{ approvalId: string; targetId?: string }>,
|
||
): { items: PreflightApprovalItem[]; invalidItems: Array<Record<string, unknown>> } {
|
||
const decoded: PreflightApprovalItem[] = [];
|
||
const invalidItems: Array<Record<string, unknown>> = [];
|
||
const seen = new Set<string>();
|
||
for (const [index, item] of items.entries()) {
|
||
try {
|
||
const ref = this.decodeApproval(user, item.approvalId);
|
||
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", "该薪福通待办当前不允许同意");
|
||
}
|
||
if (ref.provider === "xft" && action === "reject" && ref.allowReject === false) {
|
||
throw new ApprovalReferenceValidationError("action_not_allowed", "该薪福通待办当前不允许拒绝");
|
||
}
|
||
const identity = stableApprovalIdentity(ref);
|
||
if (seen.has(identity)) {
|
||
throw new ApprovalReferenceValidationError("duplicate_reference", "同一审批任务在批次中重复出现");
|
||
}
|
||
let target: TargetReference | undefined;
|
||
if (action === "return") target = this.decodeReturnTarget(user, ref, item.targetId);
|
||
else if (item.targetId) {
|
||
throw new ApprovalReferenceValidationError("unexpected_target", "同意或拒绝不应提供 targetId");
|
||
}
|
||
seen.add(identity);
|
||
decoded.push({ ...item, ref, ...(target ? { target } : {}) });
|
||
} catch (error) {
|
||
invalidItems.push({
|
||
index: index + 1,
|
||
code: error instanceof ApprovalReferenceValidationError ? error.code : "preflight_invalid",
|
||
message: errorMessage(error),
|
||
});
|
||
}
|
||
}
|
||
return { items: invalidItems.length === 0 ? decoded : [], invalidItems };
|
||
}
|
||
|
||
private decodeReturnTarget(
|
||
user: AuthenticatedDingTalkUser,
|
||
ref: ApprovalReference,
|
||
targetId: string | undefined,
|
||
): TargetReference {
|
||
if (ref.provider === "yida") {
|
||
throw new ApprovalReferenceValidationError("provider_not_supported", "宜搭审批当前不支持退回");
|
||
}
|
||
if (!targetId) throw new ApprovalReferenceValidationError("target_required", "退回必须提供 targetId");
|
||
let target: TargetReference;
|
||
try { target = this.signer.verify<TargetReference>(targetId); }
|
||
catch (error) {
|
||
if (error instanceof ReferenceFormatError) {
|
||
throw new ApprovalReferenceValidationError("target_truncated", "targetId 格式不完整,可能被宿主截断;请重新获取可执行动作");
|
||
}
|
||
throw new ApprovalReferenceValidationError("target_invalid", "targetId 已损坏或来自不同密钥的服务;请重新获取可执行动作");
|
||
}
|
||
if (target.kind !== "target") throw new ApprovalReferenceValidationError("target_type_invalid", "targetId 类型无效");
|
||
if (target.exp < Date.now()) throw new ApprovalReferenceValidationError("target_expired", "targetId 已过期;请重新获取可执行动作");
|
||
if (target.userId !== user.userId) throw new ApprovalReferenceValidationError("target_wrong_user", "targetId 不属于当前用户");
|
||
if (ref.provider === "xft") {
|
||
if (target.provider !== "xft" || !ref.billId || target.billId !== ref.billId) {
|
||
throw new ApprovalReferenceValidationError("target_task_mismatch", "targetId 不属于当前薪福通审批任务");
|
||
}
|
||
return target;
|
||
}
|
||
if (
|
||
target.provider !== "official_oa" || !ref.processInstanceId || !ref.taskId
|
||
|| target.processInstanceId !== ref.processInstanceId || target.taskId !== ref.taskId
|
||
) {
|
||
throw new ApprovalReferenceValidationError("target_task_mismatch", "targetId 不属于当前钉钉审批任务");
|
||
}
|
||
return target;
|
||
}
|
||
|
||
private approvalSelectionDigest(user: AuthenticatedDingTalkUser, items: Array<Record<string, unknown>>): string {
|
||
const identities = items.map((item) => {
|
||
if (typeof item.approvalId !== "string") throw new ApprovalSafetyError("待办缺少安全引用,不能生成批量选择");
|
||
const ref = this.decodeApproval(user, item.approvalId);
|
||
return stableApprovalIdentity(ref);
|
||
}).sort();
|
||
return hashJson(identities);
|
||
}
|
||
|
||
private decodeApprovalSelection(user: AuthenticatedDingTalkUser, selectionId: string): ApprovalSelectionReference {
|
||
let ref: ApprovalSelectionReference;
|
||
try { ref = this.signer.verify<ApprovalSelectionReference>(selectionId); }
|
||
catch (error) {
|
||
if (error instanceof ReferenceFormatError) throw new ApprovalSafetyError("selectionId 格式不完整,可能被宿主截断;请重新列出待办");
|
||
throw new ApprovalSafetyError("selectionId 已损坏或来自先前的服务会话;请重新列出待办");
|
||
}
|
||
if (ref.kind !== "approval_selection") throw new ApprovalSafetyError("selectionId 类型无效,请重新列出待办");
|
||
if (ref.exp < Date.now()) throw new ApprovalSafetyError("selectionId 已过期,请重新列出待办");
|
||
if (ref.userId !== user.userId) throw new ApprovalSafetyError("selectionId 不属于当前登录用户");
|
||
return ref;
|
||
}
|
||
|
||
private normalizeDingTalkTodo(user: AuthenticatedDingTalkUser, item: TodoItem) {
|
||
const clientOnly = item.source === "official_oa"
|
||
&& item.processCode !== undefined
|
||
&& this.config.officialClientOnlyProcessCodes.includes(item.processCode);
|
||
const ref: ApprovalReference = {
|
||
kind: "approval",
|
||
userId: user.userId,
|
||
provider: item.source,
|
||
processInstanceId: item.processInstanceId,
|
||
taskId: item.taskId,
|
||
...(item.processCode ? { processCode: item.processCode } : {}),
|
||
...(clientOnly ? { clientOnly: true } : {}),
|
||
...(item.clientUrl ? { clientUrl: item.clientUrl } : {}),
|
||
...(item.appType ? { appType: item.appType } : {}),
|
||
...(item.title ? { title: item.title } : {}),
|
||
...(item.applicantName ? { applicantName: item.applicantName } : {}),
|
||
...(item.applicantDepartment ? { applicantDepartment: item.applicantDepartment } : {}),
|
||
...(item.submittedAt ? { submittedAt: item.submittedAt } : {}),
|
||
...(item.amount !== undefined ? { amount: item.amount } : {}),
|
||
...(item.currency ? { currency: item.currency } : {}),
|
||
exp: expiresInMinutes(30),
|
||
};
|
||
return {
|
||
approvalId: this.signPortableApprovalReference(ref),
|
||
source: "dingtalk",
|
||
businessNo: item.processInstanceId,
|
||
title: item.title ?? item.processName ?? "钉钉审批",
|
||
applicant: {
|
||
...(item.applicantName ? { name: item.applicantName } : {}),
|
||
...(item.applicantDepartment ? { department: item.applicantDepartment } : {}),
|
||
},
|
||
...(item.submittedAt ? { submittedAt: localTime(item.submittedAt) } : {}),
|
||
...(item.amount !== undefined ? { amount: { value: item.amount, currency: item.currency ?? "CNY" } } : {}),
|
||
...(item.summary ? { summary: item.summary } : {}),
|
||
availableActions: [
|
||
...(clientOnly ? [] : ["approve", "reject"]),
|
||
],
|
||
...(clientOnly ? {
|
||
manualAction: {
|
||
required: true,
|
||
reason: "signature_required",
|
||
message: "该审批模板要求在钉钉客户端完成手写签名",
|
||
...(item.clientUrl ? { url: item.clientUrl } : {}),
|
||
},
|
||
} : {}),
|
||
};
|
||
}
|
||
|
||
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: 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",
|
||
...(billApiSupported ? { businessNo: item.billId } : {}),
|
||
title: item.title ?? "薪福通审批",
|
||
applicant: {
|
||
...(item.applicantName ? { name: item.applicantName } : {}),
|
||
...(item.applicantDepartment ? { department: item.applicantDepartment } : {}),
|
||
},
|
||
...(item.applyTime ? { submittedAt: localTime(item.applyTime) } : {}),
|
||
...(item.amount !== undefined ? { amount: { value: item.amount, currency: item.currency ?? "CNY" } } : {}),
|
||
...(item.reason ? { summary: item.reason } : {}),
|
||
availableActions: billApiSupported ? [
|
||
...(item.allowPass === false ? [] : ["approve"]),
|
||
...(item.allowReject === false ? [] : ["reject"]),
|
||
"return",
|
||
] : [],
|
||
...(billApiSupported ? {} : {
|
||
manualAction: {
|
||
required: true,
|
||
reason: "unsupported_xft_task_type",
|
||
message: clientOnlyMessage,
|
||
},
|
||
}),
|
||
};
|
||
}
|
||
|
||
private normalizeDingTalkHistory(user: AuthenticatedDingTalkUser, item: ApprovalHistoryItem) {
|
||
const ref: ApprovalReference = {
|
||
kind: "approval",
|
||
userId: user.userId,
|
||
provider: item.source,
|
||
historyScope: item.historyScope,
|
||
processInstanceId: item.processInstanceId,
|
||
...(item.taskId ? { taskId: item.taskId } : {}),
|
||
...(item.appType ? { appType: item.appType } : {}),
|
||
...(item.title ? { title: item.title } : {}),
|
||
...(item.applicantName ? { applicantName: item.applicantName } : {}),
|
||
...(item.applicantDepartment ? { applicantDepartment: item.applicantDepartment } : {}),
|
||
...(item.submittedAt ? { submittedAt: item.submittedAt } : {}),
|
||
...(item.amount !== undefined ? { amount: item.amount } : {}),
|
||
...(item.currency ? { currency: item.currency } : {}),
|
||
exp: expiresInMinutes(30),
|
||
};
|
||
return historyListItem(this.signPortableApprovalReference(ref), "dingtalk", item.historyScope, {
|
||
businessNo: item.processInstanceId,
|
||
title: item.title ?? item.processName ?? "钉钉审批",
|
||
applicantName: item.applicantName,
|
||
applicantDepartment: item.applicantDepartment,
|
||
submittedAt: item.submittedAt,
|
||
completedAt: item.completedAt,
|
||
amount: item.amount,
|
||
currency: item.currency,
|
||
summary: item.summary,
|
||
status: item.status,
|
||
result: item.result,
|
||
});
|
||
}
|
||
|
||
private normalizeXftHistory(user: AuthenticatedDingTalkUser, item: XftHistoryItem) {
|
||
const ref: ApprovalReference = {
|
||
kind: "approval",
|
||
userId: user.userId,
|
||
provider: "xft",
|
||
historyScope: item.historyScope,
|
||
billId: item.billId,
|
||
exp: expiresInMinutes(30),
|
||
};
|
||
return historyListItem(this.signPortableApprovalReference(ref), "xft", item.historyScope, {
|
||
businessNo: item.billId,
|
||
title: item.title ?? "薪福通审批",
|
||
applicantName: item.applicantName,
|
||
applicantDepartment: item.applicantDepartment,
|
||
submittedAt: item.applyTime,
|
||
completedAt: item.completedAt,
|
||
amount: item.amount,
|
||
currency: item.currency,
|
||
summary: item.reason,
|
||
status: item.status,
|
||
statusName: item.statusName,
|
||
});
|
||
}
|
||
|
||
private decodeApproval(user: AuthenticatedDingTalkUser, approvalId: string): ApprovalReference {
|
||
let ref: ApprovalReference;
|
||
try {
|
||
ref = approvalId.startsWith("v4.")
|
||
? this.approvalSigner.verify(approvalId, user.userId)
|
||
: this.signer.verify<ApprovalReference>(approvalId);
|
||
}
|
||
catch (error) {
|
||
if (error instanceof ReferenceFormatError) {
|
||
throw new ApprovalReferenceValidationError("reference_truncated", "approvalId 格式不完整,可能被宿主截断;请重新列出待办并使用完整引用");
|
||
}
|
||
throw new ApprovalReferenceValidationError("reference_invalid", "approvalId 已损坏、不属于当前用户或来自不同密钥的服务;请重新列出待办");
|
||
}
|
||
if (ref.kind !== "approval") throw new ApprovalReferenceValidationError("reference_type_invalid", "approvalId 类型无效,请重新列出待办");
|
||
if (ref.exp < Date.now()) throw new ApprovalReferenceValidationError("reference_expired", "approvalId 已过期,请重新列出待办");
|
||
if (ref.userId !== user.userId) throw new ApprovalReferenceValidationError("reference_wrong_user", "approvalId 不属于当前用户(当前登录身份)");
|
||
return ref;
|
||
}
|
||
|
||
private signPortableApprovalReference(ref: ApprovalReference): string {
|
||
const signed = this.approvalSigner.sign(ref);
|
||
if (signed.length > 512) throw new ApprovalSafetyError("上游审批标识过长,无法生成可安全传输的审批引用");
|
||
return signed;
|
||
}
|
||
|
||
private decodeSubmissionTemplate(user: AuthenticatedDingTalkUser, templateId: string): SubmissionTemplateReference {
|
||
const ref = this.signer.verify<SubmissionTemplateReference>(templateId);
|
||
if (ref.kind !== "submission_template" || ref.userId !== user.userId || ref.exp < Date.now()) {
|
||
throw new ApprovalSafetyError("templateId 无效、已过期或不属于当前用户,请重新列出可发起模板");
|
||
}
|
||
return ref;
|
||
}
|
||
|
||
private cleanupSubmissionNonces(): void {
|
||
for (const [nonce, expiresAt] of this.consumedSubmissionNonces) {
|
||
if (expiresAt < Date.now()) this.consumedSubmissionNonces.delete(nonce);
|
||
}
|
||
}
|
||
|
||
private async getOfficialOaActions(
|
||
user: AuthenticatedDingTalkUser,
|
||
approvalId: string,
|
||
ref: ApprovalReference,
|
||
) {
|
||
if (ref.clientOnly) {
|
||
const clientUrl = await this.findOfficialClientUrl(user, ref);
|
||
return {
|
||
approvalId,
|
||
actions: [
|
||
{ action: "approve" as const, allowed: false, signatureRequired: true, clientRequired: true },
|
||
{ action: "reject" as const, allowed: false, clientRequired: true },
|
||
{ action: "return" as const, allowed: false, clientRequired: true, unavailableReason: "signature_required", message: "该模板要求在钉钉客户端完成操作" },
|
||
],
|
||
manualAction: {
|
||
required: true,
|
||
reason: "signature_required",
|
||
message: "该审批模板要求在钉钉客户端完成手写签名",
|
||
...(clientUrl ? { url: clientUrl } : {}),
|
||
},
|
||
};
|
||
}
|
||
const unavailable = (message: string) => ({
|
||
approvalId,
|
||
actions: [...unknownSignatureActions(), { action: "return" as const, allowed: false, unavailable: true, unavailableReason: "capability_unavailable", message }],
|
||
signatureRequirement: "unknown",
|
||
preflight: "task_pending_confirmed",
|
||
preflightMessage: "网关已确认任务仍属于当前用户且处于待审批状态;签字等钉钉业务规则只能在执行时最终确认,因此 guaranteed=false。",
|
||
signatureMessage: "钉钉开放接口不提供模板签字要求预检;若模板要求手写签名,执行时会提示改用钉钉客户端。",
|
||
});
|
||
if (!ref.processInstanceId || !ref.taskId) throw new ApprovalSafetyError("审批引用缺少任务信息");
|
||
await this.dingTalk.assertOfficialTaskPending(user.userId, ref.processInstanceId, ref.taskId);
|
||
if (!this.officialOaReturns) return unavailable("钉钉 OA 官方接口当前未提供可靠的退回目标");
|
||
if (!this.officialOaReturns.supportsUser(user.userId)) return unavailable("当前登录用户不支持钉钉 OA 退回");
|
||
try {
|
||
const activities = await this.officialOaReturns.listRevertActivities(user.userId, ref.taskId);
|
||
return {
|
||
approvalId,
|
||
actions: [
|
||
...unknownSignatureActions(),
|
||
{
|
||
action: "return" as const,
|
||
allowed: activities.length > 0,
|
||
...(activities.length === 0 ? { unavailableReason: "no_revert_target", message: "当前节点没有可退回目标" } : {}),
|
||
remarkRequired: false,
|
||
targets: activities.map((activity) => ({
|
||
targetId: this.signer.sign({
|
||
kind: "target",
|
||
userId: user.userId,
|
||
provider: "official_oa",
|
||
processInstanceId: ref.processInstanceId!,
|
||
taskId: ref.taskId!,
|
||
activityId: activity.activityId,
|
||
revertAction: activity.revertAction,
|
||
exp: expiresInMinutes(30),
|
||
}),
|
||
label: activity.activityName ?? (activity.revertAction === "REVERT_FOR_RESUBMIT" ? "退回发起人" : activity.activityId),
|
||
approverNames: activity.approverNames,
|
||
revertMode: activity.revertAction,
|
||
})),
|
||
},
|
||
],
|
||
signatureRequirement: "unknown",
|
||
preflight: "task_pending_confirmed",
|
||
preflightMessage: "网关已确认任务仍属于当前用户且处于待审批状态;签字等钉钉业务规则只能在执行时最终确认,因此 guaranteed=false。",
|
||
signatureMessage: "钉钉开放接口不提供模板签字要求预检;若模板要求手写签名,执行时会提示改用钉钉客户端。",
|
||
};
|
||
} catch (error) {
|
||
return unavailable(errorMessage(error));
|
||
}
|
||
}
|
||
|
||
private async processOfficialOaReturn(
|
||
user: AuthenticatedDingTalkUser,
|
||
ref: ApprovalReference,
|
||
target: OfficialOaTargetReference,
|
||
remark: string,
|
||
): Promise<{ status: "succeeded" } | { status: "outcome_uncertain"; message: string }> {
|
||
if (!ref.processInstanceId || !ref.taskId) throw new ApprovalSafetyError("审批引用缺少任务信息");
|
||
if (!this.officialOaReturns || !this.officialOaReturns.supportsUser(user.userId)) {
|
||
throw new ApprovalSafetyError("钉钉 OA 退回组件未配置或未绑定当前用户");
|
||
}
|
||
await this.dingTalk.assertOfficialTaskPending(user.userId, ref.processInstanceId, ref.taskId);
|
||
const current = await this.officialOaReturns.listRevertActivities(user.userId, ref.taskId);
|
||
if (!current.some((activity) => activity.activityId === target.activityId && activity.revertAction === target.revertAction)) {
|
||
throw new ApprovalSafetyError("指定的退回节点已不在当前可退回节点列表中");
|
||
}
|
||
try {
|
||
await this.officialOaReturns.revertTask(user.userId, {
|
||
processInstanceId: ref.processInstanceId,
|
||
taskId: ref.taskId,
|
||
targetActivityId: target.activityId,
|
||
revertAction: target.revertAction,
|
||
...(remark ? { remark } : {}),
|
||
});
|
||
} catch {
|
||
return {
|
||
status: "outcome_uncertain",
|
||
message: "钉钉 OA 退回请求可能已经到达上游,但响应无法确认;禁止自动重试,请稍后重新查询待办",
|
||
};
|
||
}
|
||
try {
|
||
if (await this.dingTalk.isOfficialTaskPending(user.userId, ref.processInstanceId, ref.taskId)) {
|
||
return {
|
||
status: "outcome_uncertain",
|
||
message: "钉钉 OA 已接收退回提交,但原任务仍在实时待办中;禁止自动重试,请稍后重新查询待办",
|
||
};
|
||
}
|
||
} catch {
|
||
return {
|
||
status: "outcome_uncertain",
|
||
message: "钉钉 OA 已接收退回提交,但实时状态复核失败;禁止自动重试,请稍后重新查询待办",
|
||
};
|
||
}
|
||
return { status: "succeeded" };
|
||
}
|
||
|
||
private async findOfficialClientUrl(
|
||
user: AuthenticatedDingTalkUser,
|
||
ref: ApprovalReference,
|
||
): Promise<string | undefined> {
|
||
if (!ref.processInstanceId || !ref.taskId) return undefined;
|
||
try {
|
||
const live = await this.dingTalk.listMyTodos(user.userId, "official_oa", 200);
|
||
return live.items.find((item) => item.source === "official_oa"
|
||
&& item.processInstanceId === ref.processInstanceId
|
||
&& item.taskId === ref.taskId)?.clientUrl;
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
}
|
||
}
|
||
|
||
const COMPACT_APPROVAL_AAD = Buffer.from("dingmcp:approval:v4", "utf8");
|
||
const MAX_COMPACT_FIELD_BYTES = 8 * 1024;
|
||
|
||
/**
|
||
* Stateless, user-bound approval references. The authenticated user is part of
|
||
* the derived key instead of the ciphertext, and provider identifiers use a
|
||
* compact binary representation rather than encrypted JSON.
|
||
*/
|
||
class CompactApprovalReferenceCodec {
|
||
private readonly masterKey: Buffer;
|
||
|
||
constructor(secret: string) {
|
||
this.masterKey = createHash("sha256").update(secret).digest();
|
||
}
|
||
|
||
sign(ref: ApprovalReference): string {
|
||
const plaintext = encodeCompactApproval(ref);
|
||
const iv = randomBytes(12);
|
||
const cipher = createCipheriv("aes-256-gcm", this.userKey(ref.userId), iv);
|
||
cipher.setAAD(COMPACT_APPROVAL_AAD, { plaintextLength: plaintext.length });
|
||
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||
const protectedBody = Buffer.concat([iv, encrypted, cipher.getAuthTag()]);
|
||
if (protectedBody.length > 0xffff) throw new ApprovalSafetyError("审批引用长度超出安全范围");
|
||
const length = Buffer.allocUnsafe(2);
|
||
length.writeUInt16BE(protectedBody.length, 0);
|
||
const body = Buffer.concat([length, protectedBody]);
|
||
return `v4.${body.toString("base64url")}`;
|
||
}
|
||
|
||
verify(value: string, userId: string): ApprovalReference {
|
||
const parts = value.split(".");
|
||
if (parts.length !== 2 || parts[0] !== "v4" || !parts[1]) throw new ReferenceFormatError();
|
||
let body: Buffer;
|
||
try {
|
||
body = decodeCanonicalBase64Url(parts[1]);
|
||
if (body.length < 2 + 12 + 16 + 6) throw new Error("compact reference too short");
|
||
if (body.readUInt16BE(0) !== body.length - 2) throw new ReferenceFormatError();
|
||
} catch {
|
||
throw new ReferenceFormatError();
|
||
}
|
||
const iv = body.subarray(2, 14);
|
||
const tag = body.subarray(body.length - 16);
|
||
const encrypted = body.subarray(14, body.length - 16);
|
||
try {
|
||
const decipher = createDecipheriv("aes-256-gcm", this.userKey(userId), iv);
|
||
decipher.setAAD(COMPACT_APPROVAL_AAD, { plaintextLength: encrypted.length });
|
||
decipher.setAuthTag(tag);
|
||
const plaintext = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||
return decodeCompactApproval(plaintext, userId);
|
||
} catch {
|
||
throw new ReferenceIntegrityError();
|
||
}
|
||
}
|
||
|
||
private userKey(userId: string): Buffer {
|
||
return createHmac("sha256", this.masterKey)
|
||
.update("dingmcp:approval-user-key:v4\0", "utf8")
|
||
.update(userId, "utf8")
|
||
.digest();
|
||
}
|
||
}
|
||
|
||
function encodeCompactApproval(ref: ApprovalReference): Buffer {
|
||
const provider = ref.provider === "official_oa" ? 0 : ref.provider === "yida" ? 1 : 2;
|
||
let flags = provider;
|
||
if (ref.clientOnly) flags |= 1 << 2;
|
||
if (ref.allowApprove === false) flags |= 1 << 3;
|
||
if (ref.allowReject === false) flags |= 1 << 4;
|
||
if (ref.historyScope === "handled") flags |= 1 << 5;
|
||
if (ref.historyScope === "submitted") flags |= 1 << 6;
|
||
const expiresAtSeconds = Math.floor(ref.exp / 1000);
|
||
if (!Number.isSafeInteger(expiresAtSeconds) || expiresAtSeconds <= 0 || expiresAtSeconds > 0xffff_ffff) {
|
||
throw new ApprovalSafetyError("审批引用有效期超出可编码范围");
|
||
}
|
||
const header = Buffer.allocUnsafe(5);
|
||
header[0] = flags;
|
||
header.writeUInt32BE(expiresAtSeconds, 1);
|
||
if (ref.provider === "xft") {
|
||
if (!ref.billId) throw new ApprovalSafetyError("薪福通审批引用缺少单据标识");
|
||
return Buffer.concat([header, encodeCompactIdentifier(ref.billId)]);
|
||
}
|
||
if (!ref.processInstanceId || (ref.historyScope !== "submitted" && !ref.taskId)) {
|
||
throw new ApprovalSafetyError("钉钉审批引用缺少实例或任务标识");
|
||
}
|
||
const fields = [header, encodeCompactIdentifier(ref.processInstanceId)];
|
||
if (ref.historyScope !== "submitted") fields.push(encodeCompactIdentifier(ref.taskId!));
|
||
if (ref.provider === "yida") {
|
||
if (!ref.appType) throw new ApprovalSafetyError("宜搭审批引用缺少应用标识");
|
||
fields.push(encodeCompactIdentifier(ref.appType));
|
||
}
|
||
return Buffer.concat(fields);
|
||
}
|
||
|
||
function decodeCompactApproval(value: Buffer, userId: string): ApprovalReference {
|
||
if (value.length < 6) throw new Error("compact approval payload too short");
|
||
const flags = value[0]!;
|
||
if ((flags & 0b1000_0000) !== 0 || (flags & (1 << 5)) !== 0 && (flags & (1 << 6)) !== 0) {
|
||
throw new Error("unknown compact approval flags");
|
||
}
|
||
const providerCode = flags & 0b11;
|
||
const provider = providerCode === 0 ? "official_oa" : providerCode === 1 ? "yida" : providerCode === 2 ? "xft" : undefined;
|
||
if (!provider) throw new Error("unknown compact approval provider");
|
||
const ref: ApprovalReference = {
|
||
kind: "approval",
|
||
userId,
|
||
provider,
|
||
exp: value.readUInt32BE(1) * 1000,
|
||
...(flags & (1 << 5) ? { historyScope: "handled" as const } : {}),
|
||
...(flags & (1 << 6) ? { historyScope: "submitted" as const } : {}),
|
||
...(flags & (1 << 2) ? { clientOnly: true } : {}),
|
||
...(flags & (1 << 3) ? { allowApprove: false } : {}),
|
||
...(flags & (1 << 4) ? { allowReject: false } : {}),
|
||
};
|
||
let offset = 5;
|
||
if (provider === "xft") {
|
||
const decoded = decodeCompactIdentifier(value, offset);
|
||
ref.billId = decoded.value;
|
||
offset = decoded.offset;
|
||
} else {
|
||
const instance = decodeCompactIdentifier(value, offset);
|
||
ref.processInstanceId = instance.value;
|
||
offset = instance.offset;
|
||
if (ref.historyScope !== "submitted") {
|
||
const task = decodeCompactIdentifier(value, offset);
|
||
ref.taskId = task.value;
|
||
offset = task.offset;
|
||
}
|
||
if (provider === "yida") {
|
||
const app = decodeCompactIdentifier(value, offset);
|
||
ref.appType = app.value;
|
||
offset = app.offset;
|
||
}
|
||
}
|
||
if (offset !== value.length) throw new Error("trailing compact approval bytes");
|
||
return ref;
|
||
}
|
||
|
||
function encodeCompactIdentifier(value: string): Buffer {
|
||
const bytes = Buffer.from(value, "utf8");
|
||
if (!value || bytes.length > MAX_COMPACT_FIELD_BYTES) throw new ApprovalSafetyError("上游审批标识长度超出安全范围");
|
||
if (/^(?:0|[1-9]\d*)$/.test(value)) {
|
||
const numeric = BigInt(value);
|
||
if (numeric <= 0xffff_ffff_ffff_ffffn) return Buffer.concat([Buffer.from([0]), encodeVarUint(numeric)]);
|
||
}
|
||
if (/^[A-Za-z0-9_-]+$/.test(value)) {
|
||
const decoded = Buffer.from(value, "base64url");
|
||
if (decoded.toString("base64url") === value && decoded.length < bytes.length) {
|
||
return Buffer.concat([Buffer.from([1]), encodeVarUint(BigInt(decoded.length)), decoded]);
|
||
}
|
||
}
|
||
return Buffer.concat([Buffer.from([2]), encodeVarUint(BigInt(bytes.length)), bytes]);
|
||
}
|
||
|
||
function decodeCompactIdentifier(value: Buffer, start: number): { value: string; offset: number } {
|
||
if (start >= value.length) throw new Error("missing compact identifier");
|
||
const type = value[start]!;
|
||
if (type === 0) {
|
||
const decoded = decodeVarUint(value, start + 1);
|
||
return { value: decoded.value.toString(10), offset: decoded.offset };
|
||
}
|
||
if (type !== 1 && type !== 2) throw new Error("unknown compact identifier encoding");
|
||
const length = decodeVarUint(value, start + 1);
|
||
if (length.value > BigInt(MAX_COMPACT_FIELD_BYTES)) throw new Error("compact identifier too large");
|
||
const size = Number(length.value);
|
||
const end = length.offset + size;
|
||
if (end > value.length) throw new Error("truncated compact identifier");
|
||
const bytes = value.subarray(length.offset, end);
|
||
const decoded = type === 1 ? bytes.toString("base64url") : bytes.toString("utf8");
|
||
if (!decoded || (type === 2 && Buffer.from(decoded, "utf8").compare(bytes) !== 0)) throw new Error("invalid compact identifier");
|
||
return { value: decoded, offset: end };
|
||
}
|
||
|
||
function encodeVarUint(input: bigint): Buffer {
|
||
if (input < 0n) throw new Error("negative varuint");
|
||
const bytes: number[] = [];
|
||
let value = input;
|
||
do {
|
||
let byte = Number(value & 0x7fn);
|
||
value >>= 7n;
|
||
if (value > 0n) byte |= 0x80;
|
||
bytes.push(byte);
|
||
} while (value > 0n);
|
||
return Buffer.from(bytes);
|
||
}
|
||
|
||
function decodeVarUint(value: Buffer, start: number): { value: bigint; offset: number } {
|
||
let decoded = 0n;
|
||
let shift = 0n;
|
||
for (let offset = start; offset < value.length && offset < start + 10; offset += 1) {
|
||
const byte = value[offset]!;
|
||
decoded |= BigInt(byte & 0x7f) << shift;
|
||
if ((byte & 0x80) === 0) return { value: decoded, offset: offset + 1 };
|
||
shift += 7n;
|
||
}
|
||
throw new Error("invalid compact varuint");
|
||
}
|
||
|
||
class ReferenceFormatError extends Error {
|
||
constructor() { super("引用格式不完整,可能在传输中被截断,请重新列出并使用完整引用"); }
|
||
}
|
||
class ReferenceIntegrityError extends Error {
|
||
constructor() { super("引用无效或已损坏,请重新列出并使用最新引用"); }
|
||
}
|
||
|
||
class ApprovalReferenceValidationError extends ApprovalSafetyError {
|
||
constructor(readonly code: string, message: string) {
|
||
super(message);
|
||
this.name = "ApprovalReferenceValidationError";
|
||
}
|
||
}
|
||
|
||
function decodeCanonicalBase64Url(value: string): Buffer {
|
||
if (!/^[A-Za-z0-9_-]+$/.test(value)) throw new Error("invalid base64url");
|
||
const decoded = Buffer.from(value, "base64url");
|
||
if (decoded.toString("base64url") !== value) throw new Error("non-canonical base64url");
|
||
return decoded;
|
||
}
|
||
|
||
function dingTalkProcessItem(ref: ApprovalReference): ProcessApprovalItem {
|
||
if (ref.historyScope) throw new ApprovalSafetyError("历史审批引用不能执行审批动作");
|
||
if (!ref.processInstanceId || !ref.taskId || ref.provider === "xft") throw new ApprovalSafetyError("审批引用缺少任务信息");
|
||
return {
|
||
source: ref.provider,
|
||
processInstanceId: ref.processInstanceId,
|
||
taskId: ref.taskId,
|
||
...(ref.appType ? { appType: ref.appType } : {}),
|
||
};
|
||
}
|
||
|
||
function expiresInMinutes(minutes: number): number { return Date.now() + minutes * 60_000; }
|
||
|
||
const APPROVE_REJECT_ACTIONS: UnifiedActionCapability[] = [
|
||
{ action: "approve", allowed: true, submissionSupported: true, guaranteed: false, remarkRequired: false },
|
||
{ action: "reject", allowed: true, submissionSupported: true, guaranteed: false, remarkRequired: false },
|
||
];
|
||
|
||
function unknownSignatureActions() {
|
||
return APPROVE_REJECT_ACTIONS.map((action) => ({
|
||
...action,
|
||
preflight: "task_pending_confirmed",
|
||
executionValidation: "deferred",
|
||
signatureRequirement: "unknown",
|
||
message: "已确认任务仍属于当前用户且处于待处理状态,可以提交;签字等钉钉业务规则仍在执行时最终确认。",
|
||
}));
|
||
}
|
||
|
||
function stableApprovalIdentity(ref: ApprovalReference): string {
|
||
return [ref.provider, ref.historyScope ?? "todo", ref.processInstanceId ?? "", ref.taskId ?? "", ref.appType ?? "", ref.billId ?? ""].join("\u0000");
|
||
}
|
||
|
||
function historyListItem(
|
||
approvalId: string,
|
||
source: UnifiedSource,
|
||
scope: ApprovalHistoryScope,
|
||
value: {
|
||
businessNo: string;
|
||
title: string;
|
||
applicantName?: string;
|
||
applicantDepartment?: string;
|
||
submittedAt?: string;
|
||
completedAt?: string;
|
||
amount?: number;
|
||
currency?: string;
|
||
summary?: string;
|
||
status?: string;
|
||
statusName?: string;
|
||
result?: string;
|
||
},
|
||
) {
|
||
const completedTime = value.completedAt ? Date.parse(value.completedAt) : NaN;
|
||
return {
|
||
approvalId,
|
||
source,
|
||
sourceType: source === "xft" ? "smart_expense" : "official_oa",
|
||
historyScope: scope,
|
||
businessNo: value.businessNo,
|
||
title: value.title,
|
||
applicant: {
|
||
...(value.applicantName ? { name: value.applicantName } : {}),
|
||
...(value.applicantDepartment ? { department: value.applicantDepartment } : {}),
|
||
},
|
||
...(value.submittedAt ? { submittedAt: value.submittedAt, ...localTimeFields(value.submittedAt) } : {}),
|
||
...(value.completedAt ? {
|
||
completedAt: value.completedAt,
|
||
...(Number.isFinite(completedTime) ? {
|
||
completedAtLocal: new Date(completedTime + 8 * 3_600_000).toISOString().replace("Z", "+08:00"),
|
||
completedTimeZone: "Asia/Shanghai",
|
||
} : {}),
|
||
} : {}),
|
||
...(value.amount !== undefined ? { amount: { value: value.amount, currency: value.currency ?? "CNY" } } : {}),
|
||
...(value.summary ? { summary: value.summary } : {}),
|
||
...(value.status ? { status: value.status } : {}),
|
||
...(value.statusName ? { statusName: value.statusName } : {}),
|
||
...(value.result ? { result: value.result } : {}),
|
||
availableActions: [],
|
||
readOnly: true,
|
||
};
|
||
}
|
||
|
||
function sortUnifiedHistory<T extends Record<string, unknown>>(items: T[]): T[] {
|
||
return items.sort((left, right) => historyItemTime(right) - historyItemTime(left));
|
||
}
|
||
|
||
function resolveHistoryRange(
|
||
startDate: string,
|
||
endDate: string,
|
||
maxDays: number,
|
||
now = Date.now(),
|
||
): ApprovalHistoryRange {
|
||
const maximumMilliseconds = Math.max(1, maxDays) * 86_400_000;
|
||
const endTime = endDate.trim() ? parseHistoryBoundary(endDate, "end") : now;
|
||
const startTime = startDate.trim() ? parseHistoryBoundary(startDate, "start") : endTime - 30 * 86_400_000;
|
||
if (startTime > endTime) throw new ApprovalSafetyError("审批历史开始时间不能晚于结束时间");
|
||
if (endTime - startTime > maximumMilliseconds) {
|
||
throw new ApprovalSafetyError(`审批历史单次查询跨度不能超过 ${Math.max(1, maxDays)} 天`);
|
||
}
|
||
return { startTime, endTime };
|
||
}
|
||
|
||
function parseHistoryBoundary(value: string, boundary: "start" | "end"): number {
|
||
const trimmed = value.trim();
|
||
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
|
||
const start = Date.parse(`${trimmed}T00:00:00+08:00`);
|
||
const roundTrip = new Date(start + 8 * 3_600_000).toISOString().slice(0, 10);
|
||
if (!Number.isFinite(start) || roundTrip !== trimmed) throw new ApprovalSafetyError(`${boundary === "start" ? "startDate" : "endDate"} 不是有效日期`);
|
||
return boundary === "end" ? start + 86_400_000 - 1 : start;
|
||
}
|
||
if (!/^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/i.test(trimmed)) {
|
||
throw new ApprovalSafetyError(`${boundary === "start" ? "startDate" : "endDate"} 必须是 YYYY-MM-DD 或带时区的 ISO 8601 时间`);
|
||
}
|
||
const parsed = Date.parse(trimmed);
|
||
if (!Number.isFinite(parsed)) throw new ApprovalSafetyError(`${boundary === "start" ? "startDate" : "endDate"} 不是有效时间`);
|
||
return parsed;
|
||
}
|
||
|
||
function historyItemTime(item: Record<string, unknown>): number {
|
||
const raw = typeof item.completedAt === "string" ? item.completedAt
|
||
: typeof item.submittedAt === "string" ? item.submittedAt : "";
|
||
const parsed = Date.parse(raw);
|
||
return Number.isFinite(parsed) ? parsed : 0;
|
||
}
|
||
|
||
function matchesTodoQuery(item: Record<string, unknown>, query: string): boolean {
|
||
const normalized = query.trim().toLowerCase();
|
||
if (!normalized) return true;
|
||
const applicant = item.applicant && typeof item.applicant === "object" && !Array.isArray(item.applicant)
|
||
? item.applicant as Record<string, unknown> : {};
|
||
const amount = item.amount && typeof item.amount === "object" && !Array.isArray(item.amount)
|
||
? item.amount as Record<string, unknown> : {};
|
||
return [
|
||
item.source, item.sourceType, item.businessNo, item.title, item.summary,
|
||
applicant.name, applicant.department, amount.value, amount.currency,
|
||
].some((value) => String(value ?? "").toLowerCase().includes(normalized));
|
||
}
|
||
|
||
function requestedSourcesHealthy(
|
||
source: UnifiedSource | "all",
|
||
status: Record<string, Record<string, unknown>>,
|
||
): boolean {
|
||
return (source === "xft" || source === "all" ? status.xft?.status === "ok" : true)
|
||
&& (source === "dingtalk" || source === "all" ? status.dingtalk?.status === "ok" : true);
|
||
}
|
||
|
||
function localTimeFields(value: string): Record<string, string> {
|
||
const time = Date.parse(value);
|
||
if (!Number.isFinite(time)) return {};
|
||
const local = new Date(time + 8 * 3_600_000).toISOString().replace("Z", "+08:00");
|
||
return { submittedAtLocal: local, submittedTimeZone: "Asia/Shanghai" };
|
||
}
|
||
|
||
function localTime(value: string): string {
|
||
return localTimeFields(value).submittedAtLocal ?? value;
|
||
}
|
||
|
||
function dingTalkSubmissionTemplateAllowed(template: { dirName?: string; name?: string }): boolean {
|
||
if (!template.dirName || !template.name) return false;
|
||
const names = DINGTALK_SUBMISSION_TEMPLATES[template.dirName as keyof typeof DINGTALK_SUBMISSION_TEMPLATES];
|
||
return names?.includes(template.name as never) ?? false;
|
||
}
|
||
|
||
function assertDingTalkSubmissionTemplateAllowed(template: { dirName?: string; name?: string }): void {
|
||
if (!dingTalkSubmissionTemplateAllowed(template)) {
|
||
throw new ApprovalSafetyError("该钉钉审批模板不在允许发起范围;仅支持北极大的出差申请、外出、业务招待申请,以及捷康的差旅费用报销、业务招待费用报销、业务招待申请");
|
||
}
|
||
}
|
||
|
||
function dingTalkSubmissionApiIncompatibility(template: { name?: string }): string | undefined {
|
||
if (template.name === "业务招待申请_北极大") {
|
||
return "该模板含 TableField 明细;真实测试中钉钉企业应用创建接口返回系统异常,旧企业接口也拒绝组件格式。当前只能在钉钉客户端或个人 DWS 通道发起";
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function compactDingTalkSchema(schema: Record<string, unknown>): Array<Record<string, unknown>> {
|
||
let content: unknown = schema.schemaContent ?? schema.content;
|
||
if (typeof content === "string") {
|
||
try { content = JSON.parse(content); } catch { throw new ApprovalSafetyError("钉钉审批模板 Schema 内容无法解析"); }
|
||
}
|
||
const fields: Array<Record<string, unknown>> = [];
|
||
const walk = (value: unknown, parent?: string): void => {
|
||
if (Array.isArray(value)) { for (const item of value) walk(item, parent); return; }
|
||
if (!value || typeof value !== "object") return;
|
||
const record = value as Record<string, unknown>;
|
||
const props = record.props && typeof record.props === "object" && !Array.isArray(record.props)
|
||
? record.props as Record<string, unknown> : {};
|
||
const type = typeof record.componentName === "string" ? record.componentName : undefined;
|
||
const rawLabel = props.label;
|
||
let name = typeof rawLabel === "string" ? rawLabel
|
||
: Array.isArray(rawLabel) && typeof rawLabel[0] === "string" ? rawLabel[0]
|
||
: undefined;
|
||
if (!name && type === "DDBizSuite") {
|
||
name = typeof props.bizType === "string" && props.bizType.includes("businessFinance") ? "费控业务套件" : "钉钉业务套件";
|
||
}
|
||
if (type && name && type !== "TextNote") {
|
||
const unsupported = [
|
||
"DDAttachment", "SignatureField", "InvoiceField", "RecipientAccountField",
|
||
"OcrTextField", "OcrIdCardField", "CalculateField", "SeqNumberField",
|
||
"DDBizSuite", "OpenDataField",
|
||
].includes(type);
|
||
const serverManaged = parent === undefined && (
|
||
(name === "申请人" && type === "InnerContactField") || type === "DepartmentField"
|
||
);
|
||
fields.push({
|
||
name,
|
||
key: typeof props.id === "string" && props.id.trim() ? props.id.trim() : name,
|
||
type, required: props.required === true, supported: !unsupported, serverManaged,
|
||
...(parent ? { parent } : {}),
|
||
...(Array.isArray(props.options) ? { options: props.options.map(sanitizeDingTalkOption).filter((item) => item !== undefined) } : {}),
|
||
constraints: dingTalkFieldConstraints(type, props),
|
||
});
|
||
}
|
||
const children = Array.isArray(record.children) ? record.children : [];
|
||
for (const child of children) walk(child, ["TableField", "DDBizSuite"].includes(type ?? "") ? name : parent);
|
||
if (Array.isArray(record.items)) walk(record.items, parent);
|
||
};
|
||
walk(content);
|
||
return fields;
|
||
}
|
||
|
||
function sanitizeDingTalkOption(value: unknown): unknown {
|
||
if (typeof value === "string") {
|
||
try {
|
||
const parsed = JSON.parse(value) as unknown;
|
||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||
const record = parsed as Record<string, unknown>;
|
||
return typeof record.value === "string" ? record.value : typeof record.label === "string" ? record.label : value;
|
||
}
|
||
} catch { /* ordinary string option */ }
|
||
return value;
|
||
}
|
||
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
||
const record = value as Record<string, unknown>;
|
||
return typeof record.value === "string" ? record.value : typeof record.label === "string" ? record.label : undefined;
|
||
}
|
||
|
||
function dingTalkFieldConstraints(type: string, props: Record<string, unknown>): Record<string, unknown> {
|
||
const formats: Record<string, string> = {
|
||
TextField: "text", TextareaField: "text", NumberField: "numeric_string", MoneyField: "numeric_string",
|
||
DDDateField: "yyyy-MM-dd", DDDateRangeField: "JSON string array [start,end]",
|
||
DDMultiSelectField: "JSON string array", InnerContactField: "DingTalk userId or JSON string array",
|
||
DepartmentField: "department ID or JSON string array", AddressField: "JSON string array",
|
||
TableField: "JSON string array of row objects", DDPhotoField: "JSON string array of HTTPS URLs",
|
||
RelateField: "existing DingTalk processInstanceId",
|
||
};
|
||
return {
|
||
valueFormat: formats[type] ?? "string",
|
||
...(typeof props.multiple === "boolean" ? { multiple: props.multiple } : {}),
|
||
...(typeof props.choice === "string" ? { choice: props.choice } : {}),
|
||
};
|
||
}
|
||
|
||
function validateDingTalkValues(fields: Array<Record<string, unknown>>, values: Record<string, unknown>): void {
|
||
const topLevel = fields.filter((field) => field.parent === undefined);
|
||
const allowed = new Map(topLevel.map((field) => [String(field.name), field]));
|
||
for (const key of Object.keys(values)) {
|
||
if (!allowed.has(key)) throw new ApprovalSafetyError(`字段“${key}”不在当前钉钉审批模板中`);
|
||
}
|
||
for (const field of topLevel) {
|
||
if (field.serverManaged === true && !isBlankSubmissionValue(values[String(field.name)])) {
|
||
throw new ApprovalSafetyError(`字段“${String(field.name)}”由服务器根据当前登录用户填写,Agent 不得传入`);
|
||
}
|
||
if (field.required === true && field.supported === false) {
|
||
throw new ApprovalSafetyError(`必填字段“${String(field.name)}”类型为 ${String(field.type)},必须在钉钉客户端完成`);
|
||
}
|
||
if (field.required === true && field.serverManaged !== true && isBlankSubmissionValue(values[String(field.name)])) {
|
||
throw new ApprovalSafetyError(`缺少必填字段“${String(field.name)}”`);
|
||
}
|
||
if (field.type === "TableField" && !isBlankSubmissionValue(values[String(field.name)])) {
|
||
validateDingTalkTable(field, fields, values[String(field.name)]);
|
||
}
|
||
}
|
||
}
|
||
|
||
function assertDingTalkApiConstructible(
|
||
fields: Array<Record<string, unknown>>,
|
||
template?: { name?: string },
|
||
): void {
|
||
const incompatible = template ? dingTalkSubmissionApiIncompatibility(template) : undefined;
|
||
if (incompatible) throw new ApprovalSafetyError(incompatible);
|
||
const unsupported = fields.filter((field) => field.supported === false && field.type === "DDBizSuite");
|
||
if (unsupported.length) {
|
||
throw new ApprovalSafetyError(`该模板包含钉钉业务套件 ${unsupported.map((field) => `“${String(field.name)}”`).join("、")};官方发起 API 无法可靠构造套件内部字段,请在钉钉客户端发起`);
|
||
}
|
||
const requiredUnsupported = fields.filter((field) => field.required === true && field.supported === false);
|
||
if (requiredUnsupported.length) {
|
||
throw new ApprovalSafetyError(`该模板包含 API 无法构造的必填字段:${requiredUnsupported.map((field) => String(field.name)).join("、")},请在钉钉客户端发起`);
|
||
}
|
||
}
|
||
|
||
function validateDingTalkTable(
|
||
field: Record<string, unknown>,
|
||
fields: Array<Record<string, unknown>>,
|
||
value: unknown,
|
||
): void {
|
||
let rows = value;
|
||
if (typeof rows === "string") {
|
||
try { rows = JSON.parse(rows); } catch { throw new ApprovalSafetyError(`明细字段“${String(field.name)}”必须是 JSON 行数组`); }
|
||
}
|
||
if (!Array.isArray(rows) || rows.length === 0) throw new ApprovalSafetyError(`明细字段“${String(field.name)}”至少需要一行`);
|
||
if (rows.length > 100) throw new ApprovalSafetyError(`明细字段“${String(field.name)}”最多支持 100 行`);
|
||
const children = fields.filter((candidate) => candidate.parent === field.name);
|
||
rows.forEach((row, index) => {
|
||
if (!isSubmissionRecord(row)) throw new ApprovalSafetyError(`明细字段“${String(field.name)}”第 ${index + 1} 行必须是对象`);
|
||
const allowed = new Set(children.map((child) => String(child.name)));
|
||
for (const key of Object.keys(row)) if (!allowed.has(key)) {
|
||
throw new ApprovalSafetyError(`明细字段“${String(field.name)}”第 ${index + 1} 行含未知子字段“${key}”`);
|
||
}
|
||
for (const child of children) {
|
||
if (child.required === true && isBlankSubmissionValue(row[String(child.name)])) {
|
||
throw new ApprovalSafetyError(`明细字段“${String(field.name)}”第 ${index + 1} 行缺少“${String(child.name)}”`);
|
||
}
|
||
if (child.supported === false && !isBlankSubmissionValue(row[String(child.name)])) {
|
||
throw new ApprovalSafetyError(`明细字段“${String(field.name)}”的子字段“${String(child.name)}”不能通过 API 提交`);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function validateXftSubmissionValues(
|
||
kind: XftSubmissionKind,
|
||
values: Record<string, unknown>,
|
||
customFields: XftSubmissionField[],
|
||
attachments: XftResolvedSubmissionAttachment[] = [],
|
||
): void {
|
||
const required: Record<XftSubmissionKind, string[]> = {
|
||
travel_apply: ["businessTrip"], expense_apply: ["applyExpenses"],
|
||
entertainment_apply: ["entertainmentApplyApiCreateDTO"], common_apply: [], use_car_apply: ["useCarInfo"],
|
||
common_reimburse: ["reimAmount", "reimAmountCurrencyCode", "expenseList"],
|
||
travel_reimburse: ["reimAmount", "reimAmountCurrencyCode", "businessTrip", "expenseList"],
|
||
};
|
||
assertNoClientOnlyStandardValues(values);
|
||
const requiredUnsupported = customFields.filter(requiredXftFieldNeedsNativeClient);
|
||
if (requiredUnsupported.length) {
|
||
throw new ApprovalSafetyError(`该薪福通模板包含当前 MCP 无法安全填写的必填项:${requiredUnsupported.map((field) => field.name).join("、")},请在薪福通客户端发起`);
|
||
}
|
||
const missingBusiness = required[kind].filter((key) => isBlankSubmissionValue(values[key]));
|
||
const missingCustom = missingRequiredXftCustomFields(customFields, values.customFieldValues, attachments);
|
||
if (missingBusiness.length || missingCustom.length) {
|
||
const businessLabels: Record<string, string> = {
|
||
businessTrip: "差旅行程", applyExpenses: "事前申请费用", entertainmentApplyApiCreateDTO: "招待信息",
|
||
useCarInfo: "用车信息", reimAmount: "报销总金额", reimAmountCurrencyCode: "报销币种", expenseList: "费用明细",
|
||
};
|
||
const parts = [
|
||
...(missingBusiness.length ? [`业务字段:${missingBusiness.map((key) => businessLabels[key] ?? key).join("、")}`] : []),
|
||
...(missingCustom.length ? [`自定义字段:${missingCustom.map((field) => field.name).join("、")}`] : []),
|
||
];
|
||
throw new ApprovalSafetyError(`薪福通提单缺少必填${parts.join(";")}`);
|
||
}
|
||
const hasBillCompany = !isBlankSubmissionValue(values.billCompanyCode);
|
||
const hasBillDepartment = !isBlankSubmissionValue(values.billDepartmentCode);
|
||
if (hasBillCompany || hasBillDepartment) {
|
||
throw new ApprovalSafetyError("单据公司和部门由薪福通按当前登录用户填写,不应向用户索取或由 Agent 指定");
|
||
}
|
||
validateRequiredXftCustomFields(customFields, values.customFieldValues, attachments);
|
||
validateXftCustomFieldValues(customFields, values.customFieldValues);
|
||
if (kind === "common_reimburse" || kind === "travel_reimburse") {
|
||
requirePositiveDecimal(values.reimAmount, "报销总金额 reimAmount");
|
||
if (values.reimAmountCurrencyCode !== "CNY") throw new ApprovalSafetyError("报销币种目前只支持人民币");
|
||
validateXftExpenses(values.expenseList);
|
||
}
|
||
if (kind === "travel_apply" || kind === "travel_reimburse") validateXftBusinessTrip(values.businessTrip);
|
||
if (kind === "expense_apply") validateXftExpenseApplication(values.applyExpenses);
|
||
if (kind === "entertainment_apply") validateXftEntertainment(values.entertainmentApplyApiCreateDTO);
|
||
}
|
||
|
||
function requiredXftFieldNeedsNativeClient(field: XftSubmissionField): boolean {
|
||
if (!field.required) return false;
|
||
if (field.selectionCapability?.status === "client_only") return true;
|
||
return (field.itemFields ?? []).some((item) => item.required === true
|
||
&& isSubmissionRecord(item.selectionCapability)
|
||
&& item.selectionCapability.status === "client_only");
|
||
}
|
||
|
||
function assertNoClientOnlyStandardValues(values: Record<string, unknown>): void {
|
||
const reject = (record: Record<string, unknown>, key: string, label: string) => {
|
||
if (!isBlankSubmissionValue(record[key])) {
|
||
throw new ApprovalSafetyError(`${label}当前无法通过 MCP 按业务名称安全填写;不要提供内部编码或 ID,请省略该可选项或改用薪福通客户端`);
|
||
}
|
||
};
|
||
reject(values, "relatedBillId", "关联申请单");
|
||
reject(values, "peerEmpNumbers", "同行人员");
|
||
reject(values, "billCollectionInfo", "收款信息");
|
||
reject(values, "billLendPayRelList", "核销借款");
|
||
if (isSubmissionRecord(values.businessTrip) && Array.isArray(values.businessTrip.businessTripDetails)) {
|
||
values.businessTrip.businessTripDetails.filter(isSubmissionRecord).forEach((trip) => {
|
||
reject(trip, "customArchiveOptionNumbers", "行程自定义档案");
|
||
if (Array.isArray(trip.estimateExpenses)) trip.estimateExpenses.filter(isSubmissionRecord)
|
||
.forEach((expense) => reject(expense, "spreadInfo", "预计费用分摊"));
|
||
});
|
||
}
|
||
if (Array.isArray(values.expenseList)) values.expenseList.filter(isSubmissionRecord).forEach((expense) => {
|
||
reject(expense, "participants", "费用参与人员");
|
||
reject(expense, "aircraftCabin", "飞机舱位");
|
||
reject(expense, "trainCabin", "火车席别");
|
||
reject(expense, "shipCabin", "轮船舱位");
|
||
reject(expense, "spreadInfo", "费用分摊");
|
||
});
|
||
}
|
||
|
||
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} 条缺少费用类型,请先查询并选择可用名称`);
|
||
requirePositiveDecimal(item.amount, `事前申请费用第 ${index + 1} 条申请金额 amount`);
|
||
if (!isBlankSubmissionValue(item.participantNum)
|
||
&& (!Number.isSafeInteger(Number(item.participantNum)) || Number(item.participantNum) <= 0)) {
|
||
throw new ApprovalSafetyError(`事前申请费用第 ${index + 1} 条参与人数 participantNum 必须是大于 0 的整数`);
|
||
}
|
||
});
|
||
}
|
||
|
||
function validateXftEntertainment(value: unknown): void {
|
||
if (!isSubmissionRecord(value)) throw new ApprovalSafetyError("招待信息 entertainmentApplyApiCreateDTO 必须是对象");
|
||
for (const key of ["entertainmentCityCode", "entertainmentPersonCount", "entertainmentStartTime", "entertainmentEndTime"] as const) {
|
||
if (isBlankSubmissionValue(value[key])) throw new ApprovalSafetyError(`招待信息缺少 ${key}`);
|
||
}
|
||
if (!Number.isSafeInteger(Number(value.entertainmentPersonCount)) || Number(value.entertainmentPersonCount) <= 0) {
|
||
throw new ApprovalSafetyError("招待人数 entertainmentPersonCount 必须是大于 0 的整数");
|
||
}
|
||
requireDateString(value.entertainmentStartTime, "招待开始日期", /^\d{4}-\d{2}-\d{2}$/);
|
||
requireDateString(value.entertainmentEndTime, "招待结束日期", /^\d{4}-\d{2}-\d{2}$/);
|
||
if (String(value.entertainmentStartTime) > String(value.entertainmentEndTime)) throw new ApprovalSafetyError("招待结束日期不能早于开始日期");
|
||
}
|
||
|
||
function validateRequiredXftCustomFields(
|
||
fields: XftSubmissionField[],
|
||
value: unknown,
|
||
attachments: XftResolvedSubmissionAttachment[] = [],
|
||
): void {
|
||
const missing = missingRequiredXftCustomFields(fields, value, attachments);
|
||
if (missing.length) {
|
||
throw new ApprovalSafetyError(`薪福通提单缺少必填自定义字段:${missing.map((field) => field.name).join("、")}`);
|
||
}
|
||
}
|
||
|
||
function missingRequiredXftCustomFields(
|
||
fields: XftSubmissionField[],
|
||
value: unknown,
|
||
attachments: XftResolvedSubmissionAttachment[] = [],
|
||
): XftSubmissionField[] {
|
||
const entries = Array.isArray(value) ? value.filter(isSubmissionRecord) : [];
|
||
const attachmentFields = new Set(attachments.map((attachment) => attachment.fieldNumber));
|
||
return fields.filter((field) => field.required).filter((field) => {
|
||
if (isXftAttachmentField(field) && attachmentFields.has(field.fieldNumber)) return false;
|
||
return !entries.some((entry) => String(entry.fieldNumber ?? "") === field.fieldNumber
|
||
&& !isBlankSubmissionValue(entry.fieldValue));
|
||
});
|
||
}
|
||
|
||
function validateXftCustomFieldValues(fields: XftSubmissionField[], value: unknown): void {
|
||
if (value === undefined) return;
|
||
if (!Array.isArray(value)) throw new ApprovalSafetyError("薪福通 customFieldValues 必须是数组");
|
||
const known = new Map(fields.map((field) => [field.fieldNumber, field]));
|
||
const seen = new Set<string>();
|
||
value.forEach((raw, index) => {
|
||
if (!isSubmissionRecord(raw)) throw new ApprovalSafetyError(`第 ${index + 1} 个薪福通自定义字段必须是对象`);
|
||
const fieldNumber = String(raw.fieldNumber ?? "");
|
||
const field = known.get(fieldNumber);
|
||
if (!field) throw new ApprovalSafetyError(`薪福通自定义字段编码“${fieldNumber}”不在当前实时表单中`);
|
||
if (seen.has(fieldNumber)) throw new ApprovalSafetyError(`薪福通自定义字段“${field.name}(${fieldNumber})”重复`);
|
||
seen.add(fieldNumber);
|
||
if (field.selectionCapability?.status === "client_only") {
|
||
throw new ApprovalSafetyError(`“${field.name}”当前无法通过 MCP 按业务名称安全填写,请在薪福通客户端完成`);
|
||
}
|
||
if (isXftAttachmentField(field)) {
|
||
throw new ApprovalSafetyError(`附件字段“${field.name}”必须通过 attachments 使用本机文件,不能直接提供附件编号`);
|
||
}
|
||
if (typeof raw.fieldValue !== "string") throw new ApprovalSafetyError(`薪福通自定义字段“${field.name}”的 fieldValue 必须是字符串;数组和表格需传 JSON 字符串`);
|
||
const type = field.type.toUpperCase();
|
||
if (type.includes("TABLE")) validateXftTableField(field, raw.fieldValue);
|
||
if (type === "DATE" && !/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(raw.fieldValue)) {
|
||
throw new ApprovalSafetyError(`薪福通自定义字段“${field.name}”必须使用 yyyy-MM-dd HH:mm 日期时间格式`);
|
||
}
|
||
if (type.includes("DEPARTMENT") || type.includes("ORG")) {
|
||
let parsed: unknown;
|
||
try { parsed = JSON.parse(raw.fieldValue); } catch { throw new ApprovalSafetyError(`薪福通字段“${field.name}”的部门选择格式无效,请重新查询并选择部门名称`); }
|
||
if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== "string" || !item.trim())) {
|
||
throw new ApprovalSafetyError(`薪福通字段“${field.name}”的部门选择格式无效,请重新查询并选择部门名称`);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function validateXftSubmissionAttachments(
|
||
fields: XftSubmissionField[],
|
||
attachments: XftSubmissionAttachment[],
|
||
requireUploadReference: boolean,
|
||
): XftResolvedSubmissionAttachment[] {
|
||
if (!Array.isArray(attachments)) throw new ApprovalSafetyError("attachments 必须是数组");
|
||
if (attachments.length > XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION) {
|
||
throw new ApprovalSafetyError(`一次薪福通提单最多上传 ${XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION} 个附件`);
|
||
}
|
||
const seen = new Set<string>();
|
||
let totalBytes = 0;
|
||
return attachments.map((attachment) => {
|
||
if (!attachment || typeof attachment !== "object") throw new ApprovalSafetyError("附件信息格式无效");
|
||
if (!attachment.fieldName || attachment.fieldName.length > 200) throw new ApprovalSafetyError("附件缺少有效的表单字段名称");
|
||
if (!attachment.fileName || attachment.fileName.length > 240 || /[\\/\r\n\0"]/u.test(attachment.fileName)) {
|
||
throw new ApprovalSafetyError("附件文件名无效,请重新选择本机普通文件");
|
||
}
|
||
if (!Number.isSafeInteger(attachment.size) || attachment.size <= 0 || attachment.size > XFT_ATTACHMENT_MAX_FILE_BYTES) {
|
||
throw new ApprovalSafetyError(`附件“${attachment.fileName}”必须大于 0 字节且单个不超过 50MB`);
|
||
}
|
||
totalBytes += attachment.size;
|
||
if (!Number.isSafeInteger(totalBytes) || totalBytes > XFT_ATTACHMENT_MAX_TOTAL_BYTES) {
|
||
throw new ApprovalSafetyError("一次薪福通提单的附件总大小不能超过 50MB");
|
||
}
|
||
if (!/^[0-9a-f]{64}$/.test(attachment.sha256)) throw new ApprovalSafetyError(`附件“${attachment.fileName}”的 SHA-256 格式无效`);
|
||
const matches = fields.filter((field) => field.name === attachment.fieldName && isXftAttachmentField(field));
|
||
if (matches.length !== 1) {
|
||
throw new ApprovalSafetyError(matches.length === 0
|
||
? `实时薪福通表单中没有名为“${attachment.fieldName}”的附件字段,请重新读取表单`
|
||
: `实时薪福通表单中“${attachment.fieldName}”附件字段不唯一,请重新选择`);
|
||
}
|
||
if (requireUploadReference && !attachment.attachmentRef) {
|
||
throw new ApprovalSafetyError(`附件“${attachment.fileName}”尚未由本机 Bridge 上传,请重新执行最终提交`);
|
||
}
|
||
if (!requireUploadReference && attachment.attachmentRef) {
|
||
throw new ApprovalSafetyError("预览阶段不得预上传附件,请移除上传引用后重新准备");
|
||
}
|
||
const field = matches[0]!;
|
||
const duplicateKey = `${field.fieldNumber}\0${attachment.sha256}`;
|
||
if (seen.has(duplicateKey)) throw new ApprovalSafetyError(`附件“${attachment.fileName}”在同一字段中重复`);
|
||
seen.add(duplicateKey);
|
||
return {
|
||
...attachment,
|
||
fieldNumber: field.fieldNumber,
|
||
...(requireUploadReference ? { attachmentRef: attachment.attachmentRef! } : { attachmentRef: "" }),
|
||
};
|
||
});
|
||
}
|
||
|
||
function isXftAttachmentField(field: XftSubmissionField): boolean {
|
||
const type = field.type.toUpperCase();
|
||
return type.includes("ATTACH") || type.includes("ACCESSOR");
|
||
}
|
||
|
||
function validateXftTableField(field: XftSubmissionField, value: string): void {
|
||
let rows: unknown;
|
||
try { rows = JSON.parse(value); } catch { throw new ApprovalSafetyError(`薪福通表格字段“${field.name}”必须是 JSON 数组字符串`); }
|
||
if (!Array.isArray(rows)) throw new ApprovalSafetyError(`薪福通表格字段“${field.name}”必须是 JSON 数组`);
|
||
rows.forEach((row, index) => {
|
||
if (!isSubmissionRecord(row) || !Number.isSafeInteger(Number(row.sequence)) || !Array.isArray(row.columnValues)) {
|
||
throw new ApprovalSafetyError(`薪福通表格字段“${field.name}”第 ${index + 1} 行必须包含 sequence 和 columnValues`);
|
||
}
|
||
for (const column of row.columnValues) {
|
||
if (!isSubmissionRecord(column) || isBlankSubmissionValue(column.titleCode) || isBlankSubmissionValue(column.value)) {
|
||
throw new ApprovalSafetyError(`薪福通表格字段“${field.name}”第 ${index + 1} 行的列必须包含 titleCode 和 value`);
|
||
}
|
||
const itemField = (field.itemFields ?? []).find((item) => String(item.titleCode ?? "") === String(column.titleCode ?? ""));
|
||
if (itemField && isSubmissionRecord(itemField.selectionCapability) && itemField.selectionCapability.status === "client_only") {
|
||
throw new ApprovalSafetyError(`薪福通表格字段“${field.name}”中的“${String(itemField.label ?? "选项")}”当前无法通过 MCP 安全填写,请在薪福通客户端完成`);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
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} 笔费用必须是对象`);
|
||
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} 笔费用币种目前只支持人民币`);
|
||
requirePositiveDecimal(item.originalCurrencyReimAmt, `第 ${index + 1} 笔费用金额 originalCurrencyReimAmt`);
|
||
if (item.wrTim !== undefined) requireDateString(item.wrTim, `第 ${index + 1} 笔消费日期 wrTim`, /^\d{4}-\d{2}-\d{2}$/);
|
||
});
|
||
}
|
||
|
||
function validateXftBusinessTrip(value: unknown): void {
|
||
if (!isSubmissionRecord(value) || !Array.isArray(value.businessTripDetails) || value.businessTripDetails.length === 0) {
|
||
throw new ApprovalSafetyError("差旅行程 businessTrip.businessTripDetails 至少需要一段行程");
|
||
}
|
||
value.businessTripDetails.forEach((item, index) => {
|
||
if (!isSubmissionRecord(item)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程必须是对象`);
|
||
if (isBlankSubmissionValue(item.departCityCode)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程缺少出发城市`);
|
||
if (isBlankSubmissionValue(item.destinationCityCode)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程缺少目的城市`);
|
||
if (isBlankSubmissionValue(item.endTime)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程缺少结束时间`);
|
||
const beginTime = item.beginTime ?? item.startTime;
|
||
if (isBlankSubmissionValue(beginTime)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程缺少开始时间`);
|
||
requireDateString(beginTime, `第 ${index + 1} 段出发时间`, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/);
|
||
requireDateString(item.endTime, `第 ${index + 1} 段结束时间`, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/);
|
||
if (String(beginTime) > String(item.endTime)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程结束时间不能早于出发时间`);
|
||
if (item.roundTrip !== undefined && !["N", "Y"].includes(String(item.roundTrip))) {
|
||
throw new ApprovalSafetyError(`第 ${index + 1} 段行程的往返选择无效,请重新选择“单程”或“往返”`);
|
||
}
|
||
if (item.travelMode !== undefined) {
|
||
const modes = String(item.travelMode).split(",").map((mode) => mode.trim()).filter(Boolean);
|
||
const allowed = new Set(["Train", "Bus", "Ship", "Hotel", "RideHailing", "AirPlane", "Other"]);
|
||
if (!modes.length || modes.some((mode) => !allowed.has(mode))) throw new ApprovalSafetyError(`第 ${index + 1} 段行程含不支持的出行方式,请从 Schema 的中文名称中重新选择`);
|
||
}
|
||
if (item.estimateExpenses !== undefined) {
|
||
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} 笔预计费用缺少费用类型或申请金额`);
|
||
}
|
||
requirePositiveDecimal(expense.applyAmount, `第 ${index + 1} 段行程第 ${expenseIndex + 1} 笔预计费用 applyAmount`);
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
function requirePositiveDecimal(value: unknown, label: string): void {
|
||
const text = typeof value === "number" ? String(value) : typeof value === "string" ? value.trim() : "";
|
||
if (!/^\d{1,10}(?:\.\d{1,2})?$/.test(text) || Number(text) <= 0) {
|
||
throw new ApprovalSafetyError(`${label} 必须是大于 0、最多两位小数的金额`);
|
||
}
|
||
}
|
||
|
||
function requireDateString(value: unknown, label: string, pattern: RegExp): void {
|
||
if (typeof value !== "string" || !pattern.test(value)) throw new ApprovalSafetyError(`${label} 格式不正确`);
|
||
}
|
||
|
||
function isSubmissionRecord(value: unknown): value is Record<string, unknown> {
|
||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||
}
|
||
|
||
function dingTalkSubmissionRequest(
|
||
processCode: string,
|
||
input: SubmissionInput,
|
||
fields: Array<Record<string, unknown>>,
|
||
departmentId: number,
|
||
) {
|
||
const values = { ...input.values };
|
||
return {
|
||
processCode,
|
||
deptId: departmentId,
|
||
formComponentValues: fields
|
||
.filter((field) => field.parent === undefined && field.serverManaged !== true && Object.hasOwn(values, String(field.name)))
|
||
.map((field) => ({
|
||
// The legacy create API resolves top-level controls by their display
|
||
// label, while TableField row objects resolve columns by component ID.
|
||
name: String(field.name),
|
||
value: serializeDingTalkSubmissionField(field, fields, values[String(field.name)]),
|
||
})),
|
||
...(input.targetSelectActioners?.length ? { targetSelectActioners: input.targetSelectActioners } : {}),
|
||
};
|
||
}
|
||
|
||
function serializeDingTalkSubmissionField(
|
||
field: Record<string, unknown>,
|
||
fields: Array<Record<string, unknown>>,
|
||
value: unknown,
|
||
): string {
|
||
if (field.type !== "TableField") return submissionStringValue(value);
|
||
let rows: unknown = value;
|
||
if (typeof value === "string") {
|
||
try { rows = JSON.parse(value) as unknown; }
|
||
catch { throw new ApprovalSafetyError(`明细字段“${String(field.name)}”必须是有效的 JSON 行数组`); }
|
||
}
|
||
if (!Array.isArray(rows)) throw new ApprovalSafetyError(`明细字段“${String(field.name)}”必须是 JSON 行数组`);
|
||
const children = fields.filter((candidate) => candidate.parent === field.name);
|
||
return JSON.stringify(rows.map((row) => {
|
||
if (!isSubmissionRecord(row)) throw new ApprovalSafetyError(`明细字段“${String(field.name)}”的行必须是对象`);
|
||
return Object.fromEntries(children.flatMap((child) => {
|
||
const raw = row[String(child.name)];
|
||
if (isBlankSubmissionValue(raw)) return [];
|
||
return [[String(child.name), submissionStringValue(raw)]];
|
||
}));
|
||
}));
|
||
}
|
||
|
||
function forecastSelectionRequirements(forecast: Record<string, unknown>) {
|
||
const rules = forecastRuleRecords(forecast).filter((record) => forecastTargetSelect(record));
|
||
return rules.map((rule) => {
|
||
const actor = rule.workflowActor && typeof rule.workflowActor === "object" && !Array.isArray(rule.workflowActor)
|
||
? rule.workflowActor as Record<string, unknown> : {};
|
||
return {
|
||
actionerKey: typeof actor.actorKey === "string" ? actor.actorKey : "",
|
||
activityName: typeof rule.activityName === "string" ? rule.activityName : "自选处理人",
|
||
actorType: typeof actor.actorType === "string" ? actor.actorType : "approver",
|
||
required: true,
|
||
};
|
||
}).filter((item) => item.actionerKey);
|
||
}
|
||
|
||
function validateTargetSelections(
|
||
required: Array<{ actionerKey: string }>,
|
||
selections: Array<{ actionerKey: string; actionerStaffIds: string[] }>,
|
||
): void {
|
||
const allowed = new Set(required.map((item) => item.actionerKey));
|
||
for (const item of required) {
|
||
const selection = selections.find((candidate) => candidate.actionerKey === item.actionerKey);
|
||
if (!selection?.actionerStaffIds.length) throw new ApprovalSafetyError(`流程节点 ${item.actionerKey} 需要选择处理人`);
|
||
}
|
||
for (const item of selections) {
|
||
if (!allowed.has(item.actionerKey)) throw new ApprovalSafetyError(`自选节点 ${item.actionerKey} 不在当前流程预测结果中`);
|
||
if (item.actionerStaffIds.some((id) => !id.trim())) throw new ApprovalSafetyError("自选处理人 userId 不能为空");
|
||
}
|
||
}
|
||
|
||
function compactForecast(forecast: Record<string, unknown>): Record<string, unknown> {
|
||
const nodes = forecastRuleRecords(forecast).map((node) => {
|
||
const actioners = Array.isArray(node.activityActioners) ? node.activityActioners : [];
|
||
const actor = node.workflowActor && typeof node.workflowActor === "object" && !Array.isArray(node.workflowActor)
|
||
? node.workflowActor as Record<string, unknown> : {};
|
||
return {
|
||
activityId: node.activityId,
|
||
activityName: node.activityName,
|
||
activityType: node.activityType,
|
||
targetSelect: forecastTargetSelect(node),
|
||
...(typeof actor.actorType === "string" ? { actorType: actor.actorType } : {}),
|
||
...(typeof actor.approvalMethod === "string" ? { approvalMethod: actor.approvalMethod } : {}),
|
||
actioners: actioners.flatMap((item) => item && typeof item === "object" && !Array.isArray(item)
|
||
? [String((item as Record<string, unknown>).name ?? "")].filter(Boolean) : []),
|
||
};
|
||
});
|
||
const forecastSuccess = typeof forecast.forecastSuccess === "boolean" ? forecast.forecastSuccess
|
||
: typeof forecast.isForecastSuccess === "boolean" ? forecast.isForecastSuccess : undefined;
|
||
const staticWorkflow = typeof forecast.staticWorkflow === "boolean" ? forecast.staticWorkflow
|
||
: typeof forecast.isStaticWorkflow === "boolean" ? forecast.isStaticWorkflow : undefined;
|
||
return {
|
||
supported: forecast.supported !== false,
|
||
...(forecastSuccess !== undefined ? { forecastSuccess } : {}),
|
||
...(staticWorkflow !== undefined ? { staticWorkflow } : {}),
|
||
...(typeof forecast.message === "string" ? { message: forecast.message } : {}),
|
||
nodes: uniqueJson(nodes).slice(0, 100),
|
||
};
|
||
}
|
||
|
||
function forecastRuleRecords(forecast: Record<string, unknown>): Array<Record<string, unknown>> {
|
||
for (const key of ["workflowActivityRuleVOs", "workflowActivityRules"]) {
|
||
const value = forecast[key];
|
||
if (Array.isArray(value)) {
|
||
return value.flatMap((item) => item && typeof item === "object" && !Array.isArray(item)
|
||
? [item as Record<string, unknown>] : []);
|
||
}
|
||
}
|
||
return findRecordsByKey(forecast, "activityId")
|
||
.filter((record) => "activityType" in record || "workflowActor" in record);
|
||
}
|
||
|
||
function forecastTargetSelect(rule: Record<string, unknown>): boolean {
|
||
return rule.targetSelect === true || rule.isTargetSelect === true;
|
||
}
|
||
|
||
function findRecordsByKey(value: unknown, key: string, depth = 0): Array<Record<string, unknown>> {
|
||
if (depth > 8) return [];
|
||
if (Array.isArray(value)) return value.flatMap((item) => findRecordsByKey(item, key, depth + 1));
|
||
if (!value || typeof value !== "object") return [];
|
||
const record = value as Record<string, unknown>;
|
||
return [...(key in record ? [record] : []), ...Object.values(record).flatMap((item) => findRecordsByKey(item, key, depth + 1))];
|
||
}
|
||
|
||
function assertPlainSubmissionValues(values: Record<string, unknown>): void {
|
||
if (!values || typeof values !== "object" || Array.isArray(values)) throw new ApprovalSafetyError("values 必须是 JSON 对象");
|
||
const json = JSON.stringify(values);
|
||
if (json.length > 200_000) throw new ApprovalSafetyError("提单内容不能超过 200KB");
|
||
const managed = ["userId", "originatorUserId", "empNumber", "wrEmpNumber", "billStatus", "billApplyAuthCheckType", "billApplyAuthCheckTypes"];
|
||
if (managed.some((key) => Object.hasOwn(values, key))) {
|
||
throw new ApprovalSafetyError("提单内容不得包含发起人身份、员工号或审批状态字段,这些字段由服务器填写");
|
||
}
|
||
}
|
||
|
||
function normalizedSubmissionInput(input: SubmissionInput): Record<string, unknown> {
|
||
return {
|
||
templateId: input.templateId,
|
||
values: input.values,
|
||
deptId: input.deptId ?? -1,
|
||
targetSelectActioners: input.targetSelectActioners ?? [],
|
||
allowDuplicateInvoices: input.allowDuplicateInvoices === true,
|
||
attachments: submissionAttachmentManifest(input),
|
||
};
|
||
}
|
||
|
||
function submissionAttachmentManifest(input: SubmissionInput) {
|
||
return (input.attachments ?? []).map(({ fieldName, fileName, size, sha256 }) => ({ fieldName, fileName, size, sha256 }));
|
||
}
|
||
|
||
function submissionValuesForInvoiceDeduplication(input: SubmissionInput): Record<string, unknown> {
|
||
if (!(input.attachments ?? []).length) return input.values;
|
||
return {
|
||
...input.values,
|
||
invoiceAttachments: input.attachments!.map((attachment) => ({ sha256: attachment.sha256 })),
|
||
};
|
||
}
|
||
|
||
function attachmentPreview(attachments: XftResolvedSubmissionAttachment[]): Array<Record<string, unknown>> {
|
||
return attachments.map((attachment) => ({
|
||
field: attachment.fieldName,
|
||
fileName: attachment.fileName,
|
||
size: attachment.size,
|
||
sha256: `…${attachment.sha256.slice(-8)}`,
|
||
uploadStatus: "将在最终确认后上传",
|
||
}));
|
||
}
|
||
|
||
function summarizeValues(values: Record<string, unknown>): Record<string, unknown> {
|
||
return Object.fromEntries(Object.entries(values).slice(0, 200).map(([key, value]) => [key, summarizeValue(value)]));
|
||
}
|
||
|
||
function summarizeValue(value: unknown): unknown {
|
||
if (typeof value === "string") return value.length > 500 ? `${value.slice(0, 500)}…` : value;
|
||
if (typeof value === "number" || typeof value === "boolean" || value === null) return value;
|
||
if (Array.isArray(value)) return { itemCount: value.length, preview: value.slice(0, 5).map(summarizeValue) };
|
||
if (value && typeof value === "object") return { fieldCount: Object.keys(value).length, preview: summarizeValues(value as Record<string, unknown>) };
|
||
return String(value);
|
||
}
|
||
|
||
interface ReadableXftSummaryContext {
|
||
provider: XftApprovalProvider;
|
||
user: AuthenticatedDingTalkUser;
|
||
template: XftSubmissionTemplate;
|
||
customFields: XftSubmissionField[];
|
||
optionLabels: Map<string, string>;
|
||
}
|
||
|
||
async function readableXftSubmissionSummary(
|
||
provider: XftApprovalProvider,
|
||
user: AuthenticatedDingTalkUser,
|
||
template: XftSubmissionTemplate,
|
||
customFields: XftSubmissionField[],
|
||
inputContract: Record<string, unknown>,
|
||
values: Record<string, unknown>,
|
||
): Promise<Record<string, unknown>> {
|
||
const valueContract = isSubmissionRecord(inputContract.values) ? inputContract.values : undefined;
|
||
const standardFields = Array.isArray(valueContract?.standardFields)
|
||
? valueContract.standardFields.filter(isSubmissionRecord)
|
||
: [];
|
||
const context: ReadableXftSummaryContext = {
|
||
provider, user, template, customFields, optionLabels: new Map(),
|
||
};
|
||
if (!standardFields.length) {
|
||
const compatibilitySummary: Record<string, unknown> = {
|
||
"预览说明": "当前实时模板未提供可读字段定义;已完成结构校验,但内部表单值不会在预览中展示",
|
||
"已填写业务区块数": Object.keys(values).length,
|
||
};
|
||
if (customFields.length && Object.hasOwn(values, "customFieldValues")) {
|
||
const custom = await readableXftCustomFields(values.customFieldValues, context, "表单自定义字段");
|
||
if (custom.length) compatibilitySummary["表单自定义字段"] = custom;
|
||
}
|
||
// Some third-party/test providers still implement the older contract. Never
|
||
// fall back to echoing their opaque values, because those may be codes or IDs.
|
||
return compatibilitySummary;
|
||
}
|
||
const output = await readableXftRecord(values, standardFields, context, "表单", true);
|
||
const known = new Set(standardFields.flatMap((field) => [
|
||
String(field.key ?? ""),
|
||
...(Array.isArray(field.aliases) ? field.aliases.map(String) : []),
|
||
]).filter(Boolean));
|
||
const standardHasCustomFields = standardFields.some((field) => String(field.key ?? "") === "customFieldValues");
|
||
if (!standardHasCustomFields && Object.hasOwn(values, "customFieldValues")) {
|
||
const custom = await readableXftCustomFields(values.customFieldValues, context, "表单自定义字段");
|
||
if (custom.length) output["表单自定义字段"] = custom;
|
||
}
|
||
known.add("customFieldValues");
|
||
const supplementary = Object.keys(values).filter((key) => !known.has(key));
|
||
if (supplementary.length) {
|
||
const invoiceOnly = supplementary.every((key) => /invoice|发票|票据/i.test(key));
|
||
if (!invoiceOnly) {
|
||
throw new ApprovalSafetyError("当前实时模板无法为部分已填写内容生成不含内部值的完整预览,请刷新模板后重新填写");
|
||
}
|
||
output["发票查重资料"] = "已提供;唯一标识仅用于查重,并在确认预览中脱敏展示";
|
||
}
|
||
return output;
|
||
}
|
||
|
||
async function readableXftRecord(
|
||
record: Record<string, unknown>,
|
||
descriptors: Array<Record<string, unknown>>,
|
||
context: ReadableXftSummaryContext,
|
||
path: string,
|
||
allowUnknownKeys = false,
|
||
): Promise<Record<string, unknown>> {
|
||
const output: Record<string, unknown> = {};
|
||
const knownKeys = new Set<string>();
|
||
for (const descriptor of descriptors) {
|
||
const key = String(descriptor.key ?? descriptor.fieldNumber ?? descriptor.titleCode ?? "");
|
||
if (!key) continue;
|
||
const aliases = Array.isArray(descriptor.aliases) ? descriptor.aliases.map(String) : [];
|
||
[key, ...aliases].forEach((candidate) => knownKeys.add(candidate));
|
||
const matchedKey = [key, ...aliases].find((candidate) => Object.hasOwn(record, candidate));
|
||
if (!matchedKey || isBlankSubmissionValue(record[matchedKey])) continue;
|
||
if (key === "customFieldValues") {
|
||
const custom = await readableXftCustomFields(record[matchedKey], context, `${path}自定义字段`);
|
||
if (custom.length) output["自定义字段"] = custom;
|
||
continue;
|
||
}
|
||
if (descriptor.userVisible === false || descriptor.serverManaged === true || descriptor.internallyDerived === true) continue;
|
||
const label = String(descriptor.label ?? descriptor.name ?? "业务字段");
|
||
output[label] = await readableXftValue(record[matchedKey], descriptor, context, `${path}“${label}”`);
|
||
}
|
||
if (!allowUnknownKeys && Object.keys(record).some((key) => !knownKeys.has(key))) {
|
||
throw new ApprovalSafetyError(`${path}包含当前实时模板未定义的内容,无法生成不含内部值的完整预览`);
|
||
}
|
||
return output;
|
||
}
|
||
|
||
async function readableXftValue(
|
||
value: unknown,
|
||
descriptor: Record<string, unknown>,
|
||
context: ReadableXftSummaryContext,
|
||
path: string,
|
||
): Promise<unknown> {
|
||
const selection = isSubmissionRecord(descriptor.selectionCapability) ? descriptor.selectionCapability : undefined;
|
||
if (selection?.status === "client_only") {
|
||
throw new ApprovalSafetyError(`${path}当前无法通过 MCP 按业务名称安全预览,请省略该可选项或在薪福通客户端完成`);
|
||
}
|
||
if (String(descriptor.key ?? "") === "customFieldValues") {
|
||
return readableXftCustomFields(value, context, path);
|
||
}
|
||
const type = String(descriptor.type ?? "").toUpperCase();
|
||
if (type.includes("TABLE")) return readableXftTable(value, descriptor, context, path);
|
||
const enumLabels = isSubmissionRecord(descriptor.enumLabels) ? descriptor.enumLabels : undefined;
|
||
const inlineOptions = Array.isArray(descriptor.options) ? descriptor.options.filter(isSubmissionRecord) : [];
|
||
const optionSource = isSubmissionRecord(descriptor.optionSource) ? descriptor.optionSource : undefined;
|
||
if (enumLabels || inlineOptions.length || optionSource) {
|
||
const selected = type === "COMMA_SEPARATED_ENUM" && typeof value === "string"
|
||
? value.split(",").map((item) => item.trim()).filter(Boolean)
|
||
: normalizeXftSelectedValues(value, type);
|
||
const labels = await Promise.all(selected.map((item) => readableXftOptionLabel(
|
||
item, descriptor, enumLabels, inlineOptions, optionSource, context, path,
|
||
)));
|
||
return labels.length === 1 && descriptor.multiple !== true && type !== "COMMA_SEPARATED_ENUM" ? labels[0] : labels;
|
||
}
|
||
const objectFields = Array.isArray(descriptor.fields) ? descriptor.fields.filter(isSubmissionRecord) : [];
|
||
if (objectFields.length) {
|
||
if (!isSubmissionRecord(value)) throw new ApprovalSafetyError(`${path}无法生成安全的业务预览,请重新填写`);
|
||
return readableXftRecord(value, objectFields, context, path);
|
||
}
|
||
const itemFields = Array.isArray(descriptor.itemFields) ? descriptor.itemFields.filter(isSubmissionRecord) : [];
|
||
if (type === "ARRAY" || Array.isArray(value)) {
|
||
if (!Array.isArray(value)) throw new ApprovalSafetyError(`${path}无法生成安全的业务预览,请重新填写`);
|
||
if (!itemFields.length) {
|
||
if (value.some((item) => item !== null && typeof item === "object")) {
|
||
throw new ApprovalSafetyError(`${path}缺少可读字段定义,无法安全展示`);
|
||
}
|
||
return value.map((item) => summarizeValue(item));
|
||
}
|
||
return Promise.all(value.map(async (item, index) => {
|
||
if (!isSubmissionRecord(item)) throw new ApprovalSafetyError(`${path}第 ${index + 1} 项无法生成安全的业务预览`);
|
||
return readableXftRecord(item, itemFields, context, `${path}第 ${index + 1} 项`);
|
||
}));
|
||
}
|
||
if (isSubmissionRecord(value)) {
|
||
throw new ApprovalSafetyError(`${path}缺少可读字段定义,无法安全展示`);
|
||
}
|
||
return summarizeValue(value);
|
||
}
|
||
|
||
function normalizeXftSelectedValues(value: unknown, type: string): string[] {
|
||
if (Array.isArray(value)) return value.map(String);
|
||
if (typeof value !== "string") return [String(value)];
|
||
if (type.includes("DEPARTMENT") || type.includes("ORG") || value.trim().startsWith("[")) {
|
||
try {
|
||
const parsed = JSON.parse(value);
|
||
if (Array.isArray(parsed)) return parsed.map(String);
|
||
} catch {
|
||
// A non-JSON option is handled as one selected value below.
|
||
}
|
||
}
|
||
return [value];
|
||
}
|
||
|
||
async function readableXftOptionLabel(
|
||
code: string,
|
||
descriptor: Record<string, unknown>,
|
||
enumLabels: Record<string, unknown> | undefined,
|
||
inlineOptions: Array<Record<string, unknown>>,
|
||
optionSource: Record<string, unknown> | undefined,
|
||
context: ReadableXftSummaryContext,
|
||
path: string,
|
||
): Promise<string> {
|
||
const fixed = typeof enumLabels?.[code] === "string" ? String(enumLabels[code]) : undefined;
|
||
if (fixed) return fixed;
|
||
const inline = inlineOptions.find((item) => String(item.code ?? "") === code);
|
||
if (inline && typeof inline.label === "string" && inline.label.trim()) return inline.label.trim();
|
||
const kind = optionSource?.kind;
|
||
if (kind === "department" || kind === "city" || kind === "expense_type") {
|
||
const cacheKey = `${kind}\u0000${code}`;
|
||
const cached = context.optionLabels.get(cacheKey);
|
||
if (cached) return cached;
|
||
const queried = await context.provider.getSubmissionOptions(context.user, context.template, kind, code, 200);
|
||
const matched = queried.items.find((item) => item.code === code && item.label.trim());
|
||
if (matched) {
|
||
context.optionLabels.set(cacheKey, matched.label.trim());
|
||
return matched.label.trim();
|
||
}
|
||
}
|
||
throw new ApprovalSafetyError(`${path}的当前选择已失效或无法映射为业务名称,请重新查询可用名称后选择`);
|
||
}
|
||
|
||
async function readableXftCustomFields(
|
||
value: unknown,
|
||
context: ReadableXftSummaryContext,
|
||
path: string,
|
||
): Promise<Array<Record<string, unknown>>> {
|
||
if (value === undefined) return [];
|
||
if (!Array.isArray(value)) throw new ApprovalSafetyError(`${path}无法生成安全的业务预览`);
|
||
const known = new Map(context.customFields.map((field) => [field.fieldNumber, field]));
|
||
return Promise.all(value.map(async (item, index) => {
|
||
if (!isSubmissionRecord(item)) throw new ApprovalSafetyError(`${path}第 ${index + 1} 项无法生成安全的业务预览`);
|
||
const field = known.get(String(item.fieldNumber ?? ""));
|
||
if (!field) throw new ApprovalSafetyError(`${path}包含当前实时模板无法识别的字段,请刷新模板后重试`);
|
||
const descriptor: Record<string, unknown> = {
|
||
...field,
|
||
key: field.fieldNumber,
|
||
label: field.name,
|
||
};
|
||
return { [field.name]: await readableXftValue(item.fieldValue, descriptor, context, `${path}“${field.name}”`) };
|
||
}));
|
||
}
|
||
|
||
async function readableXftTable(
|
||
value: unknown,
|
||
descriptor: Record<string, unknown>,
|
||
context: ReadableXftSummaryContext,
|
||
path: string,
|
||
): Promise<Array<Record<string, unknown>>> {
|
||
let rows = value;
|
||
if (typeof rows === "string") {
|
||
try { rows = JSON.parse(rows); } catch { throw new ApprovalSafetyError(`${path}无法生成安全的业务预览`); }
|
||
}
|
||
if (!Array.isArray(rows)) throw new ApprovalSafetyError(`${path}无法生成安全的业务预览`);
|
||
const itemFields = Array.isArray(descriptor.itemFields) ? descriptor.itemFields.filter(isSubmissionRecord) : [];
|
||
return Promise.all(rows.map(async (row, index) => {
|
||
if (!isSubmissionRecord(row) || !Array.isArray(row.columnValues)) {
|
||
throw new ApprovalSafetyError(`${path}第 ${index + 1} 行无法生成安全的业务预览`);
|
||
}
|
||
const output: Record<string, unknown> = {};
|
||
for (const column of row.columnValues) {
|
||
if (!isSubmissionRecord(column)) throw new ApprovalSafetyError(`${path}第 ${index + 1} 行无法生成安全的业务预览`);
|
||
const columnDescriptor = itemFields.find((item) => String(item.titleCode ?? "") === String(column.titleCode ?? ""));
|
||
if (!columnDescriptor) throw new ApprovalSafetyError(`${path}包含当前实时模板无法识别的列,请刷新模板后重试`);
|
||
const label = String(columnDescriptor.label ?? "业务字段");
|
||
output[label] = await readableXftValue(column.value, columnDescriptor, context, `${path}第 ${index + 1} 行“${label}”`);
|
||
}
|
||
return output;
|
||
}));
|
||
}
|
||
|
||
function submissionStringValue(value: unknown): string {
|
||
if (typeof value === "string") return value;
|
||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||
return JSON.stringify(value);
|
||
}
|
||
|
||
function isBlankSubmissionValue(value: unknown): boolean {
|
||
return value === undefined || value === null || value === "" || (Array.isArray(value) && value.length === 0);
|
||
}
|
||
|
||
function hashJson(value: unknown): string {
|
||
return createHash("sha256").update(stableJson(value)).digest("base64url");
|
||
}
|
||
|
||
function stableJson(value: unknown): string {
|
||
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
||
if (value && typeof value === "object") {
|
||
const record = value as Record<string, unknown>;
|
||
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(",")}}`;
|
||
}
|
||
return JSON.stringify(value);
|
||
}
|
||
|
||
function uniqueJson<T>(values: T[]): T[] {
|
||
const seen = new Set<string>();
|
||
return values.filter((value) => { const key = stableJson(value); if (seen.has(key)) return false; seen.add(key); return true; });
|
||
}
|
||
|
||
function interleave<T>(left: T[], right: T[]): T[] {
|
||
const output: T[] = [];
|
||
const length = Math.max(left.length, right.length);
|
||
for (let index = 0; index < length; index += 1) {
|
||
if (index < left.length) output.push(left[index]!);
|
||
if (index < right.length) output.push(right[index]!);
|
||
}
|
||
return output;
|
||
}
|
||
|
||
function withReferenceMetadata(raw: unknown, ref: ApprovalReference): unknown {
|
||
const record = raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Record<string, unknown> : { value: raw };
|
||
const existingTodo = record.todo && typeof record.todo === "object" && !Array.isArray(record.todo)
|
||
? record.todo as Record<string, unknown> : {};
|
||
return {
|
||
...record,
|
||
todo: {
|
||
...existingTodo,
|
||
...(ref.title ? { title: ref.title } : {}),
|
||
...(ref.applicantName ? { applicantName: ref.applicantName } : {}),
|
||
...(ref.applicantDepartment ? { applicantDepartment: ref.applicantDepartment } : {}),
|
||
...(ref.submittedAt ? { submittedAt: ref.submittedAt } : {}),
|
||
...(ref.amount !== undefined ? { amount: ref.amount } : {}),
|
||
...(ref.currency ? { currency: ref.currency } : {}),
|
||
...(ref.summary ? { summary: ref.summary } : {}),
|
||
},
|
||
};
|
||
}
|
||
|
||
function redactSensitive<T>(value: T): T {
|
||
return redactValue(value, "", false) as T;
|
||
}
|
||
|
||
function redactValue(value: unknown, key: string, forceMask: boolean): unknown {
|
||
if ((forceMask || sensitiveLabel(key)) && (typeof value === "string" || typeof value === "number")) return maskValue(String(value));
|
||
if (typeof value === "string" && /^[\s]*[\[{]/.test(value) && value.length < 2_000_000) {
|
||
try { return JSON.stringify(redactValue(JSON.parse(value), key, forceMask)); } catch {}
|
||
}
|
||
if (Array.isArray(value)) return value.map((item) => redactValue(item, key, forceMask));
|
||
if (value && typeof value === "object") {
|
||
const record = value as Record<string, unknown>;
|
||
const dynamicSensitive = [record.name, record.title, record.label, record.fieldName, record.bizAlias, record.componentType]
|
||
.some((candidate) => typeof candidate === "string" && sensitiveLabel(candidate));
|
||
return Object.fromEntries(Object.entries(record).map(([childKey, child]) => [
|
||
childKey,
|
||
redactValue(child, childKey, forceMask || (dynamicSensitive && /^(value|content|displayValue|extValue)$/i.test(childKey))),
|
||
]));
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function maskValue(value: string): string {
|
||
if (value.length <= 4) return "****";
|
||
return `${value.slice(0, 2)}${"*".repeat(Math.min(12, value.length - 4))}${value.slice(-2)}`;
|
||
}
|