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; 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 = { 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 = { 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 = { 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; 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, ) { 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 { 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 { 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 { 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 { 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(); 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(); 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(); 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 { 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 { 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(); 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 { 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 { const item = await this.findCurrentTodo(identity, billId); if (!item) throw new ApprovalSafetyError("该薪福通单据当前未分配给此用户,或已经不在待审批状态"); return item; } private async waitForTodoTransition(identity: XftIdentity, billId: string): Promise { const deadline = Date.now() + 20_000; let current = await this.findCurrentTodo(identity, billId); while (current && Date.now() < deadline) { await new Promise((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(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(); 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 { const output: Record = {}; 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)[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(); 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(); 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(); 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 { 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(); 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 { const core: Record> = { 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> { 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 { const values: Record> = { 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> { 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> { 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> { 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> { 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> { return [...reimbursementHeaderFields(), expenseListField(false)]; } function travelReimbursementFields(): Array> { 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 { const itemFields: Array> = [ { 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> { return [ { key: "fieldNumber", label: "自定义字段编码", required: true, type: "string" }, { key: "fieldValue", label: "自定义字段值", required: true, type: "string", description: "复杂值按字段类型序列化为 JSON 字符串" }, ]; } function assertSubmissionValues(values: Record): 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 不得传入`); } } }