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
+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);