feat(P19ERP2-1,P19ERP2-2): 实现采购ERP凭证与摘要出站服务

这个提交包含在:
2026-03-08 23:50:02 +08:00
父节点 8f4a50cdd7
当前提交 486660df06
共修改 15 个文件,包含 1912 行新增和 181 行删除
@@ -0,0 +1,130 @@
const purchaseRequestRoutes = require('../../../src/routes/purchaseRequests');
describe('purchase archive ERP helper', () => {
const { handlePurchaseArchiveErpSync } = purchaseRequestRoutes.__testables;
function createRequest() {
return {
id: 'req-1',
ip: '127.0.0.1',
headers: {},
employee: {
id: 'actor-1',
department_id: 'dept-1',
permissionCodes: ['procurement.manage'],
},
};
}
function createTx() {
return {
auditLog: {
create: vi.fn().mockResolvedValue({ id: 'audit-1' }),
},
};
}
function createContext() {
return {
purchaseRequest: {
id: 'pr-1',
department_id: 'dept-1',
order_archive: {
id: 'archive-1',
po_no: 'PO-001',
contract_no: 'CT-001',
archived_at: new Date('2026-03-08T10:30:00.000Z'),
},
},
};
}
it('first archive enqueues accrual and summary', async () => {
const tx = createTx();
const loadContext = vi.fn().mockResolvedValue(createContext());
const evaluateGate = vi.fn().mockResolvedValue({
enabled: true,
reason: 'env_inherit_department_allowed',
});
const enqueueAccrual = vi.fn().mockResolvedValue({
id: 'outbox-accrual',
status: 'pending',
duplicated: false,
requeued: false,
eventType: 'erp.journal.export',
erpTarget: 'accrual_journal',
});
const enqueueSummary = vi.fn().mockResolvedValue({
id: 'outbox-summary',
status: 'pending',
duplicated: false,
requeued: false,
eventType: 'erp.procurement.export',
erpTarget: 'archive_summary',
});
const result = await handlePurchaseArchiveErpSync({
tx,
config: {},
request: createRequest(),
actorId: 'actor-1',
purchaseRequestId: 'pr-1',
archiveId: 'archive-1',
isFirstArchive: true,
loadContext,
evaluateGate,
enqueueAccrual,
enqueueSummary,
});
expect(enqueueAccrual).toHaveBeenCalledWith(
tx,
{},
expect.objectContaining({ requeueOnFailure: false }),
);
expect(enqueueSummary).toHaveBeenCalledWith(
tx,
{},
expect.objectContaining({ requeueOnFailure: false }),
);
expect(result.items).toHaveLength(2);
expect(tx.auditLog.create).toHaveBeenCalledTimes(2);
});
it('archive update only re-enqueues summary', async () => {
const tx = createTx();
const loadContext = vi.fn().mockResolvedValue(createContext());
const evaluateGate = vi.fn().mockResolvedValue({
enabled: true,
reason: 'env_inherit_department_allowed',
});
const enqueueAccrual = vi.fn();
const enqueueSummary = vi.fn().mockResolvedValue({
id: 'outbox-summary',
status: 'pending',
duplicated: false,
requeued: true,
eventType: 'erp.procurement.export',
erpTarget: 'archive_summary',
});
const result = await handlePurchaseArchiveErpSync({
tx,
config: {},
request: createRequest(),
actorId: 'actor-1',
purchaseRequestId: 'pr-1',
archiveId: 'archive-1',
isFirstArchive: false,
loadContext,
evaluateGate,
enqueueAccrual,
enqueueSummary,
});
expect(enqueueAccrual).not.toHaveBeenCalled();
expect(enqueueSummary).toHaveBeenCalled();
expect(result.items).toHaveLength(1);
expect(result.items[0].erpTarget).toBe('archive_summary');
});
});
@@ -0,0 +1,114 @@
const workflowRoutes = require('../../../src/routes/workflow');
describe('workflow purchase ERP helper', () => {
const { handlePurchaseApprovalErpSync } = workflowRoutes.__testables;
function createRequest() {
return {
id: 'req-1',
ip: '127.0.0.1',
headers: {},
employee: {
id: 'actor-1',
department_id: 'dept-1',
permissionCodes: ['procurement.manage'],
},
};
}
function createTx() {
return {
auditLog: {
create: vi.fn().mockResolvedValue({ id: 'audit-1' }),
},
};
}
it('auto enqueues commitment journal when gate is enabled', async () => {
const tx = createTx();
const loadContext = vi.fn().mockResolvedValue({
purchaseRequest: {
id: 'pr-1',
department_id: 'dept-1',
},
approvalOccurredAt: new Date('2026-03-08T10:00:00.000Z'),
});
const evaluateGate = vi.fn().mockResolvedValue({
enabled: true,
reason: 'env_inherit_department_allowed',
});
const enqueueCommitment = vi.fn().mockResolvedValue({
id: 'outbox-1',
status: 'pending',
duplicated: false,
requeued: false,
eventType: 'erp.journal.export',
});
const result = await handlePurchaseApprovalErpSync({
tx,
config: {},
request: createRequest(),
actorId: 'actor-1',
purchaseRequestId: 'pr-1',
loadContext,
evaluateGate,
enqueueCommitment,
});
expect(enqueueCommitment).toHaveBeenCalledWith(
tx,
{},
expect.objectContaining({
actorId: 'actor-1',
requeueOnFailure: false,
}),
);
expect(tx.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: 'ERP_SYNC_ENQUEUED',
target_type: 'purchase_request',
target_id: 'pr-1',
}),
}),
);
expect(result.skipped).toBe(false);
});
it('writes skip audit when approval gate is disabled', async () => {
const tx = createTx();
const loadContext = vi.fn().mockResolvedValue({
purchaseRequest: {
id: 'pr-1',
department_id: 'dept-1',
},
approvalOccurredAt: new Date('2026-03-08T10:00:00.000Z'),
});
const evaluateGate = vi.fn().mockResolvedValue({ enabled: false, reason: 'flag_force_off' });
const enqueueCommitment = vi.fn();
const result = await handlePurchaseApprovalErpSync({
tx,
config: {},
request: createRequest(),
actorId: 'actor-1',
purchaseRequestId: 'pr-1',
loadContext,
evaluateGate,
enqueueCommitment,
});
expect(enqueueCommitment).not.toHaveBeenCalled();
expect(tx.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: 'ERP_SYNC_SKIPPED_BY_FEATURE_FLAG',
target_type: 'purchase_request',
target_id: 'pr-1',
}),
}),
);
expect(result.skipped).toBe(true);
});
});