Files
dingMCP/src/xft-open-api.ts
T

1957 lines
88 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import smCrypto from "sm-crypto";
import type { AppConfig } from "./config.js";
import { ApprovalSafetyError, XftApiError, errorMessage } from "./errors.js";
import { isRecord as isObject, filterQuery } from "./json.js";
import {
isXftSuccessCode,
type ApprovalHistoryRange,
type ApprovalHistoryScope,
type AuthenticatedDingTalkUser,
type XftApprovalAction,
type XftApprovalBatchItem,
type XftApprovalBatchResultItem,
type XftApprovalProvider,
type XftHistoryItem,
type XftSubmissionField,
type XftSubmissionKind,
type XftSubmissionOption,
type XftSubmissionOptionKind,
type XftSubmissionTemplate,
type XftTodoItem,
} from "./xft.js";
type FetchLike = typeof fetch;
type JsonObject = Record<string, unknown>;
const { sm2, sm3, sm4 } = smCrypto;
interface XftIdentity {
enterpriseUserId: string;
employeeName?: string;
employeeNumber?: string;
}
interface CurrentTodo extends XftTodoItem {
raw: JsonObject;
}
interface CurrentHistory extends XftHistoryItem {
raw: JsonObject;
}
const DETAIL_ENDPOINTS: Record<string, { path: string; method: "GET" | "POST" }> = {
EXPENSE_APPLY: { path: "/itrip/xft-api/v1/bills/apply/expense/detail", method: "POST" },
INVITATION_APPLY: { path: "/itrip/xft-api/v1/bills/apply/entertainment/detail", method: "POST" },
USE_CAR_APPLY: { path: "/itrip/xft-api/v1/bills/apply/use-car/detail", method: "POST" },
TRAVEL_APPLY: { path: "/itrip/xft-api/v1/bills/apply/travel/detail", method: "POST" },
COMMON_APPLY: { path: "/itrip/xft-api/v1/bills/apply/common/detail", method: "POST" },
COMMON_REIM: { path: "/itrip/xft-api/v1/reimburse/bill/common/detail", method: "GET" },
TRAVEL_REIM: { path: "/itrip/xft-api/v1/reimburse/bill/travel/detail", method: "GET" },
SUPPLIER_REIM: { path: "/itrip/xft-api/v2/bills/corporate/supplier/reimburse/detail", method: "POST" },
PAY_APPLY: { path: "/itrip/xft-api/v2/bills/corporate/pay/apply/detail", method: "POST" },
SUPPLIER_LEND: { path: "/itrip/xft-api/v2/bills/corporate/supplier/lend/detail", method: "POST" },
EMP_LEND: { path: "/itrip/xft-api/v1/emp-lend/bill/detail", method: "POST" },
REPAYMENT: { path: "/itrip/xft-api/v1/repayment/bills/details", method: "POST" },
};
const ACTION_ENDPOINTS: Record<XftApprovalAction, string> = {
agree: "/itrip/xft-api/v1/approve/pass",
reject: "/itrip/xft-api/v1/approve/reject",
return: "/itrip/xft-api/v1/approve/back",
};
const SUBMISSION_ENDPOINTS: Record<XftSubmissionKind, string> = {
travel_apply: "/itrip/xft-api/v1/bills/apply/travel/create",
expense_apply: "/itrip/xft-api/v1/bills/apply/expense/create",
entertainment_apply: "/itrip/xft-api/v1/bills/apply/entertainment/create",
common_apply: "/itrip/xft-api/v1/bills/apply/common/create",
use_car_apply: "/itrip/xft-api/v1/bills/apply/use-car/create",
common_reimburse: "/itrip/xft-api/v1/reimburse/bill/create",
travel_reimburse: "/itrip/xft-api/v1/reimburse/bill/travel/create",
};
const XFT_MANAGED_SUBMISSION_FIELDS = new Set([
"eventNumber", "empNumber", "wrEmpNumber", "billStatus", "billApplyAuthCheckType", "billApplyAuthCheckTypes",
]);
// The custom-fields endpoint also returns standard bill header controls. The
// create endpoints reject those values inside customFieldValues. When omitted,
// XFT derives the applicant's default company/department from the authenticated
// employee profile.
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 {
private readonly api: NonNullable<AppConfig["xftOpenApi"]>;
private cityOptionsCache?: { fetchedAt: number; items: XftSubmissionOption[] };
constructor(
private readonly config: AppConfig,
private readonly fetchFn: FetchLike = fetch,
) {
if (!config.xftOpenApi) throw new Error("服务器未启用薪福通正式 API");
this.api = config.xftOpenApi;
if (!/^(?:[0-9a-fA-F]{32}|[0-9a-fA-F]{64})$/.test(this.api.authoritySecret)) {
throw new Error("薪福通 AuthoritySecret 必须是 32 或 64 位十六进制密钥");
}
}
async authStatus(user: AuthenticatedDingTalkUser) {
const identity = await this.resolveIdentity(user);
return {
authenticated: true as const,
identity: {
enterpriseUserId: identity.enterpriseUserId,
...(identity.employeeName ? { employeeName: identity.employeeName } : {}),
...(identity.employeeNumber ? { employeeNumber: identity.employeeNumber } : {}),
},
binding: {
dingTalkUserId: user.userId,
mode: "dingtalk_third_party_mapping",
appCode: this.api.dingTalkAppCode,
},
persistedBusinessData: false as const,
};
}
async listMyTodos(user: AuthenticatedDingTalkUser, limit: number, search = "") {
const identity = await this.resolveIdentity(user);
const hasQuery = search.trim().length > 0;
const scanLimit = hasQuery ? 200 : Math.min(200, Math.max(limit, 1));
const { items, totalCount, truncated } = await this.loadCurrentTodos(identity, scanLimit);
const filtered = items.filter((item) => matchesXftTodoQuery(item, search));
return {
items: filtered.slice(0, limit).map(stripRaw),
totalCount: hasQuery ? filtered.length : totalCount,
identity: {
enterpriseUserId: identity.enterpriseUserId,
...(identity.employeeName ? { employeeName: identity.employeeName } : {}),
},
diagnostics: {
source: "xft_official_openapi",
realtime: true,
identityMapping: this.api.dingTalkAppCode,
...(truncated ? { truncated: true, scanLimit } : {}),
persistedBusinessData: false,
},
};
}
async listMyHistory(
user: AuthenticatedDingTalkUser,
scope: ApprovalHistoryScope,
limit: number,
search: string,
range: ApprovalHistoryRange,
) {
const identity = await this.resolveIdentity(user);
const hasQuery = search.trim().length > 0;
const resultScanLimit = hasQuery ? 200 : Math.min(200, Math.max(limit, 1));
const upstreamScanLimit = Math.min(500, Math.max(resultScanLimit * 3, 100));
const loaded = scope === "handled"
? await this.loadHandledHistory(identity, upstreamScanLimit)
: await this.loadSubmittedHistory(identity, upstreamScanLimit);
const inRange = loaded.items.filter((item) => xftHistoryInRange(item, scope, range));
const filtered = inRange.filter((item) => matchesXftHistoryQuery(item, search));
return {
items: filtered.slice(0, limit).map(stripHistoryRaw),
totalCount: filtered.length,
diagnostics: {
source: "xft_official_openapi",
historyScope: scope,
realtime: true,
identityMapping: this.api.dingTalkAppCode,
...(loaded.truncated || filtered.length > limit ? { truncated: true, scanLimit: upstreamScanLimit } : {}),
persistedBusinessData: false,
},
};
}
async getTodoDetail(user: AuthenticatedDingTalkUser, billId: string) {
const identity = await this.resolveIdentity(user);
const todo = await this.requireCurrentTodo(identity, billId);
const { detail, detailFallback } = await this.loadBillDetail(identity, billId, todo.eventType, "task_history");
return {
todo: stripRaw(todo),
identity: { enterpriseUserId: identity.enterpriseUserId },
form: detail,
diagnostics: {
source: "xft_official_openapi",
realtime: true,
persistedBusinessData: false,
...(detailFallback ? {
detailFallback,
...(detailFallback === "task_history" ? { unsupportedEventType: todo.eventType } : {}),
} : {}),
},
};
}
async getHistoryDetail(user: AuthenticatedDingTalkUser, scope: ApprovalHistoryScope, billId: string) {
const identity = await this.resolveIdentity(user);
const history = scope === "handled"
? await this.findHandledHistory(identity, billId)
: await this.findSubmittedHistory(identity, billId);
if (!history) {
throw new ApprovalSafetyError(scope === "handled"
? "该薪福通单据不在当前登录用户已处理的审批中"
: "该薪福通单据不属于当前登录用户本人发起");
}
const { detail, detailFallback } = await this.loadBillDetail(identity, billId, history.eventType);
if (scope === "submitted") {
const ownerNumbers = submissionOwnerNumbers(detail);
const employeeNumber = await this.requireEmployeeNumber(identity);
if (ownerNumbers.length > 0 && !ownerNumbers.includes(employeeNumber)) {
throw new ApprovalSafetyError("该薪福通单据不属于当前登录用户本人发起");
}
}
return {
todo: stripHistoryRaw(history),
form: detail,
diagnostics: {
source: "xft_official_openapi",
historyScope: scope,
realtime: true,
ownershipRevalidated: true,
persistedBusinessData: false,
...(detailFallback ? { detailFallback } : {}),
},
};
}
async listReturnNodes(user: AuthenticatedDingTalkUser, billId: string) {
const identity = await this.resolveIdentity(user);
await this.requireCurrentTodo(identity, billId);
return {
billId,
nodes: [{ nodeId: "OFFICIAL_DEFAULT", nodeName: "按薪福通当前流程规则退回" }],
requirements: {
commentRequired: true,
signatureRequired: false,
canSkipPreviouslyApprovedNodes: false,
},
diagnostics: {
source: "xft_official_openapi",
realtime: true,
targetSelectionSupported: false,
persistedBusinessData: false,
},
};
}
async processApprovalBatch(
user: AuthenticatedDingTalkUser,
action: XftApprovalAction,
remark: string,
items: XftApprovalBatchItem[],
) {
const identity = await this.resolveIdentity(user);
const results: XftApprovalBatchResultItem[] = [];
for (const item of items) {
let writeStarted = false;
try {
if (action === "return" && item.targetNodeId !== "OFFICIAL_DEFAULT") {
throw new ApprovalSafetyError("薪福通正式接口仅支持按当前流程规则退回,请重新获取可执行动作");
}
await this.requireCurrentTodo(identity, item.billId);
writeStarted = true;
await this.request(ACTION_ENDPOINTS[action], {
businessList: [{
approveCategory: "BILL",
approveOpinion: remark || (action === "agree" ? "同意" : action === "reject" ? "拒绝" : "退回"),
businessId: businessIdValue(item.billId),
}],
empId: identity.enterpriseUserId,
}, "POST", identity);
const stillPending = await this.waitForTodoTransition(identity, item.billId);
if (stillPending) {
results.push({
billId: item.billId,
status: "outcome_uncertain",
success: false,
outcomeUncertain: true,
error: "薪福通已接收操作,但单据仍在该用户实时待办中;禁止自动重试,请稍后重新查询待办",
});
continue;
}
results.push({ billId: item.billId, status: "succeeded", success: true });
} catch (error) {
if (writeStarted && isUncertainXftWriteError(error)) {
results.push({
billId: item.billId,
status: "outcome_uncertain",
success: false,
outcomeUncertain: true,
error: `${errorMessage(error)};写请求可能已经到达薪福通,禁止自动重试`,
});
} else {
results.push({
billId: item.billId,
status: "failed",
success: false,
failureConfirmed: true,
error: errorMessage(error),
});
}
}
}
return {
action,
total: results.length,
succeeded: results.filter((item) => item.success).length,
failed: results.filter((item) => !item.success).length,
results,
diagnostics: {
source: "xft_official_openapi",
realtimeOwnershipValidation: true,
transactional: false,
persistedBusinessData: false,
},
};
}
async listSubmissionTemplates(user: AuthenticatedDingTalkUser, limit: number, search = "") {
const identity = await this.resolveIdentity(user);
const output: XftSubmissionTemplate[] = [];
const pageSize = Math.min(100, Math.max(limit, 20));
for (let start = 0; start < 10_000 && output.length < limit; start += pageSize) {
const page = await this.request(
"/itrip/xft-api/v1/form/query-event-list",
{ start, limit: pageSize },
"POST",
identity,
);
const list = pagedRecords(page);
for (const item of list) {
if (stringAt(item, ["eventStatus"]) !== "1") continue;
const template = normalizeSubmissionTemplate(item);
if (!template || template.kind === "use_car_apply" || !filterQuery([template], search).length) continue;
output.push(template);
if (output.length >= limit) break;
}
if (list.length < pageSize) break;
}
return output;
}
async getSubmissionSchema(user: AuthenticatedDingTalkUser, template: XftSubmissionTemplate) {
const identity = await this.resolveIdentity(user);
const current = await this.requireSubmissionTemplate(identity, template);
const rawFields = await this.request(
`/itrip/xft-api/v1/form/customFields/eventSeqId/${encodeURIComponent(current.eventSeqId)}`,
undefined,
"GET",
identity,
);
const fields = deduplicateSubmissionFields(
(Array.isArray(rawFields) ? rawFields : []).filter(isObject).flatMap(normalizeSubmissionField)
.filter((field) => !isServerManagedStandardField(field)),
);
return {
template: current,
fields,
inputContract: submissionInputContract(current.kind),
diagnostics: {
source: "xft_official_openapi",
realtime: true,
flowPredictionSupported: false,
persistedBusinessData: false,
},
};
}
async getSubmissionOptions(
user: AuthenticatedDingTalkUser,
template: XftSubmissionTemplate,
kind: XftSubmissionOptionKind,
query = "",
limit = 50,
) {
const identity = await this.resolveIdentity(user);
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 if (kind === "city") {
if (this.cityOptionsCache && Date.now() - this.cityOptionsCache.fetchedAt < CITY_OPTIONS_CACHE_MS) {
options = this.cityOptionsCache.items;
cacheHit = true;
} else {
const cities = await this.request(
"/itrip/xft-api/v1/common/city/getAllCity",
undefined,
"GET",
identity,
Math.max(this.config.requestTimeoutMs, CITY_OPTIONS_TIMEOUT_MS),
);
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, boundedLimit),
total: filtered.length,
hasMore: filtered.length > boundedLimit,
diagnostics: {
source: "xft_official_openapi",
realtime: !cacheHit,
...(kind === "city" ? { cache: cacheHit ? "memory_24h_hit" : "memory_24h_filled" } : {}),
currentUserOnly: kind === "department",
persistedBusinessData: false,
},
};
}
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,
values: Record<string, unknown>,
) {
const identity = await this.resolveIdentity(user);
const current = await this.requireSubmissionTemplate(identity, template);
assertSubmissionValues(values);
const employeeNumber = await this.requireEmployeeNumber(identity);
const payload = {
...structuredClone(values),
eventNumber: current.eventNumber,
empNumber: employeeNumber,
billStatus: "APPRV_PEND",
billApplyAuthCheckType: "Y",
};
const result = await this.request(SUBMISSION_ENDPOINTS[current.kind], payload, "POST", identity);
const billId = typeof result === "string" || typeof result === "number"
? String(result)
: isObject(result)
? stringAt(result, ["billId", "id", "body"])
: undefined;
if (!billId) throw new XftApiError("薪福通已返回成功,但响应缺少新单据编号", 502, "MISSING_BILL_ID");
return {
billId,
status: "submitted" as const,
diagnostics: {
source: "xft_official_openapi",
applicantIdentity: "server_derived",
billStatus: "APPRV_PEND",
permissionCheck: "enabled",
persistedBusinessData: false,
},
};
}
async recallSubmission(user: AuthenticatedDingTalkUser, billId: string, reason = "") {
const currentStatus = await this.getMySubmissionStatus(user, billId);
if (!currentStatus.revokeAllowed) {
throw new ApprovalSafetyError(`该薪福通单据当前状态为“${currentStatus.statusName}”,不允许撤回`);
}
const identity = await this.resolveIdentity(user);
const employeeNumber = await this.requireEmployeeNumber(identity);
let result: unknown;
try {
result = await this.request("/itrip/xft-api/v1/approve/recall", {
empId: employeeNumber,
businessId: businessIdValue(billId),
approveCategory: "BILL",
...(reason ? { withdrawReason: reason } : {}),
}, "POST", identity);
} catch (error) {
if (!(error instanceof XftApiError) || error.status === 0 || error.status >= 500 || /^XFTSYS/i.test(error.code ?? "")) {
return await this.reconcileRecallFailure(user, billId, errorMessage(error));
}
throw error;
}
const record = isObject(result) ? result : {};
const success = record.success === true || String(record.success).toLowerCase() === "true";
const successKeys = Array.isArray(record.successKey) ? record.successKey.map(String) : [];
if (!success || (successKeys.length > 0 && !successKeys.includes(billId))) {
const upstreamReason = compactRecallError(record.error) ?? "薪福通未确认该单据撤回成功";
return {
status: "failed" as const,
billId,
message: upstreamReason,
diagnostics: {
source: "xft_official_openapi", applicantIdentity: "server_derived",
upstreamSuccess: success, successKeyCount: successKeys.length,
successKeyMatched: successKeys.includes(billId), persistedBusinessData: false,
},
};
}
return {
status: "revoked" as const,
billId,
diagnostics: {
source: "xft_official_openapi", applicantIdentity: "server_derived",
approveCategory: "BILL", persistedBusinessData: false,
},
};
}
private async reconcileRecallFailure(user: AuthenticatedDingTalkUser, billId: string, upstreamMessage: string) {
try {
const observed = await this.getMySubmissionStatus(user, billId);
if (observed.revoked) {
return {
status: "revoked" as const,
billId,
message: "薪福通撤回响应异常,但实时状态已确认单据撤回成功",
diagnostics: {
source: "xft_official_openapi", applicantIdentity: "server_derived",
postWriteVerified: true, recoveredFromUpstreamError: true, observedStatus: observed.status,
persistedBusinessData: false,
},
};
}
if (observed.revokeAllowed) {
return {
status: "failed" as const,
billId,
message: `${upstreamMessage};实时复核确认单据仍为${observed.statusName}`,
diagnostics: {
source: "xft_official_openapi", applicantIdentity: "server_derived",
postWriteVerified: true, observedStatus: observed.status, persistedBusinessData: false,
},
};
}
return {
status: "outcome_uncertain" as const,
billId,
message: "薪福通撤回响应异常,实时单据状态也无法明确判定为已撤回或仍可撤回;禁止自动重试",
diagnostics: {
source: "xft_official_openapi", applicantIdentity: "server_derived",
postWriteVerified: true, observedStatus: observed.status, persistedBusinessData: false,
},
};
} catch {
return {
status: "outcome_uncertain" as const,
billId,
message: "薪福通撤回请求可能已到达上游,且实时状态复核失败;禁止自动重试,请稍后查询状态",
diagnostics: {
source: "xft_official_openapi", applicantIdentity: "server_derived",
postWriteVerified: false, persistedBusinessData: false,
},
};
}
}
async getMySubmissionStatus(user: AuthenticatedDingTalkUser, billId: string) {
const identity = await this.resolveIdentity(user);
const employeeNumber = await this.requireEmployeeNumber(identity);
const detail = await this.request(
`/itrip/xft-api/v1/bills/detail/${encodeURIComponent(billId)}`,
undefined,
"GET",
identity,
);
const ownerNumbers = submissionOwnerNumbers(detail);
let owned = ownerNumbers.includes(employeeNumber);
if (ownerNumbers.length > 0 && !owned) {
throw new ApprovalSafetyError("该薪福通单据不属于当前登录用户的已发起单据,禁止查询状态");
}
const pageSize = 100;
for (let start = 0; !owned && start < 10_000; start += pageSize) {
const page = await this.request(
"/itrip/xft-api/v1/bills/queryBillByCondition",
{ empId: employeeNumber, start, limit: pageSize },
"POST",
identity,
);
const rows = pagedRecords(page);
const ids = rows.flatMap(submissionBillId);
if (ids.includes(billId)) { owned = true; break; }
const total = pagedTotal(page) ?? rows.length;
if (rows.length < pageSize || start + pageSize >= total) break;
}
if (!owned) throw new ApprovalSafetyError("该薪福通单据不属于当前登录用户的已发起单据,禁止查询状态");
const statuses = collectStatusValues(detail);
const status = statuses.billStatus ?? statuses.billSts ?? statuses.status ?? "UNKNOWN";
const approvalStatus = statuses.billApprSts ?? statuses.approvalStatus;
const revoked = [status, approvalStatus].some((value) => /^(?:RECALL|RECALLED|WITHDRAWN|11|5)$/i.test(value ?? ""));
const revokeAllowed = /^(?:1|APPRV_PEND)$/i.test(status);
const flowHistory = compactFlowHistory(detail);
return {
billId,
status,
statusName: xftBillStatusName(status),
...(approvalStatus ? { approvalStatus } : {}),
revoked,
revokeAllowed,
...(flowHistory.length ? { flowHistory } : {}),
diagnostics: {
source: "xft_official_openapi", realtime: true, ownershipRevalidated: true,
applicantIdentity: "server_derived",
persistedBusinessData: false,
},
};
}
async validateSubmissionIdentity(user: AuthenticatedDingTalkUser) {
const identity = await this.resolveIdentity(user);
await this.requireEmployeeNumber(identity);
return {
validated: true as const,
diagnostics: {
source: "xft_official_openapi",
applicantIdentity: "server_derived",
employeeNumberResolved: true,
sensitiveIdentityReturned: false,
},
};
}
private async resolveIdentity(user: AuthenticatedDingTalkUser): Promise<XftIdentity> {
if (!user.userId.trim()) throw new ApprovalSafetyError("无法确认当前钉钉用户身份");
const result = await this.request(
"/xft-member/openapi/xft-service-organization/user-mapping/v1/get/page",
{
appCode: this.api.dingTalkAppCode,
thirdStaffIds: [user.userId],
currentPage: 1,
pageSize: 20,
},
);
const records = findObjectArrays(result).flat();
const exactRecords = records.filter((record) =>
stringAt(record, ["thirdStaffId", "thirdUserId", "externalUserId"]) === user.userId,
);
const mappedIds = [...new Set(exactRecords.flatMap((record) => {
const value = stringAt(record, ["enterpriseUserId", "enterpriseStaffId", "empId", "userId"]);
return value ? [value] : [];
}))];
if (mappedIds.length === 0) {
throw new ApprovalSafetyError("当前钉钉用户未映射到薪福通企业成员,禁止读取或处理审批");
}
if (mappedIds.length !== 1) {
throw new ApprovalSafetyError("当前钉钉用户存在多个薪福通成员映射,禁止在身份不唯一时读取或处理审批");
}
const enterpriseUserId = mappedIds[0]!;
const exact = exactRecords.find((record) =>
stringAt(record, ["enterpriseUserId", "enterpriseStaffId", "empId", "userId"]) === enterpriseUserId,
)!;
return {
enterpriseUserId,
employeeName: stringAt(exact, ["enterpriseUserName", "staffName", "empName", "userName", "name"]),
employeeNumber: stringAt(exact, ["staffNo", "empNumber", "employeeNumber"]),
};
}
private async requireEmployeeNumber(identity: XftIdentity): Promise<string> {
const member = await this.requireCurrentMember(identity);
const employeeNumber = stringAt(member, ["number", "staffNumber", "stfNumber", "empNumber"]);
if (!employeeNumber) {
throw new ApprovalSafetyError("当前用户的薪福通花名册未维护员工号,无法发起审批");
}
return employeeNumber;
}
private async requireCurrentMember(identity: XftIdentity): Promise<JsonObject> {
const member = await this.request(
"/xft-member/openapi/xft-member/member/get/by-id",
{ id: identity.enterpriseUserId, extFields: ["historyId", "org"] },
"POST",
identity,
);
if (!isObject(member)) {
throw new ApprovalSafetyError("无法从薪福通企业成员档案确认当前用户的员工号,禁止发起审批");
}
const relation = isObject(member.idRelation) ? member.idRelation : undefined;
const returnedEnterpriseUserId = relation
? stringAt(relation, ["enterpriseUserId", "memberId"])
: stringAt(member, ["enterpriseUserId", "memberId"]);
if (returnedEnterpriseUserId !== identity.enterpriseUserId) {
throw new ApprovalSafetyError("薪福通企业成员档案与当前登录身份不一致,禁止发起审批");
}
return member;
}
private async requireSubmissionTemplate(identity: XftIdentity, expected: XftSubmissionTemplate): Promise<XftSubmissionTemplate> {
const page = await this.request(
"/itrip/xft-api/v1/form/query-event-list",
{ start: 0, limit: 1000 },
"POST",
identity,
);
const records = isObject(page) && Array.isArray(page.list) ? page.list.filter(isObject) : [];
const current = records
.filter((item) => stringAt(item, ["eventStatus"]) === "1")
.map(normalizeSubmissionTemplate)
.find((item): item is XftSubmissionTemplate => Boolean(item)
&& item!.eventSeqId === expected.eventSeqId
&& item!.eventNumber === expected.eventNumber
&& item!.kind === expected.kind);
if (!current) throw new ApprovalSafetyError("薪福通表单已停用、发生变化或不属于当前企业,请重新列出可发起模板");
return current;
}
private async loadCurrentTodos(identity: XftIdentity, limit: number, billId?: string) {
const items: CurrentTodo[] = [];
const seen = new Set<string>();
const pageSize = Math.min(1000, Math.max(limit, 100));
let exhausted = false;
for (let currentPage = 1; currentPage <= 100 && items.length < limit; currentPage += 1) {
const page = await this.loadTodoPage(identity, currentPage, pageSize, billId);
for (const item of page.items) {
if (seen.has(item.billId)) continue;
seen.add(item.billId);
items.push(item);
if (items.length >= limit) break;
}
if (page.pageRecordCount < pageSize) { exhausted = true; break; }
}
return { items, totalCount: items.length, truncated: !exhausted && items.length >= limit };
}
private async loadHandledHistory(identity: XftIdentity, limit: number) {
const items: CurrentHistory[] = [];
const seen = new Set<string>();
const pageSize = Math.min(1000, Math.max(limit, 100));
let exhausted = false;
for (let currentPage = 1; currentPage <= 100 && items.length < limit; currentPage += 1) {
const page = await this.loadTaskPage(identity, currentPage, pageSize, "done");
for (const item of page.items) {
if (seen.has(item.billId)) continue;
seen.add(item.billId);
items.push(toHistoryItem(item, "handled"));
if (items.length >= limit) break;
}
if (page.pageRecordCount < pageSize) { exhausted = true; break; }
}
return { items: sortXftHistory(items), totalCount: items.length, truncated: !exhausted && items.length >= limit };
}
private async loadSubmittedHistory(identity: XftIdentity, limit: number) {
const employeeNumber = await this.requireEmployeeNumber(identity);
const rows: JsonObject[] = [];
const seen = new Set<string>();
const pageSize = Math.min(100, Math.max(limit, 20));
let exhausted = false;
for (let start = 0; start < 10_000 && rows.length < limit; start += pageSize) {
const page = await this.request(
"/itrip/xft-api/v1/bills/queryBillByCondition",
{ empId: employeeNumber, start, limit: pageSize },
"POST",
identity,
);
const list = pagedRecords(page);
for (const row of list) {
const id = submissionBillId(row)[0];
if (!id || seen.has(id)) continue;
seen.add(id);
rows.push(row);
if (rows.length >= limit) break;
}
const total = pagedTotal(page) ?? list.length;
if (list.length < pageSize || start + pageSize >= total) { exhausted = true; break; }
}
const ids = rows.flatMap((row) => submissionBillId(row).slice(0, 1));
const summaries = ids.length ? await this.request(
"/itrip/xft-api/v1/bills/bill-list",
{ billIds: ids.map(businessIdValue), start: 0, limit: ids.length },
"POST",
identity,
) : [];
const byId = new Map(findObjectArrays(summaries).flat().flatMap((item) => {
const id = submissionBillId(item)[0];
return id ? [[id, item] as const] : [];
}));
const items = rows.flatMap((row) => {
const billId = submissionBillId(row)[0];
if (!billId) return [];
return [toHistoryItem(normalizeTodo(billId, { ...row, ...(byId.get(billId) ?? {}) }), "submitted")];
});
return { items: sortXftHistory(items), totalCount: items.length, truncated: !exhausted && items.length >= limit };
}
private async findHandledHistory(identity: XftIdentity, billId: string): Promise<CurrentHistory | undefined> {
const pageSize = 1000;
for (let currentPage = 1; currentPage <= 100; currentPage += 1) {
const page = await this.loadTaskPage(identity, currentPage, pageSize, "done", billId);
const found = page.items.find((item) => item.billId === billId);
if (found) return toHistoryItem(found, "handled");
if (page.pageRecordCount < pageSize) return undefined;
}
throw new ApprovalSafetyError("薪福通已处理记录超过安全分页上限,无法确认单据归属");
}
private async findSubmittedHistory(identity: XftIdentity, billId: string): Promise<CurrentHistory | undefined> {
const employeeNumber = await this.requireEmployeeNumber(identity);
const pageSize = 100;
for (let start = 0; start < 10_000; start += pageSize) {
const page = await this.request(
"/itrip/xft-api/v1/bills/queryBillByCondition",
{ empId: employeeNumber, start, limit: pageSize },
"POST",
identity,
);
const rows = pagedRecords(page);
const found = rows.find((row) => submissionBillId(row).includes(billId));
if (found) return toHistoryItem(normalizeTodo(billId, found), "submitted");
const total = pagedTotal(page) ?? rows.length;
if (rows.length < pageSize || start + pageSize >= total) return undefined;
}
throw new ApprovalSafetyError("薪福通本人发起记录超过安全分页上限,无法确认单据归属");
}
private async loadBillDetail(
identity: XftIdentity,
billId: string,
eventType?: string,
unknownFallback: "generic" | "task_history" = "generic",
) {
const endpoint = DETAIL_ENDPOINTS[eventType ?? ""];
let detail: unknown;
let detailFallback: string | undefined;
try {
detail = !endpoint
? await this.request(unknownFallback === "task_history"
? `/itrip/xft-api/v1/bills/taskHistory/${encodeURIComponent(billId)}`
: `/itrip/xft-api/v1/bills/detail/${encodeURIComponent(billId)}`, undefined, "GET", identity)
: endpoint.method === "GET"
? await this.request(`${endpoint.path}?billId=${encodeURIComponent(billId)}`, undefined, "GET", identity)
: await this.request(endpoint.path, { billId: businessIdValue(billId) }, "POST", identity);
} catch (error) {
if (!endpoint || !(error instanceof XftApiError) || error.status !== 404) throw error;
detail = await this.request(`/itrip/xft-api/v1/bills/detail/${encodeURIComponent(billId)}`, undefined, "GET", identity);
detailFallback = "generic_bill_detail_after_specialized_404";
}
return {
detail,
...(detailFallback ? { detailFallback }
: !endpoint ? { detailFallback: unknownFallback } : {}),
};
}
private async loadTodoPage(identity: XftIdentity, currentPage: number, pageSize: number, billId?: string) {
return this.loadTaskPage(identity, currentPage, pageSize, "todo", billId);
}
private async loadTaskPage(
identity: XftIdentity,
currentPage: number,
pageSize: number,
dealStatus: "todo" | "done",
billId?: string,
) {
const page = await this.request(
"/xftmsg/msg/xft-message/v1/todo-task/page/by-user",
{ currentPage, dealStatus, pageSize },
"POST",
identity,
);
const pageRecords = objectRecords(page);
if (pageRecords.length === 0) return { items: [] as CurrentTodo[], pageRecordCount: 0 };
const todoTaskIds = pageRecords.flatMap((record) => {
const value = stringAt(record, ["todoTaskId"]);
return value ? [value] : [];
});
const detailResult = todoTaskIds.length
? await this.request(
"/xftmsg/msg/xft-message/v1/todo-task/detail/by-ids",
{ todoTaskIds },
"POST",
identity,
)
: [];
const detailed = objectRecords(detailResult);
const byTaskId = new Map(detailed.map((record) => [stringAt(record, ["todoTaskId"]) ?? "", record]));
const todoRecords = pageRecords.map((record) => ({ ...record, ...(byTaskId.get(stringAt(record, ["todoTaskId"]) ?? "") ?? {}) }));
const relevant = todoRecords.filter((record) => isSmartExpenseTodo(record));
const byBillTodo = new Map<string, JsonObject>();
for (const record of relevant) {
const id = billIdFromTodo(record);
if (id) byBillTodo.set(id, record);
}
const selectedIds = billId ? [...byBillTodo.keys()].filter((id) => id === billId) : [...byBillTodo.keys()];
if (selectedIds.length === 0) return { items: [] as CurrentTodo[], pageRecordCount: pageRecords.length };
const summaries = await this.request(
"/itrip/xft-api/v1/bills/bill-list",
{ billIds: selectedIds.map(businessIdValue), start: 0, limit: selectedIds.length },
"POST",
identity,
);
const byId = new Map(findObjectArrays(summaries).flat().flatMap((item) => {
const id = stringAt(item, ["billId", "businessId", "id"]);
return id ? [[id, item] as const] : [];
}));
const items = selectedIds.map((id) => normalizeTodo(id, { ...(byBillTodo.get(id) ?? {}), ...(byId.get(id) ?? {}) }));
return { items, pageRecordCount: pageRecords.length };
}
private async findCurrentTodo(identity: XftIdentity, billId: string): Promise<CurrentTodo | undefined> {
const pageSize = 1000;
for (let currentPage = 1; currentPage <= 100; currentPage += 1) {
const page = await this.loadTodoPage(identity, currentPage, pageSize, billId);
if (page.items[0]) return page.items[0];
if (page.pageRecordCount < pageSize) return undefined;
}
throw new ApprovalSafetyError("薪福通待办超过安全分页上限,无法确认单据归属");
}
private async requireCurrentTodo(identity: XftIdentity, billId: string): Promise<CurrentTodo> {
const item = await this.findCurrentTodo(identity, billId);
if (!item) throw new ApprovalSafetyError("该薪福通单据当前未分配给此用户,或已经不在待审批状态");
return item;
}
private async waitForTodoTransition(identity: XftIdentity, billId: string): Promise<CurrentTodo | undefined> {
const deadline = Date.now() + 20_000;
let current = await this.findCurrentTodo(identity, billId);
while (current && Date.now() < deadline) {
await new Promise<void>((resolve) => setTimeout(resolve, 1_000));
current = await this.findCurrentTodo(identity, billId);
}
return current;
}
private async request(
path: string,
body?: unknown,
method: "GET" | "POST" = body === undefined ? "GET" : "POST",
identity?: XftIdentity,
timeoutMs = this.config.requestTimeoutMs,
) {
const requestTimeMs = Date.now();
const timestamp = Math.floor(requestTimeMs / 1000).toString();
const queryStart = path.indexOf("?");
const pathname = queryStart === -1 ? path : path.slice(0, queryStart);
const endpointQuery = new URLSearchParams(queryStart === -1 ? "" : path.slice(queryStart + 1));
const query = new URLSearchParams();
query.set("CSCAPPUID", this.api.appId);
if (identity) {
query.set("CSCPRJCOD", this.api.enterpriseId);
query.set("CSCUSRNBR", identity.enterpriseUserId);
}
query.set("CSCREQTIM", String(requestTimeMs));
query.set("OPAUID", this.api.enterpriseId);
for (const [key, value] of endpointQuery) query.append(key, value);
const requestTarget = `${pathname}?${query.toString()}`;
let encodedBody: string | undefined;
if (body !== undefined) {
const json = JSON.stringify(body);
encodedBody = this.api.encrypted
? JSON.stringify({ secretMsg: sm4.encrypt(json, this.api.authoritySecret.slice(0, 32)) })
: json;
}
const digest = encodedBody === undefined ? undefined : sm3(encodedBody);
const signingText = [
`${method} ${requestTarget}`,
// The official 2.0.5 SDK signs the exact transmitted request body here;
// x-alb-digest itself still carries SM3(body).
...(encodedBody !== undefined ? [`x-alb-digest: ${encodedBody}`] : []),
`x-alb-timestamp: ${timestamp}`,
].join("\n");
const signature = sm2.doSignature(signingText, this.api.authoritySecret, {
hash: true,
userId: "1234567812345678",
});
let response: Response;
try {
response = await this.fetchFn(`${this.api.apiBaseUrl}${requestTarget}`, {
method,
headers: {
appid: this.api.appId,
"x-alb-timestamp": timestamp,
"x-alb-verify": "sm3withsm2",
apisign: signature,
...(digest ? { "x-alb-digest": digest, "content-type": "application/json;charset=UTF-8" } : {}),
},
...(encodedBody === undefined ? {} : { body: encodedBody }),
signal: AbortSignal.timeout(timeoutMs),
});
} catch (error) {
throw new XftApiError(`请求薪福通正式接口失败:${errorMessage(error)}`, 0, "TRANSPORT_ERROR");
}
let text: string;
try { text = await response.text(); }
catch (error) {
throw new XftApiError(`读取薪福通响应失败:${errorMessage(error)}`, 0, "RESPONSE_STREAM_ERROR");
}
let parsed: unknown;
try {
const plaintext = this.api.encrypted && /^[0-9a-fA-F]+$/.test(text) && text.length % 2 === 0
? String(sm4.decrypt(text, this.api.authoritySecret.slice(0, 32)))
: text;
parsed = plaintext ? JSON.parse(plaintext) : {};
} catch { throw new XftApiError(`薪福通正式接口返回解密或 JSON 解析失败(HTTP ${response.status})`, response.status); }
parsed = this.decryptEnvelope(parsed);
if (!response.ok) throw new XftApiError(apiMessage(parsed) ?? `薪福通接口 HTTP ${response.status}`, response.status, apiCode(parsed));
const code = apiCode(parsed);
if (!isXftSuccessCode(code)) {
throw new XftApiError(apiMessage(parsed) ?? `薪福通接口错误 ${code}`, response.status, code);
}
return unwrapData(parsed);
}
private decryptEnvelope(value: unknown): unknown {
if (!this.api.encrypted || !isObject(value)) return value;
const encrypted = typeof value.secretMsg === "string" ? value.secretMsg
: isObject(value.data) && typeof value.data.secretMsg === "string" ? value.data.secretMsg
: undefined;
if (!encrypted) return value;
try {
const plaintext = sm4.decrypt(encrypted, this.api.authoritySecret.slice(0, 32));
return JSON.parse(String(plaintext));
} catch {
throw new XftApiError("薪福通响应解密失败,请核对应用加密配置和 AuthoritySecret", 502, "DECRYPT_FAILED");
}
}
}
function isUncertainXftWriteError(error: unknown): boolean {
return !(error instanceof ApprovalSafetyError)
&& (!(error instanceof XftApiError)
|| error.status === 0
|| error.status >= 500
|| /^XFTSYS/i.test(error.code ?? ""));
}
function isServerManagedStandardField(field: XftSubmissionField): boolean {
if (XFT_STANDARD_FORM_FIELD_NUMBERS.has(field.fieldNumber)) return true;
return field.type.toUpperCase() === "ADV_DEPARTMENT" && /^(?:承担部门|单据部门)$/.test(field.name.trim());
}
function normalizeTodo(billId: string, raw: JsonObject): CurrentTodo {
const parsedContent = parseTodoContent(stringAt(raw, ["content"]));
return {
source: "xft",
billId,
taskId: stringAt(raw, ["processTaskId", "todoTaskId"]),
eventType: stringAt(raw, ["eventType", "billType", "eventCode"]),
title: stringAt(raw, ["billName", "title", "eventName", "eventNam", "billTypeName"]) ?? parsedContent.title,
applicantName: stringAt(raw, ["applyUserName", "applicantName", "aplNam", "empName", "applyEmpName"]) ?? parsedContent.applicantName,
applicantDepartment: stringAt(raw, ["departmentName", "deptName", "aplDepNam", "applyDeptName"]) ?? parsedContent.department,
applyTime: stringAt(raw, ["applyTime", "aplTime", "submitTime", "createTime"]),
amount: numberAt(raw, ["amount", "totalAmount", "applyAmount", "reimburseAmount", "billAmount", "expTotalCnt"]),
currency: stringAt(raw, ["currency", "currencyCode", "totalAmountCurrencyCode"]),
reason: stringAt(raw, ["reason", "applyReason", "billRemark", "remark", "summary", "content"]),
allowPass: true,
allowReject: true,
raw,
};
}
function objectRecords(value: unknown): JsonObject[] {
if (Array.isArray(value)) return value.filter(isObject);
return findObjectArrays(value).flat();
}
function isSmartExpenseTodo(record: JsonObject): boolean {
const appCode = stringAt(record, ["appCode", "APPCOD"])?.toLowerCase() ?? "";
const businessCode = stringAt(record, ["businessCode", "BUSTYP"])?.toUpperCase() ?? "";
const businessId = stringAt(record, ["businessId", "groupId", "REFNBR", "referenceNumber"]) ?? "";
return appCode.includes("itrip")
|| appCode.includes("_trip")
|| /^TRIP_\d{10,}$/i.test(businessId)
|| (appCode === "xft-bpm" && businessCode === "OA000001");
}
function billIdFromTodo(record: JsonObject): string | undefined {
for (const key of ["businessId", "groupId", "REFNBR", "referenceNumber"]) {
const value = stringAt(record, [key]);
if (!value) continue;
if (/^\d{10,}$/.test(value)) return value;
const embedded = value.match(/(?:TRIP_|billId[=:])?(\d{10,})/i)?.[1];
if (embedded) return embedded;
}
return undefined;
}
function parseTodoContent(content: string | undefined) {
if (!content) return {};
const applicantName = content.match(/^【([^】]+)】/)?.[1];
const formName = content.match(/发起了【([^】]+)】/)?.[1];
const department = content.match(/承担部门:([^,]*)/)?.[1]?.trim();
return {
...(applicantName ? { applicantName } : {}),
...(formName ? { title: formName } : {}),
...(department ? { department } : {}),
};
}
function stripRaw(item: CurrentTodo): XftTodoItem {
const { raw: _raw, ...publicItem } = item;
return publicItem;
}
function toHistoryItem(item: CurrentTodo, historyScope: ApprovalHistoryScope): CurrentHistory {
const { allowPass: _allowPass, allowReject: _allowReject, ...rest } = item;
const statuses = collectStatusValues(item.raw);
const status = statuses.billStatus ?? statuses.billSts ?? statuses.status;
const completedAt = normalizedHistoryTime(item.raw, ["finishTime", "finishedAt", "completedAt", "dealTime", "approveTime", "endTime"]);
return {
...rest,
historyScope,
...(status ? { status, statusName: xftBillStatusName(status) } : {}),
...(completedAt ? { completedAt } : {}),
};
}
function stripHistoryRaw(item: CurrentHistory): XftHistoryItem {
const { raw: _raw, ...publicItem } = item;
return publicItem;
}
function matchesXftTodoQuery(item: CurrentTodo, query: string): boolean {
const normalized = query.trim().toLowerCase();
if (!normalized) return true;
return [
item.billId, item.title, item.applicantName, item.applicantDepartment,
item.applyTime, item.amount, item.currency, item.reason, item.eventType,
].some((value) => String(value ?? "").toLowerCase().includes(normalized));
}
function matchesXftHistoryQuery(item: CurrentHistory, query: string): boolean {
const normalized = query.trim().toLowerCase();
if (!normalized) return true;
return [
item.billId, item.title, item.applicantName, item.applicantDepartment,
item.applyTime, item.completedAt, item.amount, item.currency, item.reason,
item.eventType, item.status, item.statusName,
].some((value) => String(value ?? "").toLowerCase().includes(normalized));
}
function normalizedHistoryTime(record: JsonObject, keys: string[]): string | undefined {
for (const key of keys) {
const value = record[key];
if (typeof value === "string" && value.trim()) {
const numeric = Number(value);
if (/^\d+(?:\.\d+)?$/.test(value.trim()) && Number.isFinite(numeric)) {
const milliseconds = numeric < 10_000_000_000 ? numeric * 1000 : numeric;
return new Date(milliseconds).toISOString();
}
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : value.trim();
}
if (typeof value === "number" && Number.isFinite(value)) {
const milliseconds = value < 10_000_000_000 ? value * 1000 : value;
return new Date(milliseconds).toISOString();
}
}
return undefined;
}
function sortXftHistory<T extends XftHistoryItem>(items: T[]): T[] {
return items.sort((left, right) => xftHistoryTime(right) - xftHistoryTime(left));
}
function xftHistoryTime(item: XftHistoryItem): number {
const parsed = Date.parse(item.completedAt ?? item.applyTime ?? "");
return Number.isFinite(parsed) ? parsed : 0;
}
function xftHistoryInRange(item: XftHistoryItem, scope: ApprovalHistoryScope, range: ApprovalHistoryRange): boolean {
const raw = scope === "submitted" ? item.applyTime : item.completedAt ?? item.applyTime;
const timestamp = Date.parse(raw ?? "");
return Number.isFinite(timestamp) && timestamp >= range.startTime && timestamp <= range.endTime;
}
function pagedRecords(value: unknown, depth = 0): JsonObject[] {
if (Array.isArray(value)) return value.filter(isObject);
if (!isObject(value) || depth > 3) return [];
for (const key of ["list", "records", "rows", "items", "content"]) {
if (Array.isArray(value[key])) return (value[key] as unknown[]).filter(isObject);
}
for (const key of ["page", "pageData", "data", "result", "body"]) {
const nested = pagedRecords(value[key], depth + 1);
if (nested.length > 0) return nested;
}
return [];
}
function pagedTotal(value: unknown, depth = 0): number | undefined {
if (!isObject(value) || depth > 3) return undefined;
const direct = numberAt(value, ["totalCount", "total", "totalSize", "recordCount", "count"]);
if (direct !== undefined) return direct;
for (const key of ["page", "pageData", "data", "result", "body"]) {
const nested = pagedTotal(value[key], depth + 1);
if (nested !== undefined) return nested;
}
return undefined;
}
function businessIdValue(value: string): string | number {
const numeric = Number(value);
return /^\d+$/.test(value) && Number.isSafeInteger(numeric) ? numeric : value;
}
function unwrapData(value: unknown): unknown {
if (!isObject(value)) return value;
for (const key of ["data", "result", "responseData", "body"]) if (value[key] !== undefined) return value[key];
return value;
}
function apiField(value: unknown, directKeys: string[], commonKeys: string[]): string | undefined {
if (!isObject(value)) return undefined;
const direct = stringAt(value, directKeys);
if (direct) return direct;
const common = Array.isArray(value.SYCOMRETZ) ? value.SYCOMRETZ.find(isObject) : undefined;
return common ? stringAt(common, commonKeys) : undefined;
}
function apiCode(value: unknown): string | undefined {
return apiField(value, ["code", "respCode", "returnCode", "errorCode"], ["ERRCOD"]);
}
function apiMessage(value: unknown): string | undefined {
return apiField(value, ["message", "msg", "respMsg", "returnMessage", "errorMessage", "errorMsg"], ["ERRMSG", "ERRDTL"]);
}
function stringAt(value: JsonObject, keys: string[]): string | undefined {
for (const key of keys) {
const item = value[key];
if (typeof item === "string" && item.trim()) return item.trim();
if (typeof item === "number" && Number.isFinite(item)) return String(item);
}
return undefined;
}
function numberAt(value: unknown, keys: string[]): number | undefined {
if (!isObject(value)) return undefined;
for (const key of keys) {
const raw = value[key];
if (typeof raw !== "number" && (typeof raw !== "string" || !raw.trim())) continue;
const number = Number(raw);
if (Number.isFinite(number)) return number;
}
return undefined;
}
function findObjectArrays(value: unknown, depth = 0): JsonObject[][] {
if (depth > 5) return [];
if (Array.isArray(value)) {
const objects = value.filter(isObject);
return objects.length ? [objects] : value.flatMap((item) => findObjectArrays(item, depth + 1));
}
if (!isObject(value)) return [];
return Object.values(value).flatMap((item) => findObjectArrays(item, depth + 1));
}
function extractDepartmentOptions(value: unknown): XftSubmissionOption[] {
const candidates: XftSubmissionOption[] = [];
visitObjects(value, (record, path) => {
const pathHint = path.some((part) => /organi[sz]ation|department|dept|orgrelation|belong/i.test(part));
const explicitCode = stringAt(record, [
"organizationCode", "orgCode", "departmentCode", "deptCode", "organizationNumber", "departmentNumber",
]);
const code = explicitCode ?? (pathHint ? stringAt(record, ["code", "number"]) : undefined);
const label = stringAt(record, [
"organizationName", "orgName", "departmentName", "deptName", "organizationFullName", "name",
]);
if (code && label && (explicitCode || pathHint)) candidates.push({ code, label });
});
return uniqueOptions(candidates);
}
function extractCityOptions(value: unknown): XftSubmissionOption[] {
const candidates: XftSubmissionOption[] = [];
visitObjects(value, (record, path) => {
const pathHint = path.some((part) => /city|cities|area|region/i.test(part));
const explicitCode = stringAt(record, ["cityCode", "cityNumber", "cityId"]);
const code = explicitCode ?? (pathHint ? stringAt(record, ["code", "number"]) : undefined);
const label = stringAt(record, ["cityName", "nameCn", "cnName", "city", "name"]);
if (code && label && (explicitCode || pathHint)) candidates.push({ code, label });
});
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,
path: string[] = [],
depth = 0,
): void {
if (depth > 12) return;
if (Array.isArray(value)) {
for (const item of value) visitObjects(item, visitor, path, depth + 1);
return;
}
if (!isObject(value)) return;
visitor(value, path);
for (const [key, item] of Object.entries(value)) visitObjects(item, visitor, [...path, key], depth + 1);
}
function uniqueOptions(items: XftSubmissionOption[]): XftSubmissionOption[] {
const seen = new Set<string>();
return items.filter((item) => {
const key = `${item.code}\u0000${item.label}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function collectStatusValues(value: unknown): Record<string, string> {
const output: Record<string, string> = {};
const wanted = new Set(["billStatus", "billSts", "status", "billApprSts", "approvalStatus"]);
visitObjects(value, (record) => {
for (const key of wanted) {
if (output[key] !== undefined) continue;
const raw = record[key];
if ((typeof raw === "string" && raw.trim()) || typeof raw === "number") output[key] = String(raw).trim();
}
});
return output;
}
function xftBillStatusName(status: string): string {
return ({
"0": "草稿",
"1": "待审批",
"2": "审批通过",
"4": "拒绝",
"11": "已撤回",
"12": "已退回",
DRAFT: "草稿",
APPRV_PEND: "待审批",
APPRV: "审批通过",
REJECT: "拒绝",
RECALL: "已撤回",
BACK: "已退回",
} as Record<string, string>)[status.toUpperCase()] ?? status;
}
function compactRecallError(value: unknown): string | undefined {
if (typeof value === "string" && value.trim()) return value.trim().slice(0, 1000);
const messages = new Set<string>();
visitObjects(value, (record) => {
for (const key of ["errorMessage", "errorMsg", "message", "msg", "reason", "error"]) {
const raw = record[key];
if (typeof raw === "string" && raw.trim()) messages.add(raw.trim());
}
for (const [key, raw] of Object.entries(record)) {
if (typeof raw !== "string" || !raw.trim() || /(?:id|key|code|trace)/i.test(key)) continue;
messages.add(`${key}: ${raw.trim()}`);
}
});
const text = [...messages].join(";");
return text ? text.slice(0, 1000) : undefined;
}
function compactFlowHistory(value: unknown): Array<{ nodeName?: string; actions: string[] }> {
const histories: unknown[] = [];
visitObjects(value, (record) => {
if (Array.isArray(record.aprHisList)) histories.push(...record.aprHisList);
});
return histories.filter(isObject).slice(0, 30).map((record) => {
const actions = new Set<string>();
if (Array.isArray(record.actions)) {
for (const action of record.actions) {
if (!isObject(action)) continue;
const value = stringAt(action, ["action", "actionType", "type"]);
if (value) actions.add(value);
}
}
const directAction = stringAt(record, ["action", "actionType"]);
if (directAction) actions.add(directAction);
const nodeName = stringAt(record, ["nodeName", "activityName", "name"]);
return { ...(nodeName ? { nodeName } : {}), actions: [...actions] };
}).filter((item) => item.nodeName || item.actions.length);
}
const SUBMISSION_OWNER_KEYS = [
"empNumber", "employeeNumber", "applyEmpNumber", "applicantEmpNumber", "wrEmpNumber",
] as const;
function submissionOwnerNumbers(value: unknown): string[] {
if (!isObject(value)) return [];
const records = [value];
for (const key of ["bill", "billInfo", "baseInfo", "applyInfo", "applicant", "creator", "writer"]) {
if (isObject(value[key])) records.push(value[key] as JsonObject);
}
const output = new Set<string>();
for (const record of records) {
for (const key of SUBMISSION_OWNER_KEYS) {
const raw = record[key];
if ((typeof raw === "string" && raw.trim()) || typeof raw === "number") output.add(String(raw).trim());
}
}
return [...output];
}
function submissionBillId(value: unknown): string[] {
if ((typeof value === "string" && value.trim()) || typeof value === "number") return [String(value).trim()];
if (!isObject(value)) return [];
const id = stringAt(value, ["billId", "businessId", "id", "REFNBR", "referenceNumber"]);
return id ? [id] : [];
}
function normalizeSubmissionTemplate(value: JsonObject): XftSubmissionTemplate | undefined {
const eventSeqId = stringAt(value, ["eventSeqId"]);
const eventNumber = stringAt(value, ["eventNumber"]);
const eventName = stringAt(value, ["eventName"]);
const eventType = stringAt(value, ["eventType"]);
if (!eventSeqId || !eventNumber || !eventName || !eventType) return undefined;
const kind = submissionKind(eventType, eventName);
return kind ? { eventSeqId, eventNumber, eventName, eventType, kind } : undefined;
}
function submissionKind(eventType: string, eventName: string): XftSubmissionKind | undefined {
if (eventType === "TRAVEL_REIM") return "travel_reimburse";
if (eventType === "COMMON_REIM") return "common_reimburse";
if (eventType === "TRAVEL_APPLY") return "travel_apply";
if (eventType === "EXPENSE_APPLY") return "expense_apply";
if (["INVITATION_APPLY", "ENTERTAINMENT_APPLY"].includes(eventType)) return "entertainment_apply";
if (eventType === "USE_CAR_APPLY") return "use_car_apply";
if (eventType === "COMMON_APPLY") return "common_apply";
// Only fall back to names for upstream records that have no recognized type.
// The enterprise may call an EXPENSE_APPLY template “招待费用申请单”;
// eventType remains authoritative because it selects the formal create API.
if (/招待/.test(eventName)) return "entertainment_apply";
if (/用车/.test(eventName)) return "use_car_apply";
if (/通用/.test(eventName)) return "common_apply";
return undefined;
}
function normalizeSubmissionField(value: JsonObject): XftSubmissionField[] {
const fieldNumber = stringAt(value, ["fieldNumber", "fieldCode"]);
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,
type,
required: value.required === true,
...(typeof value.description === "string" && value.description.trim() ? { description: value.description.trim() } : {}),
...(typeof value.multiple === "boolean" ? { multiple: value.multiple } : {}),
...(options.length ? { options } : {}),
...(typeof value.moduleType === "string" && value.moduleType ? { moduleType: value.moduleType } : {}),
...(optionSource ? { optionSource } : {}),
...submissionFieldShape(value, type, options, optionSource),
}];
}
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 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(normalizeSubmissionTableColumn);
return {
valueFormat: "JSON array: [{sequence:1,columnValues:[{titleCode,value}]}]",
...(itemFields.length ? { itemFields } : {}),
};
}
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")) {
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) => {
const key = JSON.stringify([
field.fieldNumber, field.name, field.type, field.required, field.multiple ?? null, field.moduleType ?? null,
]);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function sanitizeOption(value: unknown): XftSubmissionOption | undefined {
if (!isObject(value)) return undefined;
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> {
const core: Record<XftSubmissionKind, Record<string, unknown>> = {
travel_apply: { required: ["businessTrip"], description: "差旅行程及预计费用" },
expense_apply: { required: ["applyExpenses"], description: "事前申请费用信息" },
entertainment_apply: { required: ["entertainmentApplyApiCreateDTO"], description: "招待城市、人数和起止日期" },
common_apply: { required: [], description: "通用申请仅需模板要求的字段" },
use_car_apply: { required: ["useCarInfo"], description: "用车信息" },
common_reimburse: { required: ["reimAmount", "reimAmountCurrencyCode", "expenseList"], description: "报销总额、币种和费用明细" },
travel_reimburse: { required: ["reimAmount", "reimAmountCurrencyCode", "businessTrip", "expenseList"], description: "报销总额、币种、行程和费用明细" },
};
return {
values: {
...core[kind],
standardFields: submissionStandardFields(kind),
minimalExample: submissionMinimalExample(kind),
},
customFieldValues: "使用 fields[].fieldNumber 与 fieldValue 组装;具体值格式以 fields[].type/options 为准",
serverManagedFields: [...XFT_MANAGED_SUBMISSION_FIELDS],
submissionStatus: "APPRV_PEND",
notes: [
"申请人身份、表单编码、权限校验和审批状态由网关填写,Agent 不得传入",
"minimalExample 只说明 values 的 JSON 形状;尖括号占位符必须替换为用户业务回答或实时选项对应值,绝不能原样提交",
"表单配置中的 billCompanyCode/billDepartmentCode 属于标准抬头字段,不放入 customFieldValues;两者均省略时由薪福通按当前申请人默认公司和部门填写",
"薪福通不提供无副作用的流程预测接口,准备阶段只做本地结构校验,最终业务校验在提交时完成",
],
};
}
function submissionMinimalExample(kind: XftSubmissionKind): Record<string, unknown> {
const city = "<由城市选项中用户所选名称对应的code替换>";
const expense = "<由费用类型选项中用户所选名称对应的code替换>";
const currency = "<人民币对应的内部枚举值>";
if (kind === "travel_apply") return {
businessTrip: { businessTripDetails: [{
departCityCode: city, destinationCityCode: city,
beginTime: "<yyyy-MM-dd HH:mm>", endTime: "<yyyy-MM-dd HH:mm>",
}] },
};
if (kind === "expense_apply") return {
applyExpenses: [{ expenseNumber: expense, amount: "<正数金额,最多两位小数>" }],
};
if (kind === "entertainment_apply") return {
entertainmentApplyApiCreateDTO: {
entertainmentCityCode: city, entertainmentPersonCount: "<正整数>",
entertainmentStartTime: "<yyyy-MM-dd>", entertainmentEndTime: "<yyyy-MM-dd>",
},
};
if (kind === "common_apply") return {
customFieldValues: [{
fieldNumber: "<从实时fields中复制当前字段的fieldNumber,仅工具内部使用>",
fieldValue: "<按该field的type/options序列化用户业务回答>",
}],
};
if (kind === "common_reimburse") return {
reimAmount: "<报销总金额>", reimAmountCurrencyCode: currency,
expenseList: [{
expTypeCode: expense, currencyCode: currency,
originalCurrencyReimAmt: "<本笔费用金额>",
}],
};
if (kind === "travel_reimburse") return {
reimAmount: "<报销总金额>", reimAmountCurrencyCode: currency,
expenseList: [{
expTypeCode: expense, currencyCode: currency,
originalCurrencyReimAmt: "<本笔费用金额>",
}],
businessTrip: { businessTripDetails: [{
departCityCode: city, destinationCityCode: city,
beginTime: "<yyyy-MM-dd HH:mm>", endTime: "<yyyy-MM-dd HH:mm>",
}] },
};
return { clientOnly: "用车信息对象当前必须在薪福通客户端填写" };
}
function submissionStandardFields(kind: XftSubmissionKind): Array<Record<string, unknown>> {
if (kind === "travel_reimburse") return travelReimbursementFields();
if (kind === "common_reimburse") return commonReimbursementFields();
if (kind === "travel_apply") return travelApplicationFields();
if (kind === "entertainment_apply") return entertainmentApplicationFields();
if (kind === "expense_apply") return expenseApplicationFields();
return (submissionInputContractSummary(kind).required as string[]).map((key) => ({
key, required: true, type: "object", description: "具体结构以该创建接口的正式文档和模板业务规则为准",
}));
}
function submissionInputContractSummary(kind: XftSubmissionKind): Record<string, unknown> {
const values: Record<XftSubmissionKind, Record<string, unknown>> = {
travel_apply: { required: ["businessTrip"] }, expense_apply: { required: ["applyExpenses"] },
entertainment_apply: { required: ["entertainmentApplyApiCreateDTO"] }, common_apply: { required: [] },
use_car_apply: { required: ["useCarInfo"] },
common_reimburse: { required: ["reimAmount", "reimAmountCurrencyCode", "expenseList"] },
travel_reimburse: { required: ["reimAmount", "reimAmountCurrencyCode", "businessTrip", "expenseList"] },
};
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: [
expenseTypeField("expenseNumber", true),
{ key: "amount", label: "申请金额", required: true, type: "decimal", precision: 2 },
{ key: "occurrenceDate", label: "费用发生时间", required: false, type: "datetime", description: "ISO 8601 时间" },
cityField("payCityCode", "消费城市", false),
{ key: "participantNum", label: "参与人数", required: false, type: "integer" },
{ key: "merchantName", label: "商户名称", required: false, type: "string" },
{ key: "remark", label: "备注", required: false, type: "string" },
{ key: "customFieldValues", label: "费用自定义字段", required: false, type: "array" },
],
}];
}
function entertainmentApplicationFields(): Array<Record<string, unknown>> {
return [{
key: "entertainmentApplyApiCreateDTO", label: "招待信息", required: true, type: "object", fields: [
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" },
],
}];
}
function travelApplicationFields(): Array<Record<string, unknown>> {
return [{
key: "businessTrip", label: "差旅行程", required: true, type: "object", fields: [{
key: "businessTripDetails", label: "行程明细", required: true, type: "array", minItems: 1,
itemFields: [
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" },
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: [
expenseTypeField("expenseTypeNumber", true),
{ key: "applyAmount", label: "申请金额", required: true, type: "decimal" },
{ key: "remark", label: "备注", required: false, type: "string" },
clientOnlyField("spreadInfo", "分摊信息", false, "当前 MCP 无法把企业分摊名称安全映射为正式接口值", { type: "object" }),
] },
],
}] },
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 },
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" },
readableEnumField("currencyCode", "核销币种", true, CURRENCY_LABELS, { default: "CNY" }),
{ key: "originalPayAmount", label: "核销金额", required: true, type: "decimal", precision: 2 },
] }),
];
}
function commonReimbursementFields(): Array<Record<string, unknown>> {
return [...reimbursementHeaderFields(), expenseListField(false)];
}
function travelReimbursementFields(): Array<Record<string, unknown>> {
return [
...reimbursementHeaderFields(),
expenseListField(true),
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: [
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" },
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: "模板将行程事由设为必填时必须填写" },
clientOnlyField("customArchiveOptionNumbers", "行程自定义档案", false, "当前 MCP 没有按名称查询企业自定义档案的可靠能力", { type: "array" }),
],
}],
},
];
}
function expenseListField(travel: boolean): Record<string, unknown> {
const itemFields: Array<Record<string, unknown>> = [
{ ...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 },
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 },
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" },
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" },
{ key: "flightFullPrice", label: "机票全价", required: false, type: "decimal", precision: 2 },
{ 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" },
clientOnlyField("trainCabin", "火车席别", false, "正式接口文档未提供可验证的席别名称映射", { type: "string" }),
{ key: "trainETicketNo", label: "火车票票号", required: false, type: "string" },
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", 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", userVisible: false, description: "由 Agent 从实时 Schema 内部复制,不向用户展示或索取" },
{ key: "fieldValue", label: "字段值", required: true, type: "string", userVisible: false, description: "由 Agent 根据字段类型内部序列化" },
];
}
function assertSubmissionValues(values: Record<string, unknown>): void {
const serialized = JSON.stringify(values);
if (serialized.length > 200_000) throw new ApprovalSafetyError("单次薪福通提单内容不能超过 200KB");
for (const key of Object.keys(values)) {
if (XFT_MANAGED_SUBMISSION_FIELDS.has(key)) {
throw new ApprovalSafetyError(`字段 ${key} 由服务器根据登录身份或安全策略填写,Agent 不得传入`);
}
}
}