feat: expose user-readable expense types
This commit is contained in:
+3
-3
@@ -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,短期提交引用不得写入草稿。",
|
||||
instructions: "首次使用或登录链接过期时调用approval_login;每次调用会检查登录并在需要时生成有效的新链接,绝不因链接过期而重新安装。工具不会通过其他消息通道自动发送链接,必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。审批引用、调用容量和内部分批不得向用户解释;查看时只回答业务信息,用户明确授权后在授权范围内自动处理。写前校验失败时零写入,任何不确定结果都不得自动重试。提单草稿仅可保存在 Agent 所在机器的当前用户私有目录,不得上传网关;恢复草稿后必须重新读取实时模板 Schema,短期提交引用不得写入草稿。薪福通费用类型未提供时调用approval_get_submission_options(kind=expense_type),只向用户展示label并在内部使用code,绝不索取或展示内部费用编码。",
|
||||
});
|
||||
server.registerTool("approval_login", {
|
||||
title: "登录审批服务", description: "检查登录;未登录或旧链接已过期时生成新的钉钉授权链接。同一身份同时用于钉钉OA和薪福通,无需重新安装。", inputSchema: {},
|
||||
@@ -446,8 +446,8 @@ export function createBridgeMcpServer(bridge: RemoteBridge): McpServer {
|
||||
}, annotations: { readOnlyHint: true, destructiveHint: false },
|
||||
}, async (args) => bridge.call("approval_get_submission_schema", args));
|
||||
server.registerTool("approval_get_submission_options", {
|
||||
title: "查询发起审批可用选项", description: "实时查询当前登录用户本人的薪福通承担部门,或搜索城市编码。", inputSchema: {
|
||||
templateId: z.string().min(20).max(4096), kind: z.enum(["department", "city"]),
|
||||
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 },
|
||||
}, async (args) => bridge.call("approval_get_submission_options", args));
|
||||
|
||||
+3
-3
@@ -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 实时查询,不得猜测。必须先检查 submissionCapability;client_only 时停止 API 构造并引导钉钉客户端。读取 Schema 后,逐项核对用户已提供的信息;必填字段缺失或含糊时必须继续询问且不得猜测,直到全部完整。必须列出尚未填写的非必填字段并询问用户是否补充,用户可以明确跳过。prepare 若返回缺失、无效或需自选处理人的字段,继续询问后重新 prepare。prepare 成功后必须向用户展示完整预览并另行取得明确确认;确认后才用完全相同的内容调用 approval_submit。不要让用户或 Agent 提供发起人 userId、薪福通员工号或审批状态。",
|
||||
"发起审批范围仅包括薪福通已支持模板,以及 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、薪福通员工号或审批状态。",
|
||||
"用户明确要求起草、保存、继续、列出或删除提单草稿时,草稿只能由 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,它会实时校验本人已发起单据归属,但不会再次执行写操作。",
|
||||
@@ -184,10 +184,10 @@ export function createApprovalMcpServer(
|
||||
|
||||
server.registerTool("approval_get_submission_options", {
|
||||
title: "查询发起审批可用选项",
|
||||
description: "使用薪福通templateId实时查询当前登录用户本人的承担部门,或按名称搜索城市编码。只返回可填写的code/label,不返回成员档案、员工号或原始上游数据。",
|
||||
description: "使用薪福通templateId实时查询当前登录用户本人的承担部门,或按名称搜索城市和费用类型。费用类型未提供时必须用kind=expense_type并只向用户展示label,内部使用code,禁止向用户索取或展示内部编码。只返回可填写的code/label,不返回成员档案、员工号或原始上游数据。",
|
||||
inputSchema: {
|
||||
templateId: z.string().min(20).max(4096),
|
||||
kind: z.enum(["department", "city"]),
|
||||
kind: z.enum(["department", "city", "expense_type"]),
|
||||
query: z.string().max(100).default(""),
|
||||
limit: z.number().int().min(1).max(100).default(50),
|
||||
},
|
||||
|
||||
@@ -2052,7 +2052,7 @@ function validateXftExpenseApplication(value: unknown): void {
|
||||
if (!Array.isArray(value) || !value.length) throw new ApprovalSafetyError("事前申请费用 applyExpenses 至少需要 1 条");
|
||||
value.forEach((item, index) => {
|
||||
if (!isSubmissionRecord(item)) throw new ApprovalSafetyError(`事前申请费用第 ${index + 1} 条必须是对象`);
|
||||
if (isBlankSubmissionValue(item.expenseNumber)) throw new ApprovalSafetyError(`事前申请费用第 ${index + 1} 条缺少费用类型编码 expenseNumber`);
|
||||
if (isBlankSubmissionValue(item.expenseNumber)) throw new ApprovalSafetyError(`事前申请费用第 ${index + 1} 条缺少费用类型,请先查询并选择可用名称`);
|
||||
requirePositiveDecimal(item.amount, `事前申请费用第 ${index + 1} 条申请金额 amount`);
|
||||
if (!isBlankSubmissionValue(item.participantNum)
|
||||
&& (!Number.isSafeInteger(Number(item.participantNum)) || Number(item.participantNum) <= 0)) {
|
||||
@@ -2142,9 +2142,9 @@ function validateXftExpenses(value: unknown): void {
|
||||
if (!Array.isArray(value) || value.length === 0) throw new ApprovalSafetyError("费用明细 expenseList 至少需要一笔");
|
||||
value.forEach((item, index) => {
|
||||
if (!isSubmissionRecord(item)) throw new ApprovalSafetyError(`第 ${index + 1} 笔费用必须是对象`);
|
||||
for (const key of ["expTypeCode", "currencyCode", "originalCurrencyReimAmt"] as const) {
|
||||
if (isBlankSubmissionValue(item[key])) throw new ApprovalSafetyError(`第 ${index + 1} 笔费用缺少 ${key}`);
|
||||
}
|
||||
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`);
|
||||
requirePositiveDecimal(item.originalCurrencyReimAmt, `第 ${index + 1} 笔费用金额 originalCurrencyReimAmt`);
|
||||
if (item.wrTim !== undefined) requireDateString(item.wrTim, `第 ${index + 1} 笔消费日期 wrTim`, /^\d{4}-\d{2}-\d{2}$/);
|
||||
@@ -2177,7 +2177,7 @@ function validateXftBusinessTrip(value: unknown): void {
|
||||
if (!Array.isArray(item.estimateExpenses)) throw new ApprovalSafetyError(`第 ${index + 1} 段行程 estimateExpenses 必须是数组`);
|
||||
item.estimateExpenses.forEach((expense, expenseIndex) => {
|
||||
if (!isSubmissionRecord(expense) || isBlankSubmissionValue(expense.expenseTypeNumber) || isBlankSubmissionValue(expense.applyAmount)) {
|
||||
throw new ApprovalSafetyError(`第 ${index + 1} 段行程第 ${expenseIndex + 1} 笔预计费用缺少 expenseTypeNumber 或 applyAmount`);
|
||||
throw new ApprovalSafetyError(`第 ${index + 1} 段行程第 ${expenseIndex + 1} 笔预计费用缺少费用类型或申请金额`);
|
||||
}
|
||||
requirePositiveDecimal(expense.applyAmount, `第 ${index + 1} 段行程第 ${expenseIndex + 1} 笔预计费用 applyAmount`);
|
||||
});
|
||||
|
||||
+119
-6
@@ -80,6 +80,9 @@ const XFT_MANAGED_SUBMISSION_FIELDS = new Set([
|
||||
const XFT_STANDARD_FORM_FIELD_NUMBERS = new Set(["billCompanyCode", "billDepartmentCode"]);
|
||||
const CITY_OPTIONS_TIMEOUT_MS = 45_000;
|
||||
const CITY_OPTIONS_CACHE_MS = 24 * 60 * 60 * 1000;
|
||||
const EXPENSE_TYPE_SOURCE_PAGE_SIZE = 100;
|
||||
const EXPENSE_TYPE_SOURCE_MAX_PAGES = 5;
|
||||
const EXPENSE_TYPE_HINT = /费用\s*(?:类型|类别|项目)|(?:类型|类别|项目)\s*费用|费目|expense[\s_-]*type/i;
|
||||
|
||||
/** Official XFT OpenAPI adapter. It never accepts an empId from an Agent. */
|
||||
export class XftOpenApiClient implements XftApprovalProvider {
|
||||
@@ -369,13 +372,13 @@ export class XftOpenApiClient implements XftApprovalProvider {
|
||||
limit = 50,
|
||||
) {
|
||||
const identity = await this.resolveIdentity(user);
|
||||
await this.requireSubmissionTemplate(identity, template);
|
||||
const current = await this.requireSubmissionTemplate(identity, template);
|
||||
let options: XftSubmissionOption[];
|
||||
let cacheHit = false;
|
||||
if (kind === "department") {
|
||||
const member = await this.requireCurrentMember(identity);
|
||||
options = extractDepartmentOptions(member);
|
||||
} else {
|
||||
} else if (kind === "city") {
|
||||
if (this.cityOptionsCache && Date.now() - this.cityOptionsCache.fetchedAt < CITY_OPTIONS_CACHE_MS) {
|
||||
options = this.cityOptionsCache.items;
|
||||
cacheHit = true;
|
||||
@@ -390,13 +393,21 @@ export class XftOpenApiClient implements XftApprovalProvider {
|
||||
options = extractCityOptions(cities);
|
||||
this.cityOptionsCache = { fetchedAt: Date.now(), items: options };
|
||||
}
|
||||
} else {
|
||||
options = await this.loadExpenseTypeOptions(identity, current);
|
||||
if (!options.length) {
|
||||
throw new ApprovalSafetyError("暂时无法实时获取可选费用类型,请稍后重试或在薪福通客户端完成");
|
||||
}
|
||||
}
|
||||
const filtered = query.trim()
|
||||
? options.filter((item) => `${item.label}\n${item.code}`.toLowerCase().includes(query.trim().toLowerCase()))
|
||||
: options;
|
||||
const boundedLimit = Math.min(100, Math.max(1, limit));
|
||||
return {
|
||||
kind,
|
||||
items: filtered.slice(0, Math.min(100, Math.max(1, limit))),
|
||||
items: filtered.slice(0, boundedLimit),
|
||||
total: filtered.length,
|
||||
hasMore: filtered.length > boundedLimit,
|
||||
diagnostics: {
|
||||
source: "xft_official_openapi",
|
||||
realtime: !cacheHit,
|
||||
@@ -407,6 +418,64 @@ export class XftOpenApiClient implements XftApprovalProvider {
|
||||
};
|
||||
}
|
||||
|
||||
private async loadExpenseTypeOptions(
|
||||
identity: XftIdentity,
|
||||
template: XftSubmissionTemplate,
|
||||
): Promise<XftSubmissionOption[]> {
|
||||
try {
|
||||
const rawFields = await this.request(
|
||||
`/itrip/xft-api/v1/form/customFields/eventSeqId/${encodeURIComponent(template.eventSeqId)}`,
|
||||
undefined,
|
||||
"GET",
|
||||
identity,
|
||||
);
|
||||
const embedded = extractExpenseTypeOptions(rawFields);
|
||||
if (embedded.length) return embedded;
|
||||
} catch {
|
||||
// Some templates do not expose standard expense dictionaries in custom fields.
|
||||
// Fall through to the enterprise multilevel data-source APIs.
|
||||
}
|
||||
|
||||
const candidates: Array<{ id: string; raw: JsonObject }> = [];
|
||||
for (let page = 0; page < EXPENSE_TYPE_SOURCE_MAX_PAGES; page += 1) {
|
||||
let response: unknown;
|
||||
try {
|
||||
response = await this.request(
|
||||
"/itrip/xft-api/v1/dataSource/findMultilevelSourcePage",
|
||||
{ start: page * EXPENSE_TYPE_SOURCE_PAGE_SIZE, limit: EXPENSE_TYPE_SOURCE_PAGE_SIZE },
|
||||
"POST",
|
||||
identity,
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const records = pagedRecords(response);
|
||||
for (const record of records) {
|
||||
const name = stringAt(record, ["sourceName", "dataSourceName", "multilevelSourceName", "displayName", "name", "title"]);
|
||||
const id = stringAt(record, ["sourceSeqId", "dataSourceSeqId", "multilevelSourceSeqId", "seqId", "id"]);
|
||||
if (id && name && EXPENSE_TYPE_HINT.test(name)) candidates.push({ id, raw: record });
|
||||
}
|
||||
if (records.length < EXPENSE_TYPE_SOURCE_PAGE_SIZE) break;
|
||||
}
|
||||
|
||||
const output: XftSubmissionOption[] = [];
|
||||
for (const candidate of candidates.slice(0, 10)) {
|
||||
output.push(...extractExpenseTypeOptions(candidate.raw, true));
|
||||
try {
|
||||
const detail = await this.request(
|
||||
`/itrip/xft-api/v1/dataSource/getMultiLevelDetail/${encodeURIComponent(candidate.id)}`,
|
||||
undefined,
|
||||
"GET",
|
||||
identity,
|
||||
);
|
||||
output.push(...extractExpenseTypeOptions(detail, true));
|
||||
} catch {
|
||||
// Continue with other matching sources. Empty output fails closed below.
|
||||
}
|
||||
}
|
||||
return uniqueOptions(output);
|
||||
}
|
||||
|
||||
async submitApproval(
|
||||
user: AuthenticatedDingTalkUser,
|
||||
template: XftSubmissionTemplate,
|
||||
@@ -1258,6 +1327,41 @@ function extractCityOptions(value: unknown): XftSubmissionOption[] {
|
||||
return uniqueOptions(candidates);
|
||||
}
|
||||
|
||||
function extractExpenseTypeOptions(value: unknown, trustedExpenseSource = false): XftSubmissionOption[] {
|
||||
const candidates: XftSubmissionOption[] = [];
|
||||
const visit = (current: unknown, path: string[], inheritedHint: boolean, depth: number): void => {
|
||||
if (depth > 12) return;
|
||||
if (Array.isArray(current)) {
|
||||
for (const item of current) visit(item, path, inheritedHint, depth + 1);
|
||||
return;
|
||||
}
|
||||
if (!isObject(current)) return;
|
||||
const record = current;
|
||||
const recordHint = EXPENSE_TYPE_HINT.test(stringAt(record, [
|
||||
"fieldName", "name", "label", "title", "sourceName", "dataSourceName", "multilevelSourceName",
|
||||
]) ?? "");
|
||||
const pathHint = path.some((part) => EXPENSE_TYPE_HINT.test(part));
|
||||
const inExpenseContext = trustedExpenseSource || inheritedHint || recordHint || pathHint;
|
||||
if (inExpenseContext && record.selectable !== false && record.canSelect !== false && record.disabled !== true) {
|
||||
const explicitCode = stringAt(record, [
|
||||
"expenseNumber", "expenseTypeNumber", "expTypeCode", "expenseTypeCode", "expenseTypeNo", "expTypeNo",
|
||||
]);
|
||||
const code = explicitCode ?? (path.length > 0 ? stringAt(record, [
|
||||
"optionCode", "optionNumber", "itemCode", "itemNumber", "nodeCode", "nodeNumber", "dataCode", "dataNumber",
|
||||
"sourceOptionCode", "sourceOptionNumber", "code", "number", "value", "key",
|
||||
]) : undefined);
|
||||
const label = stringAt(record, [
|
||||
"expenseName", "expenseTypeName", "expTypeName", "optionName", "itemName", "nodeName", "dataName",
|
||||
"sourceOptionName", "label", "text", "displayName", "name", "title",
|
||||
]);
|
||||
if (code && label) candidates.push({ code, label });
|
||||
}
|
||||
for (const [key, child] of Object.entries(record)) visit(child, [...path, key], inExpenseContext, depth + 1);
|
||||
};
|
||||
visit(value, [], trustedExpenseSource, 0);
|
||||
return uniqueOptions(candidates);
|
||||
}
|
||||
|
||||
function visitObjects(
|
||||
value: unknown,
|
||||
visitor: (record: JsonObject, path: string[]) => void,
|
||||
@@ -1420,10 +1524,19 @@ function normalizeSubmissionField(value: JsonObject): XftSubmissionField[] {
|
||||
...(typeof value.multiple === "boolean" ? { multiple: value.multiple } : {}),
|
||||
...(Array.isArray(value.options) ? { options: value.options.map((option) => sanitizeOption(option)).filter((option) => option !== undefined) } : {}),
|
||||
...(typeof value.moduleType === "string" && value.moduleType ? { moduleType: value.moduleType } : {}),
|
||||
...(isExpenseTypeField(fieldNumber, name) ? { optionSource: expenseTypeOptionSource() } : {}),
|
||||
...submissionFieldShape(value, type),
|
||||
}];
|
||||
}
|
||||
|
||||
function isExpenseTypeField(fieldNumber: string, name: string): boolean {
|
||||
return EXPENSE_TYPE_HINT.test(name) || /^(?:expenseNumber|expenseTypeNumber|expTypeCode)$/i.test(fieldNumber);
|
||||
}
|
||||
|
||||
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"> {
|
||||
const normalized = type.toUpperCase();
|
||||
if (normalized.includes("TABLE")) {
|
||||
@@ -1522,7 +1635,7 @@ function submissionInputContractSummary(kind: XftSubmissionKind): Record<string,
|
||||
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: "企业费用管理中定义的费用类型编码" },
|
||||
{ key: "expenseNumber", label: "费用类型", required: true, type: "string", description: "向用户展示可读名称,内部填写所选项的 code", optionSource: expenseTypeOptionSource() },
|
||||
{ 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" },
|
||||
@@ -1560,7 +1673,7 @@ function travelApplicationFields(): Array<Record<string, unknown>> {
|
||||
{ key: "endTimePrecision", label: "结束时间半天精度", required: false, type: "string", enum: ["AM", "PM"] },
|
||||
{ key: "customArchiveOptionNumbers", label: "行程自定义档案选项", required: false, type: "array" },
|
||||
{ key: "estimateExpenses", label: "预计费用", required: false, type: "array", itemFields: [
|
||||
{ key: "expenseTypeNumber", label: "费用类型编码", required: true, type: "string" },
|
||||
{ key: "expenseTypeNumber", label: "费用类型", required: true, type: "string", description: "向用户展示可读名称,内部填写所选项的 code", optionSource: expenseTypeOptionSource() },
|
||||
{ key: "applyAmount", label: "申请金额", required: true, type: "decimal" },
|
||||
{ key: "remark", label: "备注", required: false, type: "string" },
|
||||
{ key: "spreadInfo", label: "分摊信息", required: false, type: "object" },
|
||||
@@ -1622,7 +1735,7 @@ 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: "企业费用管理中定义的费用类型编码" },
|
||||
{ key: "expTypeCode", label: "费用类型", required: true, type: "string", maxLength: 100, description: "向用户展示可读名称,内部填写所选项的 code", optionSource: expenseTypeOptionSource() },
|
||||
{ key: "currencyCode", label: "费用币种", required: true, type: "string", enum: ["CNY"] },
|
||||
{ key: "originalCurrencyReimAmt", label: "费用金额", required: true, type: "decimal", precision: 2 },
|
||||
{ key: "wrTim", label: "消费日期", required: false, type: "date", format: "yyyy-MM-dd" },
|
||||
|
||||
+7
-1
@@ -81,9 +81,13 @@ export interface XftSubmissionField {
|
||||
moduleType?: string;
|
||||
valueFormat?: string;
|
||||
itemFields?: Array<Record<string, unknown>>;
|
||||
optionSource?: {
|
||||
tool: "approval_get_submission_options";
|
||||
kind: XftSubmissionOptionKind;
|
||||
};
|
||||
}
|
||||
|
||||
export type XftSubmissionOptionKind = "department" | "city";
|
||||
export type XftSubmissionOptionKind = "department" | "city" | "expense_type";
|
||||
|
||||
export interface XftSubmissionOption {
|
||||
code: string;
|
||||
@@ -159,6 +163,8 @@ export interface XftApprovalProvider {
|
||||
): Promise<{
|
||||
kind: XftSubmissionOptionKind;
|
||||
items: XftSubmissionOption[];
|
||||
total?: number;
|
||||
hasMore?: boolean;
|
||||
diagnostics: Record<string, unknown>;
|
||||
}>;
|
||||
validateSubmissionIdentity(user: AuthenticatedDingTalkUser): Promise<{
|
||||
|
||||
Reference in New Issue
Block a user