汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。 统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。 纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。 关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。 预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。 完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
729 行
30 KiB
JavaScript
729 行
30 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 confirmed batch without retired ERP side effects', 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',
|
|
}),
|
|
);
|
|
expect(state.integrationOutboxes).toHaveLength(0);
|
|
|
|
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();
|
|
});
|
|
});
|
|
|
|
|
|
describe('travel reconciliation calendar input', () => {
|
|
it.each(['POST', 'GET'].flatMap((method) => ['periodStart', 'periodEnd'].map((field) => [method, field])))('rejects %s invalid %s before queries and writes', async (method, field) => {
|
|
const { prisma, state } = buildPrisma();
|
|
const app = await buildApp({ prisma });
|
|
try {
|
|
const input = { legalEntityId: '44444444-4444-4444-8444-444444444444', periodStart: '2026-04-01', periodEnd: '2026-04-30', [field]: '2026-04-31' };
|
|
const response = await app.inject({ method, url: '/api/travel/reconciliation/batches' + (method === 'GET' ? `?${new URLSearchParams(input)}` : ''), headers: { authorization: 'Bearer test' }, ...(method === 'POST' ? { payload: input } : {}) });
|
|
expect(response.statusCode).toBe(400);
|
|
expect(prisma.$transaction).not.toHaveBeenCalled();
|
|
expect(prisma.travelReconciliationBatch.findMany).not.toHaveBeenCalled();
|
|
expect(state.batches).toEqual([]);
|
|
expect(state.auditLogs).toEqual([]);
|
|
expect(state.integrationOutboxes).toEqual([]);
|
|
} finally { await app.close(); }
|
|
});
|
|
it('keeps leap-day/month-end, optional filters and reversed-period semantics', async () => {
|
|
const { prisma } = buildPrisma({ orders: [buildOrder({ payment_date: new Date('2024-03-10T00:00:00.000Z') })], transactions: [buildTransaction({ occurred_at: new Date('2024-03-10T08:30:00.000Z') })] });
|
|
const app = await buildApp({ prisma });
|
|
try {
|
|
const base = { legalEntityId: '44444444-4444-4444-8444-444444444444', periodStart: '2024-02-29', periodEnd: '2024-03-31' };
|
|
const created = await app.inject({ method: 'POST', url: '/api/travel/reconciliation/batches', headers: { authorization: 'Bearer test' }, payload: base });
|
|
expect(created.statusCode).toBe(200);
|
|
const listed = await app.inject({ method: 'GET', url: '/api/travel/reconciliation/batches', headers: { authorization: 'Bearer test' } });
|
|
expect(listed.statusCode).toBe(200);
|
|
prisma.$transaction.mockClear();
|
|
const reversed = await app.inject({ method: 'POST', url: '/api/travel/reconciliation/batches', headers: { authorization: 'Bearer test' }, payload: { ...base, periodStart: '2024-04-30' } });
|
|
expect(reversed.statusCode).toBe(409);
|
|
expect(reversed.json().error.details.period).toBe('period_start_after_period_end');
|
|
expect(prisma.$transaction).not.toHaveBeenCalled();
|
|
} finally { await app.close(); }
|
|
});
|
|
});
|
|
|
|
const errorApps = [];
|
|
afterEach(async () => { await Promise.all(errorApps.splice(0).map((app) => app.close())); });
|
|
const ERROR_ID = '66666666-6666-4666-8666-666666666666';
|
|
const ERROR_SCOPE = { legalEntityId: '44444444-4444-4444-8444-444444444444', costCenterId: '55555555-5555-4555-8555-555555555555', periodStart: '2026-03-01', periodEnd: '2026-03-31' };
|
|
async function errorContext(options) { const context = buildPrisma(options); const app = await buildApp({ prisma: context.prisma }); errorApps.push(app); return { ...context, app }; }
|
|
function errorRequest(method, path, payload) { return { method, url: `/api/travel/reconciliation${path}`, headers: { authorization: 'Bearer synthetic' }, ...(payload ? { payload } : {}) }; }
|
|
function expectError(response, status, reasonCode, details) { expect(response.statusCode).toBe(status); expect(response.json().error.details).toEqual({ ...details, reasonCode }); expect(response.json().error.code).toBe('VALID_001'); }
|
|
function noMutations(prisma) { for (const model of [prisma.travelReconciliationBatch, prisma.travelReconciliationItem, prisma.integrationOutbox, prisma.auditLog]) for (const method of ['create', 'createMany', 'update', 'upsert']) if (model?.[method]) expect(model[method]).not.toHaveBeenCalled(); }
|
|
it.each([
|
|
['GET', '/batches?page=0', undefined, 'query_invalid'], ['POST', '/batches', {}, 'create_input_invalid'],
|
|
['POST', `/items/${ERROR_ID}/confirm`, { note: 'x'.repeat(501) }, 'confirm_input_invalid'],
|
|
])('explains %s %s input before transaction or queries', async (method, path, payload, reason) => {
|
|
const { app, prisma } = await errorContext(); const response = await app.inject(errorRequest(method, path, payload)); expect(response.statusCode).toBe(400); expect(response.json().error.details).toMatchObject({ reasonCode: `travel_reconciliation.${reason}`, fieldErrors: expect.any(Object) }); noMutations(prisma); expect(prisma.$transaction).not.toHaveBeenCalled();
|
|
});
|
|
it.each([['GET', '/batches/bad', 'batchId'], ['POST', '/batches/bad/finalize', 'batchId'], ['POST', '/items/bad/confirm', 'itemId'], ['POST', '/items/bad/reopen', 'itemId']])('explains %s %s invalid id with the original 400', async (method, path, key) => {
|
|
const { app, prisma } = await errorContext(); expectError(await app.inject(errorRequest(method, path, {})), 400, `travel_reconciliation.${key === 'batchId' ? 'batch' : 'item'}_id_invalid`, { [key]: 'invalid_uuid' }); noMutations(prisma); expect(prisma.$transaction).not.toHaveBeenCalled();
|
|
});
|
|
it.each(['period', 'company', 'center', 'mismatch', 'empty'])('preserves batch creation %s refusal as 409', async (state) => {
|
|
const { app, prisma } = await errorContext(); const payload = { ...ERROR_SCOPE };
|
|
if (state === 'period') payload.periodStart = '2026-04-01';
|
|
if (state === 'company') prisma.legalEntity.findUnique.mockResolvedValue(null);
|
|
if (state === 'center') prisma.costCenter.findUnique.mockResolvedValue(null);
|
|
if (state === 'mismatch') prisma.costCenter.findUnique.mockResolvedValue({ id: ERROR_SCOPE.costCenterId, legal_entity_id: ERROR_ID });
|
|
const [reason, details] = { period: ['period_reversed', { period: 'period_start_after_period_end' }], company: ['company_not_found', { legalEntityId: 'not_found' }], center: ['cost_center_not_found', { costCenterId: 'not_found' }], mismatch: ['cost_center_company_mismatch', { costCenterId: 'legal_entity_mismatch' }], empty: ['empty_scope', { batch: 'travel_reconciliation_empty_scope' }] }[state];
|
|
expectError(await app.inject(errorRequest('POST', '/batches', payload)), 409, `travel_reconciliation.${reason}`, details); noMutations(prisma); expect(prisma.$transaction).not.toHaveBeenCalled();
|
|
});
|
|
async function createdBatch(matched = false) {
|
|
const context = await errorContext({ orders: [buildOrder()], transactions: matched ? [buildTransaction()] : [] });
|
|
const response = await context.app.inject(errorRequest('POST', '/batches', ERROR_SCOPE)); expect(response.statusCode).toBe(200);
|
|
// Preserve the synthetic database contents while clearing prior creation call history.
|
|
for (const model of Object.values(context.prisma)) for (const method of Object.values(model ?? {})) if (vi.isMockFunction(method)) method.mockClear();
|
|
return { ...context, batch: context.state.batches[0], item: context.state.items[0] };
|
|
}
|
|
it.each(['confirm', 'reopen'])('preserves non-draft batch guard on %s', async (action) => {
|
|
const { app, prisma, state, batch, item } = await createdBatch(); batch.status = 'reconciled'; const before = JSON.stringify(state);
|
|
expectError(await app.inject(errorRequest('POST', `/items/${item.id}/${action}`, {})), 409, 'travel_reconciliation.batch_not_editable', { batch: 'reconciliation_batch_not_editable' }); noMutations(prisma); expect(JSON.stringify(state)).toBe(before);
|
|
});
|
|
it.each(['already-confirmed', 'not-confirmed', 'matched', 'pending-items'])('preserves reconciliation %s service guard through actual route', async (condition) => {
|
|
const { app, prisma, state, batch, item } = await createdBatch(condition === 'matched');
|
|
if (condition === 'already-confirmed') item.resolution_status = 'confirmed';
|
|
const [path, reason, details] = {
|
|
'already-confirmed': [`/items/${item.id}/confirm`, 'item_not_pending', { item: 'reconciliation_item_not_pending' }],
|
|
'not-confirmed': [`/items/${item.id}/reopen`, 'item_not_confirmed', { item: 'reconciliation_item_not_confirmed' }],
|
|
matched: [`/items/${item.id}/reopen`, 'matched_item_not_reopenable', { item: 'matched_item_not_reopenable' }],
|
|
'pending-items': [`/batches/${batch.id}/finalize`, 'batch_has_pending_items', { batch: 'reconciliation_batch_has_pending_items' }],
|
|
}[condition]; const before = JSON.stringify(state);
|
|
expectError(await app.inject(errorRequest('POST', path, {})), 409, `travel_reconciliation.${reason}`, details); noMutations(prisma); expect(JSON.stringify(state)).toBe(before);
|
|
});
|