完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)

汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、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项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
这个提交包含在:
2026-09-19 13:11:04 +08:00
父节点 729d90576f
当前提交 1f8d5b658a
共修改 367 个文件,包含 21079 行新增和 12287 行删除
@@ -183,6 +183,7 @@ describe("AI suggestions service", () => {
statusCode: 409,
details: expect.objectContaining({
ai: "disabled",
reasonCode: "ai.feature_disabled",
featureFlagReason: "department_not_allowed",
}),
});
@@ -0,0 +1,27 @@
const { lockBudgetPoolsForUpdate, resolveApplicableBudgetPoolByContext } = require('../../../src/services/budgetPool');
describe('budget pool write protection', () => {
it('locks unique pools in stable order with parameters and rejects a root client', async () => {
const tx = { $queryRaw: vi.fn().mockResolvedValue([]), $transaction: vi.fn() };
await lockBudgetPoolsForUpdate(tx, ['b', 'a', 'b', null]);
const query = tx.$queryRaw.mock.calls[0][0];
expect(query.sql).toMatch(/ORDER BY id FOR NO KEY UPDATE/);
expect(query.values).toEqual(['a', 'b']);
await expect(lockBudgetPoolsForUpdate({ ...tx, $connect: vi.fn() }, ['a'])).rejects.toThrow(/transaction/);
});
it('does not lock preview reads but refreshes usage after a submission lock', async () => {
const first = { id: 'pool', used_amount: '0.00' }; const latest = { ...first, used_amount: '60.00' };
const tx = { $queryRaw: vi.fn().mockResolvedValue([]), budgetPool: { findMany: vi.fn().mockResolvedValueOnce([first]).mockResolvedValueOnce([first]).mockResolvedValueOnce([latest]) } };
const context = { effectiveDate: new Date('2026-09-19') };
expect(await resolveApplicableBudgetPoolByContext(tx, context)).toEqual(first);
expect(tx.$queryRaw).not.toHaveBeenCalled();
expect(await resolveApplicableBudgetPoolByContext(tx, context, { forUpdate: true })).toEqual(latest);
expect(tx.budgetPool.findMany.mock.invocationCallOrder[1]).toBeLessThan(tx.$queryRaw.mock.invocationCallOrder[0]);
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(tx.budgetPool.findMany.mock.invocationCallOrder[2]);
});
it.each([{ records: [] }, { records: [{ id: 'replacement' }] }])('rejects a candidate changed during the wait: %j', async ({ records }) => {
const tx = { $queryRaw: vi.fn().mockResolvedValue([]), budgetPool: { findMany: vi.fn().mockResolvedValueOnce([{ id: 'old' }]).mockResolvedValueOnce(records) } };
await expect(resolveApplicableBudgetPoolByContext(tx, { effectiveDate: new Date('2026-09-19') }, { forUpdate: true }))
.rejects.toMatchObject({ statusCode: 409, details: { reasonCode: 'budget.pool_changed' } });
});
});
-138
查看文件
@@ -1,138 +0,0 @@
const {
buildAssetLedgerSummaryDedupeKey,
buildAssetLedgerSummaryPayload,
enqueueAssetLedgerSummaryExport,
hashAssetPayload,
resolveAssetLedgerSummaryChange,
} = require('../../../src/services/integrations/erpAsset');
describe('ERP Asset Service', () => {
function createAsset(overrides = {}) {
return {
id: 'asset-1',
asset_no: 'ZC-001',
name: 'ThinkPad X1',
status: 'in_use',
location: '上海办公室-A区',
activated_at: '2026-03-09T00:00:00.000Z',
retired_at: null,
created_at: '2026-03-09T08:00:00.000Z',
updated_at: '2026-03-09T08:00:00.000Z',
legal_entity_id: 'le-1',
cost_center_id: 'cc-1',
purchase_request_id: 'pr-1',
assignee_employee_id: 'emp-1',
category: {
id: 'cat-1',
code: 'LAPTOP',
name: '笔记本电脑',
document_category: { code: 'ASSET_STD' },
},
legal_entity: { id: 'le-1', code: 'HY', name: '海音达' },
cost_center: { id: 'cc-1', code: 'IT001', name: 'IT中心' },
purchase_request: { id: 'pr-1', request_no: 'PR-001', title: '办公电脑采购' },
assignee: { id: 'emp-1', employee_no: 'E0001', name: '张三' },
...overrides,
};
}
it('builds asset ledger summary payload for create event', () => {
const payload = buildAssetLedgerSummaryPayload({
asset: createAsset(),
sourceEventName: 'asset.card.created',
changeType: 'created',
changedFields: [],
occurredAt: '2026-03-09T08:00:00.000Z',
});
expect(payload.exportType).toBe('asset_ledger_summary');
expect(payload.sourceEventName).toBe('asset.card.created');
expect(payload.changeType).toBe('created');
expect(payload.assetNo).toBe('ZC-001');
expect(payload.category.code).toBe('LAPTOP');
expect(payload.legalEntity.code).toBe('HY');
expect(payload.costCenter.code).toBe('IT001');
expect(payload.purchaseRequest.requestNo).toBe('PR-001');
expect(payload.assignee.employeeNo).toBe('E0001');
});
it('detects financial-dimension changes only', () => {
const previousAsset = createAsset();
expect(
resolveAssetLedgerSummaryChange(previousAsset, {
...previousAsset,
name: 'ThinkPad X1 Carbon Gen 12',
location: '上海办公室-B区',
}),
).toBeNull();
expect(
resolveAssetLedgerSummaryChange(previousAsset, {
...previousAsset,
cost_center_id: 'cc-2',
}),
).toEqual({
sourceEventName: 'asset.ledger.summary.updated',
changeType: 'financial_dimensions_changed',
changedFields: ['costCenterId'],
});
expect(
resolveAssetLedgerSummaryChange(previousAsset, {
...previousAsset,
status: 'retired',
}),
).toEqual({
sourceEventName: 'asset.ledger.summary.updated',
changeType: 'retired',
changedFields: ['status'],
});
});
it('builds deterministic dedupe key and payload hash', () => {
const asset = createAsset();
const payload = buildAssetLedgerSummaryPayload({ asset, changedFields: [] });
const changedPayload = buildAssetLedgerSummaryPayload({
asset: createAsset({ cost_center: { id: 'cc-2', code: 'IT002', name: 'IT二部' }, cost_center_id: 'cc-2' }),
sourceEventName: 'asset.ledger.summary.updated',
changeType: 'financial_dimensions_changed',
changedFields: ['costCenterId'],
});
expect(buildAssetLedgerSummaryDedupeKey('asset-1')).toBe('asset_card:asset-1:ledger_summary');
expect(hashAssetPayload(payload)).not.toBe(hashAssetPayload(changedPayload));
});
it('resets same outbox when asset payload changes', async () => {
const prisma = {
integrationOutbox: {
findUnique: vi.fn().mockResolvedValue({
id: 'outbox-1',
status: 'succeeded',
payload: { assetNo: 'ZC-001', costCenter: { id: 'cc-1', code: 'IT001' } },
}),
update: vi.fn().mockResolvedValue({ id: 'outbox-1', status: 'pending' }),
},
};
const result = await enqueueAssetLedgerSummaryExport(
prisma,
{ integrationOutboxMaxAttempts: 5 },
{
actorId: 'actor-1',
asset: createAsset({
cost_center_id: 'cc-2',
cost_center: { id: 'cc-2', code: 'IT002', name: 'IT二部' },
}),
sourceEventName: 'asset.ledger.summary.updated',
changeType: 'financial_dimensions_changed',
changedFields: ['costCenterId'],
},
);
expect(prisma.integrationOutbox.update).toHaveBeenCalled();
expect(result.eventType).toBe('erp.asset.export');
expect(result.requeued).toBe(true);
});
});
@@ -1,80 +0,0 @@
const {
buildReimbursementJournalDedupeKey,
buildReimbursementJournalPayload,
enqueueReimbursementJournalExport,
} = require('../../../src/services/integrations/erpJournal');
describe('ERP Journal Service', () => {
afterEach(() => {
vi.clearAllMocks();
});
it('prefers owner legal entity code and keeps cost center null', () => {
const payload = buildReimbursementJournalPayload({
reimbursement: {
id: 'rb-1',
form_number: 'BX-001',
owner_id: 'owner-1',
submitter_id: 'submitter-1',
department_id: 'dept-1',
total_amount: { toString: () => '88.80' },
payment_date: '2026-03-08T00:00:00.000Z',
paid_at: '2026-03-08T12:00:00.000Z',
owner: { legal_entity: { code: 'OWNER_LE' } },
submitter: { legal_entity: { code: 'SUBMITTER_LE' } },
},
});
expect(payload.dimensions.legalEntityCode).toBe('OWNER_LE');
expect(payload.dimensions.costCenterCode).toBeNull();
expect(payload.currencyCode).toBe('CNY');
});
it('falls back to submitter legal entity code', () => {
const payload = buildReimbursementJournalPayload({
reimbursement: {
id: 'rb-1',
form_number: 'BX-001',
owner: { legal_entity: null },
submitter: { legal_entity: { code: 'SUBMITTER_LE' } },
},
});
expect(payload.dimensions.legalEntityCode).toBe('SUBMITTER_LE');
});
it('builds dedupe key and enqueues standard export event', async () => {
const prisma = {
integrationOutbox: {
findUnique: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({ id: 'outbox-1', status: 'pending' }),
},
};
const config = { integrationOutboxMaxAttempts: 5 };
const reimbursement = {
id: 'rb-1',
form_number: 'BX-001',
owner_id: 'owner-1',
submitter_id: 'submitter-1',
department_id: 'dept-1',
};
const result = await enqueueReimbursementJournalExport(prisma, config, {
reimbursement,
actorId: 'actor-1',
});
expect(buildReimbursementJournalDedupeKey('rb-1')).toBe('reimbursement:rb-1:paid');
expect(prisma.integrationOutbox.findUnique).toHaveBeenCalledWith({
where: {
event_type_dedupe_key: {
event_type: 'erp.journal.export',
dedupe_key: 'reimbursement:rb-1:paid',
},
},
select: { id: true, status: true, payload: true },
});
expect(prisma.integrationOutbox.create).toHaveBeenCalled();
expect(result.eventType).toBe('erp.journal.export');
});
});
@@ -1,121 +0,0 @@
const {
buildTravelOrderPaymentJournalPayload,
buildTravelOrderPaymentJournalDedupeKey,
buildTravelOrderPaymentJournalPreview,
} = require("../../../src/services/integrations/erpJournal");
describe("erpJournal travel helpers", () => {
it("builds payment journal payload with travel dimensions", () => {
const payload = buildTravelOrderPaymentJournalPayload({
travelOrder: {
id: "order-1",
source: "ctrip",
external_order_no: "EXT-001",
employee_id: "employee-1",
department_id: "dept-1",
travel_request_id: "travel-1",
amount: "500.00",
currency: "CNY",
payment_voucher_no: "BANK-001",
payment_date: new Date("2026-03-10T00:00:00.000Z"),
payment_channel: "bank_transfer",
paid_at: new Date("2026-03-10T09:00:00.000Z"),
created_at: new Date("2026-03-10T08:00:00.000Z"),
updated_at: new Date("2026-03-10T09:00:00.000Z"),
travel_request: {
id: "travel-1",
request_no: "CL-20260310-0001",
applicant_employee_id: "employee-1",
submitter_id: null,
legal_entity: { code: "HY" },
cost_center: { code: "CC001" },
budget_pool: { code: "POOL-1" },
},
},
});
expect(payload).toEqual(
expect.objectContaining({
journalType: "travel_order_payment",
sourceEventName: "travel_order.paid",
entityType: "travel_order",
entityId: "order-1",
documentNo: "EXT-001",
paymentVoucherNo: "BANK-001",
paymentDate: "2026-03-10",
paymentChannel: "bank_transfer",
dimensions: expect.objectContaining({
legalEntityCode: "HY",
costCenterCode: "CC001",
budgetPoolCode: "POOL-1",
}),
}),
);
});
it("builds stable travel-order dedupe key", () => {
expect(buildTravelOrderPaymentJournalDedupeKey("order-1")).toBe("travel_order:order-1:paid");
});
it("builds preview with current binding and outbox state", async () => {
const prisma = {
integrationBinding: {
findUnique: vi.fn().mockResolvedValue({
id: "binding-1",
provider: "erp",
entity_type: "travel_order_payment_journal",
local_id: "order-1",
external_id: "erp-1",
external_code: "EXT-001",
last_synced_at: new Date("2026-03-10T10:00:00.000Z"),
}),
},
integrationOutbox: {
findFirst: vi.fn().mockResolvedValue({
id: "outbox-1",
event_type: "erp.journal.export",
status: "succeeded",
last_error: null,
processed_at: new Date("2026-03-10T10:00:00.000Z"),
updated_at: new Date("2026-03-10T10:00:00.000Z"),
}),
},
};
const preview = await buildTravelOrderPaymentJournalPreview(prisma, {
id: "order-1",
source: "ctrip",
external_order_no: "EXT-001",
employee_id: "employee-1",
department_id: "dept-1",
linked_reimbursement_id: null,
travel_request_id: "travel-1",
amount: "500.00",
currency: "CNY",
payment_voucher_no: "BANK-001",
payment_date: new Date("2026-03-10T00:00:00.000Z"),
payment_channel: "bank_transfer",
paid_at: new Date("2026-03-10T09:00:00.000Z"),
created_at: new Date("2026-03-10T08:00:00.000Z"),
updated_at: new Date("2026-03-10T09:00:00.000Z"),
travel_request: {
id: "travel-1",
request_no: "CL-20260310-0001",
applicant_employee_id: "employee-1",
submitter_id: null,
legal_entity: { code: "HY" },
cost_center: { code: "CC001" },
budget_pool: { code: "POOL-1" },
},
});
expect(preview.exportState).toEqual(
expect.objectContaining({
externalId: "erp-1",
lastOutboxId: "outbox-1",
lastOutboxStatus: "succeeded",
}),
);
expect(preview.binding).toEqual(expect.objectContaining({ localId: "order-1" }));
});
});
@@ -1,145 +0,0 @@
const {
normalizeRequestedResources,
syncErpMasterData,
} = require('../../../src/services/integrations/erpMasterData');
function createPrismaMock() {
const syncStates = new Map();
const prisma = {
$transaction: async (fn) => fn(prisma),
integrationSyncState: {
upsert: vi.fn().mockImplementation(async ({ where, create, update }) => {
const key = `${where.provider_resource_type.provider}:${where.provider_resource_type.resource_type}`;
const current = syncStates.get(key) ?? null;
const next = current ? { ...current, ...update } : { ...create };
syncStates.set(key, next);
return next;
}),
findMany: vi.fn().mockImplementation(async () => Array.from(syncStates.values())),
},
integrationBinding: {
findFirst: vi.fn().mockResolvedValue(null),
upsert: vi.fn().mockResolvedValue({ id: 'binding-1' }),
},
legalEntity: {
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue({ id: 'le-1' }),
update: vi.fn().mockResolvedValue({ id: 'le-1' }),
},
costCenter: {
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue({ id: 'cc-1' }),
update: vi.fn().mockResolvedValue({ id: 'cc-1' }),
},
supplier: {
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue({ id: 'sup-1' }),
update: vi.fn().mockResolvedValue({ id: 'sup-1' }),
},
};
return prisma;
}
describe('ERP Master Data Service', () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
it('normalizes resource order and dedupes input', () => {
expect(normalizeRequestedResources(['supplier', 'legal_entity', 'supplier'])).toEqual([
'legal_entity',
'supplier',
]);
expect(normalizeRequestedResources([])).toEqual(['legal_entity', 'cost_center', 'supplier']);
});
it('skips supplier rows without stable code', async () => {
const prisma = createPrismaMock();
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: vi.fn().mockResolvedValue({
suppliers: [{ code: '', name: 'bad supplier' }],
}),
});
const result = await syncErpMasterData(prisma, {
erpSyncEnabled: true,
erpSyncProvider: 'http',
erpMasterDataEndpoint: 'https://example.com/master-data',
erpMasterDataTimeoutMs: 500,
}, {
resources: ['supplier'],
actorId: 'actor-1',
logger: { error: vi.fn() },
});
const supplierState = result.items.find((item) => item.resourceType === 'supplier');
expect(result.ok).toBe(true);
expect(supplierState.lastResult.skipped).toBe(1);
expect(prisma.supplier.create).not.toHaveBeenCalled();
});
it('skips cost centers when legal entity is missing', async () => {
const prisma = createPrismaMock();
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: vi.fn().mockResolvedValue({
costCenters: [{ code: 'IT001', legalEntityCode: 'MISSING', name: 'IT中心' }],
}),
});
const result = await syncErpMasterData(prisma, {
erpSyncEnabled: true,
erpSyncProvider: 'http',
erpMasterDataEndpoint: 'https://example.com/master-data',
erpMasterDataTimeoutMs: 500,
}, {
resources: ['cost_center'],
actorId: 'actor-1',
logger: { error: vi.fn() },
});
const costCenterState = result.items.find((item) => item.resourceType === 'cost_center');
expect(costCenterState.lastResult.skipped).toBe(1);
expect(costCenterState.lastResult.sampleSkipped[0].reason).toBe('legal_entity_missing');
});
it('skips rows when integration binding conflicts', async () => {
const prisma = createPrismaMock();
prisma.supplier.findMany.mockResolvedValue([{ id: 'sup-local', code: 'SUP001', enabled: true, archived_at: null }]);
prisma.integrationBinding.findFirst.mockResolvedValue({ local_id: 'another-local' });
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: () => 'application/json' },
json: vi.fn().mockResolvedValue({
suppliers: [{ code: 'SUP001', externalId: 'ERP-SUP-001', name: '供应商' }],
}),
});
const result = await syncErpMasterData(prisma, {
erpSyncEnabled: true,
erpSyncProvider: 'http',
erpMasterDataEndpoint: 'https://example.com/master-data',
erpMasterDataTimeoutMs: 500,
}, {
resources: ['supplier'],
actorId: 'actor-1',
logger: { error: vi.fn() },
});
const supplierState = result.items.find((item) => item.resourceType === 'supplier');
expect(supplierState.lastResult.skipped).toBe(1);
expect(supplierState.lastResult.sampleSkipped[0].reason).toBe('binding_conflict');
expect(prisma.supplier.update).not.toHaveBeenCalled();
});
});
@@ -1,144 +0,0 @@
const {
buildPurchaseAccrualJournalPayload,
buildPurchaseArchiveSummaryPayload,
buildPurchaseCommitmentJournalPayload,
enqueuePurchaseArchiveSummaryExport,
hashProcurementPayload,
} = require('../../../src/services/integrations/erpProcurement');
describe('ERP Procurement Service', () => {
it('builds commitment payload with procurement dimensions', () => {
const payload = buildPurchaseCommitmentJournalPayload({
purchaseRequest: {
id: 'pr-1',
request_no: 'PR-001',
applicant_id: 'applicant-1',
submitter_id: 'submitter-1',
department_id: 'dept-1',
total_amount: { toString: () => '880.00' },
currency_code: 'CNY',
legal_entity: { code: 'HY' },
cost_center: { code: 'IT001' },
supplier: { code: 'SUP001' },
budget_pool: { code: 'BG-IT-2026' },
document_category: { code: 'PR_STANDARD' },
},
occurredAt: '2026-03-08T10:00:00.000Z',
});
expect(payload.journalType).toBe('purchase_request_commitment');
expect(payload.references.requestNo).toBe('PR-001');
expect(payload.dimensions.legalEntityCode).toBe('HY');
expect(payload.dimensions.costCenterCode).toBe('IT001');
expect(payload.dimensions.supplierCode).toBe('SUP001');
expect(payload.dimensions.budgetPoolCode).toBe('BG-IT-2026');
});
it('builds accrual payload from archive facts', () => {
const payload = buildPurchaseAccrualJournalPayload({
purchaseRequest: {
id: 'pr-1',
request_no: 'PR-001',
applicant_id: 'applicant-1',
submitter_id: 'submitter-1',
department_id: 'dept-1',
total_amount: '880.00',
currency_code: 'CNY',
legal_entity: { code: 'HY' },
cost_center: { code: 'IT001' },
supplier: { code: 'SUP001' },
budget_pool: { code: 'BG-IT-2026' },
},
archive: {
id: 'archive-1',
po_no: 'PO-001',
contract_no: 'CT-001',
archived_at: '2026-03-08T10:30:00.000Z',
},
});
expect(payload.journalType).toBe('purchase_order_accrual');
expect(payload.entityId).toBe('archive-1');
expect(payload.documentNo).toBe('PO-001');
expect(payload.references.contractNo).toBe('CT-001');
});
it('builds archive summary payload and hashes payload changes', () => {
const basePayload = buildPurchaseArchiveSummaryPayload({
purchaseRequest: {
id: 'pr-1',
request_no: 'PR-001',
department_id: 'dept-1',
total_amount: '880.00',
currency_code: 'CNY',
legal_entity: { code: 'HY' },
cost_center: { code: 'IT001' },
supplier: { id: 'sup-1', code: 'SUP001', name: '演示供应商' },
},
archive: {
id: 'archive-1',
po_no: 'PO-001',
contract_no: 'CT-001',
archived_at: '2026-03-08T10:30:00.000Z',
archived_files: [
{
storagePath: 'purchase-requests/pr-1/contract.pdf',
originalName: 'contract.pdf',
mimeType: 'application/pdf',
fileSize: 1024,
},
],
},
});
const changedPayload = {
...basePayload,
contractNo: 'CT-002',
};
expect(basePayload.files).toHaveLength(1);
expect(hashProcurementPayload(basePayload)).not.toBe(hashProcurementPayload(changedPayload));
});
it('resets archive summary outbox when payload changes', async () => {
const prisma = {
integrationOutbox: {
findUnique: vi.fn().mockResolvedValue({
id: 'outbox-1',
status: 'succeeded',
payload: { contractNo: 'CT-001', poNo: 'PO-001' },
}),
update: vi.fn().mockResolvedValue({ id: 'outbox-1', status: 'pending' }),
},
};
const result = await enqueuePurchaseArchiveSummaryExport(
prisma,
{ integrationOutboxMaxAttempts: 5 },
{
actorId: 'actor-1',
purchaseRequest: {
id: 'pr-1',
request_no: 'PR-001',
department_id: 'dept-1',
total_amount: '880.00',
currency_code: 'CNY',
supplier: { id: 'sup-1', code: 'SUP001', name: '演示供应商' },
legal_entity: { code: 'HY' },
cost_center: { code: 'IT001' },
},
archive: {
id: 'archive-1',
po_no: 'PO-001',
contract_no: 'CT-002',
archived_at: '2026-03-08T10:30:00.000Z',
archived_files: [],
},
},
);
expect(prisma.integrationOutbox.update).toHaveBeenCalled();
expect(result.requeued).toBe(true);
expect(result.eventType).toBe('erp.procurement.export');
});
});
@@ -1,170 +0,0 @@
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);
});
});
@@ -2,22 +2,19 @@ const dispatcher = require("../../../../src/services/integrations/dispatcher");
const { clearWecomAppAccessTokenCache } = require("../../../../src/services/integrations/wecomAccessToken");
describe("Integration Dispatcher", () => {
it.each(["erp.reimbursement.paid", "erp.journal.export", "erp.procurement.export", "erp.asset.export", "erp.travel.export", "erp.future"])("refuses retired %s without any dispatch or binding write", async (eventType) => {
const fetch = vi.fn(); vi.stubGlobal("fetch", fetch);
try {
await expect(dispatcher.dispatchOutboxEvent({ eventType, payload: {} })).rejects.toMatchObject({ code: "erp_retired", retryable: false });
expect(fetch).not.toHaveBeenCalled();
} finally { vi.unstubAllGlobals(); }
});
afterEach(() => {
clearWecomAppAccessTokenCache();
vi.restoreAllMocks();
});
it("dispatches erp.reimbursement.paid", async () => {
const result = await dispatcher.dispatchOutboxEvent({
config: { erpSyncEnabled: true, erpSyncProvider: "mock" },
eventType: "erp.reimbursement.paid",
payload: { reimbursementId: "rb-1", formNumber: "BX-001" },
});
expect(result.channel).toBe("erp");
expect(result.code).toBe("mock_synced");
});
it("dispatches notification.im with mock channel", async () => {
const result = await dispatcher.dispatchOutboxEvent({
prisma: {
@@ -1,237 +0,0 @@
const erp = require('../../../../src/services/integrations/erp');
describe('ERP Sync Services', () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
it('builds reimbursement journal payload via compat helper', () => {
const payload = erp.buildErpPayload({
reimbursement: {
id: 'r1',
form_number: 'F001',
owner_id: 'owner-1',
submitter_id: 'submitter-1',
department_id: 'dept-1',
total_amount: { toString: () => '100.00' },
payment_voucher_no: 'PV001',
payment_date: '2026-03-01T12:00:00.000Z',
paid_at: '2026-03-02T08:00:00.000Z',
owner: { legal_entity: { code: 'HY' } },
},
});
expect(payload.journalType).toBe('reimbursement_paid');
expect(payload.documentNo).toBe('F001');
expect(payload.paymentDate).toBe('2026-03-01');
expect(payload.dimensions.legalEntityCode).toBe('HY');
});
it('returns mock synced when journal export provider is mock', async () => {
const result = await erp.exportJournalToErp(
{ erpSyncEnabled: true, erpSyncProvider: 'mock' },
{ journalType: 'purchase_request_commitment', documentNo: 'PR-001' },
);
expect(result.ok).toBe(true);
expect(result.code).toBe('mock_synced');
expect(result.response.externalId).toContain('PR-001');
});
it('returns mock procurement result when provider is mock', async () => {
const result = await erp.exportProcurementToErp(
{ erpSyncEnabled: true, erpSyncProvider: 'mock' },
{ poNo: 'PO-001', contractNo: 'CT-001' },
);
expect(result.ok).toBe(true);
expect(result.code).toBe('mock_synced');
expect(result.response.externalPoNo).toContain('PO-001');
expect(result.response.externalContractNo).toContain('CT-001');
});
it('returns mock master data result when provider is mock', async () => {
const result = await erp.pullMasterDataFromErp(
{ erpSyncEnabled: true, erpSyncProvider: 'mock' },
{ resources: ['legal_entity', 'supplier'] },
);
expect(result.ok).toBe(true);
expect(result.code).toBe('mock_synced');
expect(result.data.legalEntities).toEqual([]);
expect(result.data.suppliers).toEqual([]);
});
it('handles http master data 4xx errors', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 422,
headers: { get: () => 'application/json' },
json: vi.fn().mockResolvedValue({ code: 'invalid_payload' }),
});
const result = await erp.pullMasterDataFromErp(
{
erpSyncEnabled: true,
erpSyncProvider: 'http',
erpMasterDataEndpoint: 'https://example.com/master-data',
erpMasterDataTimeoutMs: 500,
},
{ resources: ['legal_entity'] },
);
expect(result.ok).toBe(false);
expect(result.code).toBe('erp_master_data_http_4xx');
expect(result.retryable).toBe(false);
});
it('handles http journal export 5xx errors', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 503,
headers: { get: () => 'application/json' },
json: vi.fn().mockResolvedValue({ code: 'service_unavailable' }),
});
const result = await erp.exportJournalToErp(
{
erpSyncEnabled: true,
erpSyncProvider: 'http',
erpJournalExportEndpoint: 'https://example.com/journals',
erpSyncTimeoutMs: 500,
},
{ journalType: 'purchase_order_accrual', documentNo: 'PO-001' },
);
expect(result.ok).toBe(false);
expect(result.code).toBe('erp_sync_http_5xx');
expect(result.retryable).toBe(true);
});
it('handles http procurement export 4xx errors', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
headers: { get: () => 'application/json' },
json: vi.fn().mockResolvedValue({ code: 'bad_request' }),
});
const result = await erp.exportProcurementToErp(
{
erpSyncEnabled: true,
erpSyncProvider: 'http',
erpProcurementExportEndpoint: 'https://example.com/procurement',
erpProcurementTimeoutMs: 500,
},
{ exportType: 'purchase_order_archive', poNo: 'PO-001' },
);
expect(result.ok).toBe(false);
expect(result.code).toBe('erp_procurement_http_4xx');
expect(result.retryable).toBe(false);
});
it('handles http procurement export timeout', async () => {
global.fetch = vi.fn().mockRejectedValue({ name: 'AbortError' });
const result = await erp.exportProcurementToErp(
{
erpSyncEnabled: true,
erpSyncProvider: 'http',
erpProcurementExportEndpoint: 'https://example.com/procurement',
erpProcurementTimeoutMs: 500,
},
{ exportType: 'purchase_order_archive', poNo: 'PO-001' },
);
expect(result.ok).toBe(false);
expect(result.code).toBe('erp_procurement_timeout');
expect(result.retryable).toBe(true);
});
it('handles http journal export timeout', async () => {
global.fetch = vi.fn().mockRejectedValue({ name: 'AbortError' });
const result = await erp.exportJournalToErp(
{
erpSyncEnabled: true,
erpSyncProvider: 'http',
erpJournalExportEndpoint: 'https://example.com/journals',
erpSyncTimeoutMs: 500,
},
{ documentNo: 'BX-001' },
);
expect(result.ok).toBe(false);
expect(result.code).toBe('erp_sync_timeout');
expect(result.retryable).toBe(true);
});
it('returns mock asset export result when provider is mock', async () => {
const result = await erp.exportAssetToErp(
{ erpSyncEnabled: true, erpSyncProvider: 'mock' },
{ assetNo: 'ZC-001', entityId: 'asset-1' },
);
expect(result.ok).toBe(true);
expect(result.code).toBe('mock_synced');
expect(result.response.externalAssetId).toContain('ZC-001');
expect(result.response.externalAssetNo).toContain('ZC-001');
});
it('handles http asset export 4xx errors', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
headers: { get: () => 'application/json' },
json: vi.fn().mockResolvedValue({ code: 'bad_request' }),
});
const result = await erp.exportAssetToErp(
{
erpSyncEnabled: true,
erpSyncProvider: 'http',
erpAssetExportEndpoint: 'https://example.com/assets',
erpAssetTimeoutMs: 500,
},
{ exportType: 'asset_ledger_summary', assetNo: 'ZC-001' },
);
expect(result.ok).toBe(false);
expect(result.code).toBe('erp_asset_http_4xx');
expect(result.retryable).toBe(false);
});
it('handles http asset export timeout', async () => {
global.fetch = vi.fn().mockRejectedValue({ name: 'AbortError' });
const result = await erp.exportAssetToErp(
{
erpSyncEnabled: true,
erpSyncProvider: 'http',
erpAssetExportEndpoint: 'https://example.com/assets',
erpAssetTimeoutMs: 500,
},
{ exportType: 'asset_ledger_summary', assetNo: 'ZC-001' },
);
expect(result.ok).toBe(false);
expect(result.code).toBe('erp_asset_timeout');
expect(result.retryable).toBe(true);
});
it('returns invalid provider if unsupported', async () => {
const result = await erp.exportJournalToErp(
{ erpSyncEnabled: true, erpSyncProvider: 'invalid_xyz' },
{},
);
expect(result.ok).toBe(false);
expect(result.code).toBe('erp_sync_provider_invalid');
});
it('returns disabled if ERP sync is disabled', async () => {
const result = await erp.exportJournalToErp({ erpSyncEnabled: false }, {});
expect(result.ok).toBe(false);
expect(result.code).toBe('erp_sync_disabled');
});
});
@@ -2,6 +2,10 @@
const outbox = require("../../../../src/services/integrations/outbox");
describe("Integration Outbox", () => {
it("rejects retired ERP enqueue before touching history", async () => {
await expect(outbox.enqueueIntegrationEvent({}, { eventType: "erp.journal.export" })).rejects.toMatchObject({ code: "erp_retired" });
});
it("enqueues new event", async () => {
const mockPrisma = {
integrationOutbox: {
@@ -43,6 +47,9 @@ describe("Integration Outbox", () => {
};
const items = await outbox.fetchPendingOutboxEvents(mockPrisma, { batchSize: 5 });
expect(items.length).toBe(1);
expect(mockPrisma.integrationOutbox.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ NOT: { event_type: { startsWith: "erp." } } }),
}));
});
it("marks outbox succeeded", async () => {
@@ -63,6 +63,19 @@ describe("payment order BIFF8 generator", () => {
expect(result.totalAmount).toBe("3.20");
});
it("exports principal repayment as one transfer with decrypted account name and repayment purpose", () => {
const batch = buildBatch({ entity_type: "loan_repayment" });
batch.items = [batch.items[0]];
batch.items[0].entity_code = "HK-SYNTHETIC";
batch.items[0].snapshot_json.payee.name = "合***";
batch.items[0].snapshot_json.payee.accountNameEncrypted = encrypt("合成出借人");
const row = buildPaymentOrderRows(config, batch).rows[0];
expect(row[5]).toBe("合成出借人");
expect(row.slice(10, 12)).toEqual(["还款", "归还借款本金"]);
batch.items[0].snapshot_json.payee.accountNameEncrypted = "invalid";
expect(() => buildPaymentOrderRows(config, batch)).toThrow();
});
it("preserves complete names and document codes without truncation", () => {
const batch = buildBatch();
const name = "合成姓名".repeat(20);
@@ -270,3 +283,17 @@ describe("payment order BIFF8 generator", () => {
}
});
});
it("keeps customer serial and business row identity with an annual PB and mixed source formats", () => {
const batch = buildBatch({ batch_no: "PB-2026-0001" });
batch.items[0].entity_code = "BX-202609-0001";
const original = structuredClone(batch);
const rows = buildPaymentOrderRows(config, batch);
expect(rows.serials).toEqual(["PB-2026-0001-R001", "PB-2026-0001-R002"]);
const reversed = buildPaymentOrderRows(config, { ...batch, items: [...batch.items].reverse() });
expect(reversed).toEqual(rows);
expect(batch).toEqual(original);
const workbook = generatePaymentOrderXls(config, batch);
expect(workbook.serials).toEqual(rows.serials);
expect(workbook.buffer.subarray(0, 8).toString("hex")).toBe("d0cf11e0a1b11ae1");
});
@@ -0,0 +1,43 @@
const { personalNotificationText, loadPendingPaymentResponsibility } = require("../../../src/services/paymentResponsibility");
describe("payment responsibility and personal notification facts", () => {
it.each([
["wecom_app_sent", "success", "企微个人消息已发送(不代表已读)"],
["wecom_app_no_bound_recipients", "success", "企微个人消息未发送:接收人未绑定企微"],
["wecom_sent", "success", "仅有企微群通知记录,未确认个人消息送达"],
["mock_sent", "success", "仅有模拟通知记录,未发送企微个人消息"],
["wecom_app_invaliduser", "failed", "企微通知发送失败,请核对通知记录"],
])("projects %s without converting generic success into personal delivery", (result_code, status, message) => {
expect(personalNotificationText([{ deliveries: [{ result_code, status }] }])).toBe(message);
});
it("does not attribute one anchor delivery to every intended recipient", () => {
expect(personalNotificationText([
{ recipient_employee_id: "first", deliveries: [{ status: "success", result_code: "wecom_app_sent" }] },
{ recipient_employee_id: "second", deliveries: [] },
])).toBe("存在企微个人消息发送成功记录,接收范围需核对(不代表已读)");
});
it("does not infer delivery from a notification record alone", () => {
expect(personalNotificationText([{ deliveries: [] }])).toBe("暂无企微个人消息发送成功记录");
});
it("separates eligible people from the selected notification recipient and omits disabled/self/admin", async () => {
const person = (id, role, extra = {}) => ({ id, name: id, status: "active", archived_at: null, roles: [{ role_code: role }], ...extra });
const tx = { employee: { findMany: vi.fn(async () => [
person("maker", "ceo"), person("ceo", "ceo"), person("manager", "manager"),
person("disabled", "manager", { status: "inactive" }), person("archived", "manager", { archived_at: new Date() }),
person("admin", "admin"),
]) }, notification: { findMany: vi.fn(async () => [{ recipient: { name: "ceo" }, deliveries: [] }]) } };
const projection = await loadPendingPaymentResponsibility(tx, { id: "batch", status: "draft", created_by: "maker" });
expect(projection.actorName).toBe("可复核:ceo、manager");
expect(projection.detail).toBe("站内提醒对象:ceo;暂无企微个人消息发送成功记录");
expect(tx.notification.findMany.mock.calls[0][0].select).not.toHaveProperty("response_body");
});
it("does not replace actual completed reviewers with current role holders", async () => {
const tx = { employee: { findMany: vi.fn() } };
expect(await loadPendingPaymentResponsibility(tx, { status: "executed" })).toBeNull();
expect(tx.employee.findMany).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,17 @@
const { parsePayMonth } = require("../../../src/services/payroll");
describe("pay month UTC boundaries", () => {
it.each([
["2024-02", "2024-02-29T23:59:59.999Z"],
["2026-02", "2026-02-28T23:59:59.999Z"],
["2024-12", "2024-12-31T23:59:59.999Z"],
])("keeps exact leap/month-end %s", (month, end) => {
const result = parsePayMonth(month);
expect(result.value).toBe(month);
expect(result.startDate.toISOString()).toBe(`${month}-01T00:00:00.000Z`);
expect(result.endDate.toISOString()).toBe(end);
});
it.each(["2026-00", "2026-13", "2026-1", "", undefined, null])("rejects invalid month %s", (input) => {
expect(parsePayMonth(input)).toBeNull();
});
});
@@ -1,6 +1,8 @@
const {
summarizeBudgetLedgerByPool,
buildPurchaseBudgetValidationError,
reserveBudgetForEntity,
confirmBudgetForEntity,
releaseReservedBudgetIfAny,
} = require("../../../src/services/purchaseBudget");
describe("purchaseBudget service", () => {
@@ -32,10 +34,62 @@ describe("purchaseBudget service", () => {
]);
});
it("builds VALID_001 validation error", () => {
const error = buildPurchaseBudgetValidationError({ budget: "reserve_already_exists" });
expect(error.code).toBe("VALID_001");
expect(error.statusCode).toBe(409);
expect(error.details).toEqual({ budget: "reserve_already_exists" });
function fixture(entries = [], pool = { id: "pool-1", used_amount: "20.00" }) {
return {
$queryRaw: vi.fn().mockResolvedValue([]),
budgetLedgerEntry: { findMany: vi.fn().mockResolvedValue(entries), create: vi.fn().mockResolvedValue({}) },
budgetPool: { findUnique: vi.fn().mockResolvedValue(pool), update: vi.fn().mockResolvedValue({}) },
};
}
const input = { budgetPoolId: "pool-1", entityType: "purchase_request", entityId: "synthetic-request", amount: "12.34", actorId: "synthetic-actor", note: "synthetic" };
it("explains duplicate reserve before writes", async () => {
const tx = fixture([{ budget_pool_id: "pool-1", kind: "reserve", amount: "10.00" }]);
await expect(reserveBudgetForEntity(tx, input)).rejects.toMatchObject({ code: "VALID_001", statusCode: 409, details: { budget: "reserve_already_exists", reasonCode: "purchase_budget.reserve_already_exists" } });
expect(tx.budgetLedgerEntry.create).not.toHaveBeenCalled();
expect(tx.budgetPool.findUnique).not.toHaveBeenCalled();
expect(tx.budgetPool.update).not.toHaveBeenCalled();
});
it("preserves attempted ledger before missing-pool rejection", async () => {
const tx = fixture([], null);
await expect(reserveBudgetForEntity(tx, input)).rejects.toMatchObject({ code: "VALID_001", statusCode: 409, details: { budgetPoolId: "not_found", reasonCode: "purchase_budget.pool_not_found" } });
expect(tx.budgetLedgerEntry.create).toHaveBeenCalledWith({ data: { budget_pool_id: "pool-1", entity_type: "purchase_request", entity_id: "synthetic-request", kind: "reserve", amount: "12.34", created_by: "synthetic-actor", note: "synthetic" } });
expect(tx.budgetLedgerEntry.create.mock.invocationCallOrder[0]).toBeLessThan(tx.budgetPool.findUnique.mock.invocationCallOrder[0]);
expect(tx.budgetPool.update).not.toHaveBeenCalled();
});
it("retains successful reserve precision and pool accumulation", async () => {
const tx = fixture();
await expect(reserveBudgetForEntity(tx, input)).resolves.toEqual({ applied: true, reservedAmount: "12.34" });
expect(tx.budgetPool.update).toHaveBeenCalledWith({ where: { id: "pool-1" }, data: { used_amount: "32.34", updated_by: "synthetic-actor" } });
});
it.each([{ budgetPoolId: null }, { amount: "0" }, { amount: "-1" }, { amount: "invalid" }])("retains no-op reserve %s", async (override) => {
const tx = fixture();
await expect(reserveBudgetForEntity(tx, { ...input, ...override })).resolves.toEqual({ applied: false, reservedAmount: "0.00" });
expect(tx.$queryRaw).not.toHaveBeenCalled();
for (const model of [tx.budgetLedgerEntry, tx.budgetPool]) for (const fn of Object.values(model)) expect(fn).not.toHaveBeenCalled();
});
it("confirms remaining amount once without touching pool", async () => {
const tx = fixture([{ budget_pool_id: "pool-1", kind: "reserve", amount: "12.34" }, { budget_pool_id: "pool-1", kind: "confirm", amount: "2.34" }]);
await expect(confirmBudgetForEntity(tx, input)).resolves.toEqual({ applied: true, items: [{ budgetPoolId: "pool-1", amount: "10.00" }] });
expect(tx.budgetLedgerEntry.create).toHaveBeenCalledWith({ data: expect.objectContaining({ kind: "confirm", amount: "10.00" }) });
expect(tx.budgetPool.findUnique).not.toHaveBeenCalled();
expect(tx.budgetPool.update).not.toHaveBeenCalled();
});
it("retains missing-pool release success with ledger and no pool update", async () => {
const tx = fixture([{ budget_pool_id: "pool-1", kind: "reserve", amount: "12.34" }], null);
await expect(releaseReservedBudgetIfAny(tx, input)).resolves.toEqual({ applied: true, items: [{ budgetPoolId: "pool-1", amount: "12.34" }] });
expect(tx.budgetLedgerEntry.create).toHaveBeenCalledWith({ data: expect.objectContaining({ kind: "release", amount: "12.34" }) });
expect(tx.budgetPool.update).not.toHaveBeenCalled();
});
it("retains release clamping and no-op fully released ledger", async () => {
const tx = fixture([{ budget_pool_id: "pool-1", kind: "reserve", amount: "30.00" }]);
await releaseReservedBudgetIfAny(tx, input);
expect(tx.budgetPool.update).toHaveBeenCalledWith({ where: { id: "pool-1" }, data: { used_amount: "0.00", updated_by: "synthetic-actor" } });
const completed = fixture([{ budget_pool_id: "pool-1", kind: "reserve", amount: "30.00" }, { budget_pool_id: "pool-1", kind: "release", amount: "30.00" }]);
await expect(releaseReservedBudgetIfAny(completed, input)).resolves.toEqual({ applied: false, items: [] });
await expect(confirmBudgetForEntity(completed, input)).resolves.toEqual({ applied: false, items: [] });
expect(completed.budgetLedgerEntry.create).not.toHaveBeenCalled();
expect(completed.budgetPool.findUnique).not.toHaveBeenCalled();
expect(completed.budgetPool.update).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,109 @@
const { resolveEntityPrecheck } = require('../../../src/services/feeControl');
const ID = '11111111-1111-4111-8111-111111111111';
function project(value, select) {
if (!select || value == null) return value;
if (Array.isArray(value)) return value.map(row => project(row, select));
return Object.fromEntries(Object.entries(select).map(([key, choice]) => [
key, choice === true ? value[key] : project(value[key], choice.select),
]));
}
function fixture() {
const state = { form: {
id: ID, department_id: 'department', owner_id: 'owner', submitter_id: 'owner',
reimbursement_date: new Date('2026-09-18T00:00:00Z'), total_amount: '180.00', status: 'draft',
items: [{ id: 'item', expense_type_id: 'type', amount: '180.00',
expense_date: new Date('2026-09-18T00:00:00Z'),
expense_type: { id: 'type', name: '合成办公费', category: 'office' } }],
} };
const tx = {
reimbursementForm: { findUnique: vi.fn(async ({ select }) => project(state.form, select)) },
featureFlag: { findUnique: vi.fn(async () => ({ flag_key: 'budget_precheck_v1',
override_mode: 'force_on', rollout_type: 'global', department_ids: [], version: 1 })) },
expenseStandard: { findMany: vi.fn(async () => [{ id: 'standard', expense_type_id: 'type',
name: '合成上限', max_amount: '100.00', per_unit: 'item', control_mode: 'block', scope_type: 'global' }]) },
employee: { findUnique: vi.fn(async () => ({ id: 'owner', roles: [{ role_code: 'employee' }] })) },
employeeRole: { findFirst: vi.fn(async () => ({ employee_id: 'approver' })) },
invoicePool: { count: vi.fn(async () => 1), findMany: vi.fn(async () => [{ linked_item_id: 'item' }]) },
reimbursementAttachment: { count: vi.fn(async () => 2) },
workflowInstance: { findFirst: vi.fn(async () => null) },
approvalRule: { findFirst: vi.fn(async () => ({ id: 'rule', level1_role: 'finance', priority: 1 })) },
budgetControlRule: { findMany: vi.fn(async () => []) },
budgetPool: { findMany: vi.fn(async () => []) },
purchaseRequest: { findUnique: vi.fn(async () => ({ id: ID, department_id: 'department' })) },
};
const run = (enabled = false, extra = {}) => resolveEntityPrecheck({
prisma: tx, config: { budgetPrecheckEnabled: enabled, appTimezone: 'Asia/Shanghai' },
entityType: 'reimbursement', entityId: ID, ...extra,
});
return { state, tx, run };
}
describe('reimbursement precheck record hydration', () => {
beforeEach(() => { vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(new Date('2026-09-18T04:00:00Z')); });
afterEach(() => vi.useRealTimers());
it.each([false, true])('shares one database record across all checks with budget enabled=%s', async enabled => {
const { tx, run } = fixture();
const result = await run(enabled);
expect(result.decision).toBe('block');
expect(result.standardChecks).toEqual([expect.objectContaining({ amount: '180.00', exceededAmount: '80.00', controlMode: 'block' })]);
expect(result.evidenceSummary).toEqual({ invoiceCount: 1, attachmentCount: 2,
items: [{ itemId: 'item', linkedInvoiceCount: 1, invoiceState: 'linked' }] });
expect(result.timelinessChecks[0]).toMatchObject({ ageDays: 0, expenseDate: '2026-09-18' });
// A relation hydration issues multiple SQL statements; repeated reads were
// measured inside the five-second submit transaction under load.
expect(tx.reimbursementForm.findUnique).toHaveBeenCalledTimes(1);
expect(tx.approvalRule.findFirst).toHaveBeenCalledTimes(enabled ? 1 : 0);
});
it('does not cache a record between requests', async () => {
const { tx, state, run } = fixture();
expect((await run()).standardChecks[0].amount).toBe('180.00');
state.form.items[0].amount = '80.00';
expect((await run()).standardChecks[0]).toMatchObject({ amount: '80.00', exceeded: false });
expect(tx.reimbursementForm.findUnique).toHaveBeenCalledTimes(2);
});
it('uses the locked pool snapshot without a second budget selection and preserves one entity read', async () => {
const { tx, run } = fixture();
tx.expenseStandard.findMany.mockResolvedValue([]);
const result = await run(true, { budgetPoolForWrite: { id: 'locked-pool', code: 'SYN', name: '合成预算',
used_amount: '60.00', allocated_amount: '200.00', warning_threshold_percent: 100 } });
expect(result.budgetHint).toMatchObject({ budgetId: 'locked-pool', usedAmount: '60.00', nextUsedAmount: '240.00', exceedsBudget: true });
expect(tx.budgetPool.findMany).not.toHaveBeenCalled();
expect(tx.reimbursementForm.findUnique).toHaveBeenCalledTimes(1);
});
it('keeps finance-owner CEO requirements when budget checks are disabled', async () => {
const { tx, run } = fixture();
tx.employee.findUnique.mockResolvedValue({ roles: [{ role_code: 'finance' }] });
tx.employeeRole.findFirst.mockResolvedValue(null);
const result = await run();
expect(result.blockingReasons).toContainEqual(expect.objectContaining({ code: 'finance_owner_ceo_unavailable' }));
expect(tx.employeeRole.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: {
role_code: 'ceo', employee_id: { notIn: ['owner'] }, employee: { status: 'active', archived_at: null },
} }));
});
it.each([false, true])('rejects missing records before downstream checks: budget=%s', async enabled => {
const { state, tx, run } = fixture(); state.form = null;
await expect(run(enabled)).rejects.toMatchObject({ code: 'SYS_404', statusCode: 404 });
expect(tx.featureFlag.findUnique).not.toHaveBeenCalled();
expect(tx.expenseStandard.findMany).not.toHaveBeenCalled();
});
it('propagates database failures without a disabled or empty fallback', async () => {
const { tx, run } = fixture(); const failure = new Error('synthetic_read_failure');
tx.reimbursementForm.findUnique.mockRejectedValue(failure);
await expect(run()).rejects.toBe(failure);
});
it('retains the non-reimbursement disabled branch', async () => {
const { tx, run } = fixture();
const result = await run(false, { entityType: 'purchase_request' });
expect(result).toMatchObject({ entityType: 'purchase_request', decision: 'pass', standardChecks: [] });
expect(tx.reimbursementForm.findUnique).not.toHaveBeenCalled();
expect(tx.purchaseRequest.findUnique).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,145 @@
const { randomBytes, createHash } = require("node:crypto");
const { spawnSync } = require("node:child_process");
const path = require("node:path");
const cryptoService = require("../../../src/services/crypto");
const {
checkSyntheticSample,
runSyntheticContract,
} = require("../../../../scripts/qa/check-sensitive-recovery-contract.cjs");
describe("K0 synthetic sensitive recovery contract", () => {
let config;
let plaintext;
let encrypted;
beforeEach(() => {
config = { fieldEncryptionKey: randomBytes(32).toString("base64") };
plaintext = `synthetic-only-${randomBytes(16).toString("hex")}`;
encrypted = cryptoService.encryptStringOrThrow(config, plaintext);
});
// Boolean assertions keep failed tests from printing fixture values/digests.
function expectNoFixture(output, additional = []) {
const key = Buffer.from(config.fieldEncryptionKey, "base64");
const forbidden = [
plaintext, encrypted, config.fieldEncryptionKey, key.toString("hex"),
createHash("sha256").update(key).digest("hex"),
createHash("sha256").update(config.fieldEncryptionKey).digest("hex"),
...encrypted.split(":").slice(1), ...additional,
];
expect(forbidden.some((value) => output.includes(value))).toBe(false);
}
it("round-trips with the actual production crypto exports and only reports status", () => {
const report = checkSyntheticSample(config, encrypted, plaintext);
expect(report).toEqual({ status: "PASS", reason: "SYNTHETIC_MATCH" });
expectNoFixture(JSON.stringify(report));
});
it.each([
["no config", undefined], ["no key", {}],
["null", { fieldEncryptionKey: null }], ["empty", { fieldEncryptionKey: "" }],
["whitespace", { fieldEncryptionKey: " " }],
["wrong type", { fieldEncryptionKey: 32 }],
["unparseable", { fieldEncryptionKey: "%%%" }],
["short", { fieldEncryptionKey: Buffer.alloc(31).toString("base64") }],
["long", { fieldEncryptionKey: Buffer.alloc(33).toString("base64") }],
])("stops for an unavailable or invalid key: %s", (_label, candidate) => {
expect(checkSyntheticSample(candidate, encrypted, plaintext)).toEqual({
status: "STOP", reason: "KEY_UNAVAILABLE_OR_INVALID",
});
});
it("stops for a different but valid 32-byte key", () => {
const wrongKey = Buffer.from(config.fieldEncryptionKey, "base64");
wrongKey[0] ^= 1;
const report = checkSyntheticSample({ fieldEncryptionKey: wrongKey.toString("base64") }, encrypted, plaintext);
expect(report).toEqual({ status: "STOP", reason: "DECRYPTION_FAILED" });
expectNoFixture(JSON.stringify(report), [wrongKey.toString("base64")]);
});
it.each([["iv", 1], ["tag", 2], ["ciphertext", 3]])("stops after tampering with %s", (_label, index) => {
const parts = encrypted.split(":");
const value = Buffer.from(parts[index], "base64url");
value[0] ^= 1;
parts[index] = value.toString("base64url");
const tampered = parts.join(":");
const report = checkSyntheticSample(config, tampered, plaintext);
expect(report).toEqual({ status: "STOP", reason: "DECRYPTION_FAILED" });
expectNoFixture(JSON.stringify(report), [tampered]);
});
it.each([["empty", ""], ["null", null], ["absent", undefined]])("marks %s fields NOT_TESTED", (_label, value) => {
expect(checkSyntheticSample(config, value, plaintext)).toEqual({ status: "NOT_TESTED", reason: "EMPTY_FIELD" });
});
it("does not let an empty field hide a missing key", () => {
expect(checkSyntheticSample({}, "", plaintext)).toEqual({ status: "STOP", reason: "KEY_UNAVAILABLE_OR_INVALID" });
});
it.each([
["legacy plaintext", "synthetic-unencrypted"], ["unknown version", "v2:a:b:c"],
["missing parts", "v1:a:b"], ["extra parts", "v1:a:b:c:d"],
["empty parts", "v1:::"], ["invalid encoding", "v1:%%:%%:%%"],
["wrong type", 123],
])("stops instead of treating a null decryption as success: %s", (_label, payload) => {
expect(checkSyntheticSample(config, payload, plaintext)).toEqual({ status: "STOP", reason: "INVALID_PAYLOAD" });
});
it("requires the synthetic expectation and detects a plaintext mismatch", () => {
expect(checkSyntheticSample(config, encrypted)).toEqual({ status: "STOP", reason: "SYNTHETIC_EXPECTATION_REQUIRED" });
const report = checkSyntheticSample(config, encrypted, "different-synthetic-value");
expect(report).toEqual({ status: "STOP", reason: "PLAINTEXT_MISMATCH" });
expectNoFixture(JSON.stringify(report));
});
it("drops raw exceptions, does not log, and never uses the logging decrypt branch", () => {
const marker = `synthetic-error-${randomBytes(8).toString("hex")}`;
const spies = ["log", "warn", "error", "info", "debug"].map((name) => vi.spyOn(console, name).mockImplementation(() => {}));
const safe = vi.spyOn(cryptoService, "decryptStringSafe").mockImplementation(() => { throw new Error(marker); });
try {
const throwingConfig = { get fieldEncryptionKey() { throw new Error(marker); } };
const report = checkSyntheticSample(throwingConfig, encrypted, plaintext);
expect(report).toEqual({ status: "STOP", reason: "DECRYPTION_FAILED" });
expectNoFixture(JSON.stringify(report), [marker]);
expect(runSyntheticContract().status).toBe("PASS");
expect(safe).not.toHaveBeenCalled();
expect(spies.every((spy) => spy.mock.calls.length === 0)).toBe(true);
} finally {
safe.mockRestore();
spies.forEach((spy) => spy.mockRestore());
}
});
it("keeps real custody and real-data decryption untested after the synthetic matrix passes", () => {
const report = runSyntheticContract();
expect(report.status).toBe("PASS");
expect(report.checks).toHaveLength(7);
expect(report.checks.every((check) => check.matched)).toBe(true);
expect(report).toMatchObject({ scope: "K0_SYNTHETIC_ONLY", originalKeySource: "UNKNOWN", custody: "NOT_TESTED", realDataDecryption: "NOT_TESTED" });
});
it("CLI ignores ambient key inputs and produces only the fixed aggregate contract", () => {
const script = path.resolve(__dirname, "../../../../scripts/qa/check-sensitive-recovery-contract.cjs");
const child = spawnSync(process.execPath, [script], {
encoding: "utf8",
env: { FIELD_ENCRYPTION_KEY: config.fieldEncryptionKey, DATABASE_URL: "synthetic-unused" },
});
expect(child.status).toBe(0);
expect(child.stderr.length).toBe(0);
expectNoFixture(child.stdout + child.stderr);
const report = JSON.parse(child.stdout);
expect(Object.keys(report).sort()).toEqual(["checks", "custody", "originalKeySource", "realDataDecryption", "scope", "status"]);
expect(report.status).toBe("PASS");
expect(report.checks.every((check) => Object.keys(check).sort().join(",") === "category,matched,reason,status")).toBe(true);
});
it("CLI rejects arbitrary arguments without echoing them", () => {
const script = path.resolve(__dirname, "../../../../scripts/qa/check-sensitive-recovery-contract.cjs");
const child = spawnSync(process.execPath, [script, plaintext], { encoding: "utf8", env: {} });
expect(child.status).toBe(1);
expect(child.stderr.length).toBe(0);
expectNoFixture(child.stdout + child.stderr);
expect(JSON.parse(child.stdout)).toEqual({ scope: "K0_SYNTHETIC_ONLY", status: "FAIL", reason: "ARGUMENTS_NOT_ALLOWED" });
});
});