651 行
22 KiB
JavaScript
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();
|
|
});
|
|
});
|