feat: expose readable submission choices
This commit is contained in:
+2
-2
@@ -248,7 +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.match(instructions, /所有选择型字段只向用户展示业务名称.*optionSource.*label.*enumLabels.*城市码.*组织码.*工号.*client_only/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, "");
|
||||
@@ -258,7 +258,7 @@ test("MCP approval_login returns both portable text and a standard resource link
|
||||
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);
|
||||
assert.match(options.description ?? "", /所有选项只向用户展示label.*code仅供内部提交/s);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
|
||||
@@ -105,9 +105,10 @@ 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);
|
||||
assert.match(createApproval, /every XFT selection.*label-only.*optionSource.*returned `label` values.*`code` only inside/s);
|
||||
assert.match(createApproval, /Never show, explain, or ask.*city code.*organization code.*employee number.*numeric cabin value.*`expenseNumber`.*`expTypeCode`/s);
|
||||
assert.match(createApproval, /selectionCapability\.status=client_only.*required field stops.*optional field may be skipped/s);
|
||||
assert.match(localDrafts, /every stored selection value.*internal form value.*only that label.*never expose stored city.*department.*expense.*internal values/s);
|
||||
|
||||
const builder = readFileSync("scripts/build-release.mjs", "utf8");
|
||||
assert.match(builder, /schemaVersion: 3,\s+version,/s);
|
||||
|
||||
@@ -52,8 +52,9 @@ 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);
|
||||
assert.match(client.getInstructions() ?? "", /所有选择型字段只向用户展示业务名称.*optionSource.*options\/enumLabels.*内部映射正式值/s);
|
||||
assert.match(client.getInstructions() ?? "", /绝不向用户展示、解释或索取.*城市码.*组织码.*工号.*档案编号.*数字舱位值.*内部 ID/s);
|
||||
assert.match(client.getInstructions() ?? "", /selectionCapability\.status=client_only.*必填时停止.*可选时.*跳过/s);
|
||||
|
||||
const tools = await client.listTools();
|
||||
assert.deepEqual(tools.tools.map((tool) => tool.name), [
|
||||
|
||||
+62
-2
@@ -5,7 +5,7 @@ import { deflateRawSync } from "node:zlib";
|
||||
import type { AppConfig } from "../src/config.js";
|
||||
import { UnifiedApprovalService } from "../src/unified-approval.js";
|
||||
import type { ApprovalService } from "../src/approval-service.js";
|
||||
import type { XftApprovalProvider, XftSubmissionTemplate } from "../src/xft.js";
|
||||
import type { XftApprovalProvider, XftSubmissionField, XftSubmissionTemplate } from "../src/xft.js";
|
||||
|
||||
function config(): AppConfig {
|
||||
return {
|
||||
@@ -1131,10 +1131,70 @@ test("Xft preparation reports standard and live custom required fields together"
|
||||
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
|
||||
await assert.rejects(
|
||||
service.prepareSubmission(user, { templateId, values: {} }),
|
||||
/业务字段:applyExpenses;自定义字段:招待事由(C1)、招待日期(C2)/,
|
||||
/业务字段:事前申请费用;自定义字段:招待事由、招待日期/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Xft preparation blocks fields whose user-readable choices cannot be resolved", async () => {
|
||||
const template: XftSubmissionTemplate = {
|
||||
eventSeqId: "1", eventNumber: "XFT-E", eventName: "费用申请单",
|
||||
eventType: "EXPENSE_APPLY", kind: "expense_apply",
|
||||
};
|
||||
let fields: XftSubmissionField[] = [{
|
||||
fieldNumber: "P1", name: "参与人员", type: "PERSON", required: true,
|
||||
selectionCapability: { status: "client_only", reason: "没有按姓名查询人员的安全接口" },
|
||||
}];
|
||||
const xft = {
|
||||
async listSubmissionTemplates() { return [template]; },
|
||||
async getSubmissionSchema() { return { template, fields, inputContract: {}, diagnostics: {} }; },
|
||||
async validateSubmissionIdentity() { return { validated: true, diagnostics: {} }; },
|
||||
};
|
||||
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
|
||||
const user = { userId: "user-1" };
|
||||
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
|
||||
const schema = await service.getSubmissionSchema(user, templateId);
|
||||
assert.equal(schema.submissionCapability.status, "client_only");
|
||||
assert.match(String(schema.submissionCapability.reason), /参与人员/);
|
||||
await assert.rejects(service.prepareSubmission(user, {
|
||||
templateId, values: { applyExpenses: [{ expenseNumber: "MEAL", amount: 10 }] },
|
||||
}), /无法安全填写的必填项:参与人员.*薪福通客户端/);
|
||||
|
||||
fields = [{
|
||||
fieldNumber: "P1", name: "参与人员", type: "PERSON", required: false,
|
||||
selectionCapability: { status: "client_only", reason: "没有按姓名查询人员的安全接口" },
|
||||
}];
|
||||
await assert.rejects(service.prepareSubmission(user, {
|
||||
templateId,
|
||||
values: {
|
||||
applyExpenses: [{ expenseNumber: "MEAL", amount: 10 }],
|
||||
customFieldValues: [{ fieldNumber: "P1", fieldValue: "[\"EMP-1\"]" }],
|
||||
},
|
||||
}), /参与人员.*无法通过 MCP.*薪福通客户端/);
|
||||
});
|
||||
|
||||
test("Xft preparation refuses optional internal-value fields instead of asking the user for codes", async () => {
|
||||
const template: XftSubmissionTemplate = {
|
||||
eventSeqId: "1", eventNumber: "XFT-T", eventName: "出差申请单",
|
||||
eventType: "TRAVEL_APPLY", kind: "travel_apply",
|
||||
};
|
||||
const xft = {
|
||||
async listSubmissionTemplates() { return [template]; },
|
||||
async getSubmissionSchema() { return { template, fields: [], inputContract: {}, diagnostics: {} }; },
|
||||
};
|
||||
const service = new UnifiedApprovalService(config(), {} as ApprovalService, xft as never);
|
||||
const user = { userId: "user-1" };
|
||||
const templateId = String((await service.listSubmissionTemplates(user, "xft", 10)).items[0]?.templateId);
|
||||
await assert.rejects(service.prepareSubmission(user, {
|
||||
templateId,
|
||||
values: {
|
||||
businessTrip: { businessTripDetails: [{
|
||||
departCityCode: "1", destinationCityCode: "2", beginTime: "2026-08-12 09:00", endTime: "2026-08-12 18:00",
|
||||
}] },
|
||||
peerEmpNumbers: ["EMP-1"],
|
||||
},
|
||||
}), /同行人员.*不要提供内部编码或 ID/);
|
||||
});
|
||||
|
||||
test("submission rejects caller-supplied applicant identity", async () => {
|
||||
const ding = {
|
||||
async listSubmissionTemplates() { return [{ processCode: "PROC-1", name: "出差申请单_北极大", dirName: "北极大_人事假勤" }]; },
|
||||
|
||||
@@ -62,6 +62,13 @@ function findRecordByKey(value: unknown, key: string): Record<string, unknown> |
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findRecordsByKey(value: unknown, key: string): Array<Record<string, unknown>> {
|
||||
if (Array.isArray(value)) return value.flatMap((item) => findRecordsByKey(item, key));
|
||||
if (!value || typeof value !== "object") return [];
|
||||
const record = value as Record<string, unknown>;
|
||||
return [...(record.key === key ? [record] : []), ...Object.values(record).flatMap((item) => findRecordsByKey(item, key))];
|
||||
}
|
||||
|
||||
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) => {
|
||||
@@ -477,10 +484,91 @@ test("all formal Xft expense fields declare a user-readable expense type option
|
||||
const field = findRecordByKey(schema.inputContract, fieldKey);
|
||||
assert.equal(field?.label, "费用类型");
|
||||
assert.deepEqual(field?.optionSource, { tool: "approval_get_submission_options", kind: "expense_type" });
|
||||
assert.deepEqual(field?.selectionCapability, {
|
||||
status: "supported", userInput: "label", submitValue: "code", exposeInternalValue: false,
|
||||
});
|
||||
assert.doesNotMatch(String(field?.description ?? ""), /要求用户.*编码/);
|
||||
}
|
||||
});
|
||||
|
||||
test("all formal Xft city and enum choices expose readable labels while unresolved references fail closed", async () => {
|
||||
const records = [
|
||||
{ eventSeqId: 21, eventNumber: "EXP-21", eventName: "费用申请", eventStatus: "1", eventType: "EXPENSE_APPLY" },
|
||||
{ eventSeqId: 22, eventNumber: "ENT-22", eventName: "招待申请", eventStatus: "1", eventType: "ENTERTAINMENT_APPLY" },
|
||||
{ eventSeqId: 23, eventNumber: "TRIP-23", eventName: "差旅申请", eventStatus: "1", eventType: "TRAVEL_APPLY" },
|
||||
{ eventSeqId: 24, eventNumber: "REIM-24", eventName: "费用报销", eventStatus: "1", eventType: "COMMON_REIM" },
|
||||
{ eventSeqId: 25, eventNumber: "TR-25", eventName: "差旅报销", eventStatus: "1", eventType: "TRAVEL_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" }, 20);
|
||||
const schemas = await Promise.all(templates.map((template) => client.getSubmissionSchema({ userId: "ding-u1" }, template)));
|
||||
const combined = schemas.map((schema) => schema.inputContract);
|
||||
for (const key of ["payCityCode", "entertainmentCityCode", "departCityCode", "destinationCityCode", "departureCode", "destinationCode"]) {
|
||||
const matches = findRecordsByKey(combined, key);
|
||||
assert.ok(matches.length > 0, `${key} should exist`);
|
||||
for (const field of matches) {
|
||||
assert.deepEqual(field.optionSource, { tool: "approval_get_submission_options", kind: "city" });
|
||||
assert.equal((field.selectionCapability as Record<string, unknown>)?.exposeInternalValue, false);
|
||||
assert.doesNotMatch(String(field.label), /编码/);
|
||||
}
|
||||
}
|
||||
const travelMode = findRecordByKey(combined, "travelMode");
|
||||
assert.equal((travelMode?.enumLabels as Record<string, string>)?.AirPlane, "飞机");
|
||||
assert.equal((travelMode?.enumLabels as Record<string, string>)?.RideHailing, "网约车");
|
||||
assert.equal((findRecordByKey(combined, "roundTrip")?.enumLabels as Record<string, string>)?.Y, "往返");
|
||||
assert.equal((findRecordByKey(combined, "beginTimePrecision")?.enumLabels as Record<string, string>)?.AM, "上午");
|
||||
assert.equal((findRecordByKey(combined, "currencyCode")?.enumLabels as Record<string, string>)?.CNY, "人民币");
|
||||
assert.equal((findRecordByKey(combined, "hotelStar")?.enumLabels as Record<string, string>)?.["1"], "五星");
|
||||
for (const key of ["peerEmpNumbers", "participants", "customArchiveOptionNumbers", "aircraftCabin", "trainCabin", "shipCabin", "relatedBillId"]) {
|
||||
const field = findRecordByKey(combined, key);
|
||||
assert.equal((field?.selectionCapability as Record<string, unknown>)?.status, "client_only", key);
|
||||
assert.doesNotMatch(String(field?.label ?? ""), /工号|编码| ID/);
|
||||
}
|
||||
});
|
||||
|
||||
test("live Xft custom choices normalize labels recursively and unsupported selectors are explicit", async () => {
|
||||
const templateRecord = {
|
||||
eventSeqId: 31, eventNumber: "CUSTOM-31", eventName: "费用申请", eventStatus: "1", eventType: "EXPENSE_APPLY",
|
||||
};
|
||||
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: "S1", name: "餐别", type: "SELECT", required: true, options: [
|
||||
{ optionCode: "LUNCH", optionName: "午餐" }, { optionCode: "DINNER", optionName: "晚餐" },
|
||||
] },
|
||||
{ fieldNumber: "S2", name: "无名称映射", type: "SELECT", required: false, options: ["1", "2"] },
|
||||
{ fieldNumber: "P1", name: "参与人员", type: "PERSON", required: true },
|
||||
{ fieldNumber: "D1", name: "成本部门", type: "ADV_DEPARTMENT", required: true },
|
||||
{ fieldNumber: "T1", name: "明细", type: "TABLE", required: true, columns: [
|
||||
{ titleCode: "TS", name: "用餐类型", type: "SELECT", required: true, options: [{ code: "TEAM", label: "团队餐" }] },
|
||||
{ titleCode: "TP", name: "同行人", type: "PERSON", required: false },
|
||||
] },
|
||||
] });
|
||||
throw new Error(`unexpected ${path}`);
|
||||
});
|
||||
const template = (await client.listSubmissionTemplates({ userId: "ding-u1" }, 10))[0]!;
|
||||
const fields = (await client.getSubmissionSchema({ userId: "ding-u1" }, template)).fields;
|
||||
const select = fields.find((field) => field.fieldNumber === "S1")!;
|
||||
assert.deepEqual(select.options, [{ code: "LUNCH", label: "午餐" }, { code: "DINNER", label: "晚餐" }]);
|
||||
assert.equal(select.selectionCapability?.status, "supported");
|
||||
assert.equal(fields.find((field) => field.fieldNumber === "S2")?.selectionCapability?.status, "client_only");
|
||||
assert.equal(fields.find((field) => field.fieldNumber === "P1")?.selectionCapability?.status, "client_only");
|
||||
assert.deepEqual(fields.find((field) => field.fieldNumber === "D1")?.optionSource, {
|
||||
tool: "approval_get_submission_options", kind: "department",
|
||||
});
|
||||
const tableFields = fields.find((field) => field.fieldNumber === "T1")?.itemFields ?? [];
|
||||
assert.deepEqual(tableFields.find((field) => field.titleCode === "TS")?.options, [{ code: "TEAM", label: "团队餐" }]);
|
||||
assert.equal((tableFields.find((field) => field.titleCode === "TP")?.selectionCapability as Record<string, unknown>)?.status, "client_only");
|
||||
});
|
||||
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user