R53 A1-A6:ERP 集成运维可视化与业务同步状态接入
- ErpIntegrationPage Tab 化:主数据同步 / Outbox 队列管理 / ID 映射查看器 - Outbox 分页查询、详情、重试与统计端点 - IntegrationBinding 分页查询、routePath 推导与采购/资产深链 - 报销/采购/资产详情区接入 ERP 同步状态卡片 - 单测覆盖 Outbox 与 Binding 端点
这个提交包含在:
@@ -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();
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户