feat: add commerce adjustment operations

This commit is contained in:
Codex
2026-06-29 22:28:19 +08:00
parent f3577e257c
commit 9b17f25dd9
12 changed files with 1444 additions and 12 deletions

View File

@@ -2541,6 +2541,180 @@ async function testCommerce() {
});
assert.equal(crossTenantProviderBillJobsDenied.code, 'TENANT_ADMIN_REQUIRED', 'provider bill jobs must be tenant isolated');
const operationReconItems = await request('/api/commerce/reconciliation/items', {
userId: TENANT_ADMIN_USER_ID,
query: { batchId: reconciliationImport.item.id, matchStatus: 'amount_mismatch' },
});
const operationReconItem = operationReconItems.items?.find(item => item.matchStatus === 'amount_mismatch');
assert.ok(operationReconItem?.id, 'operation anomaly test should have an open actionable reconciliation item');
const operationIssue = await request('/api/commerce/reconciliation/issues/create', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
itemId: operationReconItem.id,
summary: '运营台展示未关闭对账差错',
note: '保持 open 供运营台测试',
metadata: { source: 'operation-anomaly-test' },
},
});
assert.equal(operationIssue.item?.status, 'open', 'operation anomaly issue should remain open');
const studentOperationsDenied = await request('/api/commerce/operations/anomalies', {
expectStatus: 403,
});
assert.equal(studentOperationsDenied.code, 'TENANT_ADMIN_REQUIRED', 'students must not access finance operation anomalies');
const operationAnomalies = await request('/api/commerce/operations/anomalies', {
userId: TENANT_ADMIN_USER_ID,
query: { provider: 'manual' },
});
assert.ok(
operationAnomalies.items?.some(item => item.type === 'reconciliation_issue' && item.id === operationIssue.item.id),
'operation anomaly console should surface open reconciliation issues',
);
const voucherSourceOrderBefore = await request('/api/commerce/orders/status', {
query: { orderNo: reconMissingProviderOrder.item.orderNo },
});
const voucherSourcePaymentBefore = voucherSourceOrderBefore.item?.payment;
const voucherEntitlementsBefore = await request('/api/commerce/entitlements', {});
const studentAdjustmentDenied = await request('/api/commerce/adjustment-vouchers', {
method: 'POST',
body: {
reconciliationIssueId: reconciliationIssue.item.id,
title: 'student should not create adjustment voucher',
},
expectStatus: 403,
});
assert.equal(studentAdjustmentDenied.code, 'TENANT_ADMIN_REQUIRED', 'students must not create adjustment vouchers');
const adjustmentVoucherNo = `ADJ-INTEGRATION-${Date.now()}`;
const adjustmentVoucher = await request('/api/commerce/adjustment-vouchers', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
voucherNo: adjustmentVoucherNo,
reconciliationIssueId: reconciliationIssue.item.id,
adjustmentType: 'write_off',
direction: 'decrease',
amountCents: reconMissingProviderOrder.item.amountCents,
title: '供应商缺失账单人工核销凭证',
description: '仅作为财务复核证据,不直接修改订单状态',
externalUrl: 'https://finance.example.test/proofs/adj-integration-001',
metadata: { source: 'api-integration-test' },
},
});
assert.ok(adjustmentVoucher.item?.id, 'tenant admin should create adjustment voucher');
assert.equal(adjustmentVoucher.item?.status, 'submitted', 'adjustment voucher should default to submitted');
assert.equal(adjustmentVoucher.item?.reconciliationIssueId, reconciliationIssue.item.id, 'voucher should link reconciliation issue');
assert.equal(adjustmentVoucher.item?.orderNo, reconMissingProviderOrder.item.orderNo, 'voucher should copy order identity from issue');
const duplicateAdjustmentVoucher = await request('/api/commerce/adjustment-vouchers', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
voucherNo: adjustmentVoucherNo,
reconciliationIssueId: reconciliationIssue.item.id,
title: 'duplicate voucher number should be rejected',
},
expectStatus: 409,
});
assert.equal(duplicateAdjustmentVoucher.code, 'ADJUSTMENT_VOUCHER_DUPLICATE', 'duplicate voucher number should return business conflict');
const invalidAdjustmentSource = await request('/api/commerce/adjustment-vouchers', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
reconciliationIssueId: 'not-a-uuid',
title: 'invalid source id should be rejected',
},
expectStatus: 400,
});
assert.equal(invalidAdjustmentSource.code, 'INVALID_UUID', 'adjustment voucher should reject malformed source UUIDs');
const listedAdjustmentVouchers = await request('/api/commerce/adjustment-vouchers', {
userId: TENANT_ADMIN_USER_ID,
query: { status: 'submitted', orderNo: reconMissingProviderOrder.item.orderNo },
});
assert.ok(
listedAdjustmentVouchers.items?.some(item => item.id === adjustmentVoucher.item.id),
'adjustment voucher list should filter submitted vouchers by order number',
);
const adjustmentReport = await request('/api/commerce/adjustment-vouchers/report', {
userId: TENANT_ADMIN_USER_ID,
query: { startDate: billDate, endDate: billDate },
});
assert.ok(adjustmentReport.item?.totalCount >= 1, 'adjustment voucher report should include created voucher');
assert.ok(
adjustmentReport.item?.byStatus?.some(item => item.status === 'submitted' && item.count >= 1),
'adjustment voucher report should aggregate by status',
);
const approvedAdjustmentVoucher = await request('/api/commerce/adjustment-vouchers/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
voucherId: adjustmentVoucher.item.id,
status: 'approved',
reviewNote: '财务复核通过,仅保留审计凭证',
metadata: { reviewedFrom: 'api-integration-test' },
},
});
assert.equal(approvedAdjustmentVoucher.item?.status, 'approved', 'reviewer should approve submitted adjustment voucher');
assert.equal(approvedAdjustmentVoucher.item?.reviewedBy, TENANT_ADMIN_USER_ID, 'voucher approval should record reviewer');
const adjustmentVoucherEvents = await request('/api/commerce/adjustment-vouchers/events', {
userId: TENANT_ADMIN_USER_ID,
query: { voucherId: adjustmentVoucher.item.id },
});
assert.ok(
adjustmentVoucherEvents.items?.some(item => item.eventType === 'submitted' && item.toStatus === 'submitted'),
'voucher events should include submit event',
);
assert.ok(
adjustmentVoucherEvents.items?.some(item => item.eventType === 'approved' && item.toStatus === 'approved'),
'voucher events should include approval event',
);
const closedVoucherChangeDenied = await request('/api/commerce/adjustment-vouchers/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
voucherId: adjustmentVoucher.item.id,
status: 'rejected',
reviewNote: 'closed voucher should not flip to rejected',
},
expectStatus: 409,
});
assert.equal(closedVoucherChangeDenied.code, 'ADJUSTMENT_VOUCHER_CLOSED', 'closed adjustment voucher must be immutable');
const crossTenantVoucherDenied = await request('/api/commerce/adjustment-vouchers', {
tenantId: PARTNER_TENANT_ID,
userId: TENANT_ADMIN_USER_ID,
query: { voucherNo: adjustmentVoucherNo },
expectStatus: 403,
});
assert.equal(crossTenantVoucherDenied.code, 'TENANT_ADMIN_REQUIRED', 'adjustment vouchers must be tenant isolated');
const voucherSourceOrderAfter = await request('/api/commerce/orders/status', {
query: { orderNo: reconMissingProviderOrder.item.orderNo },
});
const voucherEntitlementsAfter = await request('/api/commerce/entitlements', {});
assert.equal(voucherSourceOrderAfter.item?.status, voucherSourceOrderBefore.item?.status, 'adjustment voucher approval must not mutate order status');
assert.equal(
voucherSourceOrderAfter.item?.payment?.status,
voucherSourcePaymentBefore?.status,
'adjustment voucher approval must not mutate payment status',
);
assert.equal(
voucherEntitlementsAfter.items?.length,
voucherEntitlementsBefore.items?.length,
'adjustment voucher approval must not grant or revoke entitlements',
);
const fakeWechatPay = await startFakeWechatPayServer();
const wechatAccount = await request('/api/tenant-admin/payment-accounts', {
userId: TENANT_ADMIN_USER_ID,
@@ -6210,6 +6384,7 @@ async function testTenantMemberPermissionsAndAudit() {
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'badges:grant'), 'permission matrix should expose badge grant permission');
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'tenant:reconciliation:read'), 'permission matrix should expose reconciliation read permission');
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'tenant:reconciliation:write'), 'permission matrix should expose reconciliation write permission');
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'tenant:reconciliation:review'), 'permission matrix should expose reconciliation review permission');
assert.ok(permissionMatrix.menuGroups?.some(item => item.key === 'sales'), 'permission matrix should expose menu groups');
assert.ok(permissionMatrix.roleDefaults?.tenant_operator?.includes('dashboard:read'), 'tenant operator defaults should include dashboard read');
assert.ok(permissionMatrix.roleDefaults?.tenant_operator?.includes('marketing:*'), 'permission matrix should include role defaults');