feat: expose user-readable expense types
This commit is contained in:
@@ -248,6 +248,7 @@ test("MCP approval_login returns both portable text and a standard resource link
|
||||
assert.match(instructions, /登录链接过期.*approval_login.*绝不.*重新安装/);
|
||||
assert.match(instructions, /审批引用.*调用容量.*内部分批.*不得向用户解释/);
|
||||
assert.match(instructions, /提单草稿仅可保存在 Agent 所在机器.*不得上传网关.*实时模板 Schema/s);
|
||||
assert.match(instructions, /kind=expense_type.*只向用户展示label.*内部使用code.*绝不索取或展示内部费用编码/s);
|
||||
assert.doesNotMatch(instructions, /最多.*20|selectionId|approvalId|targetId/);
|
||||
const history = tools.tools.find((tool) => tool.name === "approval_list_history")!;
|
||||
assert.equal((history.inputSchema as any).properties.startDate.default, "");
|
||||
@@ -255,6 +256,9 @@ test("MCP approval_login returns both portable text and a standard resource link
|
||||
const batch = tools.tools.find((tool) => tool.name === "approval_process_batch")!;
|
||||
assert.equal((batch.inputSchema as any).properties.items.maxItems, 20);
|
||||
assert.match(batch.description ?? "", /Schema容量.*不向用户解释.*任一无效则零写入/s);
|
||||
const options = tools.tools.find((tool) => tool.name === "approval_get_submission_options")!;
|
||||
assert.deepEqual((options.inputSchema as any).properties.kind.enum, ["department", "city", "expense_type"]);
|
||||
assert.match(options.description ?? "", /费用类型.*只向用户展示label.*code仅供内部提交/s);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
|
||||
@@ -105,6 +105,9 @@ test("customer distribution prefers remote MCP OAuth and keeps a generic compati
|
||||
assert.match(localDrafts, /never on the approval gateway/);
|
||||
assert.match(localDrafts, /local attachment path.*never a valid upstream attachment field value.*native client/s);
|
||||
assert.match(createApproval, /Local file paths.*not valid attachment field values.*native client/s);
|
||||
assert.match(createApproval, /kind=expense_type.*returned `label` values.*selected label.*`code` internally/s);
|
||||
assert.match(createApproval, /Never show, explain, or ask the user for `expenseNumber`.*`expenseTypeNumber`.*`expTypeCode`/s);
|
||||
assert.match(localDrafts, /expense-type code.*internal form value.*only that label.*never expose the stored code/s);
|
||||
|
||||
const builder = readFileSync("scripts/build-release.mjs", "utf8");
|
||||
assert.match(builder, /schemaVersion: 3,\s+version,/s);
|
||||
|
||||
@@ -52,6 +52,8 @@ test("MCP initialization teaches the workflow while tools/list stays compact", a
|
||||
assert.match(client.getInstructions() ?? "", /恢复时.*实时读取 Schema/s);
|
||||
assert.match(client.getInstructions() ?? "", /自动清理超过 90 天/s);
|
||||
assert.match(client.getInstructions() ?? "", /outcome_uncertain.*冻结.*禁止自动重试/s);
|
||||
assert.match(client.getInstructions() ?? "", /kind=expense_type.*只向用户展示.*label.*内部使用.*code/s);
|
||||
assert.match(client.getInstructions() ?? "", /绝不向用户展示、解释或索取.*expenseNumber.*expenseTypeNumber.*expTypeCode/s);
|
||||
|
||||
const tools = await client.listTools();
|
||||
assert.deepEqual(tools.tools.map((tool) => tool.name), [
|
||||
@@ -83,7 +85,9 @@ test("MCP initialization teaches the workflow while tools/list stays compact", a
|
||||
assert.match(descriptions.approval_process_batch ?? "", /Schema容量.*不得转述给用户.*任一无效.*零写入/s);
|
||||
assert.match(descriptions.approval_prepare_submission ?? "", /明确确认|确认/);
|
||||
assert.match(descriptions.approval_get_submission_schema ?? "", /必填字段齐全.*可选字段/);
|
||||
assert.match(descriptions.approval_get_submission_options ?? "", /承担部门.*城市编码/);
|
||||
assert.match(descriptions.approval_get_submission_options ?? "", /承担部门.*城市.*费用类型.*只向用户展示label.*内部使用code/s);
|
||||
const optionSchema = tools.tools.find((tool) => tool.name === "approval_get_submission_options")?.inputSchema as any;
|
||||
assert.deepEqual(optionSchema.properties.kind.enum, ["department", "city", "expense_type"]);
|
||||
assert.match(descriptions.approval_prepare_submission ?? "", /继续询问/);
|
||||
assert.match(descriptions.approval_prepare_submission ?? "", /查重.*allowDuplicateInvoices/s);
|
||||
const prepareSchema = tools.tools.find((tool) => tool.name === "approval_prepare_submission")?.inputSchema as any;
|
||||
|
||||
+132
-2
@@ -44,6 +44,24 @@ function decodedBody(init?: RequestInit): Record<string, unknown> {
|
||||
return JSON.parse(String(sm4.decrypt(envelope.secretMsg, privateKey.slice(0, 32)))) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function findRecordByKey(value: unknown, key: string): Record<string, unknown> | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const match = findRecordByKey(item, key);
|
||||
if (match) return match;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (!value || typeof value !== "object") return undefined;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.key === key) return record;
|
||||
for (const child of Object.values(record)) {
|
||||
const match = findRecordByKey(child, key);
|
||||
if (match) return match;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
test("Xft OpenAPI maps the authenticated DingTalk user and signs encrypted requests", async () => {
|
||||
const calls: Array<{ path: string; body: Record<string, unknown>; headers: Headers; url: URL }> = [];
|
||||
const fakeFetch: typeof fetch = async (input, init) => {
|
||||
@@ -344,9 +362,14 @@ test("Xft submission discovers live templates and derives protected create field
|
||||
assert.equal(schema.fields.length, 1);
|
||||
const standardFields = (schema.inputContract.values as Record<string, unknown>).standardFields as Array<Record<string, unknown>>;
|
||||
const expenseList = standardFields.find((field) => field.key === "expenseList");
|
||||
assert.deepEqual((expenseList?.itemFields as Array<Record<string, unknown>>).filter((field) => field.required).map((field) => field.key), [
|
||||
const expenseItemFields = expenseList?.itemFields as Array<Record<string, unknown>>;
|
||||
assert.deepEqual(expenseItemFields.filter((field) => field.required).map((field) => field.key), [
|
||||
"expTypeCode", "currencyCode", "originalCurrencyReimAmt",
|
||||
]);
|
||||
assert.deepEqual(expenseItemFields.find((field) => field.key === "expTypeCode")?.optionSource, {
|
||||
tool: "approval_get_submission_options", kind: "expense_type",
|
||||
});
|
||||
assert.equal(expenseItemFields.find((field) => field.key === "expTypeCode")?.label, "费用类型");
|
||||
const identityCheck = await client.validateSubmissionIdentity({ userId: "ding-u1" });
|
||||
assert.equal(identityCheck.validated, true);
|
||||
assert.doesNotMatch(JSON.stringify(identityCheck), /E001|EMP-1/);
|
||||
@@ -360,11 +383,13 @@ test("Xft submission discovers live templates and derives protected create field
|
||||
assert.equal(createBodies[0]?.billApplyAuthCheckType, "Y");
|
||||
});
|
||||
|
||||
test("Xft submission options expose only the current user's departments and searchable city codes", async () => {
|
||||
test("Xft submission options expose only departments, cities, and the matching expense data source", async () => {
|
||||
const templateRecord = {
|
||||
eventSeqId: 1, eventNumber: "TRAVEL-1", eventName: "出差费用申请单", eventStatus: "1", eventType: "TRAVEL_APPLY",
|
||||
};
|
||||
let cityRequests = 0;
|
||||
let expenseSourceRequests = 0;
|
||||
let unrelatedSourceRequests = 0;
|
||||
const client = new XftOpenApiClient(config(), async (input, init) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
|
||||
@@ -386,6 +411,30 @@ test("Xft submission options expose only the current user's departments and sear
|
||||
regions: [{ cityCode: "440300", cityName: "深圳市" }, { cityCode: "441900", cityName: "东莞市" }],
|
||||
} });
|
||||
}
|
||||
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [] });
|
||||
if (path.endsWith("dataSource/findMultilevelSourcePage")) {
|
||||
assert.deepEqual(decodedBody(init), { start: 0, limit: 100 });
|
||||
return encrypted({ code: "0", data: { records: [
|
||||
{ sourceSeqId: "DS-EXP", sourceName: "企业费用类型" },
|
||||
{ sourceSeqId: "DS-PROJECT", sourceName: "项目档案" },
|
||||
] } });
|
||||
}
|
||||
if (path.endsWith("dataSource/getMultiLevelDetail/DS-EXP")) {
|
||||
expenseSourceRequests += 1;
|
||||
return encrypted({ code: "0", data: {
|
||||
sourceSeqId: "DS-EXP",
|
||||
sourceName: "企业费用类型",
|
||||
levels: [{ nodes: [
|
||||
{ optionNumber: "TRAFFIC", optionName: "交通费" },
|
||||
{ optionNumber: "HOTEL", optionName: "住宿费" },
|
||||
{ optionNumber: "HIDDEN", optionName: "停用费用", selectable: false },
|
||||
] }],
|
||||
} });
|
||||
}
|
||||
if (path.endsWith("dataSource/getMultiLevelDetail/DS-PROJECT")) {
|
||||
unrelatedSourceRequests += 1;
|
||||
return encrypted({ code: "0", data: { nodes: [{ optionNumber: "P1", optionName: "项目一" }] } });
|
||||
}
|
||||
throw new Error(`unexpected ${path}`);
|
||||
});
|
||||
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
|
||||
@@ -400,6 +449,87 @@ test("Xft submission options expose only the current user's departments and sear
|
||||
const cachedCities = await client.getSubmissionOptions({ userId: "ding-u1" }, template, "city", "东莞", 10);
|
||||
assert.deepEqual(cachedCities.items, [{ code: "441900", label: "东莞市" }]);
|
||||
assert.equal(cityRequests, 1);
|
||||
const expenseTypes = await client.getSubmissionOptions({ userId: "ding-u1" }, template, "expense_type", "住宿", 10);
|
||||
assert.deepEqual(expenseTypes.items, [{ code: "HOTEL", label: "住宿费" }]);
|
||||
assert.equal(expenseTypes.total, 1);
|
||||
assert.equal(expenseTypes.hasMore, false);
|
||||
assert.equal(expenseSourceRequests, 1);
|
||||
assert.equal(unrelatedSourceRequests, 0);
|
||||
});
|
||||
|
||||
test("all formal Xft expense fields declare a user-readable expense type option source", async () => {
|
||||
const records = [
|
||||
{ eventSeqId: 11, eventNumber: "EXP-11", eventName: "费用申请", eventStatus: "1", eventType: "EXPENSE_APPLY" },
|
||||
{ eventSeqId: 12, eventNumber: "TRIP-12", eventName: "出差费用申请", eventStatus: "1", eventType: "TRAVEL_APPLY" },
|
||||
{ eventSeqId: 13, eventNumber: "REIM-13", eventName: "费用报销", eventStatus: "1", eventType: "COMMON_REIM" },
|
||||
];
|
||||
const client = new XftOpenApiClient(config(), async (input) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
|
||||
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: records } });
|
||||
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [] });
|
||||
throw new Error(`unexpected ${path}`);
|
||||
});
|
||||
const templates = await client.listSubmissionTemplates({ userId: "ding-u1" }, 10);
|
||||
const expected = ["expenseNumber", "expenseTypeNumber", "expTypeCode"];
|
||||
for (const [index, fieldKey] of expected.entries()) {
|
||||
const schema = await client.getSubmissionSchema({ userId: "ding-u1" }, templates[index]!);
|
||||
const field = findRecordByKey(schema.inputContract, fieldKey);
|
||||
assert.equal(field?.label, "费用类型");
|
||||
assert.deepEqual(field?.optionSource, { tool: "approval_get_submission_options", kind: "expense_type" });
|
||||
assert.doesNotMatch(String(field?.description ?? ""), /要求用户.*编码/);
|
||||
}
|
||||
});
|
||||
|
||||
test("Xft expense types prefer user-readable live template options and fail closed when unavailable", async () => {
|
||||
const templateRecord = {
|
||||
eventSeqId: 8, eventNumber: "EXP-8", eventName: "招待费用申请单", eventStatus: "1", eventType: "EXPENSE_APPLY",
|
||||
};
|
||||
let dataSourceRequests = 0;
|
||||
const client = new XftOpenApiClient(config(), async (input) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
|
||||
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: [templateRecord] } });
|
||||
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [{
|
||||
fieldNumber: "expenseType", name: "费用类型", type: "SELECT", required: true,
|
||||
options: [
|
||||
{ optionCode: "MEAL", optionName: "客户招待费" },
|
||||
{ optionCode: "CAR", optionName: "市内交通费" },
|
||||
{ optionCode: "其他费", optionName: "其他费" },
|
||||
],
|
||||
}] });
|
||||
if (path.includes("dataSource/")) {
|
||||
dataSourceRequests += 1;
|
||||
throw new Error("data source must not be called");
|
||||
}
|
||||
throw new Error(`unexpected ${path}`);
|
||||
});
|
||||
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
|
||||
const schema = await client.getSubmissionSchema({ userId: "ding-u1" }, template);
|
||||
assert.deepEqual(schema.fields[0]?.optionSource, {
|
||||
tool: "approval_get_submission_options", kind: "expense_type",
|
||||
});
|
||||
const result = await client.getSubmissionOptions({ userId: "ding-u1" }, template, "expense_type", "招待", 10);
|
||||
assert.deepEqual(result.items, [{ code: "MEAL", label: "客户招待费" }]);
|
||||
const sameCodeAndLabel = await client.getSubmissionOptions({ userId: "ding-u1" }, template, "expense_type", "其他", 10);
|
||||
assert.deepEqual(sameCodeAndLabel.items, [{ code: "其他费", label: "其他费" }]);
|
||||
assert.equal(dataSourceRequests, 0);
|
||||
|
||||
const unavailable = new XftOpenApiClient(config(), async (input) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
if (path.includes("user-mapping")) return encrypted({ code: "0", data: { records: [{ thirdStaffId: "ding-u1", enterpriseUserId: "EMP-1" }] } });
|
||||
if (path.endsWith("form/query-event-list")) return encrypted({ code: "0", data: { list: [templateRecord] } });
|
||||
if (path.includes("form/customFields/eventSeqId")) return encrypted({ code: "0", data: [] });
|
||||
if (path.endsWith("dataSource/findMultilevelSourcePage")) return encrypted({ code: "0", data: { records: [
|
||||
{ sourceSeqId: "DS-PROJECT", sourceName: "项目档案" },
|
||||
] } });
|
||||
throw new Error(`unexpected ${path}`);
|
||||
});
|
||||
const unavailableTemplate = (await unavailable.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
|
||||
await assert.rejects(
|
||||
unavailable.getSubmissionOptions({ userId: "ding-u1" }, unavailableTemplate, "expense_type"),
|
||||
/暂时无法实时获取可选费用类型/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Xft recall derives empId from the authenticated user and validates the upstream success key", async () => {
|
||||
|
||||
Reference in New Issue
Block a user