feat: expose readable submission choices

This commit is contained in:
2026-08-20 00:40:36 +00:00
parent 34c3c03a5f
commit ecb2f570e0
15 changed files with 454 additions and 114 deletions
+73 -18
View File
@@ -389,11 +389,16 @@ export class UnifiedApprovalService {
const template = this.decodeSubmissionTemplate(user, templateId);
if (template.source === "xft") {
if (!this.xft || !template.xftTemplate) throw new ApprovalSafetyError("薪福通提单源未启用");
const capability = xftSubmissionCapability(template.xftTemplate.kind);
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",
...(await this.xft.getSubmissionSchema(user, template.xftTemplate)),
...schema,
submissionCapability: capability,
};
}
@@ -2022,25 +2027,34 @@ function validateXftSubmissionValues(
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);
if (missingBusiness.length || missingCustom.length) {
const businessLabels: Record<string, string> = {
businessTrip: "差旅行程", applyExpenses: "事前申请费用", entertainmentApplyApiCreateDTO: "招待信息",
useCarInfo: "用车信息", reimAmount: "报销总金额", reimAmountCurrencyCode: "报销币种", expenseList: "费用明细",
};
const parts = [
...(missingBusiness.length ? [`业务字段:${missingBusiness.join("、")}`] : []),
...(missingCustom.length ? [`自定义字段:${missingCustom.map((field) => `${field.name}(${field.fieldNumber})`).join("、")}`] : []),
...(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("薪福通单据公司 billCompanyCode 和单据部门 billDepartmentCode 必须同时填写或同时省略");
if (hasBillCompany || hasBillDepartment) {
throw new ApprovalSafetyError("单据公司和部门由薪福通按当前登录用户填写,不应向用户索取或由 Agent 指定");
}
validateRequiredXftCustomFields(customFields, values.customFieldValues);
validateXftCustomFieldValues(customFields, values.customFieldValues);
if (kind === "common_reimburse" || kind === "travel_reimburse") {
requirePositiveDecimal(values.reimAmount, "报销总金额 reimAmount");
if (values.reimAmountCurrencyCode !== "CNY") throw new ApprovalSafetyError("报销币种 reimAmountCurrencyCode 目前只支持 CNY");
if (values.reimAmountCurrencyCode !== "CNY") throw new ApprovalSafetyError("报销币种目前只支持人民币");
validateXftExpenses(values.expenseList);
}
if (kind === "travel_apply" || kind === "travel_reimburse") validateXftBusinessTrip(values.businessTrip);
@@ -2048,6 +2062,40 @@ function validateXftSubmissionValues(
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) => {
@@ -2080,7 +2128,7 @@ function validateRequiredXftCustomFields(
): void {
const missing = missingRequiredXftCustomFields(fields, value);
if (missing.length) {
throw new ApprovalSafetyError(`薪福通提单缺少必填自定义字段:${missing.map((field) => `${field.name}(${field.fieldNumber})`).join("、")}`);
throw new ApprovalSafetyError(`薪福通提单缺少必填自定义字段:${missing.map((field) => field.name).join("、")}`);
}
}
@@ -2106,17 +2154,20 @@ function validateXftCustomFieldValues(fields: XftSubmissionField[], value: unkno
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 (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("PERSON") || type.includes("EMP") || type.includes("DEPARTMENT") || type.includes("ORG")) {
if (type.includes("DEPARTMENT") || type.includes("ORG")) {
let parsed: unknown;
try { parsed = JSON.parse(raw.fieldValue); } catch { throw new ApprovalSafetyError(`薪福通自定义字段“${field.name}”必须是 JSON 数组字符串`); }
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}”必须是非空编码组成的 JSON 数组字符串`);
throw new ApprovalSafetyError(`薪福通字段“${field.name}”的部门选择格式无效,请重新查询并选择部门名称`);
}
}
});
@@ -2134,6 +2185,10 @@ function validateXftTableField(field: XftSubmissionField, value: string): void {
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 安全填写,请在薪福通客户端完成`);
}
}
});
}
@@ -2145,7 +2200,7 @@ function validateXftExpenses(value: unknown): void {
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} 笔费用币种目前只支持 CNY`);
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}$/);
});
@@ -2157,21 +2212,21 @@ function validateXftBusinessTrip(value: unknown): void {
}
value.businessTripDetails.forEach((item, index) => {
if (!isSubmissionRecord(item)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程必须是对象`);
for (const key of ["departCityCode", "destinationCityCode", "endTime"] as const) {
if (isBlankSubmissionValue(item[key])) throw new ApprovalSafetyError(`第 ${index + 1} 段行程缺少 ${key}`);
}
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} 段行程缺少 beginTime(官方示例别名 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} 段行程 roundTrip 只能是 N 或 Y`);
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} 段行程 travelMode 含不支持的出行方式`);
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 必须是数组`);