feat(P20T3-1,P20T3-2,P20T3-3): 对账批次引擎与ERP摘要出站后端

这个提交包含在:
2026-03-11 16:55:04 +08:00
父节点 41a169ea41
当前提交 fc0295249e
共修改 15 个文件,包含 3733 行新增和 28 行删除
+221
查看文件
@@ -36,6 +36,13 @@ const {
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']);
@@ -320,6 +327,78 @@ async function fetchReadableAssetContext(prisma, assetId, request) {
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: {
@@ -984,6 +1063,146 @@ async function integrationsRoutes(fastify) {
},
);
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',
{
@@ -1087,7 +1306,9 @@ async function integrationsRoutes(fastify) {
integrationsRoutes.__testables = {
canReadAsset,
canReadPurchaseRequest,
canReadTravelReconciliationBatch,
enqueueAssetExportAndMaybeRun,
enqueueTravelReconciliationExportAndMaybeRun,
ensureProcurementTargetEligible,
normalizeProcurementTargets,
normalizeResourcesInput,