fix: keep submission previews user-readable
This commit is contained in:
+241
-1
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user