feat(P19ERP3-3,P19ERP3-4): 新增资产ERP轨道接口与前端卡片挂件

这个提交包含在:
2026-03-09 11:15:21 +08:00
父节点 0168ebfe2e
当前提交 07fac7d2d2
共修改 4 个文件,包含 604 行新增和 0 行删除
+235
查看文件
@@ -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,
@@ -0,0 +1,139 @@
const integrationsRoutes = require('../../../src/routes/integrations');
describe('Integrations asset ERP route helpers', () => {
const { canReadAsset, enqueueAssetExportAndMaybeRun, parseRunNow } = integrationsRoutes.__testables;
function createAsset(overrides = {}) {
return {
id: 'asset-1',
asset_no: 'ZC-001',
assignee_employee_id: 'employee-1',
legal_entity_id: 'le-1',
cost_center_id: 'cc-1',
status: 'in_use',
...overrides,
};
}
it('respects asset read permission rules', () => {
const asset = createAsset();
expect(
canReadAsset(
{ employee: { id: 'employee-1', permissionCodes: ['asset.view_own'] } },
asset,
),
).toBe(true);
expect(
canReadAsset(
{ employee: { id: 'other', permissionCodes: ['asset.view_own'] } },
asset,
),
).toBe(false);
expect(
canReadAsset(
{ employee: { id: 'other', permissionCodes: ['asset.view_all'] } },
asset,
),
).toBe(true);
});
it('retries same asset payload without triggering runNow when duplicated', async () => {
const enqueueExport = vi.fn().mockResolvedValue({
id: 'outbox-1',
status: 'succeeded',
duplicated: true,
requeued: false,
eventType: 'erp.asset.export',
});
const runOutboxOnce = vi.fn();
const result = await enqueueAssetExportAndMaybeRun({
prisma: {},
config: {},
logger: {},
context: { asset: createAsset() },
actorId: 'actor-1',
runNow: true,
findExistingOutbox: vi.fn().mockResolvedValue({
id: 'outbox-1',
status: 'succeeded',
payload: {
exportType: 'asset_ledger_summary',
entityId: 'asset-1',
assetNo: 'ZC-001',
legalEntity: { id: 'le-1' },
costCenter: { id: 'cc-1' },
status: 'in_use',
},
}),
enqueueExport,
runOutboxOnce,
});
expect(enqueueExport).toHaveBeenCalledWith(
{},
{},
expect.objectContaining({
payload: expect.objectContaining({
assetNo: 'ZC-001',
status: 'in_use',
}),
requeueOnFailure: true,
}),
);
expect(runOutboxOnce).not.toHaveBeenCalled();
expect(result.duplicated).toBe(true);
});
it('resyncs current asset snapshot when financial drift exists', async () => {
const enqueueExport = vi.fn().mockResolvedValue({
id: 'outbox-1',
status: 'pending',
duplicated: false,
requeued: true,
eventType: 'erp.asset.export',
});
const runOutboxOnce = vi.fn().mockResolvedValue({ total: 1, succeeded: 1, failed: 0 });
const result = await enqueueAssetExportAndMaybeRun({
prisma: {},
config: {},
logger: {},
context: { asset: createAsset({ cost_center_id: 'cc-2', status: 'retired' }) },
actorId: 'actor-1',
runNow: true,
findExistingOutbox: vi.fn().mockResolvedValue({
id: 'outbox-1',
status: 'succeeded',
payload: {
exportType: 'asset_ledger_summary',
entityId: 'asset-1',
assetNo: 'ZC-001',
legalEntity: { id: 'le-1' },
costCenter: { id: 'cc-1' },
status: 'in_use',
},
}),
enqueueExport,
runOutboxOnce,
});
expect(enqueueExport).toHaveBeenCalledWith(
{},
{},
expect.objectContaining({
payload: undefined,
changeType: 'retired',
changedFields: expect.arrayContaining(['costCenterId', 'status']),
}),
);
expect(runOutboxOnce).toHaveBeenCalled();
expect(result.requeued).toBe(true);
});
it('parses runNow flags for asset requeue route', () => {
expect(parseRunNow({ runNow: '1' })).toBe(true);
expect(parseRunNow({ runNow: '0' })).toBe(false);
});
});