fix: keep submission previews user-readable

This commit is contained in:
2026-08-20 01:13:36 +00:00
parent ecb2f570e0
commit 7fbd13bf20
12 changed files with 485 additions and 23 deletions
+5 -5
View File
@@ -390,7 +390,7 @@ function parseEndpoint(argv: string[]): string {
export function createBridgeMcpServer(bridge: RemoteBridge): McpServer {
const server = new McpServer({ name: "dingtalk-approval-stdio-bridge", version: APPLICATION_VERSION }, {
instructions: "首次使用或登录链接过期时调用approval_login;每次调用会检查登录并在需要时生成有效的新链接,绝不因链接过期而重新安装。工具不会通过其他消息通道自动发送链接,必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。审批引用、调用容量和内部分批不得向用户解释;查看时只回答业务信息,用户明确授权后在授权范围内自动处理。写前校验失败时零写入,任何不确定结果都不得自动重试。提单草稿仅可保存在 Agent 所在机器的当前用户私有目录,不得上传网关;恢复草稿后必须重新读取实时模板 Schema,短期提交引用不得写入草稿。薪福通所有选择型字段只向用户展示业务名称:optionSource 选项展示label,内嵌枚举展示enumLabels;内部值仅供提交。不得向用户索取或展示城市码、组织码、工号、档案编号、数字舱位值或其他内部ID;selectionCapability=client_only时停止构造该必填项或允许跳过该可选项。",
instructions: "首次使用或登录链接过期时调用approval_login;每次调用会检查登录并在需要时生成有效的新链接,绝不因链接过期而重新安装。工具不会通过其他消息通道自动发送链接,必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。审批引用、调用容量和内部分批不得向用户解释;查看时只回答业务信息,用户明确授权后在授权范围内自动处理。写前校验失败时零写入,任何不确定结果都不得自动重试。提单草稿仅可保存在 Agent 所在机器的当前用户私有目录,不得上传网关;恢复草稿后必须重新读取实时模板 Schema,短期提交引用不得写入草稿。薪福通所有选择型字段只向用户展示业务名称:optionSource 选项展示label,内嵌枚举展示enumLabels;用户选定label后,values中必须填同一项的code而不能填label,code只存在于工具调用内部。不得向用户索取或展示城市码、组织码、工号、档案编号、数字舱位值或其他内部ID;selectionCapability=client_only时停止构造该必填项或允许跳过该可选项。",
});
server.registerTool("approval_login", {
title: "登录审批服务", description: "检查登录;未登录或旧链接已过期时生成新的钉钉授权链接。同一身份同时用于钉钉OA和薪福通,无需重新安装。", inputSchema: {},
@@ -441,19 +441,19 @@ export function createBridgeMcpServer(bridge: RemoteBridge): McpServer {
}, annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_list_submission_templates", args));
server.registerTool("approval_get_submission_schema", {
title: "获取发起审批表单结构", description: "实时读取模板字段、必填项、可读选择名称、复杂字段格式和 API 构造能力;client_only字段不得让用户提供内部值。", inputSchema: {
title: "获取发起审批表单结构", description: "实时读取模板字段、必填项、可读选择名称、minimalExample、复杂字段格式和 API 构造能力;minimalExample的尖括号必须替换,client_only字段不得让用户提供内部值。", inputSchema: {
templateId: z.string().min(20).max(4096),
}, annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_get_submission_schema", args));
server.registerTool("approval_get_submission_options", {
title: "查询发起审批可用选项", description: "实时查询当前登录用户本人的薪福通承担部门,或按名称搜索城市和费用类型。所有选项只向用户展示label,code仅供内部提交。", inputSchema: {
title: "查询发起审批可用选项", description: "实时查询当前登录用户本人的薪福通承担部门,或按名称搜索城市和费用类型。所有选项只向用户展示label;values必须填所选项的code而不能填label,code仅供工具内部提交。", inputSchema: {
templateId: z.string().min(20).max(4096), kind: z.enum(["department", "city", "expense_type"]),
query: z.string().max(100).default(""), limit: z.number().int().min(1).max(100).default(50),
}, annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_get_submission_options", args));
const submissionInputSchema = {
templateId: z.string().min(20).max(4096),
values: z.record(z.string().min(1).max(200), z.unknown()),
values: z.record(z.string().min(1).max(200), z.unknown()).describe("选择项必须使用所选label对应的code而不是label;code不得展示给用户"),
deptId: z.number().int().default(-1).describe("钉钉发起部门;默认-1,由网关按当前登录用户实时解析并验证"),
targetSelectActioners: z.array(z.object({
actionerKey: z.string().min(1).max(500),
@@ -462,7 +462,7 @@ export function createBridgeMcpServer(bridge: RemoteBridge): McpServer {
allowDuplicateInvoices: z.boolean().default(false).describe("仅在网关已提示重复风险或查重不完整、且用户明确确认仍要提交后设为 true"),
};
server.registerTool("approval_prepare_submission", {
title: "校验并预览新审批", description: "无副作用校验全部字段并自动查重本人近30天钉钉/薪福通发票;有重复风险或查重不完整时要求用户明确确认,不会直接生成提交引用。", inputSchema: submissionInputSchema,
title: "校验并预览新审批", description: "无副作用校验全部字段并自动查重本人近30天钉钉/薪福通发票;选择项在values中填所选label对应的code,成功预览只显示业务名称;有重复风险或查重不完整时要求用户明确确认,不会直接生成提交引用。", inputSchema: submissionInputSchema,
annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_prepare_submission", args));
server.registerTool("approval_submit", {
+5 -5
View File
@@ -41,7 +41,7 @@ export const APPROVAL_MCP_INSTRUCTIONS = [
"只有用户明确要求处理审批时,才调用 approval_process_batch;写操作不会二次确认。开始写入前会先校验整批 approvalId 和 targetId;任一引用无效则整批零写入。重新拉取待办后必须重新确认目标,禁止自动补偿或自动重试。",
"钉钉开放接口无法预检部分模板的签字等业务规则;要求手写签名时返回客户端处理链接。处理结果中 failed 表示操作被拒绝且未生效;除非用户看过原因后再次明确要求,否则不要重试。succeeded 表示节点提交成功;completed 才表示无需当前用户继续审批。uncertain/outcome_uncertain 表示已经提交但未确认,禁止自动重试,应稍后重新列出待办。followUpRequired 大于 0 时应重新列出待办并告知用户。",
"出现 authChallenge 时,工具不会通过其他消息通道自动发送内容。必须把 directUrl 原样写入当前面向用户的回复;有二维码图片时直接作为当前回复附件,禁止转成 ASCII,也不得只声称已生成或已推送。",
"发起审批范围仅包括薪福通已支持模板,以及 6 个钉钉候选模板:北极大的出差申请、外出、业务招待申请,捷康的差旅费用报销、业务招待费用报销、业务招待申请。禁止绕过范围限制。固定使用 approval_list_submission_templates → approval_get_submission_schema → approval_prepare_submission。所有选择型字段只向用户展示业务名称:optionSource 指向的部门、城市或费用类型必须用 approval_get_submission_options 实时查询;内嵌 options/enumLabels 只展示 label 或中文名称;用户选定后由 Agent 在内部映射正式值。绝不向用户展示、解释或索取字段编码、城市码、组织码、工号、档案编号、数字舱位值或其他内部 ID。selectionCapability.status=client_only 的字段不得猜测或构造;必填时停止并引导薪福通客户端,可选时只说明客户端可补充且允许用户跳过。必须先检查 submissionCapability;client_only 时停止 API 构造并引导原生客户端。读取 Schema 后,逐项核对用户已提供的信息;必填字段缺失或含糊时必须继续询问且不得猜测,直到全部完整。只列出 userVisible!=false 且非 client_only 的尚未填写非必填字段,使用业务名称说明并询问用户是否补充;用户可以明确跳过。prepare 若返回缺失、无效或需自选处理人的字段,继续询问后重新 prepare。prepare 成功后必须向用户展示完整预览并另行取得明确确认;确认后才用完全相同的内容调用 approval_submit。不要让用户或 Agent 提供发起人 userId、薪福通员工号或审批状态。",
"发起审批范围仅包括薪福通已支持模板,以及 6 个钉钉候选模板:北极大的出差申请、外出、业务招待申请,捷康的差旅费用报销、业务招待费用报销、业务招待申请。禁止绕过范围限制。固定使用 approval_list_submission_templates → approval_get_submission_schema → approval_prepare_submission。所有选择型字段只向用户展示业务名称:optionSource 指向的部门、城市或费用类型必须用 approval_get_submission_options 实时查询;内嵌 options/enumLabels 只展示 label 或中文名称。用户选择 label 后,Agent 必须在 values 对应字段中填入同一选项的 code,不能把 label 当作正式值;code 只存在于工具调用内部,绝不出现在用户对话。绝不向用户展示、解释或索取字段编码、城市码、组织码、工号、档案编号、数字舱位值或其他内部 ID。selectionCapability.status=client_only 的字段不得猜测或构造;必填时停止并引导薪福通客户端,可选时只说明客户端可补充且允许用户跳过。必须先检查 submissionCapability;client_only 时停止 API 构造并引导原生客户端。读取 Schema 后,逐项核对用户已提供的信息;必填字段缺失或含糊时必须继续询问且不得猜测,直到全部完整。只列出 userVisible!=false 且非 client_only 的尚未填写非必填字段,使用业务名称说明并询问用户是否补充;用户可以明确跳过。prepare 若返回缺失、无效或需自选处理人的字段,继续询问后重新 prepare。prepare 成功后必须向用户展示完整预览并另行取得明确确认;确认后才用完全相同的内容调用 approval_submit。不要让用户或 Agent 提供发起人 userId、薪福通员工号或审批状态。",
"用户明确要求起草、保存、继续、列出或删除提单草稿时,草稿只能由 Agent 保存在运行机器的当前 OS 用户私有数据目录:Windows %LOCALAPPDATA%\\DingTalkApproval\\drafts、macOS ~/Library/Application Support/DingTalkApproval/drafts、Linux ${XDG_DATA_HOME:-~/.local/share}/dingtalk-approval/drafts;POSIX 目录/文件权限为 0700/0600并用同目录临时文件原子替换。不得上传网关、写入项目仓库或声称已在钉钉/薪福通创建草稿。宿主没有本地文件能力时明确说明无法持久保存。每个 UUID.json 使用 schemaVersion=1、kind=dingtalk-approval-local-draft、draftId、title、source、templateName、status(editing或submit_uncertain)、values、optionalFieldDecision、attachments、createdAt、updatedAt;只保存用户填写值和附件本地路径/大小/mtime/SHA-256,不得保存 Token、身份 ID、短期 templateId/confirmationId/revokeId 或完整上游 Schema。恢复时必须重新列模板并实时读取 Schema,附件必须在正式准备前重新校验;本地路径不能当作上游附件值。每次草稿操作自动清理超过 90 天未更新的普通草稿。正式提交成功或即时完成后删除;明确失败时保留;outcome_uncertain 时冻结且禁止自动重试,先实时核验本人发起记录。",
"approval_prepare_submission 会自动检查当前用户近30天本人发起的钉钉与薪福通审批是否使用了相同发票。若返回 duplicate_confirmation_required 或 duplicate_check_unavailable,本次没有生成 confirmationId;必须展示匹配单号、状态和查重覆盖情况并询问用户。只有用户明确表示知晓重复或查重不完整仍要提交后,才可将 allowDuplicateInvoices=true 原样重新 prepare。不得自行代替用户确认。",
"approval_submit 发起后会返回实时核验结果;仍在审批中的单据会带 revokeId,按企业规则即时完成的薪福通流程返回 completed 且不可撤回。只有用户明确要求撤销该本人审批时才调用 approval_revoke_submission;禁止用撤销代替拒绝别人的待办,禁止自动重试 completed、failed 或 outcome_uncertain。薪福通创建或撤回需要复核时使用只读 approval_get_submission_status,它会实时校验本人已发起单据归属,但不会再次执行写操作。",
@@ -172,7 +172,7 @@ export function createApprovalMcpServer(
server.registerTool("approval_get_submission_schema", {
title: "获取发起审批表单结构",
description: "使用模板列表返回的templateId实时读取结构化字段、必填状态、可读选项和填写约束。选择项只向用户展示label/enumLabels;client_only字段不得索取内部值。Agent必须持续询问直至必填字段齐全,并主动提醒可选字段中能安全填写的业务项;不会返回超大的上游原始Schema。",
description: "使用模板列表返回的templateId实时读取结构化字段、必填状态、可读选项、填写约束和minimalExample。minimalExample只说明values形状,尖括号必须替换;选择项只向用户展示label/enumLabels;client_only字段不得索取内部值。Agent必须持续询问直至必填字段齐全,并主动提醒可选字段中能安全填写的业务项;不会返回超大的上游原始Schema。",
inputSchema: {
templateId: z.string().min(20).max(4096).describe("必须原样使用 approval_list_submission_templates 返回的短期签名引用"),
},
@@ -184,7 +184,7 @@ export function createApprovalMcpServer(
server.registerTool("approval_get_submission_options", {
title: "查询发起审批可用选项",
description: "使用薪福通templateId实时查询当前登录用户本人的承担部门,或按名称搜索城市和费用类型。所有结果只向用户展示label,Agent内部使用code提交;禁止向用户索取或展示内部编码。只返回可填写的code/label,不返回成员档案、员工号或原始上游数据。",
description: "使用薪福通templateId实时查询当前登录用户本人的承担部门,或按名称搜索城市和费用类型。所有结果只向用户展示label;用户选择后,values对应字段必须填同一项的code,不能填label。code仅留在工具调用内部,禁止向用户索取或展示。只返回可填写的code/label,不返回成员档案、员工号或原始上游数据。",
inputSchema: {
templateId: z.string().min(20).max(4096),
kind: z.enum(["department", "city", "expense_type"]),
@@ -199,7 +199,7 @@ export function createApprovalMcpServer(
const submissionInputSchema = {
templateId: z.string().min(20).max(4096).describe("模板列表返回的短期签名引用"),
values: z.record(z.string().min(1).max(200), z.unknown()).describe("按模板Schema填写的表单业务字段;不得包含发起人身份、员工号或审批状态"),
values: z.record(z.string().min(1).max(200), z.unknown()).describe("按模板Schema填写的表单业务字段;选择项必须使用所选label对应的code而不是label,code不得展示给用户;不得包含发起人身份、员工号或审批状态"),
deptId: z.number().int().default(-1).describe("仅钉钉OA使用;默认传-1,由服务器实时解析并验证当前登录用户所属部门"),
targetSelectActioners: z.array(z.object({
actionerKey: z.string().min(1).max(500),
@@ -210,7 +210,7 @@ export function createApprovalMcpServer(
server.registerTool("approval_prepare_submission", {
title: "校验并预览新审批",
description: "仅在用户已补齐所有必填字段并选择是否填写可选字段后调用。实时复核模板和字段构造能力,并自动查重本人近30天钉钉/薪福通发票。若返回缺失或无效字段,Agent必须继续询问;若为 client_only 则停止 API 构造。重复风险或查重不完整时不生成confirmationId,必须展示结果并询问用户;只有用户明确接受后才以allowDuplicateInvoices=true重试准备。成功预览仍需另行获得最终提交确认。",
description: "仅在用户已补齐所有必填字段并选择是否填写可选字段后调用。选择项在values中使用所选label对应的code,不能直接填label;code不得展示给用户。实时复核模板和字段构造能力,并自动查重本人近30天钉钉/薪福通发票。若返回缺失或无效字段,Agent必须继续询问;若为 client_only 则停止 API 构造。重复风险或查重不完整时不生成confirmationId,必须展示结果并询问用户;只有用户明确接受后才以allowDuplicateInvoices=true重试准备。成功预览会反向显示业务名称,仍需另行获得最终提交确认。",
inputSchema: submissionInputSchema,
annotations: { readOnlyHint: true, destructiveHint: false },
}, async ({ templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices }) => {
+241 -1
View File
@@ -19,6 +19,11 @@ const DINGTALK_SUBMISSION_TEMPLATES = {
} as const;
const DINGTALK_SUBMISSION_CATEGORIES = Object.keys(DINGTALK_SUBMISSION_TEMPLATES);
function xftSubmissionCapability(kind: XftSubmissionKind): Record<string, unknown> {
if (kind === "use_car_apply") return {
status: "client_only",
reason: "正式创建接口要求用车信息对象,但当前文档与实时模板未提供可验证的业务字段结构",
kind,
};
return {
status: "api_supported_with_constraints",
finalValidation: "薪福通创建接口会在正式提交时完成预算、权限和流程业务校验;创建后网关实时核验结果,流程可能进入待审批,也可能按企业规则即时完成",
@@ -467,7 +472,7 @@ export class UnifiedApprovalService {
applicant: "当前登录用户(由服务器映射,不能由 Agent 指定)",
identityValidation,
submissionStatus: "APPRV_PEND",
formSummary: summarizeValues(input.values),
formSummary: await readableXftSubmissionSummary(this.xft, user, schema.template, schema.fields, schema.inputContract, input.values),
processForecast: { supported: false, message: "薪福通正式接口不提供无副作用的流程预测;提交时由上游完成最终业务校验" },
};
fingerprint = hashJson({ template: schema.template, fields: schema.fields });
@@ -2021,6 +2026,9 @@ function validateXftSubmissionValues(
values: Record<string, unknown>,
customFields: XftSubmissionField[],
): void {
if (kind === "use_car_apply") {
throw new ApprovalSafetyError("用车申请的正式接口字段结构当前无法可靠构造,请在薪福通客户端发起");
}
const required: Record<XftSubmissionKind, string[]> = {
travel_apply: ["businessTrip"], expense_apply: ["applyExpenses"],
entertainment_apply: ["entertainmentApplyApiCreateDTO"], common_apply: [], use_car_apply: ["useCarInfo"],
@@ -2414,6 +2422,238 @@ function summarizeValue(value: unknown): 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);
+48
View File
@@ -1663,18 +1663,66 @@ function submissionInputContract(kind: XftSubmissionKind): Record<string, unknow
values: {
...core[kind],
standardFields: submissionStandardFields(kind),
minimalExample: submissionMinimalExample(kind),
},
customFieldValues: "使用 fields[].fieldNumber 与 fieldValue 组装;具体值格式以 fields[].type/options 为准",
serverManagedFields: [...XFT_MANAGED_SUBMISSION_FIELDS],
submissionStatus: "APPRV_PEND",
notes: [
"申请人身份、表单编码、权限校验和审批状态由网关填写,Agent 不得传入",
"minimalExample 只说明 values 的 JSON 形状;尖括号占位符必须替换为用户业务回答或实时选项对应值,绝不能原样提交",
"表单配置中的 billCompanyCode/billDepartmentCode 属于标准抬头字段,不放入 customFieldValues;两者均省略时由薪福通按当前申请人默认公司和部门填写",
"薪福通不提供无副作用的流程预测接口,准备阶段只做本地结构校验,最终业务校验在提交时完成",
],
};
}
function submissionMinimalExample(kind: XftSubmissionKind): Record<string, unknown> {
const city = "<由城市选项中用户所选名称对应的code替换>";
const expense = "<由费用类型选项中用户所选名称对应的code替换>";
const currency = "<人民币对应的内部枚举值>";
if (kind === "travel_apply") return {
businessTrip: { businessTripDetails: [{
departCityCode: city, destinationCityCode: city,
beginTime: "<yyyy-MM-dd HH:mm>", endTime: "<yyyy-MM-dd HH:mm>",
}] },
};
if (kind === "expense_apply") return {
applyExpenses: [{ expenseNumber: expense, amount: "<正数金额,最多两位小数>" }],
};
if (kind === "entertainment_apply") return {
entertainmentApplyApiCreateDTO: {
entertainmentCityCode: city, entertainmentPersonCount: "<正整数>",
entertainmentStartTime: "<yyyy-MM-dd>", entertainmentEndTime: "<yyyy-MM-dd>",
},
};
if (kind === "common_apply") return {
customFieldValues: [{
fieldNumber: "<从实时fields中复制当前字段的fieldNumber,仅工具内部使用>",
fieldValue: "<按该field的type/options序列化用户业务回答>",
}],
};
if (kind === "common_reimburse") return {
reimAmount: "<报销总金额>", reimAmountCurrencyCode: currency,
expenseList: [{
expTypeCode: expense, currencyCode: currency,
originalCurrencyReimAmt: "<本笔费用金额>",
}],
};
if (kind === "travel_reimburse") return {
reimAmount: "<报销总金额>", reimAmountCurrencyCode: currency,
expenseList: [{
expTypeCode: expense, currencyCode: currency,
originalCurrencyReimAmt: "<本笔费用金额>",
}],
businessTrip: { businessTripDetails: [{
departCityCode: city, destinationCityCode: city,
beginTime: "<yyyy-MM-dd HH:mm>", endTime: "<yyyy-MM-dd HH:mm>",
}] },
};
return { clientOnly: "用车信息对象当前必须在薪福通客户端填写" };
}
function submissionStandardFields(kind: XftSubmissionKind): Array<Record<string, unknown>> {
if (kind === "travel_reimburse") return travelReimbursementFields();
if (kind === "common_reimburse") return commonReimbursementFields();