feat(P19ERP3-3,P19ERP3-4): 新增资产ERP轨道接口与前端卡片挂件
这个提交包含在:
@@ -27,6 +27,15 @@ const {
|
||||
enqueuePurchaseCommitmentJournalExport,
|
||||
loadPurchaseRequestErpContext,
|
||||
} = require('../services/integrations/erpProcurement');
|
||||
const {
|
||||
ASSET_ERP_EVENT_TYPE,
|
||||
ASSET_ERP_TARGET,
|
||||
buildAssetErpTrack,
|
||||
buildAssetLedgerSummaryDedupeKey,
|
||||
enqueueAssetLedgerSummaryExport,
|
||||
loadAssetErpContext,
|
||||
resolveAssetLedgerSummaryDrift,
|
||||
} = require('../services/integrations/erpAsset');
|
||||
|
||||
const UUID_SCHEMA = z.string().uuid();
|
||||
const MASTER_DATA_RESOURCE_ENUM = z.enum(['legal_entity', 'cost_center', 'supplier']);
|
||||
@@ -268,6 +277,98 @@ async function fetchReadablePurchaseRequestContext(prisma, purchaseRequestId, re
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -751,6 +852,138 @@ async function integrationsRoutes(fastify) {
|
||||
},
|
||||
);
|
||||
|
||||
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.post(
|
||||
'/integrations/erp/sync/:reimbursementId',
|
||||
{
|
||||
@@ -852,7 +1085,9 @@ async function integrationsRoutes(fastify) {
|
||||
}
|
||||
|
||||
integrationsRoutes.__testables = {
|
||||
canReadAsset,
|
||||
canReadPurchaseRequest,
|
||||
enqueueAssetExportAndMaybeRun,
|
||||
ensureProcurementTargetEligible,
|
||||
normalizeProcurementTargets,
|
||||
normalizeResourcesInput,
|
||||
|
||||
在新工单中引用
屏蔽一个用户