feat(P20T3-1,P20T3-2,P20T3-3): 对账批次引擎与ERP摘要出站后端
这个提交包含在:
@@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户