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