文件
hyb-oa/server/src/routes/integrations.js
T
wangsli 7cc6dc3397 R53 A1-A6:ERP 集成运维可视化与业务同步状态接入
- ErpIntegrationPage Tab 化:主数据同步 / Outbox 队列管理 / ID 映射查看器
- Outbox 分页查询、详情、重试与统计端点
- IntegrationBinding 分页查询、routePath 推导与采购/资产深链
- 报销/采购/资产详情区接入 ERP 同步状态卡片
- 单测覆盖 Outbox 与 Binding 端点
2026-04-07 10:33:58 +08:00

1905 行
55 KiB
JavaScript

const { z } = require('zod');
const { AppError } = require('../utils/AppError');
const { sendData } = require('../utils/reply');
const { buildAuditLogData } = require('../utils/audit');
const {
buildDataEnvelopeSchema,
buildErrorEnvelopeSchema,
} = require('../utils/openapi');
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require('../services/featureFlags');
const { runIntegrationOutboxOnce } = require('../jobs/integrationOutboxJob');
const {
ERP_MASTER_DATA_RESOURCES,
listMasterDataSyncStates,
normalizeRequestedResources,
syncErpMasterData,
} = require('../services/integrations/erpMasterData');
const { OUTBOX_STATUSES } = require('../services/integrations/outbox');
const {
buildReimbursementJournalPreview,
enqueueReimbursementJournalExport,
} = require('../services/integrations/erpJournal');
const {
PROCUREMENT_ERP_TARGETS,
buildPurchaseRequestErpTrack,
enqueuePurchaseAccrualJournalExport,
enqueuePurchaseArchiveSummaryExport,
enqueuePurchaseCommitmentJournalExport,
loadPurchaseRequestErpContext,
} = require('../services/integrations/erpProcurement');
const {
ASSET_ERP_EVENT_TYPE,
ASSET_ERP_TARGET,
buildAssetErpTrack,
buildAssetLedgerSummaryDedupeKey,
enqueueAssetLedgerSummaryExport,
loadAssetErpContext,
resolveAssetLedgerSummaryDrift,
} = require('../services/integrations/erpAsset');
const {
TRAVEL_ERP_EVENT_TYPE,
buildTravelReconciliationErpTrack,
buildTravelReconciliationSummaryDedupeKey,
enqueueTravelReconciliationSummaryExport,
loadTravelReconciliationErpContext,
} = require('../services/integrations/erpTravel');
const UUID_SCHEMA = z.string().uuid();
const MASTER_DATA_RESOURCE_ENUM = z.enum(['legal_entity', 'cost_center', 'supplier']);
const MASTER_DATA_SYNC_BODY_SCHEMA = z.object({
resources: z.array(MASTER_DATA_RESOURCE_ENUM).max(3).optional(),
});
const PROCUREMENT_REQUEUE_TARGET_ENUM = z.enum([
'commitment_journal',
'accrual_journal',
'archive_summary',
]);
const PROCUREMENT_REQUEUE_BODY_SCHEMA = z.object({
targets: z.array(PROCUREMENT_REQUEUE_TARGET_ENUM).min(1).max(3),
});
const OUTBOX_STATUS_ENUM = z.enum([
OUTBOX_STATUSES.pending,
OUTBOX_STATUSES.processing,
OUTBOX_STATUSES.succeeded,
OUTBOX_STATUSES.failed,
OUTBOX_STATUSES.dead_letter,
]);
const OUTBOX_LIST_QUERY_SCHEMA = z.object({
page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional(),
status: OUTBOX_STATUS_ENUM.optional(),
event_type: z.string().trim().max(64).optional(),
});
const BINDINGS_LIST_QUERY_SCHEMA = z.object({
page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional(),
provider: z.string().trim().max(32).optional(),
entity_type: z.string().trim().max(64).optional(),
});
const OUTBOX_SUMMARY_SCHEMA = {
type: 'object',
required: ['entityType', 'entityId', 'sourceEventName', 'journalType', 'exportType', 'identityCode'],
properties: {
entityType: { anyOf: [{ type: 'string' }, { type: 'null' }] },
entityId: { anyOf: [{ type: 'string' }, { type: 'null' }] },
sourceEventName: { anyOf: [{ type: 'string' }, { type: 'null' }] },
journalType: { anyOf: [{ type: 'string' }, { type: 'null' }] },
exportType: { anyOf: [{ type: 'string' }, { type: 'null' }] },
identityCode: { anyOf: [{ type: 'string' }, { type: 'null' }] },
},
};
const OUTBOX_ITEM_SCHEMA = {
type: 'object',
required: [
'id',
'eventType',
'status',
'payloadSummary',
'attempts',
'maxAttempts',
'nextRetryAt',
'createdAt',
'processedAt',
'deadLetterAt',
],
properties: {
id: { type: 'string', format: 'uuid' },
eventType: { type: 'string' },
status: { type: 'string' },
payloadSummary: OUTBOX_SUMMARY_SCHEMA,
attempts: { type: 'integer' },
maxAttempts: { type: 'integer' },
nextRetryAt: { anyOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }] },
createdAt: { type: 'string', format: 'date-time' },
processedAt: { anyOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }] },
deadLetterAt: { anyOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }] },
},
};
const OUTBOX_DETAIL_SCHEMA = {
type: 'object',
required: ['item'],
properties: {
item: {
allOf: [
OUTBOX_ITEM_SCHEMA,
{
type: 'object',
required: ['payload', 'lastError'],
properties: {
payload: {
anyOf: [
{ type: 'object', additionalProperties: true },
{ type: 'null' },
],
},
lastError: { anyOf: [{ type: 'string' }, { type: 'null' }] },
},
},
],
},
},
};
const OUTBOX_LIST_RESPONSE_SCHEMA = {
type: 'object',
required: ['page', 'pageSize', 'total', 'items'],
properties: {
page: { type: 'integer' },
pageSize: { type: 'integer' },
total: { type: 'integer' },
items: {
type: 'array',
items: OUTBOX_ITEM_SCHEMA,
},
},
};
const OUTBOX_STATS_RESPONSE_SCHEMA = {
type: 'object',
required: ['pending', 'processing', 'succeeded', 'failed', 'deadLetter'],
properties: {
pending: { type: 'integer' },
processing: { type: 'integer' },
succeeded: { type: 'integer' },
failed: { type: 'integer' },
deadLetter: { type: 'integer' },
},
};
const BINDING_ITEM_SCHEMA = {
type: 'object',
required: [
'id',
'provider',
'entityType',
'localId',
'externalId',
'externalCode',
'lastSyncedAt',
'routePath',
],
properties: {
id: { type: 'string', format: 'uuid' },
provider: { type: 'string' },
entityType: { type: 'string' },
localId: { type: 'string', format: 'uuid' },
externalId: { anyOf: [{ type: 'string' }, { type: 'null' }] },
externalCode: { anyOf: [{ type: 'string' }, { type: 'null' }] },
lastSyncedAt: { anyOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }] },
routePath: { anyOf: [{ type: 'string' }, { type: 'null' }] },
},
};
const BINDINGS_LIST_RESPONSE_SCHEMA = {
type: 'object',
required: ['page', 'pageSize', 'total', 'items'],
properties: {
page: { type: 'integer' },
pageSize: { type: 'integer' },
total: { type: 'integer' },
items: {
type: 'array',
items: BINDING_ITEM_SCHEMA,
},
},
};
const ERP_SYNC_RESPONSE_SCHEMA = {
type: 'object',
required: ['outboxId', 'status', 'duplicated'],
properties: {
outboxId: { type: 'string', format: 'uuid' },
status: { type: 'string' },
duplicated: { type: 'boolean' },
},
};
const ERP_JOURNAL_EXPORT_RESPONSE_SCHEMA = {
type: 'object',
required: ['outboxId', 'status', 'duplicated', 'requeued', 'eventType'],
properties: {
outboxId: { type: 'string', format: 'uuid' },
status: { type: 'string' },
duplicated: { type: 'boolean' },
requeued: { type: 'boolean' },
eventType: { type: 'string' },
},
};
const ERP_MASTER_DATA_STATUS_SCHEMA = {
type: 'object',
required: ['items', 'gate'],
properties: {
gate: {
type: 'object',
required: ['envEnabled', 'effectiveEnabled', 'effectiveReason', 'provider'],
properties: {
envEnabled: { type: 'boolean' },
effectiveEnabled: { type: 'boolean' },
effectiveReason: { type: 'string' },
provider: { type: 'string' },
masterDataEndpointConfigured: { type: 'boolean' },
journalEndpointConfigured: { type: 'boolean' },
masterDataSyncCron: { anyOf: [{ type: 'string' }, { type: 'null' }] },
},
},
items: {
type: 'array',
items: {
type: 'object',
required: ['resourceType', 'status'],
properties: {
resourceType: { type: 'string' },
status: { type: 'string' },
lastStartedAt: { anyOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }] },
lastFinishedAt: { anyOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }] },
lastSuccessAt: { anyOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }] },
lastError: { anyOf: [{ type: 'string' }, { type: 'null' }] },
lastResult: { anyOf: [{ type: 'object' }, { type: 'null' }] },
},
},
},
},
};
function parseRunNow(query) {
return (
query?.runNow === '1' ||
query?.runNow === 1 ||
query?.runNow === true ||
query?.runNow === 'true'
);
}
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: 'VALID_001',
message: '参数校验失败',
statusCode,
details,
});
}
function parseOutboxListQuery(query) {
const parsed = OUTBOX_LIST_QUERY_SCHEMA.safeParse(query ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return {
page: parsed.data.page ?? 1,
pageSize: parsed.data.pageSize ?? 20,
status: parsed.data.status ?? null,
eventType: String(parsed.data.event_type ?? '').trim() || null,
};
}
function parseBindingsListQuery(query) {
const parsed = BINDINGS_LIST_QUERY_SCHEMA.safeParse(query ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return {
page: parsed.data.page ?? 1,
pageSize: parsed.data.pageSize ?? 20,
provider: String(parsed.data.provider ?? '').trim() || null,
entityType: String(parsed.data.entity_type ?? '').trim() || null,
};
}
function parseUuidParam(value, fieldName) {
const parsed = UUID_SCHEMA.safeParse(value);
if (!parsed.success) {
throw buildValidationError({ [fieldName]: 'invalid_uuid' });
}
return parsed.data;
}
function toIsoString(value) {
return value?.toISOString?.() ?? null;
}
function resolveOutboxIdentityCode(payload) {
return (
payload?.documentNo ??
payload?.formNumber ??
payload?.assetNo ??
payload?.batchNo ??
payload?.poNo ??
payload?.contractNo ??
payload?.requestNo ??
payload?.externalOrderNo ??
payload?.references?.poNo ??
payload?.references?.contractNo ??
payload?.references?.requestNo ??
payload?.references?.externalOrderNo ??
null
);
}
function buildOutboxPayloadSummary(payload) {
return {
entityType: typeof payload?.entityType === 'string' ? payload.entityType : null,
entityId: typeof payload?.entityId === 'string' ? payload.entityId : null,
sourceEventName: typeof payload?.sourceEventName === 'string' ? payload.sourceEventName : null,
journalType: typeof payload?.journalType === 'string' ? payload.journalType : null,
exportType: typeof payload?.exportType === 'string' ? payload.exportType : null,
identityCode: resolveOutboxIdentityCode(payload),
};
}
function mapOutboxItem(record) {
return {
id: record.id,
eventType: record.event_type,
status: record.status,
payloadSummary: buildOutboxPayloadSummary(record.payload ?? null),
attempts: Number(record.attempts ?? 0),
maxAttempts: Number(record.max_attempts ?? 0),
nextRetryAt: toIsoString(record.next_retry_at),
createdAt: toIsoString(record.created_at),
processedAt: toIsoString(record.processed_at),
deadLetterAt: toIsoString(record.dead_letter_at),
};
}
function mapOutboxDetail(record) {
return {
...mapOutboxItem(record),
payload: record.payload ?? null,
lastError: record.last_error ?? null,
};
}
function mapIntegrationBindingListItem(record, routePath) {
return {
id: record.id,
provider: record.provider,
entityType: record.entity_type,
localId: record.local_id,
externalId: record.external_id ?? null,
externalCode: record.external_code ?? null,
lastSyncedAt: toIsoString(record.last_synced_at),
routePath: routePath ?? null,
};
}
async function resolveBindingRoutePaths(prisma, rows) {
const archiveEntityTypes = new Set([
'purchase_order_accrual_journal',
'purchase_order_po',
'purchase_order_contract',
]);
const archiveIds = Array.from(
new Set(
(rows ?? [])
.filter((row) => archiveEntityTypes.has(row.entity_type))
.map((row) => row.local_id)
.filter(Boolean),
),
);
const purchaseRequestByArchiveId = new Map();
if (archiveIds.length && prisma?.purchaseOrderArchive?.findMany) {
const archiveRows = await prisma.purchaseOrderArchive.findMany({
where: {
id: {
in: archiveIds,
},
},
select: {
id: true,
purchase_request_id: true,
},
});
for (const item of archiveRows) {
purchaseRequestByArchiveId.set(item.id, item.purchase_request_id);
}
}
const routePathByBindingId = new Map();
for (const row of rows ?? []) {
let routePath = null;
if (row.entity_type === 'reimbursement_journal') {
routePath = `/reimbursements/${row.local_id}`;
} else if (row.entity_type === 'purchase_request_commitment_journal') {
routePath = `/purchase-requests?id=${row.local_id}`;
} else if (archiveEntityTypes.has(row.entity_type)) {
const purchaseRequestId = purchaseRequestByArchiveId.get(row.local_id) ?? null;
routePath = purchaseRequestId ? `/purchase-requests?id=${purchaseRequestId}` : null;
} else if (row.entity_type === 'asset_erp_card') {
routePath = `/assets?id=${row.local_id}`;
}
routePathByBindingId.set(row.id, routePath);
}
return routePathByBindingId;
}
async function buildOutboxStats(prisma) {
const [pending, processing, succeeded, failed, deadLetter] = await Promise.all([
prisma.integrationOutbox.count({ where: { status: OUTBOX_STATUSES.pending } }),
prisma.integrationOutbox.count({ where: { status: OUTBOX_STATUSES.processing } }),
prisma.integrationOutbox.count({ where: { status: OUTBOX_STATUSES.succeeded } }),
prisma.integrationOutbox.count({ where: { status: OUTBOX_STATUSES.failed } }),
prisma.integrationOutbox.count({ where: { status: OUTBOX_STATUSES.dead_letter } }),
]);
return {
pending,
processing,
succeeded,
failed,
deadLetter,
};
}
async function resolveErpGate(prisma, config) {
const evaluation = await evaluateFeatureFlag({
prisma,
config,
flagKey: FEATURE_FLAG_KEYS.erpSync,
departmentId: null,
strictDepartment: false,
});
const envEnabled = Boolean(config.erpSyncEnabled);
const featureFlagEnabled = Boolean(evaluation.enabled);
return {
envEnabled,
featureFlagEnabled,
effectiveEnabled: envEnabled && featureFlagEnabled,
effectiveReason: envEnabled ? evaluation.reason : 'env_disabled',
provider: config.erpSyncProvider,
masterDataEndpointConfigured: Boolean(String(config.erpMasterDataEndpoint ?? '').trim()),
journalEndpointConfigured: Boolean(
String(config.erpJournalExportEndpoint ?? config.erpSyncEndpoint ?? '').trim(),
),
masterDataSyncCron: String(config.erpMasterDataSyncCron ?? '').trim() || null,
featureFlag: FEATURE_FLAG_KEYS.erpSync,
};
}
async function requireErpManagementGate(prisma, config) {
const gate = await resolveErpGate(prisma, config);
if (gate.effectiveEnabled) {
return gate;
}
throw new AppError({
code: 'VALID_001',
message: '参数校验失败',
statusCode: 409,
details: {
integration: gate.envEnabled ? 'feature_flag_blocked' : 'erp_sync_disabled',
featureFlag: gate.featureFlag,
reason: gate.effectiveReason,
},
});
}
async function fetchReimbursementForErp(prisma, reimbursementId) {
const reimbursement = await prisma.reimbursementForm.findUnique({
where: { id: reimbursementId },
select: {
id: true,
form_number: true,
owner_id: true,
submitter_id: true,
department_id: true,
status: true,
total_amount: true,
payment_voucher_no: true,
payment_date: true,
paid_at: true,
owner: {
select: {
legal_entity: {
select: {
code: true,
},
},
},
},
submitter: {
select: {
legal_entity: {
select: {
code: true,
},
},
},
},
},
});
if (!reimbursement) {
throw new AppError({
code: 'SYS_404',
message: 'Not Found',
statusCode: 404,
});
}
return reimbursement;
}
function ensurePaidReimbursement(reimbursement) {
if (reimbursement.status !== 'paid') {
throw new AppError({
code: 'VALID_001',
message: '参数校验失败',
statusCode: 409,
details: { reimbursement: 'erp_sync_requires_paid' },
});
}
}
function canReadPurchaseRequest(request, purchaseRequest) {
if (!purchaseRequest) {
return false;
}
const permissionCodes = request?.employee?.permissionCodes ?? [];
if (permissionCodes.includes('*') || permissionCodes.includes('procurement.view_all') || permissionCodes.includes('procurement.manage')) {
return true;
}
return (
purchaseRequest.applicant_id === request.employee.id ||
purchaseRequest.submitter_id === request.employee.id
);
}
function normalizeProcurementTargets(body) {
const parsed = PROCUREMENT_REQUEUE_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: 'VALID_001',
message: '参数校验失败',
statusCode: 400,
details: parsed.error.flatten(),
});
}
const orderedTargets = [
PROCUREMENT_ERP_TARGETS.commitmentJournal,
PROCUREMENT_ERP_TARGETS.accrualJournal,
PROCUREMENT_ERP_TARGETS.archiveSummary,
];
const requested = new Set(parsed.data.targets);
return orderedTargets.filter((target) => requested.has(target));
}
async function fetchReadablePurchaseRequestContext(prisma, purchaseRequestId, request) {
const context = await loadPurchaseRequestErpContext(prisma, purchaseRequestId);
if (!context?.purchaseRequest) {
throw new AppError({
code: 'SYS_404',
message: 'Not Found',
statusCode: 404,
});
}
if (!canReadPurchaseRequest(request, context.purchaseRequest)) {
throw new AppError({
code: 'PERM_001',
message: '无权限',
statusCode: 403,
});
}
return context;
}
function canReadAsset(request, asset) {
if (!asset) {
return false;
}
const permissionCodes = request?.employee?.permissionCodes ?? [];
if (
permissionCodes.includes('*') ||
permissionCodes.includes('asset.view_all') ||
permissionCodes.includes('asset.manage') ||
permissionCodes.includes('asset.transfer') ||
permissionCodes.includes('asset.inventory')
) {
return true;
}
return (
permissionCodes.includes('asset.view_own') &&
asset.assignee_employee_id === request?.employee?.id
);
}
async function fetchReadableAssetContext(prisma, assetId, request) {
const context = await loadAssetErpContext(prisma, assetId);
if (!context?.asset) {
throw new AppError({
code: 'SYS_404',
message: 'Not Found',
statusCode: 404,
});
}
if (!canReadAsset(request, context.asset)) {
throw new AppError({
code: 'PERM_001',
message: '无权限',
statusCode: 403,
});
}
return context;
}
function canReadTravelReconciliationBatch(request) {
const permissionCodes = request?.employee?.permissionCodes ?? [];
return (
permissionCodes.includes('*') ||
permissionCodes.includes('travel.payment.manage') ||
permissionCodes.includes('integration.manage')
);
}
async function fetchReadableTravelReconciliationContext(prisma, batchId, request) {
const context = await loadTravelReconciliationErpContext(prisma, batchId);
if (!context) {
throw new AppError({
code: 'SYS_404',
message: 'Not Found',
statusCode: 404,
});
}
if (!canReadTravelReconciliationBatch(request)) {
throw new AppError({
code: 'PERM_001',
message: '无权限',
statusCode: 403,
});
}
return context;
}
async function findTravelReconciliationOutboxByDedupeKey(prisma, batchId) {
return prisma.integrationOutbox.findUnique({
where: {
event_type_dedupe_key: {
event_type: TRAVEL_ERP_EVENT_TYPE,
dedupe_key: buildTravelReconciliationSummaryDedupeKey(batchId),
},
},
select: {
id: true,
status: true,
payload: true,
},
});
}
async function enqueueTravelReconciliationExportAndMaybeRun({
prisma,
config,
logger,
context,
actorId,
runNow,
findExistingOutbox = findTravelReconciliationOutboxByDedupeKey,
enqueueExport = enqueueTravelReconciliationSummaryExport,
runOutboxOnce = runIntegrationOutboxOnce,
}) {
const existingOutbox = await findExistingOutbox(prisma, context.id);
const enqueued = await enqueueExport(prisma, config, {
batch: context,
payload: existingOutbox?.payload,
actorId,
requeueOnFailure: true,
});
if (runNow && (!enqueued.duplicated || enqueued.requeued)) {
await runOutboxOnce(prisma, config, logger);
}
return enqueued;
}
async function findAssetOutboxByDedupeKey(prisma, assetId) {
return prisma.integrationOutbox.findUnique({
where: {
event_type_dedupe_key: {
event_type: ASSET_ERP_EVENT_TYPE,
dedupe_key: buildAssetLedgerSummaryDedupeKey(assetId),
},
},
select: {
id: true,
status: true,
payload: true,
},
});
}
async function enqueueAssetExportAndMaybeRun({
prisma,
config,
logger,
context,
actorId,
runNow,
findExistingOutbox = findAssetOutboxByDedupeKey,
enqueueExport = enqueueAssetLedgerSummaryExport,
runOutboxOnce = runIntegrationOutboxOnce,
}) {
const asset = context?.asset;
const existingOutbox = await findExistingOutbox(prisma, asset.id);
const drift = resolveAssetLedgerSummaryDrift(asset, existingOutbox?.payload ?? null);
const enqueued = await enqueueExport(prisma, config, {
asset,
payload: existingOutbox?.payload && !drift ? existingOutbox.payload : undefined,
sourceEventName: drift?.sourceEventName,
changeType: drift?.changeType,
changedFields: drift?.changedFields,
occurredAt: new Date(),
actorId,
requeueOnFailure: true,
});
if (runNow && (!enqueued.duplicated || enqueued.requeued)) {
await runOutboxOnce(prisma, config, logger);
}
return enqueued;
}
function ensureProcurementTargetEligible(target, context) {
const purchaseRequest = context?.purchaseRequest;
const archive = purchaseRequest?.order_archive ?? null;
if (target === PROCUREMENT_ERP_TARGETS.commitmentJournal) {
if (purchaseRequest?.status !== 'approved') {
throw new AppError({
code: 'VALID_001',
message: '参数校验失败',
statusCode: 409,
details: { purchaseRequest: 'purchase_request_not_approved', target },
});
}
return;
}
if (!archive) {
throw new AppError({
code: 'VALID_001',
message: '参数校验失败',
statusCode: 409,
details: { purchaseOrderArchive: 'purchase_order_archive_missing', target },
});
}
}
async function enqueueProcurementTargetsAndMaybeRun({
prisma,
config,
logger,
context,
actorId,
targets,
runNow,
}) {
const purchaseRequest = context.purchaseRequest;
const archive = purchaseRequest.order_archive ?? null;
const items = [];
for (const target of targets) {
ensureProcurementTargetEligible(target, context);
if (target === PROCUREMENT_ERP_TARGETS.commitmentJournal) {
const enqueued = await enqueuePurchaseCommitmentJournalExport(prisma, config, {
purchaseRequest,
occurredAt: context.approvalOccurredAt ?? new Date(),
actorId,
requeueOnFailure: true,
});
items.push({ target, ...enqueued });
continue;
}
if (target === PROCUREMENT_ERP_TARGETS.accrualJournal) {
const enqueued = await enqueuePurchaseAccrualJournalExport(prisma, config, {
purchaseRequest,
archive,
occurredAt: archive.archived_at ?? new Date(),
actorId,
requeueOnFailure: true,
});
items.push({ target, ...enqueued });
continue;
}
const enqueued = await enqueuePurchaseArchiveSummaryExport(prisma, config, {
purchaseRequest,
archive,
archivedAt: archive.archived_at ?? new Date(),
actorId,
requeueOnFailure: true,
});
items.push({ target, ...enqueued });
}
if (runNow && items.some((item) => !item.duplicated || item.requeued)) {
await runIntegrationOutboxOnce(prisma, config, logger);
}
return items;
}
async function enqueueReimbursementExportAndMaybeRun({
prisma,
config,
logger,
reimbursement,
actorId,
runNow,
}) {
const enqueued = await enqueueReimbursementJournalExport(prisma, config, {
reimbursement,
actorId,
});
if (runNow) {
await runIntegrationOutboxOnce(prisma, config, logger);
}
return enqueued;
}
function normalizeResourcesInput(value) {
const parsed = MASTER_DATA_SYNC_BODY_SCHEMA.safeParse(value ?? {});
if (!parsed.success) {
throw new AppError({
code: 'VALID_001',
message: '参数校验失败',
statusCode: 400,
details: parsed.error.flatten(),
});
}
return normalizeRequestedResources(parsed.data.resources ?? []);
}
async function integrationsRoutes(fastify) {
fastify.get(
'/integrations/outbox/stats',
{
preHandler: fastify.requirePermission('integration.manage'),
schema: {
tags: ['integrations'],
summary: '查询 ERP Outbox 队列状态统计',
security: [{ bearerAuth: [] }],
response: {
200: buildDataEnvelopeSchema(OUTBOX_STATS_RESPONSE_SCHEMA, 'ERP Outbox 状态统计'),
},
},
},
async (request, reply) => {
const stats = await buildOutboxStats(fastify.prisma);
return sendData(reply, request, stats);
},
);
fastify.get(
'/integrations/outbox',
{
preHandler: fastify.requirePermission('integration.manage'),
schema: {
tags: ['integrations'],
summary: '分页查询 ERP Outbox 事件',
security: [{ bearerAuth: [] }],
response: {
200: buildDataEnvelopeSchema(OUTBOX_LIST_RESPONSE_SCHEMA, 'ERP Outbox 列表'),
400: buildErrorEnvelopeSchema({
description: '参数校验失败',
codeExample: 'VALID_001',
messageExample: '参数校验失败',
}),
},
},
},
async (request, reply) => {
const query = parseOutboxListQuery(request.query);
const skip = (query.page - 1) * query.pageSize;
const where = {};
if (query.status) {
where.status = query.status;
}
if (query.eventType) {
where.event_type = query.eventType;
}
const [items, total] = await Promise.all([
fastify.prisma.integrationOutbox.findMany({
where,
orderBy: [{ created_at: 'desc' }],
skip,
take: query.pageSize,
}),
fastify.prisma.integrationOutbox.count({ where }),
]);
return sendData(reply, request, {
page: query.page,
pageSize: query.pageSize,
total,
items: items.map(mapOutboxItem),
});
},
);
fastify.get(
'/integrations/outbox/:id',
{
preHandler: fastify.requirePermission('integration.manage'),
schema: {
tags: ['integrations'],
summary: '查询单条 ERP Outbox 事件详情',
security: [{ bearerAuth: [] }],
response: {
200: buildDataEnvelopeSchema(OUTBOX_DETAIL_SCHEMA, 'ERP Outbox 详情'),
400: buildErrorEnvelopeSchema({
description: '参数校验失败',
codeExample: 'VALID_001',
messageExample: '参数校验失败',
}),
404: buildErrorEnvelopeSchema({
description: 'Outbox 事件不存在',
codeExample: 'SYS_404',
messageExample: 'Not Found',
}),
},
},
},
async (request, reply) => {
const outboxId = parseUuidParam(request.params?.id, 'id');
const item = await fastify.prisma.integrationOutbox.findUnique({
where: { id: outboxId },
});
if (!item) {
throw new AppError({
code: 'SYS_404',
message: 'Not Found',
statusCode: 404,
});
}
return sendData(reply, request, {
item: mapOutboxDetail(item),
});
},
);
fastify.post(
'/integrations/outbox/:id/retry',
{
preHandler: fastify.requirePermission('integration.manage'),
schema: {
tags: ['integrations'],
summary: '重试 failed/dead_letter 的 ERP Outbox 事件',
security: [{ bearerAuth: [] }],
response: {
200: buildDataEnvelopeSchema(OUTBOX_DETAIL_SCHEMA, 'ERP Outbox 已重置为 pending'),
400: buildErrorEnvelopeSchema({
description: '参数校验失败',
codeExample: 'VALID_001',
messageExample: '参数校验失败',
}),
404: buildErrorEnvelopeSchema({
description: 'Outbox 事件不存在',
codeExample: 'SYS_404',
messageExample: 'Not Found',
}),
409: buildErrorEnvelopeSchema({
description: '当前状态不允许重试',
codeExample: 'VALID_001',
messageExample: '参数校验失败',
}),
},
},
},
async (request, reply) => {
const outboxId = parseUuidParam(request.params?.id, 'id');
const existed = await fastify.prisma.integrationOutbox.findUnique({
where: { id: outboxId },
});
if (!existed) {
throw new AppError({
code: 'SYS_404',
message: 'Not Found',
statusCode: 404,
});
}
if (![OUTBOX_STATUSES.failed, OUTBOX_STATUSES.dead_letter].includes(existed.status)) {
throw buildValidationError(
{
outbox: 'integration_outbox_retry_status_invalid',
status: existed.status,
},
409,
);
}
const retried = await fastify.prisma.integrationOutbox.update({
where: { id: outboxId },
data: {
status: OUTBOX_STATUSES.pending,
attempts: 0,
last_error: null,
next_retry_at: new Date(),
processed_at: null,
dead_letter_at: null,
updated_by: request.employee.id,
},
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: 'ERP_OUTBOX_RETRIED',
targetType: 'integration_outbox',
targetId: retried.id,
extra: {
previousStatus: existed.status,
nextStatus: retried.status,
eventType: retried.event_type,
outboxId: retried.id,
},
}),
});
return sendData(reply, request, {
item: mapOutboxDetail(retried),
});
},
);
fastify.get(
'/integrations/bindings',
{
preHandler: fastify.requirePermission('integration.manage'),
schema: {
tags: ['integrations'],
summary: '分页查询 ERP IntegrationBinding',
security: [{ bearerAuth: [] }],
response: {
200: buildDataEnvelopeSchema(BINDINGS_LIST_RESPONSE_SCHEMA, 'ERP Binding 列表'),
400: buildErrorEnvelopeSchema({
description: '参数校验失败',
codeExample: 'VALID_001',
messageExample: '参数校验失败',
}),
},
},
},
async (request, reply) => {
const query = parseBindingsListQuery(request.query);
const skip = (query.page - 1) * query.pageSize;
const where = {};
if (query.provider) {
where.provider = query.provider;
}
if (query.entityType) {
where.entity_type = query.entityType;
}
const [items, total] = await Promise.all([
fastify.prisma.integrationBinding.findMany({
where,
orderBy: [{ last_synced_at: { sort: 'desc', nulls: 'last' } }, { created_at: 'desc' }],
skip,
take: query.pageSize,
}),
fastify.prisma.integrationBinding.count({ where }),
]);
const routePathByBindingId = await resolveBindingRoutePaths(fastify.prisma, items);
return sendData(reply, request, {
page: query.page,
pageSize: query.pageSize,
total,
items: items.map((item) => mapIntegrationBindingListItem(item, routePathByBindingId.get(item.id) ?? null)),
});
},
);
fastify.get(
'/integrations/erp/master-data/status',
{
preHandler: fastify.requirePermission('integration.manage'),
schema: {
tags: ['integrations'],
summary: '查询 ERP 主数据同步状态',
security: [{ bearerAuth: [] }],
response: {
200: buildDataEnvelopeSchema(ERP_MASTER_DATA_STATUS_SCHEMA, 'ERP 主数据同步状态'),
},
},
},
async (request, reply) => {
const [items, gate] = await Promise.all([
listMasterDataSyncStates(fastify.prisma),
resolveErpGate(fastify.prisma, fastify.config),
]);
return sendData(reply, request, { gate, items });
},
);
fastify.post(
'/integrations/erp/master-data/sync',
{
preHandler: fastify.requirePermission('integration.manage'),
schema: {
tags: ['integrations'],
summary: '手动触发 ERP 主数据同步',
security: [{ bearerAuth: [] }],
response: {
200: buildDataEnvelopeSchema(ERP_MASTER_DATA_STATUS_SCHEMA, 'ERP 主数据同步结果'),
400: buildErrorEnvelopeSchema({
description: '参数校验失败',
codeExample: 'VALID_001',
messageExample: '参数校验失败',
}),
409: buildErrorEnvelopeSchema({
description: 'ERP 总闸关闭或同步失败',
codeExample: 'VALID_001',
messageExample: '参数校验失败',
}),
},
},
},
async (request, reply) => {
const resources = normalizeResourcesInput(request.body);
const gate = await requireErpManagementGate(fastify.prisma, fastify.config);
const result = await syncErpMasterData(fastify.prisma, fastify.config, {
resources,
actorId: request.employee.id,
logger: fastify.log,
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: 'ERP_MASTER_DATA_SYNC_TRIGGERED',
targetType: 'integration_master_data',
extra: {
resources,
gateReason: gate.effectiveReason,
provider: gate.provider,
ok: result.ok,
errorCode: result.error?.code ?? null,
},
}),
});
if (!result.ok) {
throw new AppError({
code: 'VALID_001',
message: '参数校验失败',
statusCode: 409,
details: {
integration: result.error?.code ?? 'erp_master_data_sync_failed',
reason: result.error?.message ?? 'erp_master_data_sync_failed',
},
});
}
return sendData(reply, request, {
gate,
items: result.items.filter((item) => resources.includes(item.resourceType)),
triggerMode: 'manual',
});
},
);
fastify.get(
'/integrations/erp/journals/reimbursements/:reimbursementId/preview',
{
preHandler: fastify.requirePermission('integration.manage'),
schema: {
tags: ['integrations'],
summary: '预览报销付款凭证切片',
security: [{ bearerAuth: [] }],
params: {
type: 'object',
required: ['reimbursementId'],
properties: {
reimbursementId: { type: 'string', format: 'uuid' },
},
},
},
},
async (request, reply) => {
const parsedId = UUID_SCHEMA.safeParse(request.params?.reimbursementId);
if (!parsedId.success) {
throw new AppError({
code: 'VALID_001',
message: '参数校验失败',
statusCode: 400,
details: { reimbursementId: 'invalid_uuid' },
});
}
const reimbursement = await fetchReimbursementForErp(fastify.prisma, parsedId.data);
ensurePaidReimbursement(reimbursement);
const preview = await buildReimbursementJournalPreview(fastify.prisma, reimbursement);
return sendData(reply, request, {
journal: preview.journal,
exportState: preview.exportState,
});
},
);
fastify.post(
'/integrations/erp/journals/reimbursements/:reimbursementId/export',
{
preHandler: fastify.requirePermission('integration.manage'),
schema: {
tags: ['integrations'],
summary: '导出报销付款凭证到 ERP',
security: [{ bearerAuth: [] }],
params: {
type: 'object',
required: ['reimbursementId'],
properties: {
reimbursementId: { type: 'string', format: 'uuid' },
},
},
querystring: {
type: 'object',
properties: {
runNow: {
anyOf: [
{ type: 'string', enum: ['0', '1', 'false', 'true'] },
{ type: 'boolean' },
],
},
},
additionalProperties: true,
},
response: {
200: buildDataEnvelopeSchema(ERP_JOURNAL_EXPORT_RESPONSE_SCHEMA, 'ERP 导出任务已入队'),
400: buildErrorEnvelopeSchema({
description: '参数校验失败',
codeExample: 'VALID_001',
messageExample: '参数校验失败',
}),
404: buildErrorEnvelopeSchema({
description: '报销单不存在',
codeExample: 'SYS_404',
messageExample: 'Not Found',
}),
409: buildErrorEnvelopeSchema({
description: '状态不满足或开关阻断',
codeExample: 'VALID_001',
messageExample: '参数校验失败',
}),
},
},
},
async (request, reply) => {
const parsedId = UUID_SCHEMA.safeParse(request.params?.reimbursementId);
if (!parsedId.success) {
throw new AppError({
code: 'VALID_001',
message: '参数校验失败',
statusCode: 400,
details: { reimbursementId: 'invalid_uuid' },
});
}
const reimbursement = await fetchReimbursementForErp(fastify.prisma, parsedId.data);
ensurePaidReimbursement(reimbursement);
const gate = await requireErpManagementGate(fastify.prisma, fastify.config);
const enqueued = await enqueueReimbursementExportAndMaybeRun({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
reimbursement,
actorId: request.employee.id,
runNow: parseRunNow(request.query),
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: 'ERP_SYNC_ENQUEUED',
targetType: 'reimbursement_form',
targetId: reimbursement.id,
extra: {
outboxId: enqueued.id,
duplicated: enqueued.duplicated,
requeued: enqueued.requeued,
eventType: enqueued.eventType,
featureFlag: FEATURE_FLAG_KEYS.erpSync,
featureFlagReason: gate.effectiveReason,
},
}),
});
return sendData(reply, request, {
outboxId: enqueued.id,
status: enqueued.status,
duplicated: enqueued.duplicated,
requeued: enqueued.requeued,
eventType: enqueued.eventType,
});
},
);
fastify.get(
'/integrations/erp/procurement/purchase-requests/:purchaseRequestId/track',
{
preHandler: fastify.authenticate,
schema: {
tags: ['integrations'],
summary: '查询采购 ERP 同步轨道',
security: [{ bearerAuth: [] }],
params: {
type: 'object',
required: ['purchaseRequestId'],
properties: {
purchaseRequestId: { type: 'string', format: 'uuid' },
},
},
},
},
async (request, reply) => {
const parsedId = UUID_SCHEMA.safeParse(request.params?.purchaseRequestId);
if (!parsedId.success) {
throw new AppError({
code: 'VALID_001',
message: '参数校验失败',
statusCode: 400,
details: { purchaseRequestId: 'invalid_uuid' },
});
}
const [context, gate] = await Promise.all([
fetchReadablePurchaseRequestContext(fastify.prisma, parsedId.data, request),
resolveErpGate(fastify.prisma, fastify.config),
]);
const canManage = fastify.hasPermission(request.employee, 'integration.manage');
const track = await buildPurchaseRequestErpTrack(fastify.prisma, fastify.config, parsedId.data, {
context,
canManage,
gate,
});
return sendData(reply, request, track);
},
);
fastify.post(
'/integrations/erp/procurement/purchase-requests/:purchaseRequestId/requeue',
{
preHandler: fastify.requirePermission('integration.manage'),
schema: {
tags: ['integrations'],
summary: '重试或补发采购 ERP 同步',
security: [{ bearerAuth: [] }],
params: {
type: 'object',
required: ['purchaseRequestId'],
properties: {
purchaseRequestId: { type: 'string', format: 'uuid' },
},
},
querystring: {
type: 'object',
properties: {
runNow: {
anyOf: [
{ type: 'string', enum: ['0', '1', 'false', 'true'] },
{ type: 'boolean' },
],
},
},
additionalProperties: true,
},
},
},
async (request, reply) => {
const parsedId = UUID_SCHEMA.safeParse(request.params?.purchaseRequestId);
if (!parsedId.success) {
throw new AppError({
code: 'VALID_001',
message: '参数校验失败',
statusCode: 400,
details: { purchaseRequestId: 'invalid_uuid' },
});
}
const targets = normalizeProcurementTargets(request.body);
const gate = await requireErpManagementGate(fastify.prisma, fastify.config);
const context = await loadPurchaseRequestErpContext(fastify.prisma, parsedId.data);
if (!context?.purchaseRequest) {
throw new AppError({
code: 'SYS_404',
message: 'Not Found',
statusCode: 404,
});
}
const items = await enqueueProcurementTargetsAndMaybeRun({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
context,
actorId: request.employee.id,
targets,
runNow: parseRunNow(request.query),
});
for (const item of items) {
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: 'ERP_SYNC_ENQUEUED',
targetType: item.target === PROCUREMENT_ERP_TARGETS.commitmentJournal ? 'purchase_request' : 'purchase_order_archive',
targetId:
item.target === PROCUREMENT_ERP_TARGETS.commitmentJournal
? context.purchaseRequest.id
: context.purchaseRequest.order_archive?.id ?? null,
extra: {
erpTarget: item.target,
eventType: item.eventType,
purchaseRequestId: context.purchaseRequest.id,
purchaseOrderArchiveId: context.purchaseRequest.order_archive?.id ?? null,
outboxId: item.id,
duplicated: item.duplicated,
requeued: item.requeued,
gateReason: gate.effectiveReason,
featureFlag: FEATURE_FLAG_KEYS.erpSync,
},
}),
});
}
return sendData(reply, request, {
items: items.map((item) => ({
target: item.target,
outboxId: item.id,
status: item.status,
duplicated: item.duplicated,
requeued: item.requeued,
eventType: item.eventType,
})),
});
},
);
fastify.get(
'/integrations/erp/assets/:assetId/track',
{
preHandler: fastify.authenticate,
schema: {
tags: ['integrations'],
summary: '查询资产 ERP 同步轨道',
security: [{ bearerAuth: [] }],
params: {
type: 'object',
required: ['assetId'],
properties: {
assetId: { type: 'string', format: 'uuid' },
},
},
},
},
async (request, reply) => {
const parsedId = UUID_SCHEMA.safeParse(request.params?.assetId);
if (!parsedId.success) {
throw new AppError({
code: 'VALID_001',
message: '参数校验失败',
statusCode: 400,
details: { assetId: 'invalid_uuid' },
});
}
const [context, gate] = await Promise.all([
fetchReadableAssetContext(fastify.prisma, parsedId.data, request),
resolveErpGate(fastify.prisma, fastify.config),
]);
const canManage = fastify.hasPermission(request.employee, 'integration.manage');
const track = await buildAssetErpTrack(fastify.prisma, fastify.config, parsedId.data, {
context,
canManage,
gate,
});
return sendData(reply, request, track);
},
);
fastify.post(
'/integrations/erp/assets/:assetId/requeue',
{
preHandler: fastify.requirePermission('integration.manage'),
schema: {
tags: ['integrations'],
summary: '补发或重试资产 ERP 同步',
security: [{ bearerAuth: [] }],
params: {
type: 'object',
required: ['assetId'],
properties: {
assetId: { type: 'string', format: 'uuid' },
},
},
querystring: {
type: 'object',
properties: {
runNow: {
anyOf: [
{ type: 'string', enum: ['0', '1', 'false', 'true'] },
{ type: 'boolean' },
],
},
},
additionalProperties: true,
},
},
},
async (request, reply) => {
const parsedId = UUID_SCHEMA.safeParse(request.params?.assetId);
if (!parsedId.success) {
throw new AppError({
code: 'VALID_001',
message: '参数校验失败',
statusCode: 400,
details: { assetId: 'invalid_uuid' },
});
}
const gate = await requireErpManagementGate(fastify.prisma, fastify.config);
const context = await loadAssetErpContext(fastify.prisma, parsedId.data);
if (!context?.asset) {
throw new AppError({
code: 'SYS_404',
message: 'Not Found',
statusCode: 404,
});
}
const enqueued = await enqueueAssetExportAndMaybeRun({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
context,
actorId: request.employee.id,
runNow: parseRunNow(request.query),
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: 'ERP_SYNC_ENQUEUED',
targetType: 'asset_card',
targetId: context.asset.id,
extra: {
erpTarget: ASSET_ERP_TARGET,
eventType: enqueued.eventType,
assetId: context.asset.id,
assetNo: context.asset.asset_no ?? null,
outboxId: enqueued.id,
duplicated: enqueued.duplicated,
requeued: enqueued.requeued,
gateReason: gate.effectiveReason,
featureFlag: FEATURE_FLAG_KEYS.erpSync,
},
}),
});
return sendData(reply, request, {
outboxId: enqueued.id,
status: enqueued.status,
duplicated: enqueued.duplicated,
requeued: enqueued.requeued,
eventType: enqueued.eventType,
});
},
);
fastify.get(
'/integrations/erp/travel/reconciliation-batches/:batchId/track',
{
preHandler: fastify.authenticate,
schema: {
tags: ['integrations'],
summary: '查询差旅对账批次 ERP 同步轨道',
security: [{ bearerAuth: [] }],
params: {
type: 'object',
required: ['batchId'],
properties: {
batchId: { type: 'string', format: 'uuid' },
},
},
},
},
async (request, reply) => {
const parsedId = UUID_SCHEMA.safeParse(request.params?.batchId);
if (!parsedId.success) {
throw new AppError({
code: 'VALID_001',
message: '参数校验失败',
statusCode: 400,
details: { batchId: 'invalid_uuid' },
});
}
const [context, gate] = await Promise.all([
fetchReadableTravelReconciliationContext(fastify.prisma, parsedId.data, request),
resolveErpGate(fastify.prisma, fastify.config),
]);
const canManage = fastify.hasPermission(request.employee, 'integration.manage');
const track = await buildTravelReconciliationErpTrack(fastify.prisma, fastify.config, parsedId.data, {
context,
canManage,
gate,
});
return sendData(reply, request, track);
},
);
fastify.post(
'/integrations/erp/travel/reconciliation-batches/:batchId/requeue',
{
preHandler: fastify.requirePermission('integration.manage'),
schema: {
tags: ['integrations'],
summary: '重试差旅对账批次 ERP 摘要桥同步',
security: [{ bearerAuth: [] }],
params: {
type: 'object',
required: ['batchId'],
properties: {
batchId: { type: 'string', format: 'uuid' },
},
},
querystring: {
type: 'object',
properties: {
runNow: {
anyOf: [
{ type: 'string', enum: ['0', '1', 'false', 'true'] },
{ type: 'boolean' },
],
},
},
additionalProperties: true,
},
},
},
async (request, reply) => {
const parsedId = UUID_SCHEMA.safeParse(request.params?.batchId);
if (!parsedId.success) {
throw new AppError({
code: 'VALID_001',
message: '参数校验失败',
statusCode: 400,
details: { batchId: 'invalid_uuid' },
});
}
const gate = await requireErpManagementGate(fastify.prisma, fastify.config);
const context = await loadTravelReconciliationErpContext(fastify.prisma, parsedId.data);
if (!context) {
throw new AppError({
code: 'SYS_404',
message: 'Not Found',
statusCode: 404,
});
}
if (context.status !== 'reconciled') {
throw new AppError({
code: 'VALID_001',
message: '参数校验失败',
statusCode: 409,
details: { batch: 'travel_reconciliation_batch_not_reconciled' },
});
}
const enqueued = await enqueueTravelReconciliationExportAndMaybeRun({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
context,
actorId: request.employee.id,
runNow: parseRunNow(request.query),
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: 'ERP_SYNC_ENQUEUED',
targetType: 'travel_reconciliation_batch',
targetId: context.id,
extra: {
erpTarget: 'travel_reconciliation_summary',
eventType: enqueued.eventType,
batchId: context.id,
batchNo: context.batch_no,
outboxId: enqueued.id,
duplicated: enqueued.duplicated,
requeued: enqueued.requeued,
gateReason: gate.effectiveReason,
featureFlag: FEATURE_FLAG_KEYS.erpSync,
},
}),
});
return sendData(reply, request, {
outboxId: enqueued.id,
status: enqueued.status,
duplicated: enqueued.duplicated,
requeued: enqueued.requeued,
eventType: enqueued.eventType,
});
},
);
fastify.post(
'/integrations/erp/sync/:reimbursementId',
{
preHandler: fastify.requirePermission('integration.manage'),
schema: {
tags: ['integrations'],
summary: '手动触发 ERP 同步',
security: [{ bearerAuth: [] }],
params: {
type: 'object',
required: ['reimbursementId'],
properties: {
reimbursementId: { type: 'string', format: 'uuid' },
},
},
querystring: {
type: 'object',
properties: {
runNow: {
anyOf: [
{ type: 'string', enum: ['0', '1', 'false', 'true'] },
{ type: 'boolean' },
],
},
},
additionalProperties: true,
},
response: {
200: buildDataEnvelopeSchema(ERP_SYNC_RESPONSE_SCHEMA, 'ERP 同步任务已入队'),
400: buildErrorEnvelopeSchema({
description: '参数校验失败',
codeExample: 'VALID_001',
messageExample: '参数校验失败',
}),
404: buildErrorEnvelopeSchema({
description: '报销单不存在',
codeExample: 'SYS_404',
messageExample: 'Not Found',
}),
409: buildErrorEnvelopeSchema({
description: '状态不满足或开关阻断',
codeExample: 'VALID_001',
messageExample: '参数校验失败',
detailsExample: {
integration: 'feature_flag_blocked',
featureFlag: 'erp_sync',
reason: 'department_not_allowed',
},
}),
},
},
},
async (request, reply) => {
const parsedId = UUID_SCHEMA.safeParse(request.params?.reimbursementId);
if (!parsedId.success) {
throw new AppError({
code: 'VALID_001',
message: '参数校验失败',
statusCode: 400,
details: { reimbursementId: 'invalid_uuid' },
});
}
const reimbursement = await fetchReimbursementForErp(fastify.prisma, parsedId.data);
ensurePaidReimbursement(reimbursement);
const gate = await requireErpManagementGate(fastify.prisma, fastify.config);
const enqueued = await enqueueReimbursementExportAndMaybeRun({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
reimbursement,
actorId: request.employee.id,
runNow: parseRunNow(request.query),
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: 'ERP_SYNC_ENQUEUED',
targetType: 'reimbursement_form',
targetId: reimbursement.id,
extra: {
outboxId: enqueued.id,
duplicated: enqueued.duplicated,
requeued: enqueued.requeued,
featureFlag: FEATURE_FLAG_KEYS.erpSync,
featureFlagReason: gate.effectiveReason,
},
}),
});
return sendData(reply, request, {
outboxId: enqueued.id,
status: enqueued.status,
duplicated: enqueued.duplicated,
});
},
);
}
integrationsRoutes.__testables = {
canReadAsset,
canReadPurchaseRequest,
canReadTravelReconciliationBatch,
enqueueAssetExportAndMaybeRun,
enqueueTravelReconciliationExportAndMaybeRun,
ensureProcurementTargetEligible,
normalizeProcurementTargets,
normalizeResourcesInput,
parseRunNow,
};
module.exports = integrationsRoutes;