文件
hyb-oa/server/tests/unit/routes/travelReconciliation.test.js
T

651 行
22 KiB
JavaScript

const Fastify = require('fastify');
const { AppError } = require('../../../src/utils/AppError');
const travelReconciliationRoutes = require('../../../src/routes/travelReconciliation');
function buildTravelRequest(overrides = {}) {
return {
id: '11111111-1111-4111-8111-111111111111',
request_no: 'CL-20260311-0001',
...overrides,
};
}
function buildOrder(overrides = {}) {
return {
id: '22222222-2222-4222-8222-222222222222',
source: 'ctrip',
external_order_no: 'EXT-001',
linked_reimbursement_id: null,
travel_request_id: '11111111-1111-4111-8111-111111111111',
amount: '100.00',
currency: 'CNY',
status: 'paid',
payment_date: new Date('2026-03-10T00:00:00.000Z'),
paid_at: new Date('2026-03-10T08:00:00.000Z'),
created_at: new Date('2026-03-10T08:00:00.000Z'),
updated_at: new Date('2026-03-10T08:00:00.000Z'),
travel_request: {
id: '11111111-1111-4111-8111-111111111111',
request_no: 'CL-20260311-0001',
legal_entity_id: '44444444-4444-4444-8444-444444444444',
cost_center_id: '55555555-5555-4555-8555-555555555555',
},
...overrides,
};
}
function buildTransaction(overrides = {}) {
return {
id: '33333333-3333-4333-8333-333333333333',
source: 'corp-card',
external_txn_id: 'TXN-001',
travel_request_id: '11111111-1111-4111-8111-111111111111',
travel_order_id: '22222222-2222-4222-8222-222222222222',
amount: '100.00',
currency: 'CNY',
txn_type: 'charge',
status: 'settled',
occurred_at: new Date('2026-03-10T08:30:00.000Z'),
created_at: new Date('2026-03-10T08:30:00.000Z'),
travel_request: {
id: '11111111-1111-4111-8111-111111111111',
request_no: 'CL-20260311-0001',
},
travel_order: {
id: '22222222-2222-4222-8222-222222222222',
external_order_no: 'EXT-001',
linked_reimbursement_id: null,
status: 'paid',
},
...overrides,
};
}
function buildPrisma({
orders = [],
transactions = [],
invoices = [],
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-11T00:00:00.000Z'),
updated_at: new Date('2026-03-11T00:00:00.000Z'),
},
} = {}) {
const state = {
legalEntities: [{ id: '44444444-4444-4444-8444-444444444444', code: 'HY', name: '海音达' }],
costCenters: [{ id: '55555555-5555-4555-8555-555555555555', code: 'CC001', name: '销售中心', legal_entity_id: '44444444-4444-4444-8444-444444444444' }],
travelRequests: [buildTravelRequest()],
orders: orders.map((item) => ({ ...item })),
transactions: transactions.map((item) => ({ ...item })),
invoices: invoices.map((item) => ({ ...item })),
batches: [],
items: [],
integrationOutboxes: [],
auditLogs: [],
sequenceMap: {},
};
let batchCounter = 1;
let itemCounter = 1;
function materializeTravelRequest(id) {
const request = state.travelRequests.find((item) => item.id === id);
return request ? { id: request.id, request_no: request.request_no } : null;
}
function materializeOrder(id) {
const order = state.orders.find((item) => item.id === id);
return order
? {
id: order.id,
external_order_no: order.external_order_no,
status: order.status,
}
: null;
}
function materializeTransaction(id) {
const transaction = state.transactions.find((item) => item.id === id);
return transaction
? {
id: transaction.id,
external_txn_id: transaction.external_txn_id,
status: transaction.status,
txn_type: transaction.txn_type,
}
: null;
}
function materializeItem(item) {
const batch = state.batches.find((row) => row.id === item.travel_reconciliation_batch_id);
return {
...item,
batch: batch
? {
id: batch.id,
status: batch.status,
summary_json: batch.summary_json,
}
: null,
travel_request: materializeTravelRequest(item.travel_request_id),
travel_order: materializeOrder(item.travel_order_id),
card_transaction: materializeTransaction(item.card_transaction_id),
};
}
function materializeBatch(batch) {
if (!batch) {
return null;
}
return {
...batch,
legal_entity: state.legalEntities.find((item) => item.id === batch.legal_entity_id) ?? null,
cost_center: state.costCenters.find((item) => item.id === batch.cost_center_id) ?? null,
confirmed_by_employee: batch.confirmed_by
? { id: batch.confirmed_by, employee_no: 'E001', name: '财务一号' }
: null,
items: state.items
.filter((item) => item.travel_reconciliation_batch_id === batch.id)
.map(materializeItem),
};
}
const prisma = {
legalEntity: {
findUnique: vi.fn().mockImplementation(async ({ where }) => {
return state.legalEntities.find((item) => item.id === where.id) ?? null;
}),
},
costCenter: {
findUnique: vi.fn().mockImplementation(async ({ where }) => {
return state.costCenters.find((item) => item.id === where.id) ?? null;
}),
},
travelOrder: {
findMany: vi.fn().mockImplementation(async () => state.orders.map((item) => ({ ...item }))),
},
cardTransaction: {
findMany: vi.fn().mockImplementation(async () => state.transactions.map((item) => ({ ...item }))),
},
invoicePool: {
findMany: vi.fn().mockImplementation(async ({ where }) => {
const ids = new Set(where?.travel_order_id?.in ?? []);
return state.invoices.filter((item) => ids.has(item.travel_order_id)).map((item) => ({ ...item }));
}),
},
documentCategory: {
findUnique: vi.fn().mockImplementation(async ({ where }) => {
if (where.code === 'TR_RECON') {
return {
code: 'TR_RECON',
entity_type: 'travel_reconciliation_batch',
sequence_prefix: 'TRB',
number_length: 4,
reset_period: 'yearly',
enabled: true,
archived_at: null,
};
}
return null;
}),
findFirst: vi.fn().mockResolvedValue(null),
},
documentSequence: {
findUnique: vi.fn().mockImplementation(async ({ where }) => state.sequenceMap[where.document_type] ?? null),
upsert: vi.fn().mockImplementation(async ({ where, create, update }) => {
const current = state.sequenceMap[where.document_type];
if (current) {
const next = {
...current,
prefix: update.prefix,
date_format: update.date_format,
separator: update.separator,
number_length: update.number_length,
reset_period: update.reset_period,
current_number: current.current_number + 1,
updated_by: update.updated_by,
updated_at: new Date('2026-03-11T09:00:00.000Z'),
};
state.sequenceMap[where.document_type] = next;
return next;
}
const created = {
document_type: create.document_type,
prefix: create.prefix,
date_format: create.date_format,
separator: create.separator,
number_length: create.number_length,
reset_period: create.reset_period,
current_number: create.current_number,
last_reset_date: null,
updated_at: new Date('2026-03-11T09:00:00.000Z'),
updated_by: create.updated_by,
};
state.sequenceMap[where.document_type] = created;
return created;
}),
},
travelReconciliationBatch: {
create: vi.fn().mockImplementation(async ({ data }) => {
const batch = {
id: `66666666-6666-4666-8666-${String(batchCounter++).padStart(12, '0')}`,
batch_no: data.batch_no,
legal_entity_id: data.legal_entity_id,
cost_center_id: data.cost_center_id ?? null,
period_start: data.period_start,
period_end: data.period_end,
status: data.status,
summary_json: data.summary_json,
confirmed_at: null,
confirmed_by: null,
created_at: new Date('2026-03-11T09:00:00.000Z'),
created_by: data.created_by ?? null,
updated_at: new Date('2026-03-11T09:00:00.000Z'),
updated_by: data.updated_by ?? null,
};
state.batches.push(batch);
return { id: batch.id };
}),
findUnique: vi.fn().mockImplementation(async ({ where }) => {
const batch = state.batches.find((item) => item.id === where.id) ?? null;
return materializeBatch(batch);
}),
findMany: vi.fn().mockImplementation(async ({ where }) => {
return state.batches
.filter((item) => {
if (where?.status && item.status !== where.status) return false;
if (where?.legal_entity_id && item.legal_entity_id !== where.legal_entity_id) return false;
if (where?.cost_center_id && item.cost_center_id !== where.cost_center_id) return false;
return true;
})
.map(materializeBatch);
}),
count: vi.fn().mockImplementation(async ({ where }) => {
return state.batches.filter((item) => {
if (where?.status && item.status !== where.status) return false;
if (where?.legal_entity_id && item.legal_entity_id !== where.legal_entity_id) return false;
if (where?.cost_center_id && item.cost_center_id !== where.cost_center_id) return false;
return true;
}).length;
}),
update: vi.fn().mockImplementation(async ({ where, data }) => {
const index = state.batches.findIndex((item) => item.id === where.id);
state.batches[index] = {
...state.batches[index],
...data,
updated_at: new Date('2026-03-11T10:00:00.000Z'),
};
return materializeBatch(state.batches[index]);
}),
},
travelReconciliationItem: {
createMany: vi.fn().mockImplementation(async ({ data }) => {
data.forEach((row) => {
state.items.push({
id: `77777777-7777-4777-8777-${String(itemCounter++).padStart(12, '0')}`,
...row,
created_at: new Date('2026-03-11T09:00:00.000Z'),
updated_at: new Date('2026-03-11T09:00:00.000Z'),
});
});
return { count: data.length };
}),
findUnique: vi.fn().mockImplementation(async ({ where }) => {
const item = state.items.find((row) => row.id === where.id) ?? null;
return item ? materializeItem(item) : null;
}),
update: vi.fn().mockImplementation(async ({ where, data }) => {
const index = state.items.findIndex((item) => item.id === where.id);
state.items[index] = {
...state.items[index],
...data,
updated_at: new Date('2026-03-11T10:00:00.000Z'),
};
return materializeItem(state.items[index]);
}),
},
integrationOutbox: {
findUnique: vi.fn().mockImplementation(async ({ where }) => {
const key = where?.event_type_dedupe_key;
if (!key) {
return null;
}
return (
state.integrationOutboxes.find(
(item) => item.event_type === key.event_type && item.dedupe_key === key.dedupe_key,
) ?? null
);
}),
create: vi.fn().mockImplementation(async ({ data }) => {
const row = {
id: `outbox-${state.integrationOutboxes.length + 1}`,
event_type: data.event_type,
status: data.status,
payload: data.payload,
dedupe_key: data.dedupe_key ?? null,
max_attempts: data.max_attempts,
created_by: data.created_by ?? null,
updated_by: data.updated_by ?? null,
attempts: 0,
last_error: null,
processed_at: null,
dead_letter_at: null,
next_retry_at: null,
created_at: new Date('2026-03-11T10:00:00.000Z'),
updated_at: new Date('2026-03-11T10:00:00.000Z'),
};
state.integrationOutboxes.push(row);
return { id: row.id, status: row.status };
}),
update: vi.fn().mockImplementation(async ({ where, data }) => {
const index = state.integrationOutboxes.findIndex((item) => item.id === where.id);
state.integrationOutboxes[index] = {
...state.integrationOutboxes[index],
...data,
updated_at: new Date('2026-03-11T10:05:00.000Z'),
};
return { id: state.integrationOutboxes[index].id, status: state.integrationOutboxes[index].status };
}),
},
auditLog: {
create: vi.fn().mockImplementation(async ({ data }) => {
state.auditLogs.push(data);
return { id: `audit-${state.auditLogs.length}` };
}),
},
featureFlag: {
findUnique: vi.fn().mockResolvedValue(featureFlag),
},
$queryRaw: vi.fn().mockImplementation(async (strings, id) => {
const sql = Array.isArray(strings) ? strings.join('?') : String(strings);
if (sql.includes('FROM "travel_reconciliation_batches"')) {
return state.batches.some((item) => item.id === id) ? [{ id }] : [];
}
if (sql.includes('FROM "travel_reconciliation_items"')) {
return state.items.some((item) => item.id === id) ? [{ id }] : [];
}
return [];
}),
$transaction: vi.fn().mockImplementation(async (callback) => callback(prisma)),
};
return { prisma, state };
}
async function buildApp({ prisma, permissionCodes = ['travel.payment.manage'], config = {} }) {
const app = Fastify();
app.decorate('prisma', prisma);
app.decorate('config', {
appTimezone: 'Asia/Shanghai',
erpSyncEnabled: true,
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', department_id: 'dept-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(travelReconciliationRoutes, { prefix: '/api' });
return app;
}
describe('travelReconciliation routes', () => {
it('creates batches and supports list/detail queries', async () => {
const { prisma } = buildPrisma({
orders: [buildOrder()],
transactions: [buildTransaction()],
invoices: [{ id: 'inv-1', travel_order_id: '22222222-2222-4222-8222-222222222222', total_amount: '100.00' }],
});
const app = await buildApp({ prisma });
const createResponse = await app.inject({
method: 'POST',
url: '/api/travel/reconciliation/batches',
headers: { authorization: 'Bearer test' },
payload: {
legalEntityId: '44444444-4444-4444-8444-444444444444',
costCenterId: '55555555-5555-4555-8555-555555555555',
periodStart: '2026-03-01',
periodEnd: '2026-03-31',
},
});
expect(createResponse.statusCode).toBe(200);
const created = createResponse.json().data;
expect(created).toEqual(
expect.objectContaining({
batchNo: expect.stringMatching(/^TRB-\d{8}-\d{4}$/),
status: 'draft',
permissions: expect.objectContaining({
canManagePayments: true,
}),
}),
);
expect(created.summary).toEqual(
expect.objectContaining({
totalOrders: 1,
totalTransactions: 1,
matchedItems: 1,
pendingItems: 0,
}),
);
const listResponse = await app.inject({
method: 'GET',
url: '/api/travel/reconciliation/batches?status=draft',
headers: { authorization: 'Bearer test' },
});
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json().data.total).toBe(1);
const detailResponse = await app.inject({
method: 'GET',
url: `/api/travel/reconciliation/batches/${created.id}`,
headers: { authorization: 'Bearer test' },
});
expect(detailResponse.statusCode).toBe(200);
expect(detailResponse.json().data.items).toHaveLength(1);
await app.close();
});
it('confirms and reopens anomaly items inside draft batch', async () => {
const { prisma } = buildPrisma({
orders: [buildOrder()],
transactions: [],
});
const app = await buildApp({ prisma });
const createResponse = await app.inject({
method: 'POST',
url: '/api/travel/reconciliation/batches',
headers: { authorization: 'Bearer test' },
payload: {
legalEntityId: '44444444-4444-4444-8444-444444444444',
costCenterId: '55555555-5555-4555-8555-555555555555',
periodStart: '2026-03-01',
periodEnd: '2026-03-31',
},
});
const created = createResponse.json().data;
const item = created.items.find((row) => row.matchStatus !== 'matched');
const confirmResponse = await app.inject({
method: 'POST',
url: `/api/travel/reconciliation/items/${item.id}/confirm`,
headers: { authorization: 'Bearer test' },
payload: { note: '金额差异已人工确认' },
});
expect(confirmResponse.statusCode).toBe(200);
expect(confirmResponse.json().data.summary.pendingItems).toBe(0);
expect(confirmResponse.json().data.item.resolutionStatus).toBe('confirmed');
expect(confirmResponse.json().data.item.confirmNote).toBe('金额差异已人工确认');
const reopenResponse = await app.inject({
method: 'POST',
url: `/api/travel/reconciliation/items/${item.id}/reopen`,
headers: { authorization: 'Bearer test' },
payload: {},
});
expect(reopenResponse.statusCode).toBe(200);
expect(reopenResponse.json().data.summary.pendingItems).toBe(1);
expect(reopenResponse.json().data.item.resolutionStatus).toBe('pending');
await app.close();
});
it('rejects finalize when pending anomaly items remain', async () => {
const { prisma } = buildPrisma({
orders: [buildOrder()],
transactions: [],
});
const app = await buildApp({ prisma });
const createResponse = await app.inject({
method: 'POST',
url: '/api/travel/reconciliation/batches',
headers: { authorization: 'Bearer test' },
payload: {
legalEntityId: '44444444-4444-4444-8444-444444444444',
costCenterId: '55555555-5555-4555-8555-555555555555',
periodStart: '2026-03-01',
periodEnd: '2026-03-31',
},
});
const batchId = createResponse.json().data.id;
const finalizeResponse = await app.inject({
method: 'POST',
url: `/api/travel/reconciliation/batches/${batchId}/finalize`,
headers: { authorization: 'Bearer test' },
payload: {},
});
expect(finalizeResponse.statusCode).toBe(409);
expect(finalizeResponse.json().error.details.batch).toBe('reconciliation_batch_has_pending_items');
await app.close();
});
it('finalizes fully confirmed batch and enqueues ERP export when gate is on', async () => {
const { prisma, state } = buildPrisma({
orders: [buildOrder()],
transactions: [buildTransaction()],
});
const app = await buildApp({ prisma, config: { erpSyncEnabled: true } });
const createResponse = await app.inject({
method: 'POST',
url: '/api/travel/reconciliation/batches',
headers: { authorization: 'Bearer test' },
payload: {
legalEntityId: '44444444-4444-4444-8444-444444444444',
costCenterId: '55555555-5555-4555-8555-555555555555',
periodStart: '2026-03-01',
periodEnd: '2026-03-31',
},
});
const batchId = createResponse.json().data.id;
const finalizeResponse = await app.inject({
method: 'POST',
url: `/api/travel/reconciliation/batches/${batchId}/finalize`,
headers: { authorization: 'Bearer test' },
payload: {},
});
expect(finalizeResponse.statusCode).toBe(200);
expect(finalizeResponse.json().data).toEqual(
expect.objectContaining({
batchId,
status: 'reconciled',
erpExport: expect.objectContaining({
eventType: 'erp.travel.export',
enqueued: true,
}),
}),
);
expect(state.integrationOutboxes).toHaveLength(1);
expect(state.integrationOutboxes[0].event_type).toBe('erp.travel.export');
await app.close();
});
it('returns 409 for empty reconciliation scope', async () => {
const { prisma } = buildPrisma({ orders: [], transactions: [] });
const app = await buildApp({ prisma });
const response = await app.inject({
method: 'POST',
url: '/api/travel/reconciliation/batches',
headers: { authorization: 'Bearer test' },
payload: {
legalEntityId: '44444444-4444-4444-8444-444444444444',
costCenterId: '55555555-5555-4555-8555-555555555555',
periodStart: '2026-03-01',
periodEnd: '2026-03-31',
},
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details.batch).toBe('travel_reconciliation_empty_scope');
await app.close();
});
it('blocks reads without travel payment or integration management permission', async () => {
const { prisma } = buildPrisma({
orders: [buildOrder()],
transactions: [buildTransaction()],
});
const app = await buildApp({ prisma, permissionCodes: ['travel.view_own'] });
const response = await app.inject({
method: 'GET',
url: '/api/travel/reconciliation/batches',
headers: { authorization: 'Bearer test' },
});
expect(response.statusCode).toBe(403);
await app.close();
});
});