feat(P20T3-1,P20T3-2,P20T3-3): 对账批次引擎与ERP摘要出站后端
这个提交包含在:
@@ -164,6 +164,28 @@ describe("documentSequence service", () => {
|
||||
expect(config.prefix).toBe("CL");
|
||||
});
|
||||
|
||||
it("falls back to default travel reconciliation batch category code", async () => {
|
||||
const prisma = buildPrisma({
|
||||
categoryMap: {
|
||||
TR_RECON: {
|
||||
code: "TR_RECON",
|
||||
entity_type: "travel_reconciliation_batch",
|
||||
sequence_prefix: "TRB",
|
||||
number_length: 4,
|
||||
reset_period: "yearly",
|
||||
enabled: true,
|
||||
archived_at: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const config = await resolveDocumentSequenceConfig(prisma, { documentType: "travel_reconciliation_batch" });
|
||||
|
||||
expect(config.source).toBe("document_category");
|
||||
expect(config.documentCategoryCode).toBe("TR_RECON");
|
||||
expect(config.prefix).toBe("TRB");
|
||||
});
|
||||
|
||||
it("falls back to default asset category code for asset cards", async () => {
|
||||
const prisma = buildPrisma({
|
||||
categoryMap: {
|
||||
|
||||
@@ -209,6 +209,51 @@ describe('dispatchOutboxEvent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('records summary and settlement bindings for travel reconciliation export', async () => {
|
||||
const prisma = {
|
||||
integrationBinding: {
|
||||
upsert: vi.fn().mockResolvedValue({ id: 'binding-travel-1' }),
|
||||
},
|
||||
};
|
||||
|
||||
await dispatchOutboxEvent({
|
||||
prisma,
|
||||
config: { erpSyncEnabled: true, erpSyncProvider: 'mock' },
|
||||
eventType: 'erp.travel.export',
|
||||
payload: {
|
||||
exportType: 'travel_reconciliation_summary',
|
||||
entityType: 'travel_reconciliation_batch',
|
||||
entityId: 'batch-1',
|
||||
batchNo: 'TRB-20260311-0001',
|
||||
},
|
||||
});
|
||||
|
||||
expect(prisma.integrationBinding.upsert).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
provider_entity_type_local_id: {
|
||||
provider: 'erp',
|
||||
entity_type: 'travel_payable_summary',
|
||||
local_id: 'batch-1',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(prisma.integrationBinding.upsert).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
provider_entity_type_local_id: {
|
||||
provider: 'erp',
|
||||
entity_type: 'travel_settlement_batch',
|
||||
local_id: 'batch-1',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('loads IM channel and dispatches mock wecom message', async () => {
|
||||
const prisma = {
|
||||
notificationChannel: {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,650 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
const {
|
||||
TRAVEL_ERP_BINDING_TYPES,
|
||||
TRAVEL_ERP_EVENT_TYPE,
|
||||
buildTravelReconciliationErpTrack,
|
||||
buildTravelReconciliationSummaryDedupeKey,
|
||||
buildTravelReconciliationSummaryPayload,
|
||||
} = require('../../../src/services/integrations/erpTravel');
|
||||
|
||||
describe('erpTravel service', () => {
|
||||
function createBatch(overrides = {}) {
|
||||
return {
|
||||
id: 'batch-1',
|
||||
batch_no: 'TRB-20260311-0001',
|
||||
status: 'reconciled',
|
||||
period_start: new Date('2026-03-01T00:00:00.000Z'),
|
||||
period_end: new Date('2026-03-31T00:00:00.000Z'),
|
||||
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'),
|
||||
summary_json: {
|
||||
totalOrders: 2,
|
||||
totalTransactions: 2,
|
||||
anomalyItems: 1,
|
||||
matchRate: '50.00',
|
||||
totalOrderAmount: '200.00',
|
||||
totalSettledAmount: '180.00',
|
||||
totalReversedAmount: '20.00',
|
||||
},
|
||||
legal_entity: {
|
||||
id: 'legal-1',
|
||||
code: 'HY',
|
||||
name: '海音达',
|
||||
},
|
||||
cost_center: {
|
||||
id: 'cc-1',
|
||||
code: 'CC001',
|
||||
name: '销售中心',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
id: 'item-1',
|
||||
travel_request_id: 'travel-1',
|
||||
travel_order_id: 'order-1',
|
||||
card_transaction_id: 'txn-1',
|
||||
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,
|
||||
},
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('builds travel reconciliation summary payload with batch snapshot', () => {
|
||||
const payload = buildTravelReconciliationSummaryPayload({ batch: createBatch() });
|
||||
|
||||
expect(payload).toEqual(
|
||||
expect.objectContaining({
|
||||
exportType: 'travel_reconciliation_summary',
|
||||
entityType: 'travel_reconciliation_batch',
|
||||
entityId: 'batch-1',
|
||||
batchNo: 'TRB-20260311-0001',
|
||||
periodStart: '2026-03-01',
|
||||
periodEnd: '2026-03-31',
|
||||
dimensions: {
|
||||
legalEntityCode: 'HY',
|
||||
costCenterCode: 'CC001',
|
||||
},
|
||||
summary: {
|
||||
totalOrders: 2,
|
||||
totalTransactions: 2,
|
||||
anomalyItems: 1,
|
||||
matchRate: '50.00',
|
||||
totalOrderAmount: '200.00',
|
||||
totalSettledAmount: '180.00',
|
||||
totalReversedAmount: '20.00',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(payload.items[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
travelRequestId: 'travel-1',
|
||||
requestNo: 'CL-20260310-0001',
|
||||
travelOrderId: 'order-1',
|
||||
externalOrderNo: 'EXT-001',
|
||||
cardTransactionId: 'txn-1',
|
||||
externalTxnId: 'TXN-001',
|
||||
linkedReimbursementId: null,
|
||||
boundInvoiceCount: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('builds stable dedupe key for travel reconciliation batch summary export', () => {
|
||||
expect(buildTravelReconciliationSummaryDedupeKey('batch-1')).toBe(
|
||||
'travel_reconciliation_batch:batch-1:summary',
|
||||
);
|
||||
});
|
||||
|
||||
it('builds ERP track with redacted bindings for non integration managers', async () => {
|
||||
const batch = createBatch();
|
||||
const prisma = {
|
||||
integrationOutbox: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: 'outbox-1',
|
||||
event_type: TRAVEL_ERP_EVENT_TYPE,
|
||||
status: 'succeeded',
|
||||
attempts: 1,
|
||||
last_error: null,
|
||||
processed_at: new Date('2026-03-31T10:01:00.000Z'),
|
||||
updated_at: new Date('2026-03-31T10:01:00.000Z'),
|
||||
}),
|
||||
},
|
||||
integrationBinding: {
|
||||
findUnique: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
id: 'binding-summary',
|
||||
provider: 'erp',
|
||||
entity_type: TRAVEL_ERP_BINDING_TYPES.payableSummary,
|
||||
local_id: 'batch-1',
|
||||
external_id: 'SUM-001',
|
||||
external_code: 'TRB-SUM-001',
|
||||
last_synced_at: new Date('2026-03-31T10:02:00.000Z'),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'binding-batch',
|
||||
provider: 'erp',
|
||||
entity_type: TRAVEL_ERP_BINDING_TYPES.settlementBatch,
|
||||
local_id: 'batch-1',
|
||||
external_id: 'BAT-001',
|
||||
external_code: 'TRB-BAT-001',
|
||||
last_synced_at: new Date('2026-03-31T10:02:00.000Z'),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const track = await buildTravelReconciliationErpTrack(prisma, {}, 'batch-1', {
|
||||
context: batch,
|
||||
canManage: false,
|
||||
gate: { effectiveEnabled: true },
|
||||
});
|
||||
|
||||
expect(track.stage.summaryExport.status).toBe('succeeded');
|
||||
expect(track.stage.summaryExport.bindings.payableSummary).toEqual(
|
||||
expect.objectContaining({
|
||||
entityType: TRAVEL_ERP_BINDING_TYPES.payableSummary,
|
||||
externalId: null,
|
||||
externalCode: null,
|
||||
}),
|
||||
);
|
||||
expect(track.stage.summaryExport.bindings.settlementBatch).toEqual(
|
||||
expect.objectContaining({
|
||||
entityType: TRAVEL_ERP_BINDING_TYPES.settlementBatch,
|
||||
externalId: null,
|
||||
externalCode: null,
|
||||
}),
|
||||
);
|
||||
expect(track.permissions.canRetry).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
const {
|
||||
TRAVEL_RECONCILIATION_MATCH_STATUSES,
|
||||
TRAVEL_RECONCILIATION_RESOLUTION_STATUSES,
|
||||
buildReconciliationItemsSnapshot,
|
||||
} = require('../../../src/services/travelReconciliation');
|
||||
|
||||
function buildOrder(overrides = {}) {
|
||||
return {
|
||||
id: 'order-1',
|
||||
external_order_no: 'EXT-001',
|
||||
linked_reimbursement_id: null,
|
||||
travel_request_id: 'travel-1',
|
||||
amount: '100.00',
|
||||
currency: 'CNY',
|
||||
status: 'paid',
|
||||
travel_request: {
|
||||
id: 'travel-1',
|
||||
request_no: 'CL-20260311-0001',
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTransaction(overrides = {}) {
|
||||
return {
|
||||
id: 'txn-1',
|
||||
external_txn_id: 'TXN-001',
|
||||
travel_request_id: 'travel-1',
|
||||
travel_order_id: 'order-1',
|
||||
amount: '100.00',
|
||||
currency: 'CNY',
|
||||
txn_type: 'charge',
|
||||
status: 'settled',
|
||||
travel_request: {
|
||||
id: 'travel-1',
|
||||
request_no: 'CL-20260311-0001',
|
||||
},
|
||||
travel_order: {
|
||||
id: 'order-1',
|
||||
external_order_no: 'EXT-001',
|
||||
linked_reimbursement_id: null,
|
||||
status: 'paid',
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildInvoice(overrides = {}) {
|
||||
return {
|
||||
id: 'inv-1',
|
||||
travel_order_id: 'order-1',
|
||||
total_amount: '100.00',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('travelReconciliation service', () => {
|
||||
it('auto-confirms exact matched order items', () => {
|
||||
const now = new Date('2026-03-11T00:00:00.000Z');
|
||||
const snapshot = buildReconciliationItemsSnapshot({
|
||||
orders: [buildOrder()],
|
||||
transactions: [buildTransaction()],
|
||||
invoices: [buildInvoice()],
|
||||
actorId: 'actor-1',
|
||||
now,
|
||||
});
|
||||
|
||||
expect(snapshot.summary).toEqual(
|
||||
expect.objectContaining({
|
||||
totalOrders: 1,
|
||||
totalTransactions: 1,
|
||||
matchedItems: 1,
|
||||
pendingItems: 0,
|
||||
anomalyItems: 0,
|
||||
totalOrderAmount: '100.00',
|
||||
totalSettledAmount: '100.00',
|
||||
totalReversedAmount: '0.00',
|
||||
matchRate: '100.00',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(snapshot.items[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
match_status: TRAVEL_RECONCILIATION_MATCH_STATUSES.matched,
|
||||
resolution_status: TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.confirmed,
|
||||
confirmed_by: 'actor-1',
|
||||
confirmed_at: now,
|
||||
snapshot_json: expect.objectContaining({
|
||||
boundInvoiceCount: 1,
|
||||
boundInvoiceAmount: '100.00',
|
||||
matchedTransactionIds: ['txn-1'],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('marks amount mismatch when settled charge amount differs', () => {
|
||||
const snapshot = buildReconciliationItemsSnapshot({
|
||||
orders: [buildOrder()],
|
||||
transactions: [buildTransaction({ amount: '88.00' })],
|
||||
invoices: [],
|
||||
actorId: 'actor-1',
|
||||
now: new Date('2026-03-11T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(snapshot.items[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
match_status: TRAVEL_RECONCILIATION_MATCH_STATUSES.amountMismatch,
|
||||
resolution_status: TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.pending,
|
||||
anomaly_code: TRAVEL_RECONCILIATION_MATCH_STATUSES.amountMismatch,
|
||||
transaction_amount: '88.00',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('marks currency mismatch when settled charge currency differs', () => {
|
||||
const snapshot = buildReconciliationItemsSnapshot({
|
||||
orders: [buildOrder()],
|
||||
transactions: [buildTransaction({ currency: 'USD' })],
|
||||
invoices: [],
|
||||
actorId: 'actor-1',
|
||||
now: new Date('2026-03-11T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(snapshot.items[0].match_status).toBe(TRAVEL_RECONCILIATION_MATCH_STATUSES.currencyMismatch);
|
||||
expect(snapshot.items[0].resolution_status).toBe(TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.pending);
|
||||
});
|
||||
|
||||
it('marks missing transaction when no settled charge exists', () => {
|
||||
const snapshot = buildReconciliationItemsSnapshot({
|
||||
orders: [buildOrder()],
|
||||
transactions: [],
|
||||
invoices: [],
|
||||
actorId: 'actor-1',
|
||||
now: new Date('2026-03-11T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(snapshot.items[0].match_status).toBe(TRAVEL_RECONCILIATION_MATCH_STATUSES.missingTransaction);
|
||||
});
|
||||
|
||||
it('marks duplicate charge when multiple settled charges exist', () => {
|
||||
const snapshot = buildReconciliationItemsSnapshot({
|
||||
orders: [buildOrder()],
|
||||
transactions: [
|
||||
buildTransaction({ id: 'txn-1', external_txn_id: 'TXN-001' }),
|
||||
buildTransaction({ id: 'txn-2', external_txn_id: 'TXN-002' }),
|
||||
],
|
||||
invoices: [],
|
||||
actorId: 'actor-1',
|
||||
now: new Date('2026-03-11T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(snapshot.items[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
match_status: TRAVEL_RECONCILIATION_MATCH_STATUSES.duplicateCharge,
|
||||
resolution_status: TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.pending,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('marks reversed after payment when refund or reversal appears', () => {
|
||||
const snapshot = buildReconciliationItemsSnapshot({
|
||||
orders: [buildOrder()],
|
||||
transactions: [buildTransaction({ txn_type: 'refund', status: 'settled' })],
|
||||
invoices: [],
|
||||
actorId: 'actor-1',
|
||||
now: new Date('2026-03-11T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(snapshot.items[0].match_status).toBe(TRAVEL_RECONCILIATION_MATCH_STATUSES.reversedAfterPayment);
|
||||
});
|
||||
|
||||
it('creates orphan transaction items for unlinked settled or reversed transactions', () => {
|
||||
const snapshot = buildReconciliationItemsSnapshot({
|
||||
orders: [buildOrder()],
|
||||
transactions: [
|
||||
buildTransaction(),
|
||||
buildTransaction({
|
||||
id: 'txn-orphan',
|
||||
external_txn_id: 'TXN-ORPHAN',
|
||||
travel_order_id: null,
|
||||
txn_type: 'charge',
|
||||
status: 'settled',
|
||||
travel_order: null,
|
||||
}),
|
||||
],
|
||||
invoices: [],
|
||||
actorId: 'actor-1',
|
||||
now: new Date('2026-03-11T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
const orphan = snapshot.items.find((item) => item.card_transaction_id === 'txn-orphan');
|
||||
expect(orphan).toEqual(
|
||||
expect.objectContaining({
|
||||
item_kind: 'orphan_transaction',
|
||||
match_status: TRAVEL_RECONCILIATION_MATCH_STATUSES.orphanTransaction,
|
||||
resolution_status: TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.pending,
|
||||
}),
|
||||
);
|
||||
expect(snapshot.summary).toEqual(
|
||||
expect.objectContaining({
|
||||
totalOrders: 1,
|
||||
totalTransactions: 2,
|
||||
matchedItems: 1,
|
||||
pendingItems: 1,
|
||||
anomalyItems: 1,
|
||||
matchRate: '50.00',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户