Files
dingMCP/src/xft-open-api.ts
T
gitea-admin 504145aec6
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s
feat: initial public release
2026-08-17 03:04:09 +00:00

1677 lines
76 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;
/** 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 || !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);
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 (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 };
}
}
const filtered = query.trim()
? options.filter((item) => `${item.label}\n${item.code}`.toLowerCase().includes(query.trim().toLowerCase()))
: options;
return {
kind,
items: filtered.slice(0, Math.min(100, Math.max(1, limit))),
diagnostics: {
source: "xft_official_openapi",
realtime: !cacheHit,
...(kind === "city" ? { cache: cacheHit ? "memory_24h_hit" : "memory_24h_filled" } : {}),
currentUserOnly: kind === "department",
persistedBusinessData: false,
},
};
}
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 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 [];
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 } : {}),
...(Array.isArray(value.options) ? { options: value.options.map((option) => sanitizeOption(option)).filter((option) => option !== undefined) } : {}),
...(typeof value.moduleType === "string" && value.moduleType ? { moduleType: value.moduleType } : {}),
...submissionFieldShape(value, type),
}];
}
function submissionFieldShape(value: JsonObject, type: string): Pick<XftSubmissionField, "valueFormat" | "itemFields"> {
const normalized = type.toUpperCase();
if (normalized.includes("TABLE")) {
const candidates = [value.columns, value.children, value.fieldList, value.tableColumns]
.find(Array.isArray) as unknown[] | undefined;
const itemFields = (candidates ?? []).filter(isObject).flatMap((column) => {
const titleCode = stringAt(column, ["titleCode", "fieldNumber", "fieldCode", "code"]);
const label = stringAt(column, ["name", "title", "label"]);
const columnType = stringAt(column, ["type", "fieldType"]);
if (!titleCode) return [];
return [{ titleCode, ...(label ? { label } : {}), ...(columnType ? { type: columnType } : {}), required: column.required === true }];
});
return {
valueFormat: "JSON array: [{sequence:1,columnValues:[{titleCode,value}]}]",
...(itemFields.length ? { itemFields } : {}),
};
}
if (normalized.includes("ATTACH") || normalized.includes("ACCESSOR")) return { valueFormat: "附件编号字符串(须先通过薪福通附件接口取得)" };
if (normalized.includes("PERSON") || normalized.includes("EMP")) return { valueFormat: "薪福通花名册编号 JSON 数组" };
if (normalized.includes("DEPARTMENT") || normalized.includes("ORG")) return { valueFormat: "薪福通组织编码 JSON 数组" };
if (normalized === "DATE") return { valueFormat: "yyyy-MM-dd HH:mm" };
if (normalized.includes("SELECT")) return { valueFormat: "使用 options 中的编码" };
if (normalized.includes("INPUT")) return { valueFormat: "文本字符串" };
return {};
}
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): unknown {
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
if (!isObject(value)) return undefined;
const label = stringAt(value, ["label", "name", "value", "optionName"]);
const code = stringAt(value, ["code", "number", "optionCode", "key"]);
if (!label && !code) return undefined;
return { ...(label ? { label } : {}), ...(code ? { code } : {}) };
}
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),
},
customFieldValues: "使用 fields[].fieldNumber 与 fieldValue 组装;具体值格式以 fields[].type/options 为准",
serverManagedFields: [...XFT_MANAGED_SUBMISSION_FIELDS],
submissionStatus: "APPRV_PEND",
notes: [
"申请人身份、表单编码、权限校验和审批状态由网关填写,Agent 不得传入",
"表单配置中的 billCompanyCode/billDepartmentCode 属于标准抬头字段,不放入 customFieldValues;两者均省略时由薪福通按当前申请人默认公司和部门填写",
"薪福通不提供无副作用的流程预测接口,准备阶段只做本地结构校验,最终业务校验在提交时完成",
],
};
}
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 expenseApplicationFields(): Array<Record<string, unknown>> {
return [{
key: "applyExpenses", label: "事前申请费用列表", required: true, type: "array", minItems: 1, itemFields: [
{ key: "expenseNumber", label: "费用类型编码", required: true, type: "string", description: "企业费用管理中定义的费用类型编码" },
{ key: "amount", label: "申请金额", required: true, type: "decimal", precision: 2 },
{ key: "occurrenceDate", label: "费用发生时间", required: false, type: "datetime", description: "ISO 8601 时间" },
{ key: "payCityCode", label: "消费城市编码", required: false, type: "string" },
{ 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: [
{ key: "entertainmentCityCode", label: "招待城市编码", required: true, type: "string", description: "薪福通城市 cityCode" },
{ 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: [
{ key: "departCityCode", label: "出发城市编码", required: true, type: "string" },
{ key: "destinationCityCode", label: "目的城市编码", required: true, type: "string" },
{ key: "beginTime", label: "开始时间", required: true, type: "datetime", format: "yyyy-MM-dd HH:mm" },
{ key: "endTime", label: "结束时间", required: true, type: "datetime", format: "yyyy-MM-dd HH:mm" },
{ key: "travelMode", label: "出行方式", required: false, type: "comma_separated_enum", enum: ["Train", "Bus", "Ship", "Hotel", "RideHailing", "AirPlane", "Other"] },
{ key: "roundTrip", label: "是否往返", required: false, type: "string", enum: ["N", "Y"] },
{ key: "beginTimePrecision", label: "开始时间半天精度", required: false, type: "string", enum: ["AM", "PM"] },
{ key: "endTimePrecision", label: "结束时间半天精度", required: false, type: "string", enum: ["AM", "PM"] },
{ key: "customArchiveOptionNumbers", label: "行程自定义档案选项", required: false, type: "array" },
{ key: "estimateExpenses", label: "预计费用", required: false, type: "array", itemFields: [
{ key: "expenseTypeNumber", label: "费用类型编码", required: true, type: "string" },
{ key: "applyAmount", label: "申请金额", required: true, type: "decimal" },
{ key: "remark", label: "备注", required: false, type: "string" },
{ key: "spreadInfo", label: "分摊信息", required: false, type: "object" },
] },
],
}] },
{ key: "relatedBillId", label: "关联单据 ID", required: false, type: "string" },
{ key: "peerEmpNumbers", label: "同行人员工号", required: false, type: "array" },
{ key: "billCompanyCode", label: "单据公司编码", required: false, type: "string" },
{ key: "billDepartmentCode", label: "单据部门编码", required: false, type: "string" },
];
}
function reimbursementHeaderFields(): Array<Record<string, unknown>> {
return [
{ key: "reimAmount", label: "报销总金额", required: true, type: "decimal", precision: 2 },
{ key: "reimAmountCurrencyCode", label: "报销币种", required: true, type: "string", enum: ["CNY"], description: "正式接口目前仅支持人民币" },
{ key: "outRelId", label: "外部关联 ID", required: false, type: "string", maxLength: 100 },
{ key: "billDepartmentCode", label: "单据部门编码", required: false, type: "string", maxLength: 100 },
{ key: "billCompanyCode", label: "单据公司编码", required: false, type: "string", maxLength: 50 },
{ key: "customFieldValues", label: "单据自定义字段", required: false, type: "array", itemFields: customFieldValueFields() },
{ key: "billCollectionInfo", label: "收款信息", required: false, type: "object", description: "需要付款时填写;包含收款人、银行、账号、金额和币种" },
{ key: "billLendPayRelList", label: "核销借款信息", required: false, type: "array", itemFields: [
{ key: "lendBillId", label: "借款单号", required: true, type: "string" },
{ key: "currencyCode", label: "核销币种", required: true, type: "string", enum: ["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),
{ key: "peerEmpNumbers", label: "同行人员工号", required: false, type: "array", maxItems: 50 },
{
key: "businessTrip", label: "差旅行程", required: true, type: "object", fields: [{
key: "businessTripDetails", label: "行程明细", required: true, type: "array", minItems: 1,
itemFields: [
{ key: "departCityCode", label: "出发城市编码", required: true, type: "string", description: "薪福通城市 cityCode" },
{ key: "destinationCityCode", label: "目的城市编码", required: true, type: "string", description: "薪福通城市 cityCode" },
{ key: "beginTime", aliases: ["startTime"], label: "出发时间", required: true, type: "datetime", format: "yyyy-MM-dd HH:mm" },
{ key: "endTime", label: "结束时间", required: true, type: "datetime", format: "yyyy-MM-dd HH:mm" },
{ key: "travelMode", label: "出行方式", required: false, type: "comma_separated_enum", enum: ["Train", "Bus", "Ship", "Hotel", "RideHailing", "AirPlane", "Other"] },
{ key: "roundTrip", label: "是否往返", required: false, type: "string", enum: ["N", "Y"], default: "N" },
{ key: "beginTimePrecision", label: "开始时间半天精度", required: false, type: "string", enum: ["AM", "PM"], description: "模板时间精度为半天时必填" },
{ key: "endTimePrecision", label: "结束时间半天精度", required: false, type: "string", enum: ["AM", "PM"], description: "模板时间精度为半天时必填" },
{ key: "tripReason", label: "出差事由", required: false, type: "string", maxLength: 200, description: "模板将行程事由设为必填时必须填写" },
{ key: "customArchiveOptionNumbers", label: "行程自定义档案选项", required: false, type: "array" },
],
}],
},
];
}
function expenseListField(travel: boolean): Record<string, unknown> {
const itemFields: Array<Record<string, unknown>> = [
{ key: "expTypeCode", label: "费用类型编码", required: true, type: "string", maxLength: 100, description: "企业费用管理中定义的费用类型编码" },
{ key: "currencyCode", label: "费用币种", required: true, type: "string", enum: ["CNY"] },
{ key: "originalCurrencyReimAmt", label: "费用金额", required: true, type: "decimal", precision: 2 },
{ key: "wrTim", label: "消费日期", required: false, type: "date", format: "yyyy-MM-dd" },
{ key: "remark", label: "费用备注", required: false, type: "string", maxLength: 500 },
{ key: "merchantName", label: "商户名称", required: false, type: "string", maxLength: 200 },
{ key: "participants", label: "费用参与人员工号", required: false, type: "array", maxItems: 50 },
{ key: "partiNbr", label: "参与人数", required: false, type: "integer" },
{ key: "taxRate", label: "税率", required: false, type: "decimal", precision: 2 },
{ key: "taxFreeAmount", label: "不含税金额", required: false, type: "decimal", precision: 2 },
{ key: "departureCode", label: "费用出发城市编码", required: false, type: "string" },
{ key: "destinationCode", label: "费用目的城市编码", required: false, type: "string" },
{ key: "bgnTim", label: "交通出发日期", required: false, type: "date", format: "yyyy-MM-dd" },
{ key: "endTim", label: "交通到达日期", required: false, type: "date", format: "yyyy-MM-dd" },
{ key: "checkInDate", label: "酒店入住日期", required: false, type: "date", format: "yyyy-MM-dd" },
{ key: "checkOutDate", label: "酒店离店日期", required: false, type: "date", format: "yyyy-MM-dd" },
{ key: "payCityCode", label: "消费城市编码", required: false, type: "string" },
{ key: "hotelStar", label: "酒店星级", required: false, type: "string", enum: ["1", "2", "3", "4"], enumLabels: { "1": "五星", "2": "四星", "3": "三星", "4": "其他" } },
{ key: "aircraftCabin", label: "飞机舱位", required: false, type: "string", enum: ["1", "2", "3", "4"] },
{ 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" },
{ key: "trainCabin", label: "火车席别", required: false, type: "string", description: "使用官方席别编码 1-15" },
{ key: "trainETicketNo", label: "火车票票号", required: false, type: "string" },
{ key: "shipCabin", label: "轮船舱位", required: false, type: "string", enum: ["1", "2", "3"] },
{ key: "customFieldValues", label: "费用自定义字段", required: false, type: "array", itemFields: customFieldValueFields() },
{ key: "spreadInfo", label: "费用分摊", required: false, type: "object", description: "按企业分摊方式填写" },
];
if (travel) itemFields.push({ key: "dstntIndex", label: "归属行程序号", required: false, type: "string", description: "从 0 开始;不填则不归组到行程" });
return { key: "expenseList", label: "费用明细", required: true, type: "array", minItems: 1, itemFields };
}
function customFieldValueFields(): Array<Record<string, unknown>> {
return [
{ key: "fieldNumber", label: "自定义字段编码", required: true, type: "string" },
{ key: "fieldValue", label: "自定义字段值", required: true, type: "string", description: "复杂值按字段类型序列化为 JSON 字符串" },
];
}
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 不得传入`);
}
}
}