R53 A1-A6:ERP 集成运维可视化与业务同步状态接入
- ErpIntegrationPage Tab 化:主数据同步 / Outbox 队列管理 / ID 映射查看器 - Outbox 分页查询、详情、重试与统计端点 - IntegrationBinding 分页查询、routePath 推导与采购/资产深链 - 报销/采购/资产详情区接入 ERP 同步状态卡片 - 单测覆盖 Outbox 与 Binding 端点
这个提交包含在:
@@ -15,6 +15,7 @@ const {
|
||||
normalizeRequestedResources,
|
||||
syncErpMasterData,
|
||||
} = require('../services/integrations/erpMasterData');
|
||||
const { OUTBOX_STATUSES } = require('../services/integrations/outbox');
|
||||
const {
|
||||
buildReimbursementJournalPreview,
|
||||
enqueueReimbursementJournalExport,
|
||||
@@ -57,6 +58,155 @@ const PROCUREMENT_REQUEUE_TARGET_ENUM = z.enum([
|
||||
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',
|
||||
@@ -125,6 +275,195 @@ function parseRunNow(query) {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -565,6 +904,253 @@ function normalizeResourcesInput(value) {
|
||||
}
|
||||
|
||||
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',
|
||||
{
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
const Fastify = require('fastify');
|
||||
|
||||
const integrationsRoutes = require('../../../src/routes/integrations');
|
||||
const { AppError } = require('../../../src/utils/AppError');
|
||||
|
||||
const ACTOR_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
||||
|
||||
function buildBinding(overrides = {}) {
|
||||
return {
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
provider: 'erp',
|
||||
entity_type: 'purchase_order_po',
|
||||
local_id: '22222222-2222-4222-8222-222222222222',
|
||||
external_id: 'ERP-PO-001',
|
||||
external_code: 'PO-001',
|
||||
last_synced_at: new Date('2026-04-07T10:00:00.000Z'),
|
||||
created_at: new Date('2026-04-07T09:00:00.000Z'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildApp(options = {}) {
|
||||
const app = Fastify();
|
||||
const actor = options.actor ?? {
|
||||
id: ACTOR_ID,
|
||||
permissionCodes: ['integration.manage'],
|
||||
};
|
||||
const findMany = options.findMany ?? vi.fn().mockResolvedValue([
|
||||
buildBinding(),
|
||||
buildBinding({
|
||||
id: '33333333-3333-4333-8333-333333333333',
|
||||
entity_type: 'reimbursement_journal',
|
||||
local_id: '44444444-4444-4444-8444-444444444444',
|
||||
external_id: 'ERP-BX-001',
|
||||
external_code: 'BX-001',
|
||||
}),
|
||||
]);
|
||||
const count = options.count ?? vi.fn().mockResolvedValue(2);
|
||||
const archiveFindMany = options.archiveFindMany ?? vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: '22222222-2222-4222-8222-222222222222',
|
||||
purchase_request_id: '55555555-5555-4555-8555-555555555555',
|
||||
},
|
||||
]);
|
||||
|
||||
app.decorate('authenticate', async (request) => {
|
||||
request.employee = actor;
|
||||
});
|
||||
app.decorate('requirePermission', (permissionCode) => async (request) => {
|
||||
request.employee = actor;
|
||||
const permissionCodes = actor.permissionCodes ?? [];
|
||||
if (!permissionCodes.includes('*') && !permissionCodes.includes(permissionCode)) {
|
||||
throw new AppError({ code: 'PERM_001', message: '无权限', statusCode: 403 });
|
||||
}
|
||||
});
|
||||
app.decorate('hasPermission', (employee, permissionCode) => {
|
||||
const permissionCodes = employee.permissionCodes ?? [];
|
||||
return permissionCodes.includes('*') || permissionCodes.includes(permissionCode);
|
||||
});
|
||||
app.decorate('config', {
|
||||
erpSyncEnabled: true,
|
||||
erpSyncProvider: 'mock',
|
||||
erpMasterDataEndpoint: 'http://example.test/master-data',
|
||||
erpJournalExportEndpoint: 'http://example.test/journal',
|
||||
erpSyncEndpoint: 'http://example.test/journal',
|
||||
erpMasterDataSyncCron: '0 * * * *',
|
||||
});
|
||||
app.decorate('prisma', {
|
||||
featureFlag: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: 'flag-erp',
|
||||
flag_key: 'erp_sync',
|
||||
override_mode: 'force_on',
|
||||
rollout_type: 'global',
|
||||
department_ids: [],
|
||||
}),
|
||||
},
|
||||
integrationBinding: {
|
||||
findMany,
|
||||
count,
|
||||
},
|
||||
purchaseOrderArchive: {
|
||||
findMany: archiveFindMany,
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
});
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
reply.status(error.statusCode || 500).send({
|
||||
requestId: request.id,
|
||||
error: {
|
||||
code: error.code || 'SYS_001',
|
||||
message: error.message,
|
||||
details: error.details ?? null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(integrationsRoutes, { prefix: '/api' });
|
||||
return { app, findMany, count, archiveFindMany };
|
||||
}
|
||||
|
||||
describe('integrations binding routes', () => {
|
||||
it('lists bindings with filters and route paths', async () => {
|
||||
const { app, findMany, count, archiveFindMany } = await buildApp();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/integrations/bindings?page=2&pageSize=10&provider=erp&entity_type=purchase_order_po',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
provider: 'erp',
|
||||
entity_type: 'purchase_order_po',
|
||||
},
|
||||
orderBy: [{ last_synced_at: { sort: 'desc', nulls: 'last' } }, { created_at: 'desc' }],
|
||||
skip: 10,
|
||||
take: 10,
|
||||
}),
|
||||
);
|
||||
expect(count).toHaveBeenCalledWith({
|
||||
where: {
|
||||
provider: 'erp',
|
||||
entity_type: 'purchase_order_po',
|
||||
},
|
||||
});
|
||||
expect(archiveFindMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: {
|
||||
in: ['22222222-2222-4222-8222-222222222222'],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
purchase_request_id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = response.json();
|
||||
expect(payload.data.items[0]).toMatchObject({
|
||||
entityType: 'purchase_order_po',
|
||||
localId: '22222222-2222-4222-8222-222222222222',
|
||||
routePath: '/purchase-requests?id=55555555-5555-4555-8555-555555555555',
|
||||
});
|
||||
expect(payload.data.items[1]).toMatchObject({
|
||||
entityType: 'reimbursement_journal',
|
||||
routePath: '/reimbursements/44444444-4444-4444-8444-444444444444',
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('returns null routePath for unsupported entity types', async () => {
|
||||
const { app } = await buildApp({
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
buildBinding({
|
||||
entity_type: 'travel_payable_summary',
|
||||
local_id: '66666666-6666-4666-8666-666666666666',
|
||||
}),
|
||||
]),
|
||||
count: vi.fn().mockResolvedValue(1),
|
||||
archiveFindMany: vi.fn().mockResolvedValue([]),
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/integrations/bindings',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.items[0].routePath).toBe(null);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('blocks users without integration.manage', async () => {
|
||||
const { app } = await buildApp({
|
||||
actor: {
|
||||
id: ACTOR_ID,
|
||||
permissionCodes: [],
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/integrations/bindings',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(response.json().error.code).toBe('PERM_001');
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
const Fastify = require('fastify');
|
||||
|
||||
const integrationsRoutes = require('../../../src/routes/integrations');
|
||||
const { AppError } = require('../../../src/utils/AppError');
|
||||
|
||||
const ACTOR_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
||||
const OUTBOX_ID = '11111111-1111-4111-8111-111111111111';
|
||||
|
||||
function buildOutbox(overrides = {}) {
|
||||
return {
|
||||
id: OUTBOX_ID,
|
||||
event_type: 'erp.journal.export',
|
||||
status: 'failed',
|
||||
payload: {
|
||||
entityType: 'reimbursement_form',
|
||||
entityId: '22222222-2222-4222-8222-222222222222',
|
||||
sourceEventName: 'reimbursement.paid',
|
||||
journalType: 'reimbursement_paid',
|
||||
documentNo: 'BX-20260407-001',
|
||||
token: 'sanitized-value',
|
||||
},
|
||||
attempts: 2,
|
||||
max_attempts: 5,
|
||||
last_error: 'HTTP 500 | timeout',
|
||||
next_retry_at: new Date('2026-04-07T10:00:00.000Z'),
|
||||
created_at: new Date('2026-04-07T09:00:00.000Z'),
|
||||
processed_at: null,
|
||||
dead_letter_at: null,
|
||||
updated_at: new Date('2026-04-07T09:30:00.000Z'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildApp(options = {}) {
|
||||
const app = Fastify();
|
||||
const actor = options.actor ?? {
|
||||
id: ACTOR_ID,
|
||||
permissionCodes: ['integration.manage'],
|
||||
};
|
||||
const findMany = options.findMany ?? vi.fn().mockResolvedValue([buildOutbox()]);
|
||||
const findUnique = options.findUnique ?? vi.fn().mockResolvedValue(buildOutbox());
|
||||
const update = options.update ?? vi.fn().mockImplementation(async ({ where, data }) => ({
|
||||
...buildOutbox({ id: where.id }),
|
||||
...data,
|
||||
updated_at: new Date('2026-04-07T10:30:00.000Z'),
|
||||
}));
|
||||
const count = options.count ?? vi.fn().mockImplementation(async ({ where } = {}) => {
|
||||
if (where?.status === 'pending') return 3;
|
||||
if (where?.status === 'processing') return 1;
|
||||
if (where?.status === 'succeeded') return 8;
|
||||
if (where?.status === 'failed') return 2;
|
||||
if (where?.status === 'dead_letter') return 1;
|
||||
return 1;
|
||||
});
|
||||
const auditCreate = options.auditCreate ?? vi.fn().mockResolvedValue({});
|
||||
|
||||
app.decorate('authenticate', async (request) => {
|
||||
request.employee = actor;
|
||||
});
|
||||
app.decorate('requirePermission', (permissionCode) => async (request) => {
|
||||
request.employee = actor;
|
||||
const permissionCodes = actor.permissionCodes ?? [];
|
||||
if (!permissionCodes.includes('*') && !permissionCodes.includes(permissionCode)) {
|
||||
throw new AppError({ code: 'PERM_001', message: '无权限', statusCode: 403 });
|
||||
}
|
||||
});
|
||||
app.decorate('hasPermission', (employee, permissionCode) => {
|
||||
const permissionCodes = employee.permissionCodes ?? [];
|
||||
return permissionCodes.includes('*') || permissionCodes.includes(permissionCode);
|
||||
});
|
||||
app.decorate('config', {
|
||||
erpSyncEnabled: true,
|
||||
erpSyncProvider: 'mock',
|
||||
erpMasterDataEndpoint: 'http://example.test/master-data',
|
||||
erpJournalExportEndpoint: 'http://example.test/journal',
|
||||
erpSyncEndpoint: 'http://example.test/journal',
|
||||
erpMasterDataSyncCron: '0 * * * *',
|
||||
});
|
||||
app.decorate('prisma', {
|
||||
featureFlag: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: 'flag-erp',
|
||||
flag_key: 'erp_sync',
|
||||
override_mode: 'force_on',
|
||||
rollout_type: 'global',
|
||||
department_ids: [],
|
||||
}),
|
||||
},
|
||||
integrationOutbox: {
|
||||
findMany,
|
||||
findUnique,
|
||||
count,
|
||||
update,
|
||||
},
|
||||
auditLog: {
|
||||
create: auditCreate,
|
||||
},
|
||||
});
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
reply.status(error.statusCode || 500).send({
|
||||
requestId: request.id,
|
||||
error: {
|
||||
code: error.code || 'SYS_001',
|
||||
message: error.message,
|
||||
details: error.details ?? null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(integrationsRoutes, { prefix: '/api' });
|
||||
return { app, findMany, findUnique, count, update, auditCreate };
|
||||
}
|
||||
|
||||
describe('integrations outbox routes', () => {
|
||||
it('lists outbox events with filters and payload summary', async () => {
|
||||
const { app, findMany, count } = await buildApp();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/integrations/outbox?page=2&pageSize=10&status=failed&event_type=erp.journal.export',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
status: 'failed',
|
||||
event_type: 'erp.journal.export',
|
||||
},
|
||||
orderBy: [{ created_at: 'desc' }],
|
||||
skip: 10,
|
||||
take: 10,
|
||||
}),
|
||||
);
|
||||
expect(count).toHaveBeenCalledWith({
|
||||
where: {
|
||||
status: 'failed',
|
||||
event_type: 'erp.journal.export',
|
||||
},
|
||||
});
|
||||
|
||||
const payload = response.json();
|
||||
expect(payload.data.page).toBe(2);
|
||||
expect(payload.data.items[0]).toMatchObject({
|
||||
id: OUTBOX_ID,
|
||||
eventType: 'erp.journal.export',
|
||||
status: 'failed',
|
||||
payloadSummary: {
|
||||
entityType: 'reimbursement_form',
|
||||
entityId: '22222222-2222-4222-8222-222222222222',
|
||||
sourceEventName: 'reimbursement.paid',
|
||||
journalType: 'reimbursement_paid',
|
||||
exportType: null,
|
||||
identityCode: 'BX-20260407-001',
|
||||
},
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('returns stats with missing buckets defaulted to zero', async () => {
|
||||
const { app } = await buildApp({
|
||||
count: vi.fn().mockImplementation(async ({ where } = {}) => {
|
||||
if (where?.status === 'succeeded') return 5;
|
||||
return 0;
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/integrations/outbox/stats',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data).toEqual({
|
||||
pending: 0,
|
||||
processing: 0,
|
||||
succeeded: 5,
|
||||
failed: 0,
|
||||
deadLetter: 0,
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('returns outbox detail with full payload', async () => {
|
||||
const { app, findUnique } = await buildApp();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/integrations/outbox/${OUTBOX_ID}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(findUnique).toHaveBeenCalledWith({ where: { id: OUTBOX_ID } });
|
||||
expect(response.json().data.item.payload).toMatchObject({
|
||||
documentNo: 'BX-20260407-001',
|
||||
token: 'sanitized-value',
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('retries failed or dead-letter outbox events by resetting status to pending', async () => {
|
||||
const { app, update, auditCreate } = await buildApp({
|
||||
findUnique: vi.fn().mockResolvedValue(buildOutbox({ status: 'dead_letter', dead_letter_at: new Date('2026-04-07T09:40:00.000Z') })),
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/integrations/outbox/${OUTBOX_ID}/retry`,
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: OUTBOX_ID },
|
||||
data: expect.objectContaining({
|
||||
status: 'pending',
|
||||
attempts: 0,
|
||||
last_error: null,
|
||||
next_retry_at: expect.any(Date),
|
||||
processed_at: null,
|
||||
dead_letter_at: null,
|
||||
updated_by: ACTOR_ID,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(auditCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
action: 'ERP_OUTBOX_RETRIED',
|
||||
target_type: 'integration_outbox',
|
||||
target_id: OUTBOX_ID,
|
||||
extra: expect.objectContaining({
|
||||
previousStatus: 'dead_letter',
|
||||
nextStatus: 'pending',
|
||||
eventType: 'erp.journal.export',
|
||||
outboxId: OUTBOX_ID,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects retry for succeeded items with 409', async () => {
|
||||
const { app, update, auditCreate } = await buildApp({
|
||||
findUnique: vi.fn().mockResolvedValue(buildOutbox({ status: 'succeeded', last_error: null })),
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/integrations/outbox/${OUTBOX_ID}/retry`,
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(auditCreate).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('blocks users without integration.manage', async () => {
|
||||
const { app } = await buildApp({
|
||||
actor: {
|
||||
id: ACTOR_ID,
|
||||
permissionCodes: [],
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/integrations/outbox',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(response.json().error.code).toBe('PERM_001');
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户