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
+3 -3
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,短期提交引用不得写入草稿。薪福通费用类型未提供时调用approval_get_submission_options(kind=expense_type),只向用户展示label并在内部使用code,绝不索取或展示内部费用编码。",
instructions: "首次使用或登录链接过期时调用approval_login;每次调用会检查登录并在需要时生成有效的新链接,绝不因链接过期而重新安装。工具不会通过其他消息通道自动发送链接,必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。审批引用、调用容量和内部分批不得向用户解释;查看时只回答业务信息,用户明确授权后在授权范围内自动处理。写前校验失败时零写入,任何不确定结果都不得自动重试。提单草稿仅可保存在 Agent 所在机器的当前用户私有目录,不得上传网关;恢复草稿后必须重新读取实时模板 Schema,短期提交引用不得写入草稿。薪福通所有选择型字段只向用户展示业务名称:optionSource 选项展示label,内嵌枚举展示enumLabels;内部值仅供提交。不得向用户索取或展示城市码、组织码、工号、档案编号、数字舱位值或其他内部ID;selectionCapability=client_only时停止构造该必填项或允许跳过该可选项。",
});
server.registerTool("approval_login", {
title: "登录审批服务", description: "检查登录;未登录或旧链接已过期时生成新的钉钉授权链接。同一身份同时用于钉钉OA和薪福通,无需重新安装。", inputSchema: {},
@@ -441,12 +441,12 @@ 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 构造能力。", inputSchema: {
title: "获取发起审批表单结构", description: "实时读取模板字段、必填项、可读选择名称、复杂字段格式和 API 构造能力;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,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 },
+3 -3
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。薪福通部门、城市或费用类型选项必须用 approval_get_submission_options 实时查询,不得猜测。用户未提供费用类型时,必须用 kind=expense_type 获取可选项,只向用户展示并询问 label(如交通费、住宿费),用户选定后在内部使用对应 code;绝不向用户展示、解释或索取 expenseNumber、expenseTypeNumber、expTypeCode 等字段的内部编码。必须先检查 submissionCapability;client_only 时停止 API 构造并引导钉钉客户端。读取 Schema 后,逐项核对用户已提供的信息;必填字段缺失或含糊时必须继续询问且不得猜测,直到全部完整。必须列出尚未填写的非必填字段并询问用户是否补充,用户可以明确跳过。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 或中文名称;用户选定后由 Agent 在内部映射正式值。绝不向用户展示、解释或索取字段编码、城市码、组织码、工号、档案编号、数字舱位值或其他内部 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实时读取结构化字段、必填状态和填写约束。Agent必须持续询问直至必填字段齐全,并主动提醒可选字段;不会返回超大的上游原始Schema。",
description: "使用模板列表返回的templateId实时读取结构化字段、必填状态、可读选项和填写约束。选择项只向用户展示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实时查询当前登录用户本人的承担部门,或按名称搜索城市和费用类型。费用类型未提供时必须用kind=expense_type并只向用户展示label,内部使用code,禁止向用户索取或展示内部编码。只返回可填写的code/label,不返回成员档案、员工号或原始上游数据。",
description: "使用薪福通templateId实时查询当前登录用户本人的承担部门,或按名称搜索城市和费用类型。所有结果只向用户展示label,Agent内部使用code提交;禁止向用户索取或展示内部编码。只返回可填写的code/label,不返回成员档案、员工号或原始上游数据。",
inputSchema: {
templateId: z.string().min(20).max(4096),
kind: z.enum(["department", "city", "expense_type"]),
+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 必须是数组`);
+188 -69
View File
@@ -1515,6 +1515,11 @@ function normalizeSubmissionField(value: JsonObject): XftSubmissionField[] {
const name = stringAt(value, ["name"]);
const type = stringAt(value, ["type"]);
if (!fieldNumber || !name || !type || value.isDisplay === false) return [];
const options = Array.isArray(value.options) ? normalizeSubmissionOptions(value.options) : [];
const normalizedType = type.toUpperCase();
const optionSource = isExpenseTypeField(fieldNumber, name) ? expenseTypeOptionSource()
: normalizedType.includes("DEPARTMENT") || normalizedType.includes("ORG") ? departmentOptionSource()
: /城市|city/i.test(`${fieldNumber} ${name}`) ? cityOptionSource() : undefined;
return [{
fieldNumber,
name,
@@ -1522,10 +1527,10 @@ function normalizeSubmissionField(value: JsonObject): XftSubmissionField[] {
required: value.required === true,
...(typeof value.description === "string" && value.description.trim() ? { description: value.description.trim() } : {}),
...(typeof value.multiple === "boolean" ? { multiple: value.multiple } : {}),
...(Array.isArray(value.options) ? { options: value.options.map((option) => sanitizeOption(option)).filter((option) => option !== undefined) } : {}),
...(options.length ? { options } : {}),
...(typeof value.moduleType === "string" && value.moduleType ? { moduleType: value.moduleType } : {}),
...(isExpenseTypeField(fieldNumber, name) ? { optionSource: expenseTypeOptionSource() } : {}),
...submissionFieldShape(value, type),
...(optionSource ? { optionSource } : {}),
...submissionFieldShape(value, type, options, optionSource),
}];
}
@@ -1537,32 +1542,89 @@ function expenseTypeOptionSource() {
return { tool: "approval_get_submission_options" as const, kind: "expense_type" as const };
}
function submissionFieldShape(value: JsonObject, type: string): Pick<XftSubmissionField, "valueFormat" | "itemFields"> {
function cityOptionSource() {
return { tool: "approval_get_submission_options" as const, kind: "city" as const };
}
function departmentOptionSource() {
return { tool: "approval_get_submission_options" as const, kind: "department" as const };
}
const LABEL_ONLY_SELECTION = {
status: "supported" as const,
userInput: "label" as const,
submitValue: "code" as const,
exposeInternalValue: false as const,
};
function clientOnlySelection(reason: string) {
return { status: "client_only" as const, reason };
}
function submissionFieldShape(
value: JsonObject,
type: string,
options: XftSubmissionOption[],
optionSource?: ReturnType<typeof expenseTypeOptionSource> | ReturnType<typeof cityOptionSource> | ReturnType<typeof departmentOptionSource>,
): Pick<XftSubmissionField, "valueFormat" | "itemFields" | "selectionCapability"> {
const normalized = type.toUpperCase();
if (normalized.includes("TABLE")) {
const candidates = [value.columns, value.children, value.fieldList, value.tableColumns]
.find(Array.isArray) as unknown[] | undefined;
const itemFields = (candidates ?? []).filter(isObject).flatMap((column) => {
const titleCode = stringAt(column, ["titleCode", "fieldNumber", "fieldCode", "code"]);
const label = stringAt(column, ["name", "title", "label"]);
const columnType = stringAt(column, ["type", "fieldType"]);
if (!titleCode) return [];
return [{ titleCode, ...(label ? { label } : {}), ...(columnType ? { type: columnType } : {}), required: column.required === true }];
});
const itemFields = (candidates ?? []).filter(isObject).flatMap(normalizeSubmissionTableColumn);
return {
valueFormat: "JSON array: [{sequence:1,columnValues:[{titleCode,value}]}]",
...(itemFields.length ? { itemFields } : {}),
};
}
if (normalized.includes("ATTACH") || normalized.includes("ACCESSOR")) return { valueFormat: "附件编号字符串(须先通过薪福通附件接口取得)" };
if (normalized.includes("PERSON") || normalized.includes("EMP")) return { valueFormat: "薪福通花名册编号 JSON 数组" };
if (normalized.includes("DEPARTMENT") || normalized.includes("ORG")) return { valueFormat: "薪福通组织编码 JSON 数组" };
if (normalized.includes("ATTACH") || normalized.includes("ACCESSOR")) return {
valueFormat: "当前 MCP 无法安全上传附件,请在薪福通客户端补充",
selectionCapability: clientOnlySelection("当前 MCP 没有薪福通附件上传能力"),
};
if (normalized.includes("PERSON") || normalized.includes("EMP")) return {
valueFormat: "当前 MCP 无法按姓名安全选择人员,请在薪福通客户端补充",
selectionCapability: clientOnlySelection("正式接口需要内部花名册值,但当前没有按姓名查询人员的安全接口"),
};
if (normalized.includes("DEPARTMENT") || normalized.includes("ORG")) return {
valueFormat: "向用户展示部门名称,Agent 内部使用所选项",
selectionCapability: LABEL_ONLY_SELECTION,
};
if (normalized === "DATE") return { valueFormat: "yyyy-MM-dd HH:mm" };
if (normalized.includes("SELECT")) return { valueFormat: "使用 options 中的编码" };
if (normalized.includes("SELECT")) {
if (options.length || optionSource) return {
valueFormat: "向用户展示选项名称,Agent 内部使用所选项",
selectionCapability: LABEL_ONLY_SELECTION,
};
return {
valueFormat: "当前模板未返回可读选项,请在薪福通客户端补充",
selectionCapability: clientOnlySelection("模板没有返回可验证的名称与内部值映射"),
};
}
if (normalized.includes("INPUT")) return { valueFormat: "文本字符串" };
return {};
}
function normalizeSubmissionTableColumn(column: JsonObject): Array<Record<string, unknown>> {
const titleCode = stringAt(column, ["titleCode", "fieldNumber", "fieldCode", "code"]);
const label = stringAt(column, ["name", "title", "label"]);
const type = stringAt(column, ["type", "fieldType"]);
if (!titleCode) return [];
const options = Array.isArray(column.options) ? normalizeSubmissionOptions(column.options) : [];
const normalizedType = (type ?? "").toUpperCase();
const optionSource = isExpenseTypeField(titleCode, label ?? "") ? expenseTypeOptionSource()
: normalizedType.includes("DEPARTMENT") || normalizedType.includes("ORG") ? departmentOptionSource()
: /城市|city/i.test(`${titleCode} ${label ?? ""}`) ? cityOptionSource() : undefined;
return [{
titleCode,
...(label ? { label } : {}),
...(type ? { type } : {}),
required: column.required === true,
...(options.length ? { options } : {}),
...(optionSource ? { optionSource } : {}),
...submissionFieldShape(column, type ?? "", options, optionSource),
}];
}
function deduplicateSubmissionFields(fields: XftSubmissionField[]): XftSubmissionField[] {
const seen = new Set<string>();
return fields.filter((field) => {
@@ -1575,13 +1637,16 @@ function deduplicateSubmissionFields(fields: XftSubmissionField[]): XftSubmissio
});
}
function sanitizeOption(value: unknown): unknown {
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
function sanitizeOption(value: unknown): XftSubmissionOption | undefined {
if (!isObject(value)) return undefined;
const label = stringAt(value, ["label", "name", "value", "optionName"]);
const code = stringAt(value, ["code", "number", "optionCode", "key"]);
if (!label && !code) return undefined;
return { ...(label ? { label } : {}), ...(code ? { code } : {}) };
const label = stringAt(value, ["label", "name", "optionName", "displayName", "value"]);
const code = stringAt(value, ["code", "number", "optionCode", "key", "value"]);
if (!label || !code) return undefined;
return { label, code };
}
function normalizeSubmissionOptions(values: unknown[]): XftSubmissionOption[] {
return uniqueOptions(values.map(sanitizeOption).filter((item): item is XftSubmissionOption => Boolean(item)));
}
function submissionInputContract(kind: XftSubmissionKind): Record<string, unknown> {
@@ -1632,13 +1697,67 @@ function submissionInputContractSummary(kind: XftSubmissionKind): Record<string,
return values[kind];
}
function cityField(key: string, label: string, required: boolean): Record<string, unknown> {
return {
key, label, required, type: "string",
description: "向用户展示城市名称,Agent 内部使用所选项",
optionSource: cityOptionSource(),
selectionCapability: LABEL_ONLY_SELECTION,
};
}
function expenseTypeField(key: string, required: boolean): Record<string, unknown> {
return {
key, label: "费用类型", required, type: "string",
description: "向用户展示费用名称,Agent 内部使用所选项",
optionSource: expenseTypeOptionSource(),
selectionCapability: LABEL_ONLY_SELECTION,
};
}
function readableEnumField(
key: string,
label: string,
required: boolean,
labels: Record<string, string>,
extra: Record<string, unknown> = {},
): Record<string, unknown> {
return {
key, label, required, type: "string", enum: Object.keys(labels), enumLabels: labels,
description: "只向用户展示中文选项名称,Agent 内部使用对应值",
selectionCapability: LABEL_ONLY_SELECTION,
...extra,
};
}
function clientOnlyField(
key: string,
label: string,
required: boolean,
reason: string,
extra: Record<string, unknown> = {},
): Record<string, unknown> {
return {
key, label, required, ...extra,
selectionCapability: clientOnlySelection(reason),
description: `${reason};不要向用户索取内部编码或 ID,如需填写请在薪福通客户端完成`,
};
}
const TRAVEL_MODE_LABELS = {
Train: "火车", Bus: "汽车", Ship: "轮船", Hotel: "住宿", RideHailing: "网约车", AirPlane: "飞机", Other: "其他",
} as const;
const ROUND_TRIP_LABELS = { N: "单程", Y: "往返" } as const;
const HALF_DAY_LABELS = { AM: "上午", PM: "下午" } as const;
const CURRENCY_LABELS = { CNY: "人民币" } as const;
function expenseApplicationFields(): Array<Record<string, unknown>> {
return [{
key: "applyExpenses", label: "事前申请费用列表", required: true, type: "array", minItems: 1, itemFields: [
{ key: "expenseNumber", label: "费用类型", required: true, type: "string", description: "向用户展示可读名称,内部填写所选项的 code", optionSource: expenseTypeOptionSource() },
expenseTypeField("expenseNumber", true),
{ key: "amount", label: "申请金额", required: true, type: "decimal", precision: 2 },
{ key: "occurrenceDate", label: "费用发生时间", required: false, type: "datetime", description: "ISO 8601 时间" },
{ key: "payCityCode", label: "消费城市编码", required: false, type: "string" },
cityField("payCityCode", "消费城市", false),
{ key: "participantNum", label: "参与人数", required: false, type: "integer" },
{ key: "merchantName", label: "商户名称", required: false, type: "string" },
{ key: "remark", label: "备注", required: false, type: "string" },
@@ -1650,7 +1769,7 @@ function expenseApplicationFields(): Array<Record<string, unknown>> {
function entertainmentApplicationFields(): Array<Record<string, unknown>> {
return [{
key: "entertainmentApplyApiCreateDTO", label: "招待信息", required: true, type: "object", fields: [
{ key: "entertainmentCityCode", label: "招待城市编码", required: true, type: "string", description: "薪福通城市 cityCode" },
cityField("entertainmentCityCode", "招待城市", true),
{ key: "entertainmentPersonCount", label: "招待人数", required: true, type: "integer", minimum: 1 },
{ key: "entertainmentStartTime", label: "招待开始日期", required: true, type: "date", format: "yyyy-MM-dd" },
{ key: "entertainmentEndTime", label: "招待结束日期", required: true, type: "date", format: "yyyy-MM-dd" },
@@ -1663,44 +1782,44 @@ function travelApplicationFields(): Array<Record<string, unknown>> {
key: "businessTrip", label: "差旅行程", required: true, type: "object", fields: [{
key: "businessTripDetails", label: "行程明细", required: true, type: "array", minItems: 1,
itemFields: [
{ key: "departCityCode", label: "出发城市编码", required: true, type: "string" },
{ key: "destinationCityCode", label: "目的城市编码", required: true, type: "string" },
cityField("departCityCode", "出发城市", true),
cityField("destinationCityCode", "目的城市", true),
{ key: "beginTime", label: "开始时间", required: true, type: "datetime", format: "yyyy-MM-dd HH:mm" },
{ key: "endTime", label: "结束时间", required: true, type: "datetime", format: "yyyy-MM-dd HH:mm" },
{ key: "travelMode", label: "出行方式", required: false, type: "comma_separated_enum", enum: ["Train", "Bus", "Ship", "Hotel", "RideHailing", "AirPlane", "Other"] },
{ key: "roundTrip", label: "是否往返", required: false, type: "string", enum: ["N", "Y"] },
{ key: "beginTimePrecision", label: "开始时间半天精度", required: false, type: "string", enum: ["AM", "PM"] },
{ key: "endTimePrecision", label: "结束时间半天精度", required: false, type: "string", enum: ["AM", "PM"] },
{ key: "customArchiveOptionNumbers", label: "行程自定义档案选项", required: false, type: "array" },
readableEnumField("travelMode", "出行方式", false, TRAVEL_MODE_LABELS, { type: "comma_separated_enum", multiple: true }),
readableEnumField("roundTrip", "是否往返", false, ROUND_TRIP_LABELS),
readableEnumField("beginTimePrecision", "开始时间范围", false, HALF_DAY_LABELS),
readableEnumField("endTimePrecision", "结束时间范围", false, HALF_DAY_LABELS),
clientOnlyField("customArchiveOptionNumbers", "行程自定义档案", false, "当前 MCP 没有按名称查询企业自定义档案的可靠能力", { type: "array" }),
{ key: "estimateExpenses", label: "预计费用", required: false, type: "array", itemFields: [
{ key: "expenseTypeNumber", label: "费用类型", required: true, type: "string", description: "向用户展示可读名称,内部填写所选项的 code", optionSource: expenseTypeOptionSource() },
expenseTypeField("expenseTypeNumber", true),
{ key: "applyAmount", label: "申请金额", required: true, type: "decimal" },
{ key: "remark", label: "备注", required: false, type: "string" },
{ key: "spreadInfo", label: "分摊信息", required: false, type: "object" },
clientOnlyField("spreadInfo", "分摊信息", false, "当前 MCP 无法把企业分摊名称安全映射为正式接口值", { type: "object" }),
] },
],
}] },
{ key: "relatedBillId", label: "关联单据 ID", required: false, type: "string" },
{ key: "peerEmpNumbers", label: "同行人员工号", required: false, type: "array" },
{ key: "billCompanyCode", label: "单据公司编码", required: false, type: "string" },
{ key: "billDepartmentCode", label: "单据部门编码", required: false, type: "string" },
clientOnlyField("relatedBillId", "关联申请单", false, "当前 MCP 没有按业务名称安全选择前置单据的能力", { type: "string" }),
clientOnlyField("peerEmpNumbers", "同行人员", false, "当前 MCP 没有按姓名查询薪福通人员的安全接口", { type: "array" }),
{ key: "billCompanyCode", label: "单据公司", required: false, type: "string", serverManaged: true, userVisible: false, description: "省略后由薪福通按当前登录用户填写" },
{ key: "billDepartmentCode", label: "单据部门", required: false, type: "string", serverManaged: true, userVisible: false, description: "省略后由薪福通按当前登录用户填写" },
];
}
function reimbursementHeaderFields(): Array<Record<string, unknown>> {
return [
{ key: "reimAmount", label: "报销总金额", required: true, type: "decimal", precision: 2 },
{ key: "reimAmountCurrencyCode", label: "报销币种", required: true, type: "string", enum: ["CNY"], description: "正式接口目前仅支持人民币" },
{ key: "outRelId", label: "外部关联 ID", required: false, type: "string", maxLength: 100 },
{ key: "billDepartmentCode", label: "单据部门编码", required: false, type: "string", maxLength: 100 },
{ key: "billCompanyCode", label: "单据公司编码", required: false, type: "string", maxLength: 50 },
{ key: "customFieldValues", label: "单据自定义字段", required: false, type: "array", itemFields: customFieldValueFields() },
{ key: "billCollectionInfo", label: "收款信息", required: false, type: "object", description: "需要付款时填写;包含收款人、银行、账号、金额和币种" },
{ key: "billLendPayRelList", label: "核销借款信息", required: false, type: "array", itemFields: [
readableEnumField("reimAmountCurrencyCode", "报销币种", true, CURRENCY_LABELS, { default: "CNY" }),
{ key: "outRelId", label: "外部关联标识", required: false, type: "string", maxLength: 100, userVisible: false, description: "仅供系统集成使用,不应询问普通用户" },
{ key: "billDepartmentCode", label: "单据部门", required: false, type: "string", maxLength: 100, serverManaged: true, userVisible: false, description: "省略后由薪福通按当前登录用户填写" },
{ key: "billCompanyCode", label: "单据公司", required: false, type: "string", maxLength: 50, serverManaged: true, userVisible: false, description: "省略后由薪福通按当前登录用户填写" },
{ key: "customFieldValues", label: "单据自定义字段", required: false, type: "array", userVisible: false, description: "由 Agent 根据实时 fields 组装,不向用户展示字段编码", itemFields: customFieldValueFields() },
clientOnlyField("billCollectionInfo", "收款信息", false, "当前 MCP 无法完整验证收款账户的正式接口引用", { type: "object" }),
clientOnlyField("billLendPayRelList", "核销借款", false, "当前 MCP 没有按可读单号安全选择本人借款单的能力", { type: "array", itemFields: [
{ key: "lendBillId", label: "借款单号", required: true, type: "string" },
{ key: "currencyCode", label: "核销币种", required: true, type: "string", enum: ["CNY"] },
readableEnumField("currencyCode", "核销币种", true, CURRENCY_LABELS, { default: "CNY" }),
{ key: "originalPayAmount", label: "核销金额", required: true, type: "decimal", precision: 2 },
] },
] }),
];
}
@@ -1712,21 +1831,21 @@ function travelReimbursementFields(): Array<Record<string, unknown>> {
return [
...reimbursementHeaderFields(),
expenseListField(true),
{ key: "peerEmpNumbers", label: "同行人员工号", required: false, type: "array", maxItems: 50 },
clientOnlyField("peerEmpNumbers", "同行人员", false, "当前 MCP 没有按姓名查询薪福通人员的安全接口", { type: "array", maxItems: 50 }),
{
key: "businessTrip", label: "差旅行程", required: true, type: "object", fields: [{
key: "businessTripDetails", label: "行程明细", required: true, type: "array", minItems: 1,
itemFields: [
{ key: "departCityCode", label: "出发城市编码", required: true, type: "string", description: "薪福通城市 cityCode" },
{ key: "destinationCityCode", label: "目的城市编码", required: true, type: "string", description: "薪福通城市 cityCode" },
cityField("departCityCode", "出发城市", true),
cityField("destinationCityCode", "目的城市", true),
{ key: "beginTime", aliases: ["startTime"], label: "出发时间", required: true, type: "datetime", format: "yyyy-MM-dd HH:mm" },
{ key: "endTime", label: "结束时间", required: true, type: "datetime", format: "yyyy-MM-dd HH:mm" },
{ key: "travelMode", label: "出行方式", required: false, type: "comma_separated_enum", enum: ["Train", "Bus", "Ship", "Hotel", "RideHailing", "AirPlane", "Other"] },
{ key: "roundTrip", label: "是否往返", required: false, type: "string", enum: ["N", "Y"], default: "N" },
{ key: "beginTimePrecision", label: "开始时间半天精度", required: false, type: "string", enum: ["AM", "PM"], description: "模板时间精度为半天时必填" },
{ key: "endTimePrecision", label: "结束时间半天精度", required: false, type: "string", enum: ["AM", "PM"], description: "模板时间精度为半天时必填" },
readableEnumField("travelMode", "出行方式", false, TRAVEL_MODE_LABELS, { type: "comma_separated_enum", multiple: true }),
readableEnumField("roundTrip", "是否往返", false, ROUND_TRIP_LABELS, { default: "N" }),
readableEnumField("beginTimePrecision", "开始时间范围", false, HALF_DAY_LABELS, { condition: "模板时间精度为半天时必填" }),
readableEnumField("endTimePrecision", "结束时间范围", false, HALF_DAY_LABELS, { condition: "模板时间精度为半天时必填" }),
{ key: "tripReason", label: "出差事由", required: false, type: "string", maxLength: 200, description: "模板将行程事由设为必填时必须填写" },
{ key: "customArchiveOptionNumbers", label: "行程自定义档案选项", required: false, type: "array" },
clientOnlyField("customArchiveOptionNumbers", "行程自定义档案", false, "当前 MCP 没有按名称查询企业自定义档案的可靠能力", { type: "array" }),
],
}],
},
@@ -1735,25 +1854,25 @@ function travelReimbursementFields(): Array<Record<string, unknown>> {
function expenseListField(travel: boolean): Record<string, unknown> {
const itemFields: Array<Record<string, unknown>> = [
{ key: "expTypeCode", label: "费用类型", required: true, type: "string", maxLength: 100, description: "向用户展示可读名称,内部填写所选项的 code", optionSource: expenseTypeOptionSource() },
{ key: "currencyCode", label: "费用币种", required: true, type: "string", enum: ["CNY"] },
{ ...expenseTypeField("expTypeCode", true), maxLength: 100 },
readableEnumField("currencyCode", "费用币种", true, CURRENCY_LABELS, { default: "CNY" }),
{ key: "originalCurrencyReimAmt", label: "费用金额", required: true, type: "decimal", precision: 2 },
{ key: "wrTim", label: "消费日期", required: false, type: "date", format: "yyyy-MM-dd" },
{ key: "remark", label: "费用备注", required: false, type: "string", maxLength: 500 },
{ key: "merchantName", label: "商户名称", required: false, type: "string", maxLength: 200 },
{ key: "participants", label: "费用参与人员工号", required: false, type: "array", maxItems: 50 },
clientOnlyField("participants", "费用参与人员", false, "当前 MCP 没有按姓名查询薪福通人员的安全接口", { type: "array", maxItems: 50 }),
{ key: "partiNbr", label: "参与人数", required: false, type: "integer" },
{ key: "taxRate", label: "税率", required: false, type: "decimal", precision: 2 },
{ key: "taxFreeAmount", label: "不含税金额", required: false, type: "decimal", precision: 2 },
{ key: "departureCode", label: "费用出发城市编码", required: false, type: "string" },
{ key: "destinationCode", label: "费用目的城市编码", required: false, type: "string" },
cityField("departureCode", "费用出发城市", false),
cityField("destinationCode", "费用目的城市", false),
{ key: "bgnTim", label: "交通出发日期", required: false, type: "date", format: "yyyy-MM-dd" },
{ key: "endTim", label: "交通到达日期", required: false, type: "date", format: "yyyy-MM-dd" },
{ key: "checkInDate", label: "酒店入住日期", required: false, type: "date", format: "yyyy-MM-dd" },
{ key: "checkOutDate", label: "酒店离店日期", required: false, type: "date", format: "yyyy-MM-dd" },
{ key: "payCityCode", label: "消费城市编码", required: false, type: "string" },
{ key: "hotelStar", label: "酒店星级", required: false, type: "string", enum: ["1", "2", "3", "4"], enumLabels: { "1": "五星", "2": "四星", "3": "三星", "4": "其他" } },
{ key: "aircraftCabin", label: "飞机舱位", required: false, type: "string", enum: ["1", "2", "3", "4"] },
cityField("payCityCode", "消费城市", false),
readableEnumField("hotelStar", "酒店星级", false, { "1": "五星", "2": "四星", "3": "三星", "4": "其他" }),
clientOnlyField("aircraftCabin", "飞机舱位", false, "正式接口文档未提供可验证的舱位名称映射", { type: "string" }),
{ key: "airlineNo", label: "航班号", required: false, type: "string" },
{ key: "airlineCom", label: "航空公司", required: false, type: "string" },
{ key: "flightETicketNo", label: "电子客票号", required: false, type: "string" },
@@ -1761,20 +1880,20 @@ function expenseListField(travel: boolean): Record<string, unknown> {
{ key: "civilAviationFund", label: "民航发展基金", required: false, type: "decimal", precision: 2 },
{ key: "discount", label: "机票折扣", required: false, type: "decimal", precision: 2 },
{ key: "trainNo", label: "列车号", required: false, type: "string" },
{ key: "trainCabin", label: "火车席别", required: false, type: "string", description: "使用官方席别编码 1-15" },
clientOnlyField("trainCabin", "火车席别", false, "正式接口文档未提供可验证的席别名称映射", { type: "string" }),
{ key: "trainETicketNo", label: "火车票票号", required: false, type: "string" },
{ key: "shipCabin", label: "轮船舱位", required: false, type: "string", enum: ["1", "2", "3"] },
{ key: "customFieldValues", label: "费用自定义字段", required: false, type: "array", itemFields: customFieldValueFields() },
{ key: "spreadInfo", label: "费用分摊", required: false, type: "object", description: "按企业分摊方式填写" },
clientOnlyField("shipCabin", "轮船舱位", false, "正式接口文档未提供可验证的舱位名称映射", { type: "string" }),
{ key: "customFieldValues", label: "费用自定义字段", required: false, type: "array", userVisible: false, description: "由 Agent 根据实时 fields 组装,不向用户展示字段编码", itemFields: customFieldValueFields() },
clientOnlyField("spreadInfo", "费用分摊", false, "当前 MCP 无法把企业分摊名称安全映射为正式接口值", { type: "object" }),
];
if (travel) itemFields.push({ key: "dstntIndex", label: "归属行程序号", required: false, type: "string", description: "从 0 开始;不填则不归组到行程" });
if (travel) itemFields.push({ key: "dstntIndex", label: "归属行程", required: false, type: "string", userVisible: false, internallyDerived: true, description: "由 Agent 根据用户选择的行程位置内部换算,不向用户展示序号" });
return { key: "expenseList", label: "费用明细", required: true, type: "array", minItems: 1, itemFields };
}
function customFieldValueFields(): Array<Record<string, unknown>> {
return [
{ key: "fieldNumber", label: "自定义字段编码", required: true, type: "string" },
{ key: "fieldValue", label: "自定义字段值", required: true, type: "string", description: "复杂值按字段类型序列化为 JSON 字符串" },
{ key: "fieldNumber", label: "字段引用", required: true, type: "string", userVisible: false, description: "由 Agent 从实时 Schema 内部复制,不向用户展示或索取" },
{ key: "fieldValue", label: "字段值", required: true, type: "string", userVisible: false, description: "由 Agent 根据字段类型内部序列化" },
];
}
+15 -1
View File
@@ -77,7 +77,8 @@ export interface XftSubmissionField {
required: boolean;
description?: string;
multiple?: boolean;
options?: unknown[];
options?: XftSubmissionOption[];
enumLabels?: Record<string, string>;
moduleType?: string;
valueFormat?: string;
itemFields?: Array<Record<string, unknown>>;
@@ -85,6 +86,7 @@ export interface XftSubmissionField {
tool: "approval_get_submission_options";
kind: XftSubmissionOptionKind;
};
selectionCapability?: XftSubmissionSelectionCapability;
}
export type XftSubmissionOptionKind = "department" | "city" | "expense_type";
@@ -94,6 +96,18 @@ export interface XftSubmissionOption {
label: string;
}
export type XftSubmissionSelectionCapability =
| {
status: "supported";
userInput: "label";
submitValue: "code";
exposeInternalValue: false;
}
| {
status: "client_only";
reason: string;
};
export interface XftApprovalProvider {
authStatus(user: AuthenticatedDingTalkUser): Promise<{
authenticated: true;