feat(P19ERP2-3,P19ERP2-4): 新增采购ERP轨道接口与详情挂件

这个提交包含在:
2026-03-08 23:50:11 +08:00
父节点 486660df06
当前提交 69c831d578
共修改 5 个文件,包含 707 行新增和 1 行删除
+300
查看文件
@@ -19,12 +19,28 @@ const {
buildReimbursementJournalPreview,
enqueueReimbursementJournalExport,
} = require('../services/integrations/erpJournal');
const {
PROCUREMENT_ERP_TARGETS,
buildPurchaseRequestErpTrack,
enqueuePurchaseAccrualJournalExport,
enqueuePurchaseArchiveSummaryExport,
enqueuePurchaseCommitmentJournalExport,
loadPurchaseRequestErpContext,
} = require('../services/integrations/erpProcurement');
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 ERP_SYNC_RESPONSE_SCHEMA = {
type: 'object',
@@ -195,6 +211,145 @@ function ensurePaidReimbursement(reimbursement) {
}
}
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 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,
@@ -454,6 +609,148 @@ async function integrationsRoutes(fastify) {
},
);
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.post(
'/integrations/erp/sync/:reimbursementId',
{
@@ -555,6 +852,9 @@ async function integrationsRoutes(fastify) {
}
integrationsRoutes.__testables = {
canReadPurchaseRequest,
ensureProcurementTargetEligible,
normalizeProcurementTargets,
normalizeResourcesInput,
parseRunNow,
};