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; const EXPENSE_TYPE_SOURCE_PAGE_SIZE = 100; const EXPENSE_TYPE_SOURCE_MAX_PAGES = 5; const EXPENSE_TYPE_HINT = /费用\s*(?:类型|类别|项目)|(?:类型|类别|项目)\s*费用|费目|expense[\s_-]*type/i; /** Official XFT OpenAPI adapter. It never accepts an empId from an Agent. */ export class XftOpenApiClient implements XftApprovalProvider { private readonly api: NonNullable; private cityOptionsCache?: { fetchedAt: number; items: XftSubmissionOption[] }; constructor( private readonly config: AppConfig, private readonly fetchFn: FetchLike = fetch, ) { if (!config.xftOpenApi) throw new Error("服务器未启用薪福通正式 API"); this.api = config.xftOpenApi; if (!/^(?:[0-9a-fA-F]{32}|[0-9a-fA-F]{64})$/.test(this.api.authoritySecret)) { throw new Error("薪福通 AuthoritySecret 必须是 32 或 64 位十六进制密钥"); } } async authStatus(user: AuthenticatedDingTalkUser) { const identity = await this.resolveIdentity(user); return { authenticated: true as const, identity: { enterpriseUserId: identity.enterpriseUserId, ...(identity.employeeName ? { employeeName: identity.employeeName } : {}), ...(identity.employeeNumber ? { employeeNumber: identity.employeeNumber } : {}), }, binding: { dingTalkUserId: user.userId, mode: "dingtalk_third_party_mapping", appCode: this.api.dingTalkAppCode, }, persistedBusinessData: false as const, }; } async listMyTodos(user: AuthenticatedDingTalkUser, limit: number, search = "") { const identity = await this.resolveIdentity(user); const hasQuery = search.trim().length > 0; const scanLimit = hasQuery ? 200 : Math.min(200, Math.max(limit, 1)); const { items, totalCount, truncated } = await this.loadCurrentTodos(identity, scanLimit); const filtered = items.filter((item) => matchesXftTodoQuery(item, search)); return { items: filtered.slice(0, limit).map(stripRaw), totalCount: hasQuery ? filtered.length : totalCount, identity: { enterpriseUserId: identity.enterpriseUserId, ...(identity.employeeName ? { employeeName: identity.employeeName } : {}), }, diagnostics: { source: "xft_official_openapi", realtime: true, identityMapping: this.api.dingTalkAppCode, ...(truncated ? { truncated: true, scanLimit } : {}), persistedBusinessData: false, }, }; } async listMyHistory( user: AuthenticatedDingTalkUser, scope: ApprovalHistoryScope, limit: number, search: string, range: ApprovalHistoryRange, ) { const identity = await this.resolveIdentity(user); const hasQuery = search.trim().length > 0; const resultScanLimit = hasQuery ? 200 : Math.min(200, Math.max(limit, 1)); const upstreamScanLimit = Math.min(500, Math.max(resultScanLimit * 3, 100)); const loaded = scope === "handled" ? await this.loadHandledHistory(identity, upstreamScanLimit) : await this.loadSubmittedHistory(identity, upstreamScanLimit); const inRange = loaded.items.filter((item) => xftHistoryInRange(item, scope, range)); const filtered = inRange.filter((item) => matchesXftHistoryQuery(item, search)); return { items: filtered.slice(0, limit).map(stripHistoryRaw), totalCount: filtered.length, diagnostics: { source: "xft_official_openapi", historyScope: scope, realtime: true, identityMapping: this.api.dingTalkAppCode, ...(loaded.truncated || filtered.length > limit ? { truncated: true, scanLimit: upstreamScanLimit } : {}), persistedBusinessData: false, }, }; } async getTodoDetail(user: AuthenticatedDingTalkUser, billId: string) { const identity = await this.resolveIdentity(user); const todo = await this.requireCurrentTodo(identity, billId); const { detail, detailFallback } = await this.loadBillDetail(identity, billId, todo.eventType, "task_history"); return { todo: stripRaw(todo), identity: { enterpriseUserId: identity.enterpriseUserId }, form: detail, diagnostics: { source: "xft_official_openapi", realtime: true, persistedBusinessData: false, ...(detailFallback ? { detailFallback, ...(detailFallback === "task_history" ? { unsupportedEventType: todo.eventType } : {}), } : {}), }, }; } async getHistoryDetail(user: AuthenticatedDingTalkUser, scope: ApprovalHistoryScope, billId: string) { const identity = await this.resolveIdentity(user); const history = scope === "handled" ? await this.findHandledHistory(identity, billId) : await this.findSubmittedHistory(identity, billId); if (!history) { throw new ApprovalSafetyError(scope === "handled" ? "该薪福通单据不在当前登录用户已处理的审批中" : "该薪福通单据不属于当前登录用户本人发起"); } const { detail, detailFallback } = await this.loadBillDetail(identity, billId, history.eventType); if (scope === "submitted") { const ownerNumbers = submissionOwnerNumbers(detail); const employeeNumber = await this.requireEmployeeNumber(identity); if (ownerNumbers.length > 0 && !ownerNumbers.includes(employeeNumber)) { throw new ApprovalSafetyError("该薪福通单据不属于当前登录用户本人发起"); } } return { todo: stripHistoryRaw(history), form: detail, diagnostics: { source: "xft_official_openapi", historyScope: scope, realtime: true, ownershipRevalidated: true, persistedBusinessData: false, ...(detailFallback ? { detailFallback } : {}), }, }; } async listReturnNodes(user: AuthenticatedDingTalkUser, billId: string) { const identity = await this.resolveIdentity(user); await this.requireCurrentTodo(identity, billId); return { billId, nodes: [{ nodeId: "OFFICIAL_DEFAULT", nodeName: "按薪福通当前流程规则退回" }], requirements: { commentRequired: true, signatureRequired: false, canSkipPreviouslyApprovedNodes: false, }, diagnostics: { source: "xft_official_openapi", realtime: true, targetSelectionSupported: false, persistedBusinessData: false, }, }; } async processApprovalBatch( user: AuthenticatedDingTalkUser, action: XftApprovalAction, remark: string, items: XftApprovalBatchItem[], ) { const identity = await this.resolveIdentity(user); const results: XftApprovalBatchResultItem[] = []; for (const item of items) { let writeStarted = false; try { if (action === "return" && item.targetNodeId !== "OFFICIAL_DEFAULT") { throw new ApprovalSafetyError("薪福通正式接口仅支持按当前流程规则退回,请重新获取可执行动作"); } await this.requireCurrentTodo(identity, item.billId); writeStarted = true; await this.request(ACTION_ENDPOINTS[action], { businessList: [{ approveCategory: "BILL", approveOpinion: remark || (action === "agree" ? "同意" : action === "reject" ? "拒绝" : "退回"), businessId: businessIdValue(item.billId), }], empId: identity.enterpriseUserId, }, "POST", identity); const stillPending = await this.waitForTodoTransition(identity, item.billId); if (stillPending) { results.push({ billId: item.billId, status: "outcome_uncertain", success: false, outcomeUncertain: true, error: "薪福通已接收操作,但单据仍在该用户实时待办中;禁止自动重试,请稍后重新查询待办", }); continue; } results.push({ billId: item.billId, status: "succeeded", success: true }); } catch (error) { if (writeStarted && isUncertainXftWriteError(error)) { results.push({ billId: item.billId, status: "outcome_uncertain", success: false, outcomeUncertain: true, error: `${errorMessage(error)};写请求可能已经到达薪福通,禁止自动重试`, }); } else { results.push({ billId: item.billId, status: "failed", success: false, failureConfirmed: true, error: errorMessage(error), }); } } } return { action, total: results.length, succeeded: results.filter((item) => item.success).length, failed: results.filter((item) => !item.success).length, results, diagnostics: { source: "xft_official_openapi", realtimeOwnershipValidation: true, transactional: false, persistedBusinessData: false, }, }; } async listSubmissionTemplates(user: AuthenticatedDingTalkUser, limit: number, search = "") { const identity = await this.resolveIdentity(user); const output: XftSubmissionTemplate[] = []; const pageSize = Math.min(100, Math.max(limit, 20)); for (let start = 0; start < 10_000 && output.length < limit; start += pageSize) { const page = await this.request( "/itrip/xft-api/v1/form/query-event-list", { start, limit: pageSize }, "POST", identity, ); const list = pagedRecords(page); for (const item of list) { if (stringAt(item, ["eventStatus"]) !== "1") continue; const template = normalizeSubmissionTemplate(item); if (!template || template.kind === "use_car_apply" || !filterQuery([template], search).length) continue; output.push(template); if (output.length >= limit) break; } if (list.length < pageSize) break; } return output; } async getSubmissionSchema(user: AuthenticatedDingTalkUser, template: XftSubmissionTemplate) { const identity = await this.resolveIdentity(user); const current = await this.requireSubmissionTemplate(identity, template); const rawFields = await this.request( `/itrip/xft-api/v1/form/customFields/eventSeqId/${encodeURIComponent(current.eventSeqId)}`, undefined, "GET", identity, ); const fields = deduplicateSubmissionFields( (Array.isArray(rawFields) ? rawFields : []).filter(isObject).flatMap(normalizeSubmissionField) .filter((field) => !isServerManagedStandardField(field)), ); return { template: current, fields, inputContract: submissionInputContract(current.kind), diagnostics: { source: "xft_official_openapi", realtime: true, flowPredictionSupported: false, persistedBusinessData: false, }, }; } async getSubmissionOptions( user: AuthenticatedDingTalkUser, template: XftSubmissionTemplate, kind: XftSubmissionOptionKind, query = "", limit = 50, ) { const identity = await this.resolveIdentity(user); const current = await this.requireSubmissionTemplate(identity, template); let options: XftSubmissionOption[]; let cacheHit = false; if (kind === "department") { const member = await this.requireCurrentMember(identity); options = extractDepartmentOptions(member); } else if (kind === "city") { if (this.cityOptionsCache && Date.now() - this.cityOptionsCache.fetchedAt < CITY_OPTIONS_CACHE_MS) { options = this.cityOptionsCache.items; cacheHit = true; } else { const cities = await this.request( "/itrip/xft-api/v1/common/city/getAllCity", undefined, "GET", identity, Math.max(this.config.requestTimeoutMs, CITY_OPTIONS_TIMEOUT_MS), ); options = extractCityOptions(cities); this.cityOptionsCache = { fetchedAt: Date.now(), items: options }; } } else { options = await this.loadExpenseTypeOptions(identity, current); if (!options.length) { throw new ApprovalSafetyError("暂时无法实时获取可选费用类型,请稍后重试或在薪福通客户端完成"); } } const filtered = query.trim() ? options.filter((item) => `${item.label}\n${item.code}`.toLowerCase().includes(query.trim().toLowerCase())) : options; const boundedLimit = Math.min(100, Math.max(1, limit)); return { kind, items: filtered.slice(0, boundedLimit), total: filtered.length, hasMore: filtered.length > boundedLimit, diagnostics: { source: "xft_official_openapi", realtime: !cacheHit, ...(kind === "city" ? { cache: cacheHit ? "memory_24h_hit" : "memory_24h_filled" } : {}), currentUserOnly: kind === "department", persistedBusinessData: false, }, }; } private async loadExpenseTypeOptions( identity: XftIdentity, template: XftSubmissionTemplate, ): Promise { try { const rawFields = await this.request( `/itrip/xft-api/v1/form/customFields/eventSeqId/${encodeURIComponent(template.eventSeqId)}`, undefined, "GET", identity, ); const embedded = extractExpenseTypeOptions(rawFields); if (embedded.length) return embedded; } catch { // Some templates do not expose standard expense dictionaries in custom fields. // Fall through to the enterprise multilevel data-source APIs. } const candidates: Array<{ id: string; raw: JsonObject }> = []; for (let page = 0; page < EXPENSE_TYPE_SOURCE_MAX_PAGES; page += 1) { let response: unknown; try { response = await this.request( "/itrip/xft-api/v1/dataSource/findMultilevelSourcePage", { start: page * EXPENSE_TYPE_SOURCE_PAGE_SIZE, limit: EXPENSE_TYPE_SOURCE_PAGE_SIZE }, "POST", identity, ); } catch { return []; } const records = pagedRecords(response); for (const record of records) { const name = stringAt(record, ["sourceName", "dataSourceName", "multilevelSourceName", "displayName", "name", "title"]); const id = stringAt(record, ["sourceSeqId", "dataSourceSeqId", "multilevelSourceSeqId", "seqId", "id"]); if (id && name && EXPENSE_TYPE_HINT.test(name)) candidates.push({ id, raw: record }); } if (records.length < EXPENSE_TYPE_SOURCE_PAGE_SIZE) break; } const output: XftSubmissionOption[] = []; for (const candidate of candidates.slice(0, 10)) { output.push(...extractExpenseTypeOptions(candidate.raw, true)); try { const detail = await this.request( `/itrip/xft-api/v1/dataSource/getMultiLevelDetail/${encodeURIComponent(candidate.id)}`, undefined, "GET", identity, ); output.push(...extractExpenseTypeOptions(detail, true)); } catch { // Continue with other matching sources. Empty output fails closed below. } } return uniqueOptions(output); } async submitApproval( user: AuthenticatedDingTalkUser, template: XftSubmissionTemplate, values: Record, ) { 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 extractExpenseTypeOptions(value: unknown, trustedExpenseSource = false): XftSubmissionOption[] { const candidates: XftSubmissionOption[] = []; const visit = (current: unknown, path: string[], inheritedHint: boolean, depth: number): void => { if (depth > 12) return; if (Array.isArray(current)) { for (const item of current) visit(item, path, inheritedHint, depth + 1); return; } if (!isObject(current)) return; const record = current; const recordHint = EXPENSE_TYPE_HINT.test(stringAt(record, [ "fieldName", "name", "label", "title", "sourceName", "dataSourceName", "multilevelSourceName", ]) ?? ""); const pathHint = path.some((part) => EXPENSE_TYPE_HINT.test(part)); const inExpenseContext = trustedExpenseSource || inheritedHint || recordHint || pathHint; if (inExpenseContext && record.selectable !== false && record.canSelect !== false && record.disabled !== true) { const explicitCode = stringAt(record, [ "expenseNumber", "expenseTypeNumber", "expTypeCode", "expenseTypeCode", "expenseTypeNo", "expTypeNo", ]); const code = explicitCode ?? (path.length > 0 ? stringAt(record, [ "optionCode", "optionNumber", "itemCode", "itemNumber", "nodeCode", "nodeNumber", "dataCode", "dataNumber", "sourceOptionCode", "sourceOptionNumber", "code", "number", "value", "key", ]) : undefined); const label = stringAt(record, [ "expenseName", "expenseTypeName", "expTypeName", "optionName", "itemName", "nodeName", "dataName", "sourceOptionName", "label", "text", "displayName", "name", "title", ]); if (code && label) candidates.push({ code, label }); } for (const [key, child] of Object.entries(record)) visit(child, [...path, key], inExpenseContext, depth + 1); }; visit(value, [], trustedExpenseSource, 0); return uniqueOptions(candidates); } function visitObjects( value: unknown, visitor: (record: JsonObject, path: string[]) => void, path: string[] = [], depth = 0, ): void { if (depth > 12) return; if (Array.isArray(value)) { for (const item of value) visitObjects(item, visitor, path, depth + 1); return; } if (!isObject(value)) return; visitor(value, path); for (const [key, item] of Object.entries(value)) visitObjects(item, visitor, [...path, key], depth + 1); } function uniqueOptions(items: XftSubmissionOption[]): XftSubmissionOption[] { const seen = new Set(); 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 []; const options = Array.isArray(value.options) ? normalizeSubmissionOptions(value.options) : []; const normalizedType = type.toUpperCase(); const optionSource = isExpenseTypeField(fieldNumber, name) ? expenseTypeOptionSource() : normalizedType.includes("DEPARTMENT") || normalizedType.includes("ORG") ? departmentOptionSource() : /城市|city/i.test(`${fieldNumber} ${name}`) ? cityOptionSource() : undefined; return [{ fieldNumber, name, type, required: value.required === true, ...(typeof value.description === "string" && value.description.trim() ? { description: value.description.trim() } : {}), ...(typeof value.multiple === "boolean" ? { multiple: value.multiple } : {}), ...(options.length ? { options } : {}), ...(typeof value.moduleType === "string" && value.moduleType ? { moduleType: value.moduleType } : {}), ...(optionSource ? { optionSource } : {}), ...submissionFieldShape(value, type, options, optionSource), }]; } function isExpenseTypeField(fieldNumber: string, name: string): boolean { return EXPENSE_TYPE_HINT.test(name) || /^(?:expenseNumber|expenseTypeNumber|expTypeCode)$/i.test(fieldNumber); } function expenseTypeOptionSource() { return { tool: "approval_get_submission_options" as const, kind: "expense_type" as const }; } function cityOptionSource() { return { tool: "approval_get_submission_options" as const, kind: "city" as const }; } function departmentOptionSource() { return { tool: "approval_get_submission_options" as const, kind: "department" as const }; } const LABEL_ONLY_SELECTION = { status: "supported" as const, userInput: "label" as const, submitValue: "code" as const, exposeInternalValue: false as const, }; function clientOnlySelection(reason: string) { return { status: "client_only" as const, reason }; } function submissionFieldShape( value: JsonObject, type: string, options: XftSubmissionOption[], optionSource?: ReturnType | ReturnType | ReturnType, ): 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(normalizeSubmissionTableColumn); return { valueFormat: "JSON array: [{sequence:1,columnValues:[{titleCode,value}]}]", ...(itemFields.length ? { itemFields } : {}), }; } if (normalized.includes("ATTACH") || normalized.includes("ACCESSOR")) return { valueFormat: "当前 MCP 无法安全上传附件,请在薪福通客户端补充", selectionCapability: clientOnlySelection("当前 MCP 没有薪福通附件上传能力"), }; if (normalized.includes("PERSON") || normalized.includes("EMP")) return { valueFormat: "当前 MCP 无法按姓名安全选择人员,请在薪福通客户端补充", selectionCapability: clientOnlySelection("正式接口需要内部花名册值,但当前没有按姓名查询人员的安全接口"), }; if (normalized.includes("DEPARTMENT") || normalized.includes("ORG")) return { valueFormat: "向用户展示部门名称,Agent 内部使用所选项", selectionCapability: LABEL_ONLY_SELECTION, }; if (normalized === "DATE") return { valueFormat: "yyyy-MM-dd HH:mm" }; if (normalized.includes("SELECT")) { if (options.length || optionSource) return { valueFormat: "向用户展示选项名称,Agent 内部使用所选项", selectionCapability: LABEL_ONLY_SELECTION, }; return { valueFormat: "当前模板未返回可读选项,请在薪福通客户端补充", selectionCapability: clientOnlySelection("模板没有返回可验证的名称与内部值映射"), }; } if (normalized.includes("INPUT")) return { valueFormat: "文本字符串" }; return {}; } function normalizeSubmissionTableColumn(column: JsonObject): Array> { const titleCode = stringAt(column, ["titleCode", "fieldNumber", "fieldCode", "code"]); const label = stringAt(column, ["name", "title", "label"]); const type = stringAt(column, ["type", "fieldType"]); if (!titleCode) return []; const options = Array.isArray(column.options) ? normalizeSubmissionOptions(column.options) : []; const normalizedType = (type ?? "").toUpperCase(); const optionSource = isExpenseTypeField(titleCode, label ?? "") ? expenseTypeOptionSource() : normalizedType.includes("DEPARTMENT") || normalizedType.includes("ORG") ? departmentOptionSource() : /城市|city/i.test(`${titleCode} ${label ?? ""}`) ? cityOptionSource() : undefined; return [{ titleCode, ...(label ? { label } : {}), ...(type ? { type } : {}), required: column.required === true, ...(options.length ? { options } : {}), ...(optionSource ? { optionSource } : {}), ...submissionFieldShape(column, type ?? "", options, optionSource), }]; } function deduplicateSubmissionFields(fields: XftSubmissionField[]): XftSubmissionField[] { const seen = new Set(); return fields.filter((field) => { const key = JSON.stringify([ field.fieldNumber, field.name, field.type, field.required, field.multiple ?? null, field.moduleType ?? null, ]); if (seen.has(key)) return false; seen.add(key); return true; }); } function sanitizeOption(value: unknown): XftSubmissionOption | undefined { if (!isObject(value)) return undefined; const label = stringAt(value, ["label", "name", "optionName", "displayName", "value"]); const code = stringAt(value, ["code", "number", "optionCode", "key", "value"]); if (!label || !code) return undefined; return { label, code }; } function normalizeSubmissionOptions(values: unknown[]): XftSubmissionOption[] { return uniqueOptions(values.map(sanitizeOption).filter((item): item is XftSubmissionOption => Boolean(item))); } function submissionInputContract(kind: XftSubmissionKind): Record { 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), minimalExample: submissionMinimalExample(kind), }, customFieldValues: "使用 fields[].fieldNumber 与 fieldValue 组装;具体值格式以 fields[].type/options 为准", serverManagedFields: [...XFT_MANAGED_SUBMISSION_FIELDS], submissionStatus: "APPRV_PEND", notes: [ "申请人身份、表单编码、权限校验和审批状态由网关填写,Agent 不得传入", "minimalExample 只说明 values 的 JSON 形状;尖括号占位符必须替换为用户业务回答或实时选项对应值,绝不能原样提交", "表单配置中的 billCompanyCode/billDepartmentCode 属于标准抬头字段,不放入 customFieldValues;两者均省略时由薪福通按当前申请人默认公司和部门填写", "薪福通不提供无副作用的流程预测接口,准备阶段只做本地结构校验,最终业务校验在提交时完成", ], }; } function submissionMinimalExample(kind: XftSubmissionKind): Record { const city = "<由城市选项中用户所选名称对应的code替换>"; const expense = "<由费用类型选项中用户所选名称对应的code替换>"; const currency = "<人民币对应的内部枚举值>"; if (kind === "travel_apply") return { businessTrip: { businessTripDetails: [{ departCityCode: city, destinationCityCode: city, beginTime: "", endTime: "", }] }, }; if (kind === "expense_apply") return { applyExpenses: [{ expenseNumber: expense, amount: "<正数金额,最多两位小数>" }], }; if (kind === "entertainment_apply") return { entertainmentApplyApiCreateDTO: { entertainmentCityCode: city, entertainmentPersonCount: "<正整数>", entertainmentStartTime: "", entertainmentEndTime: "", }, }; if (kind === "common_apply") return { customFieldValues: [{ fieldNumber: "<从实时fields中复制当前字段的fieldNumber,仅工具内部使用>", fieldValue: "<按该field的type/options序列化用户业务回答>", }], }; if (kind === "common_reimburse") return { reimAmount: "<报销总金额>", reimAmountCurrencyCode: currency, expenseList: [{ expTypeCode: expense, currencyCode: currency, originalCurrencyReimAmt: "<本笔费用金额>", }], }; if (kind === "travel_reimburse") return { reimAmount: "<报销总金额>", reimAmountCurrencyCode: currency, expenseList: [{ expTypeCode: expense, currencyCode: currency, originalCurrencyReimAmt: "<本笔费用金额>", }], businessTrip: { businessTripDetails: [{ departCityCode: city, destinationCityCode: city, beginTime: "", endTime: "", }] }, }; return { clientOnly: "用车信息对象当前必须在薪福通客户端填写" }; } 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 cityField(key: string, label: string, required: boolean): Record { return { key, label, required, type: "string", description: "向用户展示城市名称,Agent 内部使用所选项", optionSource: cityOptionSource(), selectionCapability: LABEL_ONLY_SELECTION, }; } function expenseTypeField(key: string, required: boolean): Record { return { key, label: "费用类型", required, type: "string", description: "向用户展示费用名称,Agent 内部使用所选项", optionSource: expenseTypeOptionSource(), selectionCapability: LABEL_ONLY_SELECTION, }; } function readableEnumField( key: string, label: string, required: boolean, labels: Record, extra: Record = {}, ): Record { return { key, label, required, type: "string", enum: Object.keys(labels), enumLabels: labels, description: "只向用户展示中文选项名称,Agent 内部使用对应值", selectionCapability: LABEL_ONLY_SELECTION, ...extra, }; } function clientOnlyField( key: string, label: string, required: boolean, reason: string, extra: Record = {}, ): Record { return { key, label, required, ...extra, selectionCapability: clientOnlySelection(reason), description: `${reason};不要向用户索取内部编码或 ID,如需填写请在薪福通客户端完成`, }; } const TRAVEL_MODE_LABELS = { Train: "火车", Bus: "汽车", Ship: "轮船", Hotel: "住宿", RideHailing: "网约车", AirPlane: "飞机", Other: "其他", } as const; const ROUND_TRIP_LABELS = { N: "单程", Y: "往返" } as const; const HALF_DAY_LABELS = { AM: "上午", PM: "下午" } as const; const CURRENCY_LABELS = { CNY: "人民币" } as const; function expenseApplicationFields(): Array> { return [{ key: "applyExpenses", label: "事前申请费用列表", required: true, type: "array", minItems: 1, itemFields: [ expenseTypeField("expenseNumber", true), { key: "amount", label: "申请金额", required: true, type: "decimal", precision: 2 }, { key: "occurrenceDate", label: "费用发生时间", required: false, type: "datetime", description: "ISO 8601 时间" }, cityField("payCityCode", "消费城市", false), { key: "participantNum", label: "参与人数", required: false, type: "integer" }, { key: "merchantName", label: "商户名称", required: false, type: "string" }, { key: "remark", label: "备注", required: false, type: "string" }, { key: "customFieldValues", label: "费用自定义字段", required: false, type: "array" }, ], }]; } function entertainmentApplicationFields(): Array> { return [{ key: "entertainmentApplyApiCreateDTO", label: "招待信息", required: true, type: "object", fields: [ cityField("entertainmentCityCode", "招待城市", true), { key: "entertainmentPersonCount", label: "招待人数", required: true, type: "integer", minimum: 1 }, { key: "entertainmentStartTime", label: "招待开始日期", required: true, type: "date", format: "yyyy-MM-dd" }, { key: "entertainmentEndTime", label: "招待结束日期", required: true, type: "date", format: "yyyy-MM-dd" }, ], }]; } function travelApplicationFields(): Array> { return [{ key: "businessTrip", label: "差旅行程", required: true, type: "object", fields: [{ key: "businessTripDetails", label: "行程明细", required: true, type: "array", minItems: 1, itemFields: [ cityField("departCityCode", "出发城市", true), cityField("destinationCityCode", "目的城市", true), { key: "beginTime", label: "开始时间", required: true, type: "datetime", format: "yyyy-MM-dd HH:mm" }, { key: "endTime", label: "结束时间", required: true, type: "datetime", format: "yyyy-MM-dd HH:mm" }, readableEnumField("travelMode", "出行方式", false, TRAVEL_MODE_LABELS, { type: "comma_separated_enum", multiple: true }), readableEnumField("roundTrip", "是否往返", false, ROUND_TRIP_LABELS), readableEnumField("beginTimePrecision", "开始时间范围", false, HALF_DAY_LABELS), readableEnumField("endTimePrecision", "结束时间范围", false, HALF_DAY_LABELS), clientOnlyField("customArchiveOptionNumbers", "行程自定义档案", false, "当前 MCP 没有按名称查询企业自定义档案的可靠能力", { type: "array" }), { key: "estimateExpenses", label: "预计费用", required: false, type: "array", itemFields: [ expenseTypeField("expenseTypeNumber", true), { key: "applyAmount", label: "申请金额", required: true, type: "decimal" }, { key: "remark", label: "备注", required: false, type: "string" }, clientOnlyField("spreadInfo", "分摊信息", false, "当前 MCP 无法把企业分摊名称安全映射为正式接口值", { type: "object" }), ] }, ], }] }, clientOnlyField("relatedBillId", "关联申请单", false, "当前 MCP 没有按业务名称安全选择前置单据的能力", { type: "string" }), clientOnlyField("peerEmpNumbers", "同行人员", false, "当前 MCP 没有按姓名查询薪福通人员的安全接口", { type: "array" }), { key: "billCompanyCode", label: "单据公司", required: false, type: "string", serverManaged: true, userVisible: false, description: "省略后由薪福通按当前登录用户填写" }, { key: "billDepartmentCode", label: "单据部门", required: false, type: "string", serverManaged: true, userVisible: false, description: "省略后由薪福通按当前登录用户填写" }, ]; } function reimbursementHeaderFields(): Array> { return [ { key: "reimAmount", label: "报销总金额", required: true, type: "decimal", precision: 2 }, readableEnumField("reimAmountCurrencyCode", "报销币种", true, CURRENCY_LABELS, { default: "CNY" }), { key: "outRelId", label: "外部关联标识", required: false, type: "string", maxLength: 100, userVisible: false, description: "仅供系统集成使用,不应询问普通用户" }, { key: "billDepartmentCode", label: "单据部门", required: false, type: "string", maxLength: 100, serverManaged: true, userVisible: false, description: "省略后由薪福通按当前登录用户填写" }, { key: "billCompanyCode", label: "单据公司", required: false, type: "string", maxLength: 50, serverManaged: true, userVisible: false, description: "省略后由薪福通按当前登录用户填写" }, { key: "customFieldValues", label: "单据自定义字段", required: false, type: "array", userVisible: false, description: "由 Agent 根据实时 fields 组装,不向用户展示字段编码", itemFields: customFieldValueFields() }, clientOnlyField("billCollectionInfo", "收款信息", false, "当前 MCP 无法完整验证收款账户的正式接口引用", { type: "object" }), clientOnlyField("billLendPayRelList", "核销借款", false, "当前 MCP 没有按可读单号安全选择本人借款单的能力", { type: "array", itemFields: [ { key: "lendBillId", label: "借款单号", required: true, type: "string" }, readableEnumField("currencyCode", "核销币种", true, CURRENCY_LABELS, { default: "CNY" }), { key: "originalPayAmount", label: "核销金额", required: true, type: "decimal", precision: 2 }, ] }), ]; } function commonReimbursementFields(): Array> { return [...reimbursementHeaderFields(), expenseListField(false)]; } function travelReimbursementFields(): Array> { return [ ...reimbursementHeaderFields(), expenseListField(true), clientOnlyField("peerEmpNumbers", "同行人员", false, "当前 MCP 没有按姓名查询薪福通人员的安全接口", { type: "array", maxItems: 50 }), { key: "businessTrip", label: "差旅行程", required: true, type: "object", fields: [{ key: "businessTripDetails", label: "行程明细", required: true, type: "array", minItems: 1, itemFields: [ cityField("departCityCode", "出发城市", true), cityField("destinationCityCode", "目的城市", true), { key: "beginTime", aliases: ["startTime"], label: "出发时间", required: true, type: "datetime", format: "yyyy-MM-dd HH:mm" }, { key: "endTime", label: "结束时间", required: true, type: "datetime", format: "yyyy-MM-dd HH:mm" }, readableEnumField("travelMode", "出行方式", false, TRAVEL_MODE_LABELS, { type: "comma_separated_enum", multiple: true }), readableEnumField("roundTrip", "是否往返", false, ROUND_TRIP_LABELS, { default: "N" }), readableEnumField("beginTimePrecision", "开始时间范围", false, HALF_DAY_LABELS, { condition: "模板时间精度为半天时必填" }), readableEnumField("endTimePrecision", "结束时间范围", false, HALF_DAY_LABELS, { condition: "模板时间精度为半天时必填" }), { key: "tripReason", label: "出差事由", required: false, type: "string", maxLength: 200, description: "模板将行程事由设为必填时必须填写" }, clientOnlyField("customArchiveOptionNumbers", "行程自定义档案", false, "当前 MCP 没有按名称查询企业自定义档案的可靠能力", { type: "array" }), ], }], }, ]; } function expenseListField(travel: boolean): Record { const itemFields: Array> = [ { ...expenseTypeField("expTypeCode", true), maxLength: 100 }, readableEnumField("currencyCode", "费用币种", true, CURRENCY_LABELS, { default: "CNY" }), { key: "originalCurrencyReimAmt", label: "费用金额", required: true, type: "decimal", precision: 2 }, { key: "wrTim", label: "消费日期", required: false, type: "date", format: "yyyy-MM-dd" }, { key: "remark", label: "费用备注", required: false, type: "string", maxLength: 500 }, { key: "merchantName", label: "商户名称", required: false, type: "string", maxLength: 200 }, clientOnlyField("participants", "费用参与人员", false, "当前 MCP 没有按姓名查询薪福通人员的安全接口", { type: "array", maxItems: 50 }), { key: "partiNbr", label: "参与人数", required: false, type: "integer" }, { key: "taxRate", label: "税率", required: false, type: "decimal", precision: 2 }, { key: "taxFreeAmount", label: "不含税金额", required: false, type: "decimal", precision: 2 }, cityField("departureCode", "费用出发城市", false), cityField("destinationCode", "费用目的城市", false), { key: "bgnTim", label: "交通出发日期", required: false, type: "date", format: "yyyy-MM-dd" }, { key: "endTim", label: "交通到达日期", required: false, type: "date", format: "yyyy-MM-dd" }, { key: "checkInDate", label: "酒店入住日期", required: false, type: "date", format: "yyyy-MM-dd" }, { key: "checkOutDate", label: "酒店离店日期", required: false, type: "date", format: "yyyy-MM-dd" }, cityField("payCityCode", "消费城市", false), readableEnumField("hotelStar", "酒店星级", false, { "1": "五星", "2": "四星", "3": "三星", "4": "其他" }), clientOnlyField("aircraftCabin", "飞机舱位", false, "正式接口文档未提供可验证的舱位名称映射", { type: "string" }), { key: "airlineNo", label: "航班号", required: false, type: "string" }, { key: "airlineCom", label: "航空公司", required: false, type: "string" }, { key: "flightETicketNo", label: "电子客票号", required: false, type: "string" }, { key: "flightFullPrice", label: "机票全价", required: false, type: "decimal", precision: 2 }, { key: "civilAviationFund", label: "民航发展基金", required: false, type: "decimal", precision: 2 }, { key: "discount", label: "机票折扣", required: false, type: "decimal", precision: 2 }, { key: "trainNo", label: "列车号", required: false, type: "string" }, clientOnlyField("trainCabin", "火车席别", false, "正式接口文档未提供可验证的席别名称映射", { type: "string" }), { key: "trainETicketNo", label: "火车票票号", required: false, type: "string" }, clientOnlyField("shipCabin", "轮船舱位", false, "正式接口文档未提供可验证的舱位名称映射", { type: "string" }), { key: "customFieldValues", label: "费用自定义字段", required: false, type: "array", userVisible: false, description: "由 Agent 根据实时 fields 组装,不向用户展示字段编码", itemFields: customFieldValueFields() }, clientOnlyField("spreadInfo", "费用分摊", false, "当前 MCP 无法把企业分摊名称安全映射为正式接口值", { type: "object" }), ]; if (travel) itemFields.push({ key: "dstntIndex", label: "归属行程", required: false, type: "string", userVisible: false, internallyDerived: true, description: "由 Agent 根据用户选择的行程位置内部换算,不向用户展示序号" }); return { key: "expenseList", label: "费用明细", required: true, type: "array", minItems: 1, itemFields }; } function customFieldValueFields(): Array> { return [ { key: "fieldNumber", label: "字段引用", required: true, type: "string", userVisible: false, description: "由 Agent 从实时 Schema 内部复制,不向用户展示或索取" }, { key: "fieldValue", label: "字段值", required: true, type: "string", userVisible: false, description: "由 Agent 根据字段类型内部序列化" }, ]; } function assertSubmissionValues(values: Record): 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 不得传入`); } } }