feat: expose readable submission choices
This commit is contained in:
+73
-18
@@ -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 必须是数组`);
|
||||
|
||||
Reference in New Issue
Block a user