feat: add reconciliation issue workflow

This commit is contained in:
Codex
2026-06-29 19:05:18 +08:00
parent 7258e4a7d5
commit d696fc38b0
14 changed files with 924 additions and 26 deletions

View File

@@ -2205,6 +2205,114 @@ async function testCommerce() {
'reconciliation anomalies endpoint should expose imported issues',
);
const actionableReconItem = reconciliationItems.items?.find(item => item.orderNo === reconMissingProviderOrder.item.orderNo);
assert.ok(actionableReconItem?.id, 'reconciliation test should have an actionable missing_provider item');
const studentReconciliationIssueDenied = await request('/api/commerce/reconciliation/issues/create', {
method: 'POST',
body: { itemId: actionableReconItem.id },
expectStatus: 403,
});
assert.equal(studentReconciliationIssueDenied.code, 'TENANT_ADMIN_REQUIRED', 'students must not create reconciliation issues');
const reconciliationIssue = await request('/api/commerce/reconciliation/issues/create', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
itemId: actionableReconItem.id,
assignedTo: TENANT_OPERATOR_USER_ID,
dueAt: new Date(Date.now() + 86_400_000).toISOString(),
summary: '人工核对供应商缺失账单',
note: '集成测试创建差错工单',
metadata: { source: 'api-integration-test' },
},
});
assert.ok(reconciliationIssue.item?.id, 'tenant admin should create a reconciliation issue');
assert.equal(reconciliationIssue.item?.status, 'open', 'created reconciliation issue should start open');
assert.equal(reconciliationIssue.item?.assignedTo, TENANT_OPERATOR_USER_ID, 'created issue should keep assignee');
assert.equal(reconciliationIssue.item?.matchStatus, 'missing_provider', 'created issue should copy match status');
const duplicateReconciliationIssue = await request('/api/commerce/reconciliation/issues/create', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { itemId: actionableReconItem.id },
});
assert.equal(duplicateReconciliationIssue.item?.id, reconciliationIssue.item.id, 'duplicate active issue creation should be idempotent');
assert.equal(duplicateReconciliationIssue.idempotent, true, 'duplicate active issue creation should return idempotent=true');
const reconciliationIssues = await request('/api/commerce/reconciliation/issues', {
userId: TENANT_ADMIN_USER_ID,
query: { status: 'open', assignedTo: TENANT_OPERATOR_USER_ID },
});
assert.ok(
reconciliationIssues.items?.some(item => item.id === reconciliationIssue.item.id),
'reconciliation issues endpoint should filter by status and assignee',
);
const startedReconciliationIssue = await request('/api/commerce/reconciliation/issues/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
issueId: reconciliationIssue.item.id,
action: 'start',
note: '开始核查',
},
});
assert.equal(startedReconciliationIssue.item?.status, 'investigating', 'start action should mark issue investigating');
const assignedReconciliationIssue = await request('/api/commerce/reconciliation/issues/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
issueId: reconciliationIssue.item.id,
action: 'assign',
assignedTo: TENANT_ADMIN_USER_ID,
note: '转交财务管理员复核',
},
});
assert.equal(assignedReconciliationIssue.item?.assignedTo, TENANT_ADMIN_USER_ID, 'assign action should update assignee');
assert.equal(assignedReconciliationIssue.item?.status, 'investigating', 'assign action should not close issue');
const resolvedReconciliationIssue = await request('/api/commerce/reconciliation/issues/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
issueId: reconciliationIssue.item.id,
action: 'resolve',
resolutionType: 'manual_adjustment',
note: '已生成线下调整凭证',
metadata: { voucherNo: 'VOUCHER-RECON-001' },
},
});
assert.equal(resolvedReconciliationIssue.item?.status, 'resolved', 'resolve action should close issue');
assert.equal(resolvedReconciliationIssue.item?.resolutionType, 'manual_adjustment', 'resolve action should keep resolution type');
assert.equal(resolvedReconciliationIssue.item?.resolvedBy, TENANT_ADMIN_USER_ID, 'resolve action should record resolver');
const reconciliationIssueEvents = await request('/api/commerce/reconciliation/issues/events', {
userId: TENANT_ADMIN_USER_ID,
query: { issueId: reconciliationIssue.item.id },
});
assert.ok(
reconciliationIssueEvents.items?.some(item => item.eventType === 'created' && item.toStatus === 'open'),
'reconciliation issue events should include creation',
);
assert.ok(
reconciliationIssueEvents.items?.some(item => item.eventType === 'start' && item.toStatus === 'investigating'),
'reconciliation issue events should include start transition',
);
assert.ok(
reconciliationIssueEvents.items?.some(item => item.eventType === 'resolve' && item.toStatus === 'resolved'),
'reconciliation issue events should include resolution transition',
);
const crossTenantReconciliationIssueDenied = await request('/api/commerce/reconciliation/issues/events', {
tenantId: PARTNER_TENANT_ID,
userId: TENANT_ADMIN_USER_ID,
query: { issueId: reconciliationIssue.item.id },
expectStatus: 403,
});
assert.equal(crossTenantReconciliationIssueDenied.code, 'TENANT_ADMIN_REQUIRED', 'reconciliation issue events must be tenant isolated');
const crossTenantReconciliationDenied = await request('/api/commerce/reconciliation/items', {
tenantId: PARTNER_TENANT_ID,
userId: TENANT_ADMIN_USER_ID,