feat(P20T3-1,P20T3-2,P20T3-3): 对账批次引擎与ERP摘要出站后端
这个提交包含在:
@@ -0,0 +1,331 @@
|
||||
const Fastify = require('fastify');
|
||||
|
||||
const { AppError } = require('../../../src/utils/AppError');
|
||||
const integrationsRoutes = require('../../../src/routes/integrations');
|
||||
|
||||
function buildBatch(overrides = {}) {
|
||||
return {
|
||||
id: '66666666-6666-4666-8666-000000000001',
|
||||
batch_no: 'TRB-20260311-0001',
|
||||
legal_entity_id: '44444444-4444-4444-8444-444444444444',
|
||||
cost_center_id: '55555555-5555-4555-8555-555555555555',
|
||||
period_start: new Date('2026-03-01T00:00:00.000Z'),
|
||||
period_end: new Date('2026-03-31T00:00:00.000Z'),
|
||||
status: 'reconciled',
|
||||
summary_json: {
|
||||
totalOrders: 1,
|
||||
totalTransactions: 1,
|
||||
anomalyItems: 0,
|
||||
matchRate: '100.00',
|
||||
totalOrderAmount: '100.00',
|
||||
totalSettledAmount: '100.00',
|
||||
totalReversedAmount: '0.00',
|
||||
},
|
||||
confirmed_at: new Date('2026-03-31T10:00:00.000Z'),
|
||||
created_at: new Date('2026-03-31T09:00:00.000Z'),
|
||||
updated_at: new Date('2026-03-31T10:00:00.000Z'),
|
||||
legal_entity: {
|
||||
id: '44444444-4444-4444-8444-444444444444',
|
||||
code: 'HY',
|
||||
name: '海音达',
|
||||
},
|
||||
cost_center: {
|
||||
id: '55555555-5555-4555-8555-555555555555',
|
||||
code: 'CC001',
|
||||
name: '销售中心',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
id: 'item-1',
|
||||
travel_request_id: '11111111-1111-4111-8111-111111111111',
|
||||
travel_order_id: '22222222-2222-4222-8222-222222222222',
|
||||
card_transaction_id: '33333333-3333-4333-8333-333333333333',
|
||||
match_status: 'matched',
|
||||
resolution_status: 'confirmed',
|
||||
order_amount: '100.00',
|
||||
transaction_amount: '100.00',
|
||||
currency: 'CNY',
|
||||
snapshot_json: {
|
||||
requestNo: 'CL-20260310-0001',
|
||||
externalOrderNo: 'EXT-001',
|
||||
externalTxnId: 'TXN-001',
|
||||
linkedReimbursementId: null,
|
||||
boundInvoiceCount: 1,
|
||||
},
|
||||
created_at: new Date('2026-03-31T09:00:00.000Z'),
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPrisma({ batch = buildBatch(), featureFlag, existingOutbox = null }) {
|
||||
const state = {
|
||||
batch: batch ? { ...batch } : null,
|
||||
outbox: existingOutbox ? { ...existingOutbox } : null,
|
||||
auditLogs: [],
|
||||
};
|
||||
|
||||
const payableBinding = {
|
||||
id: 'binding-summary',
|
||||
provider: 'erp',
|
||||
entity_type: 'travel_payable_summary',
|
||||
local_id: '66666666-6666-4666-8666-000000000001',
|
||||
external_id: 'EXT-SUM-001',
|
||||
external_code: 'SUM-001',
|
||||
last_synced_at: new Date('2026-03-31T10:01:00.000Z'),
|
||||
};
|
||||
const settlementBinding = {
|
||||
id: 'binding-batch',
|
||||
provider: 'erp',
|
||||
entity_type: 'travel_settlement_batch',
|
||||
local_id: '66666666-6666-4666-8666-000000000001',
|
||||
external_id: 'EXT-BATCH-001',
|
||||
external_code: 'BAT-001',
|
||||
last_synced_at: new Date('2026-03-31T10:01:00.000Z'),
|
||||
};
|
||||
|
||||
const prisma = {
|
||||
travelReconciliationBatch: {
|
||||
findUnique: vi.fn().mockImplementation(async ({ where }) => {
|
||||
if (!state.batch || where.id !== state.batch.id) {
|
||||
return null;
|
||||
}
|
||||
return state.batch;
|
||||
}),
|
||||
},
|
||||
featureFlag: {
|
||||
findUnique: vi.fn().mockResolvedValue(
|
||||
featureFlag ?? {
|
||||
id: 'flag-1',
|
||||
flag_key: 'erp_sync',
|
||||
override_mode: 'force_on',
|
||||
rollout_type: 'global',
|
||||
department_ids: [],
|
||||
note: '',
|
||||
version: 1,
|
||||
created_at: new Date('2026-03-31T00:00:00.000Z'),
|
||||
updated_at: new Date('2026-03-31T00:00:00.000Z'),
|
||||
},
|
||||
),
|
||||
},
|
||||
integrationOutbox: {
|
||||
findUnique: vi.fn().mockImplementation(async ({ where }) => {
|
||||
const key = where?.event_type_dedupe_key;
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
if (!state.outbox) {
|
||||
return null;
|
||||
}
|
||||
return state.outbox.event_type === key.event_type && state.outbox.dedupe_key === key.dedupe_key
|
||||
? state.outbox
|
||||
: null;
|
||||
}),
|
||||
create: vi.fn().mockImplementation(async ({ data }) => {
|
||||
state.outbox = {
|
||||
id: 'outbox-created',
|
||||
event_type: data.event_type,
|
||||
status: data.status,
|
||||
payload: data.payload,
|
||||
dedupe_key: data.dedupe_key,
|
||||
attempts: 0,
|
||||
last_error: null,
|
||||
processed_at: null,
|
||||
updated_at: new Date('2026-03-31T10:05:00.000Z'),
|
||||
};
|
||||
return { id: state.outbox.id, status: state.outbox.status };
|
||||
}),
|
||||
update: vi.fn().mockImplementation(async ({ where, data }) => {
|
||||
if (!state.outbox || state.outbox.id !== where.id) {
|
||||
throw new Error('outbox_not_found');
|
||||
}
|
||||
state.outbox = {
|
||||
...state.outbox,
|
||||
...data,
|
||||
updated_at: new Date('2026-03-31T10:06:00.000Z'),
|
||||
};
|
||||
return { id: state.outbox.id, status: state.outbox.status };
|
||||
}),
|
||||
},
|
||||
integrationBinding: {
|
||||
findUnique: vi.fn().mockImplementation(async ({ where }) => {
|
||||
const bindingType = where.provider_entity_type_local_id.entity_type;
|
||||
if (bindingType === 'travel_payable_summary') {
|
||||
return payableBinding;
|
||||
}
|
||||
if (bindingType === 'travel_settlement_batch') {
|
||||
return settlementBinding;
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockImplementation(async ({ data }) => {
|
||||
state.auditLogs.push(data);
|
||||
return { id: `audit-${state.auditLogs.length}` };
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
return { prisma, state };
|
||||
}
|
||||
|
||||
async function buildApp({ prisma, permissionCodes, config = {} }) {
|
||||
const app = Fastify();
|
||||
|
||||
app.decorate('prisma', prisma);
|
||||
app.decorate('config', {
|
||||
erpSyncEnabled: true,
|
||||
erpSyncProvider: 'mock',
|
||||
integrationOutboxMaxAttempts: 5,
|
||||
...config,
|
||||
});
|
||||
app.decorate('authenticate', async (request) => {
|
||||
if (!request.headers.authorization) {
|
||||
throw new AppError({ code: 'AUTH_001', message: '未登录', statusCode: 401 });
|
||||
}
|
||||
request.employee = { id: 'actor-1', permissionCodes };
|
||||
});
|
||||
app.decorate('hasPermission', (employee, permissionCode) => {
|
||||
return (employee?.permissionCodes ?? []).includes('*') || (employee?.permissionCodes ?? []).includes(permissionCode);
|
||||
});
|
||||
app.decorate('requirePermission', (permissionCode) => async (request) => {
|
||||
await app.authenticate(request);
|
||||
if (!app.hasPermission(request.employee, permissionCode)) {
|
||||
throw new AppError({ code: 'PERM_001', message: '无权限', statusCode: 403 });
|
||||
}
|
||||
});
|
||||
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
if (error instanceof AppError) {
|
||||
return reply.status(error.statusCode).send({
|
||||
requestId: request.id,
|
||||
error: {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
details: error.details ?? {},
|
||||
},
|
||||
});
|
||||
}
|
||||
return reply.status(500).send({
|
||||
requestId: request.id,
|
||||
error: { code: 'SYS_001', message: '服务器内部错误', details: {} },
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(integrationsRoutes, { prefix: '/api' });
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('Integrations travel reconciliation ERP routes', () => {
|
||||
const { canReadTravelReconciliationBatch } = integrationsRoutes.__testables;
|
||||
|
||||
it('recognizes readable reconciliation roles', () => {
|
||||
expect(
|
||||
canReadTravelReconciliationBatch({ employee: { permissionCodes: ['travel.payment.manage'] } }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
canReadTravelReconciliationBatch({ employee: { permissionCodes: ['integration.manage'] } }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
canReadTravelReconciliationBatch({ employee: { permissionCodes: ['travel.view_all'] } }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('redacts ERP binding refs for non integration managers on track endpoint', async () => {
|
||||
const { prisma } = buildPrisma({
|
||||
existingOutbox: {
|
||||
id: 'outbox-1',
|
||||
event_type: 'erp.travel.export',
|
||||
dedupe_key: 'travel_reconciliation_batch:66666666-6666-4666-8666-000000000001:summary',
|
||||
status: 'succeeded',
|
||||
payload: { batchNo: 'TRB-20260311-0001' },
|
||||
attempts: 1,
|
||||
last_error: null,
|
||||
processed_at: new Date('2026-03-31T10:00:00.000Z'),
|
||||
updated_at: new Date('2026-03-31T10:00:00.000Z'),
|
||||
},
|
||||
});
|
||||
const app = await buildApp({ prisma, permissionCodes: ['travel.payment.manage'] });
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/integrations/erp/travel/reconciliation-batches/66666666-6666-4666-8666-000000000001/track',
|
||||
headers: { authorization: 'Bearer test' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const track = response.json().data;
|
||||
expect(track.stage.summaryExport.bindings.payableSummary.externalId).toBe(null);
|
||||
expect(track.stage.summaryExport.bindings.settlementBatch.externalCode).toBe(null);
|
||||
expect(track.permissions.canRetry).toBe(false);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('shows full ERP binding refs to integration managers and allows requeue', async () => {
|
||||
const { prisma, state } = buildPrisma({
|
||||
existingOutbox: {
|
||||
id: 'outbox-1',
|
||||
event_type: 'erp.travel.export',
|
||||
dedupe_key: 'travel_reconciliation_batch:66666666-6666-4666-8666-000000000001:summary',
|
||||
status: 'failed',
|
||||
payload: { entityId: '66666666-6666-4666-8666-000000000001', batchNo: 'TRB-20260311-0001' },
|
||||
attempts: 2,
|
||||
last_error: 'failed_once',
|
||||
processed_at: null,
|
||||
updated_at: new Date('2026-03-31T10:00:00.000Z'),
|
||||
},
|
||||
});
|
||||
const app = await buildApp({ prisma, permissionCodes: ['integration.manage'] });
|
||||
|
||||
const trackResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/integrations/erp/travel/reconciliation-batches/66666666-6666-4666-8666-000000000001/track',
|
||||
headers: { authorization: 'Bearer test' },
|
||||
});
|
||||
expect(trackResponse.statusCode).toBe(200);
|
||||
expect(trackResponse.json().data.stage.summaryExport.bindings.payableSummary.externalId).toBe('EXT-SUM-001');
|
||||
|
||||
const requeueResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/integrations/erp/travel/reconciliation-batches/66666666-6666-4666-8666-000000000001/requeue',
|
||||
headers: { authorization: 'Bearer test' },
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(requeueResponse.statusCode).toBe(200);
|
||||
expect(requeueResponse.json().data).toEqual(
|
||||
expect.objectContaining({
|
||||
outboxId: 'outbox-1',
|
||||
eventType: 'erp.travel.export',
|
||||
requeued: true,
|
||||
}),
|
||||
);
|
||||
expect(state.outbox.status).toBe('pending');
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('blocks requeue when ERP gate is disabled', async () => {
|
||||
const { prisma } = buildPrisma({});
|
||||
const app = await buildApp({
|
||||
prisma,
|
||||
permissionCodes: ['integration.manage'],
|
||||
config: { erpSyncEnabled: false },
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/integrations/erp/travel/reconciliation-batches/66666666-6666-4666-8666-000000000001/requeue',
|
||||
headers: { authorization: 'Bearer test' },
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details.integration).toBe('erp_sync_disabled');
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户