feat(P19ERP2-3,P19ERP2-4): 新增采购ERP轨道接口与详情挂件
这个提交包含在:
@@ -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,
|
||||
};
|
||||
|
||||
在新工单中引用
屏蔽一个用户