This commit is contained in:
2026-08-12 17:34:03 +08:00
parent b5649cf8ae
commit b3b8d94957
10 changed files with 333 additions and 13 deletions
@@ -86,5 +86,9 @@ public final class ContainerDtos {
@Size(max = 64) String voyageNo,
@NotNull @PositiveOrZero Integer version,
@NotBlank @Size(max = 500) String reason
) {}
) {
public TransportUpdateRequest(String containerNo, String voyageNo, Integer version) {
this(containerNo, voyageNo, version, "运输信息更新");
}
}
}
@@ -65,6 +65,11 @@ public final class LoadingPickDtos {
@NotBlank @Size(max = 64) String inventorySnapshotToken,
@NotEmpty @Size(max = 500) List<@Valid BatchItemRequest> items
) {
@Deprecated
public BatchRequest(Long warehouseId, String containerNo, Integer expectedVersion,
String inventorySnapshotToken, List<BatchItemRequest> items) {
this(warehouseId, null, null, containerNo, expectedVersion, inventorySnapshotToken, items);
}
}
public record BatchItemRequest(
@@ -9,6 +9,12 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
BigDecimal unitWeightKg, BigDecimal unitVolumeCbm) {
this(accountId,inboundId,inboundDetailId,orderId,orderNo,inboundCode,warehouseId,null,null,null,destinationWarehouseCode,null,customerName,goodsName,availablePackageCount,unitWeightKg,unitVolumeCbm);
}
public Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode,
Long warehouseId, Long destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName,
String customerName, String goodsName, Integer availablePackageCount, BigDecimal unitWeightKg, BigDecimal unitVolumeCbm) {
this(accountId,inboundId,inboundDetailId,orderId,orderNo,inboundCode,warehouseId,null,null,destinationWarehouseId,
destinationWarehouseCode,destinationWarehouseName,customerName,goodsName,availablePackageCount,unitWeightKg,unitVolumeCbm);
}
}
public record CreateRequest(@NotNull @Positive Long bookingId, @Size(max=500) String remark) {}
public record AssignRequest(@NotNull @Positive Long inboundId,@NotNull @Positive Long inboundDetailId,
@@ -132,7 +132,7 @@ class Phase3FinalUnifiedSqlContractTest {
.contains("存在缺失中文注释的业务字段")
.doesNotContain("CREATE PROCEDURE upgrade_column_comment");
assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(150);
assertThat(count(sql, "CALL queue_column_comment(")).isEqualTo(2457);
assertThat(count(sql, "CALL queue_column_comment(")).isEqualTo(2459);
assertThat(sql.indexOf("CALL apply_queued_comments();",
sql.indexOf("-- END GENERATED NINE-SCHEMA COMMENT UPGRADE CALLS")))
.isGreaterThan(sql.indexOf("-- END GENERATED NINE-SCHEMA COMMENT UPGRADE CALLS"));
@@ -226,7 +226,7 @@ class LoadingPickServiceTest {
ApiResponse.success(List.of(new WarehousePalletLocationClient.OccupiedResource(
71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 2))));
String snapshotToken = service.inventorySnapshot(20L, 101L).snapshotToken();
BatchRequest request = new BatchRequest(101L, "CSLU1234567", 0, snapshotToken, List.of(
BatchRequest request = new BatchRequest(101L, 10L, 99L, "CSLU1234567", 0, snapshotToken, List.of(
new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null),
new BatchItemRequest(31L, "SKIP", null, null, null, null, 0, "damaged carton")));
@@ -256,7 +256,7 @@ class LoadingPickServiceTest {
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.items(20L)).thenReturn(items);
when(repository.items(20L, 101L)).thenReturn(items);
BatchRequest request = new BatchRequest(101L, "CSLU1234567", 0, "stale-token", List.of(
BatchRequest request = new BatchRequest(101L, 10L, 99L, "CSLU1234567", 0, "stale-token", List.of(
new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null),
new BatchItemRequest(31L, "PARTIAL", null, 4, "AUTO", null, 0, null)));
@@ -280,7 +280,7 @@ class LoadingPickServiceTest {
when(warehouse.occupiedResources(any(), any(Long.class), any(), any(Long.class))).thenReturn(
ApiResponse.success(List.of(new WarehousePalletLocationClient.OccupiedResource(
71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 2))));
BatchRequest request = new BatchRequest(101L, "CSLU1234567", 0, "0".repeat(64), List.of(
BatchRequest request = new BatchRequest(101L, 10L, 99L, "CSLU1234567", 0, "0".repeat(64), List.of(
new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null)));
assertThatThrownBy(() -> service.batch(20L, "batch-inventory-stale", request, 9L, "operator"))
@@ -298,7 +298,7 @@ class LoadingPickServiceTest {
LoadingPickTaskEntity task = task();
task.setVersion(3);
when(repository.taskForUpdate(20L)).thenReturn(task);
BatchRequest request = new BatchRequest(101L, "CSLU1234567", 2, "stale-token", List.of(
BatchRequest request = new BatchRequest(101L, 10L, 99L, "CSLU1234567", 2, "stale-token", List.of(
new BatchItemRequest(30L, "PICK", 2, null, "AUTO", null, 0, null)));
assertThatThrownBy(() -> service.batch(20L, "batch-stale", request, 9L, "operator"))
@@ -334,6 +334,7 @@ class LoadingPickServiceTest {
LoadingPickTaskEntity task = new LoadingPickTaskEntity();
task.setId(20L);
task.setLoadingPlanId(10L);
task.setContainerBookingId(99L);
task.setContainerNo("CSLU1234567");
task.setTaskStatus("LOADING");
task.setVersion(0);
@@ -455,13 +455,14 @@ class SmartLoadingServiceTest {
}
private static Candidate candidate(int boxes) {
return new Candidate(9L, 10L, 20L, 30L, "O", "I", 100L, 200L,
return new Candidate(9L, 10L, 20L, 30L, "O", "I", 100L, "FBA", "FBA渠道", 200L,
"FBA1", "FBA仓", "客户", "货物", boxes, new BigDecimal("2"), new BigDecimal("0.5"));
}
private static ContainerBookingEntity booking() {
ContainerBookingEntity value = new ContainerBookingEntity();
value.setId(99L); value.setWarehouseId(100L); value.setDestinationWarehouseCode("FBA1");
value.setChannelCode("FBA");
value.setContainerNo("MSCU1234567");
value.setCapacityCbm(new BigDecimal("60")); value.setMaxWeightKg(new BigDecimal("20000"));
return value;
@@ -37,7 +37,7 @@ test('所有财务写操作使用 marker 幂等键并上传真实凭证', () =>
assert.match(source, /finance-full-fixtures\.mjs/)
assert.match(source, /context\.createdResources\.financeDemo = matrix/)
assert.match(source, /`source-\$\{role\}-\$\{source\.feeSourceId\}-include`/)
assert.match(source, /`ap-\$\{role\}-\$\{source\.feeSourceId\}-create`/)
assert.match(source, /`ap-\$\{role\}-\$\{source\.feeSourceId\}-\$\{source\.supplierId\}-create`/)
})
test('额外应付审核路径先追加 feeId 再追加 accountId 查询参数', () => {
@@ -49,13 +49,17 @@ test('费用来源使用 operationVersion,应付供应商长整型保持字符
assert.match(source, /expectedVersion: operationVersion\(current\.value\)/)
assert.match(source, /supplierId: id\(source\.supplierId\)/)
assert.doesNotMatch(source, /supplierId: Number\(source\.supplierId\)/)
assert.match(source, /partnerId, partnerName:/)
assert.doesNotMatch(source, /partnerId: Number\(partnerId\)/)
})
test('恢复模式只续补固定报告来源并校验 AR AP 金额状态', () => {
assert.match(source, /process\.env\.FINANCE_DEMO_RECOVERY_REPORT/)
assert.match(source, /恢复报告不是 FIN-DEMO-20260812/)
assert.match(source, /拒绝借历史数据/)
assert.match(source, /supplierId: id\(fixture\.supplier\?\.supplierId\)/)
assert.match(source, /const recoverySupplierId = role === 'apPartial'/)
assert.match(source, /matrix\.payableSettlements\?\.apPaid\?\.supplierId/)
assert.match(source, /pageSize=200/)
assert.match(source, /const payableIds = \['307', '310'\]/)
assert.match(source, /finance-container-partner-not-deployed/)
assert.match(source, /findPayableBySource/)
@@ -79,7 +79,7 @@ async function createExtraPayable(context, fixture, index) {
if (!feeItem || !inboundId || !partnerId) return null
const body = { feeItemId: Number(feeItem.id), direction: 'PAYABLE', businessDate: new Date().toISOString().slice(0, 10),
unitPrice: 90 + index * 10, amount: 90 + index * 10, currency: 'CNY', settlementIncluded: true,
partnerId: Number(partnerId), partnerName: fixture.supplier?.supplierName || null, remark: `${MARKER}-AP-${index}` }
partnerId, partnerName: fixture.supplier?.supplierName || null, remark: `${MARKER}-AP-${index}` }
const basePath = `/api/platform/v1/inbounds/${inboundId}/fee-details`
const createdResponse = await write(context, 'POST', `${basePath}?accountId=10001`, body, `extra-payable-${index}`)
const created = data(createdResponse)
@@ -133,7 +133,7 @@ async function createAp(context, fixture, source, role) {
const response = await write(context, 'POST', `${BASE}/payable-settlements`, {
customerId: 10001, supplierId: id(source.supplierId), feeSourceIds: [Number(source.feeSourceId)],
dueDate: new Date().toISOString().slice(0, 10), currency: 'CNY',
}, `ap-${role}-${source.feeSourceId}-create`)
}, `ap-${role}-${source.feeSourceId}-${source.supplierId}-create`)
const value = data(response)
record(context, `${role} 创建应付结算单`, response, ok(response) && settlementId(value),
`settlementId=${settlementId(value)}, status=${value?.paymentStatus}`)
@@ -141,7 +141,7 @@ async function createAp(context, fixture, source, role) {
}
async function findPayableBySource(context, feeSourceId) {
const listResponse = await context.request('GET', `${BASE}/payable-settlements?customerId=10001`)
const listResponse = await context.request('GET', `${BASE}/payable-settlements?customerId=10001&page=1&pageSize=200`)
for (const item of rows(data(listResponse))) {
const detailResponse = await context.request('GET', `${BASE}/settlements/${settlementId(item)}`)
const value = data(detailResponse)
@@ -205,8 +205,11 @@ async function recoverExistingDemo(context, reportPath) {
}
const approved = await approveAndInclude(context, { feeSourceId: payableIds[index] }, role)
const existing = approved?.statementGenerated === true ? await findPayableBySource(context, payableIds[index]) : null
const recoverySupplierId = role === 'apPartial'
? id(matrix.payableSettlements?.apPaid?.supplierId)
: id(fixture.supplier?.supplierId)
const settlement = existing?.value ?? await createAp(context, fixture, {
feeSourceId: payableIds[index], supplierId: id(fixture.supplier?.supplierId),
feeSourceId: payableIds[index], supplierId: recoverySupplierId,
}, role)
if (existing?.value) record(context, `${role} 回读已有应付结算单`, existing.response, true,
`settlementId=${settlementId(existing.value)}, feeSourceId=${payableIds[index]}`)
@@ -0,0 +1,52 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const here = path.dirname(fileURLToPath(import.meta.url))
const source = fs.readFileSync(path.join(here, 'finance-extended-fees.mjs'), 'utf8')
test('extended fee module is fixed-marker isolated and exports the acceptance entry', () => {
assert.match(source, /FIXED_MARKER = 'FIN-EXT-FEE-20260812'/)
assert.match(source, /export async function run\(context\)/)
assert.match(source, /baseFixture\.marker !== FIXED_MARKER/)
assert.match(source, /createdResources\.financeExtendedFees/)
})
test('extended fee module covers six orders and both automatic billing bases', () => {
assert.match(source, /创建第六张独立订单与入库单/)
assert.match(source, /resources\.length >= 6/)
assert.match(source, /'WEIGHT_KG', 'VOLUME_CBM'/)
assert.match(source, /length >= 2/)
})
test('manual status matrix covers receivable, payable, rejection, approval and void', () => {
for (const name of ['AR-PENDING', 'AR-REJECTED', 'AR-APPROVED', 'AP-PENDING', 'AP-APPROVED', 'AR-VOID']) {
assert.match(source, new RegExp(name))
}
assert.match(source, /expectedFinance: 'AVAILABLE'/)
assert.match(source, /expectedFinance: 'ABSENT'/)
assert.match(source, /作废来源不可选择出账/)
})
test('every source is traced through order, inbound, fee, and Finance pool reads', () => {
assert.match(source, /readOrderInboundAndFee/)
assert.match(source, /financeState/)
assert.match(source, /回读订单/)
assert.match(source, /回读入库单/)
assert.match(source, /回读费用/)
assert.match(source, /回读 Finance 池/)
})
test('writes carry marker idempotency and include replay and stale-version conflict', () => {
assert.match(source, /'Idempotency-Key': key\(action\)/)
assert.match(source, /同键同参人工费用创建幂等重放/)
assert.match(source, /expectedSourceVersion=999/)
assert.match(source, /错误 sourceVersion 调整产生并发版本冲突/)
})
test('uses existing ACTIVE partner and never creates one', () => {
assert.match(source, /\/api\/platform\/v1\/partners\?page=1&size=200&status=ACTIVE/)
assert.doesNotMatch(source, /request\(context, 'POST', '\/api\/platform\/v1\/partners'/)
})
@@ -0,0 +1,244 @@
const MODULE = 'finance-extended-fees'
const OPTIONS = { scenario: 'finance-extended-real-fee-statuses', deploymentBlocker: true }
const FIXED_MARKER = 'FIN-EXT-FEE-20260812'
const BASE = '/api/platform/finance/v1'
function id(value) { return value == null ? '' : String(value) }
function rows(value) { return Array.isArray(value) ? value : Array.isArray(value?.records) ? value.records : [] }
function dateOffset(days) { const value = new Date(); value.setUTCDate(value.getUTCDate() + days); return value.toISOString().slice(0, 10) }
function key(action) { return `${FIXED_MARKER}-${action}`.slice(0, 128) }
function request(context, method, path, body, action) {
return context.request(method, path, body, context.token, { 'Idempotency-Key': key(action) })
}
function blocker(context, name, evidence, response = null) {
context.addStep(MODULE, name, response, false, evidence, OPTIONS)
}
async function createSixthOrder(context) {
const originalMarker = context.marker
const original = context.createdResources.orders
context.marker = `${FIXED_MARKER}-STATUS6`
try {
const orders = await import(new URL('./orders.mjs', import.meta.url))
await orders.run(context)
const resource = context.createdResources.orders
return resource?.marker === context.marker && resource?.orderId && resource?.inboundId ? { ...resource } : null
} finally {
context.marker = originalMarker
if (original) context.createdResources.orders = original
else delete context.createdResources.orders
}
}
async function loadFeeItem(context, direction) {
const response = await context.request('GET', '/api/platform/v1/fee-items?page=1&pageSize=200')
const data = context.apiSuccess(MODULE, `读取 ${direction} ACTIVE 费用项目`, response, '', OPTIONS)
return rows(data).find((item) => item?.status === 'ACTIVE' && (direction === 'RECEIVABLE'
? item.warehouseReceivable === true : item.warehousePayable === true)) || null
}
async function loadActivePartner(context) {
const response = await context.request('GET', '/api/platform/v1/partners?page=1&size=200&status=ACTIVE')
const data = context.apiSuccess(MODULE, '读取现有 ACTIVE 往来单位', response, '', OPTIONS)
return rows(data).find((item) => item?.status === 'ACTIVE') || null
}
async function createFee(context, resource, item, spec, partner) {
const base = `/api/platform/v1/inbounds/${encodeURIComponent(resource.inboundId)}/fee-details`
const path = `${base}?accountId=${encodeURIComponent(resource.tenantId)}`
const body = {
feeItemId: Number(item.id), direction: spec.direction, businessDate: spec.businessDate,
unitPrice: spec.amount, currency: 'CNY', amount: spec.amount, settlementIncluded: true,
partnerId: spec.direction === 'PAYABLE' ? String(partner.id) : null,
partnerName: spec.direction === 'PAYABLE' ? (partner.partnerName || partner.name || null) : null,
remark: `${FIXED_MARKER}-${spec.name}`,
}
const response = await request(context, 'POST', path, body, `create-${spec.name}`)
const detail = context.apiSuccess(MODULE, `创建${spec.label}`, response,
`orderId=${resource.orderId}, inboundId=${resource.inboundId}`, OPTIONS)
const feeId = id(detail?.id)
context.assert(MODULE, `${spec.label}初始状态和归属`, response,
Boolean(feeId) && id(detail?.inboundId) === id(resource.inboundId)
&& detail?.reviewStatus === 'PENDING' && Number(detail?.amount) === spec.amount,
`feeId=${feeId || 'missing'}, review=${detail?.reviewStatus || 'missing'}, amount=${detail?.amount ?? 'missing'}`, OPTIONS)
return feeId ? { ...spec, feeId, base, resource, createResponse: response } : null
}
async function readOrderInboundAndFee(context, fixture) {
const orderResponse = await context.request('GET',
`/api/platform/v1/orders/first-leg/${encodeURIComponent(fixture.resource.orderId)}?tenantId=${encodeURIComponent(fixture.resource.tenantId)}`)
const order = context.apiSuccess(MODULE, `${fixture.label}回读订单`, orderResponse,
`orderId=${fixture.resource.orderId}`, OPTIONS)
const inboundResponse = await context.request('GET',
`/api/platform/inbounds/${encodeURIComponent(fixture.resource.inboundId)}?tenantId=${encodeURIComponent(fixture.resource.tenantId)}`)
const inbound = context.apiSuccess(MODULE, `${fixture.label}回读入库单`, inboundResponse,
`inboundId=${fixture.resource.inboundId}`, OPTIONS)
const feeResponse = await context.request('GET',
`${fixture.base}?accountId=${encodeURIComponent(fixture.resource.tenantId)}`)
const feeRows = context.apiSuccess(MODULE, `${fixture.label}回读费用`, feeResponse, '', OPTIONS)
const fee = rows(feeRows).find((item) => id(item.id) === fixture.feeId)
context.assert(MODULE, `${fixture.label}订单、入库和费用关联一致`, feeResponse,
Boolean(order) && Boolean(inbound) && id(fee?.inboundId) === id(fixture.resource.inboundId),
`order=${Boolean(order)}, inbound=${Boolean(inbound)}, fee=${Boolean(fee)}`, OPTIONS)
return { order, inbound, fee, responses: { orderResponse, inboundResponse, feeResponse } }
}
async function financeState(context, customerId, fixture) {
const response = await context.request('GET',
`${BASE}/fee-pool?customerId=${encodeURIComponent(customerId)}&direction=${fixture.direction}`)
const data = context.apiSuccess(MODULE, `${fixture.label}回读 Finance 池`, response, '', OPTIONS)
const source = rows(data).find((item) => id(item.feeSourceId || item.id) === fixture.feeId)
const shouldExist = fixture.expectedFinance === 'AVAILABLE'
context.assert(MODULE, `${fixture.label} Finance 池状态`, response,
shouldExist ? source?.sourceStatus === 'AVAILABLE' : !source,
`expected=${fixture.expectedFinance}, actual=${source?.sourceStatus || 'ABSENT'}, feeSourceId=${fixture.feeId}`, OPTIONS)
return { response, source: source || null }
}
export async function run(context) {
const start = context.steps.length
const originalMarker = context.marker
context.marker = FIXED_MARKER
try {
const full = await import(new URL('./finance-full-fixtures.mjs', import.meta.url))
await full.run(context)
const baseFixture = context.createdResources.financeFullFixtures
if (!baseFixture || baseFixture.marker !== FIXED_MARKER) {
blocker(context, '生成基础重量/体积/人工费用 fixture',
`financeFullFixtures.marker=${baseFixture?.marker || 'missing'};禁止消费其他 marker`)
return
}
const automatic = baseFixture.feeSources.filter((item) => item.sourceType === 'FBA_PRICING')
for (const basis of ['WEIGHT_KG', 'VOLUME_CBM']) {
context.assert(MODULE, `${basis} 至少两条真实自动费用`, null,
automatic.filter((item) => item.billingBasis === basis).length >= 2,
`count=${automatic.filter((item) => item.billingBasis === basis).length}`, OPTIONS)
}
const sixth = await createSixthOrder(context)
context.assert(MODULE, '创建第六张独立订单与入库单', null, Boolean(sixth),
sixth ? `orderId=${sixth.orderId}, inboundId=${sixth.inboundId}` : 'missing', OPTIONS)
const resources = [...baseFixture.orders.map((item) => ({ ...item, tenantId: baseFixture.customerId })), sixth].filter(Boolean)
context.assert(MODULE, '本轮至少六张订单/入库单且无重复', null,
resources.length >= 6 && new Set(resources.map((item) => id(item.orderId))).size >= 6
&& new Set(resources.map((item) => id(item.inboundId))).size >= 6,
`orders=${resources.length}, uniqueOrders=${new Set(resources.map((item) => id(item.orderId))).size}, uniqueInbounds=${new Set(resources.map((item) => id(item.inboundId))).size}`, OPTIONS)
const receivableItem = await loadFeeItem(context, 'RECEIVABLE')
const payableItem = await loadFeeItem(context, 'PAYABLE')
const partner = await loadActivePartner(context)
if (!receivableItem || !payableItem || !partner?.id) {
blocker(context, '费用状态矩阵前置主数据完整',
`receivableFeeItem=${id(receivableItem?.id) || 'missing'}, payableFeeItem=${id(payableItem?.id) || 'missing'}, activePartner=${id(partner?.id) || 'missing'}`)
return
}
const specs = [
{ name: 'AR-PENDING', label: '手工应收待审核', direction: 'RECEIVABLE', amount: 111, businessDate: dateOffset(-5), action: null, expectedReview: 'PENDING', expectedFinance: 'ABSENT' },
{ name: 'AR-REJECTED', label: '手工应收驳回', direction: 'RECEIVABLE', amount: 112, businessDate: dateOffset(-4), action: 'reject', expectedReview: 'REJECTED', expectedFinance: 'ABSENT' },
{ name: 'AR-APPROVED', label: '手工应收已审核待出账', direction: 'RECEIVABLE', amount: 113, businessDate: dateOffset(-3), action: 'approve', expectedReview: 'APPROVED', expectedFinance: 'AVAILABLE' },
{ name: 'AP-PENDING', label: '手工应付待审核', direction: 'PAYABLE', amount: 81, businessDate: dateOffset(-2), action: null, expectedReview: 'PENDING', expectedFinance: 'ABSENT' },
{ name: 'AP-APPROVED', label: '手工应付已审核', direction: 'PAYABLE', amount: 82, businessDate: dateOffset(-1), action: 'approve', expectedReview: 'APPROVED', expectedFinance: 'AVAILABLE' },
{ name: 'AR-VOID', label: '手工应收作废', direction: 'RECEIVABLE', amount: 114, businessDate: dateOffset(-6), action: 'approve-void', expectedReview: 'APPROVED', expectedFinance: 'ABSENT' },
]
const fixtures = []
for (const [index, spec] of specs.entries()) {
const resource = resources[index % resources.length]
const fixture = await createFee(context, resource,
spec.direction === 'RECEIVABLE' ? receivableItem : payableItem, spec, partner)
if (!fixture) continue
if (spec.action === 'reject') {
const response = await request(context, 'POST', `${fixture.base}/${fixture.feeId}/reject?accountId=${encodeURIComponent(resource.tenantId)}`,
{ reason: FIXED_MARKER }, `reject-${spec.name}`)
context.apiSuccess(MODULE, `${spec.label}执行驳回`, response, '', OPTIONS)
} else if (spec.action?.startsWith('approve')) {
const response = await request(context, 'POST', `${fixture.base}/${fixture.feeId}/approve?accountId=${encodeURIComponent(resource.tenantId)}`,
undefined, `approve-${spec.name}`)
context.apiSuccess(MODULE, `${spec.label}执行审核`, response, '', OPTIONS)
if (spec.action === 'approve-void') {
const voidResponse = await request(context, 'POST', `${fixture.base}/${fixture.feeId}/void?accountId=${encodeURIComponent(resource.tenantId)}`,
{ expectedSourceVersion: 1 }, `void-${spec.name}`)
context.apiSuccess(MODULE, `${spec.label}执行作废`, voidResponse, '', OPTIONS)
}
}
fixtures.push(fixture)
}
const conflictTarget = fixtures.find((item) => item.name === 'AR-APPROVED')
if (conflictTarget) {
const adjustBody = { feeItemId: Number(receivableItem.id), direction: 'RECEIVABLE', businessDate: conflictTarget.businessDate,
unitPrice: 115, currency: 'CNY', amount: 115, settlementIncluded: true,
partnerId: null, partnerName: null, remark: `${FIXED_MARKER}-CONFLICT` }
const conflict = await request(context, 'POST',
`${conflictTarget.base}/${conflictTarget.feeId}/adjust?accountId=${encodeURIComponent(conflictTarget.resource.tenantId)}&expectedSourceVersion=999`,
adjustBody, 'stale-adjust')
context.assert(MODULE, '错误 sourceVersion 调整产生并发版本冲突', conflict,
conflict?.body?.code !== '200',
`code=${conflict?.body?.code || 'missing'}, requestId=${conflict?.requestId || 'missing'}, traceId=${conflict?.traceId || 'missing'}`, OPTIONS)
}
const replayTarget = fixtures.find((item) => item.name === 'AR-PENDING')
if (replayTarget) {
const replayPath = `${replayTarget.base}?accountId=${encodeURIComponent(replayTarget.resource.tenantId)}`
const replayBody = { feeItemId: Number(receivableItem.id), direction: 'RECEIVABLE', businessDate: replayTarget.businessDate,
unitPrice: 116, currency: 'CNY', amount: 116, settlementIncluded: true,
partnerId: null, partnerName: null, remark: `${FIXED_MARKER}-IDEMPOTENT` }
const first = await request(context, 'POST', replayPath, replayBody, 'idempotent-create')
const second = await request(context, 'POST', replayPath, replayBody, 'idempotent-create')
const firstData = first?.body?.data; const secondData = second?.body?.data
context.assert(MODULE, '同键同参人工费用创建幂等重放', second,
first?.body?.code === '200' && second?.body?.code === '200' && id(firstData?.id) === id(secondData?.id),
`first=${id(firstData?.id) || 'missing'}, replay=${id(secondData?.id) || 'missing'}, requestId=${second?.requestId || 'missing'}`, OPTIONS)
}
const statusMatrix = []
for (const fixture of fixtures) {
const read = await readOrderInboundAndFee(context, fixture)
context.assert(MODULE, `${fixture.label}最终审核状态`, read.responses.feeResponse,
read.fee?.reviewStatus === fixture.expectedReview,
`expected=${fixture.expectedReview}, actual=${read.fee?.reviewStatus || 'missing'}, publication=${read.fee?.publicationStatus || 'missing'}`, OPTIONS)
const finance = await financeState(context, baseFixture.customerId, fixture)
statusMatrix.push({ name: fixture.name, label: fixture.label, orderId: id(fixture.resource.orderId),
orderNo: fixture.resource.orderNo || null, inboundId: id(fixture.resource.inboundId), feeId: fixture.feeId,
direction: fixture.direction, businessDate: fixture.businessDate, amount: fixture.amount,
reviewStatus: read.fee?.reviewStatus || null, publicationStatus: read.fee?.publicationStatus || null,
financeState: finance.source?.sourceStatus || 'ABSENT', requestId: finance.response?.requestId || null,
traceId: finance.response?.traceId || null })
}
const voided = fixtures.find((item) => item.name === 'AR-VOID')
if (voided) {
const create = await request(context, 'POST', `${BASE}/statements`, {
customerId: String(baseFixture.customerId), currency: 'CNY', generationMode: 'SELECTED',
feeSourceIds: [voided.feeId], periodStart: voided.businessDate, periodEnd: voided.businessDate,
remark: `${FIXED_MARKER}-VOID-STATEMENT`,
}, 'voided-statement')
context.assert(MODULE, '作废来源不可选择出账', create, create?.body?.code !== '200',
`code=${create?.body?.code || 'missing'}, requestId=${create?.requestId || 'missing'}, traceId=${create?.traceId || 'missing'}`, OPTIONS)
}
const orderFeeCounts = new Map()
for (const item of [...baseFixture.feeSources, ...fixtures]) {
if (!item.orderId) continue
orderFeeCounts.set(id(item.orderId), (orderFeeCounts.get(id(item.orderId)) || 0) + 1)
}
context.assert(MODULE, '至少一张订单包含多个费用项目', null,
[...orderFeeCounts.values()].some((count) => count >= 2),
`maxFeeCount=${Math.max(0, ...orderFeeCounts.values())}`, OPTIONS)
context.assert(MODULE, '状态矩阵覆盖多个业务日期', null,
new Set(statusMatrix.map((item) => item.businessDate)).size >= 4,
`dates=${[...new Set(statusMatrix.map((item) => item.businessDate))].join(',')}`, OPTIONS)
context.createdResources.financeExtendedFees = {
marker: FIXED_MARKER, customerId: id(baseFixture.customerId),
orders: resources.map((item) => ({ orderId: id(item.orderId), orderNo: item.orderNo || null, inboundId: id(item.inboundId) })),
automaticFees: automatic, statusMatrix,
baseDeploymentBlockers: baseFixture.deploymentBlocker || [],
}
} finally {
context.marker = originalMarker
const steps = context.steps.slice(start)
context.scenarios.push({ name: OPTIONS.scenario, module: MODULE,
success: steps.every((step) => step.passed), deploymentBlocked: steps.some((step) => step.deploymentBlocker),
passed: steps.filter((step) => step.passed).length, failed: steps.filter((step) => !step.passed).length })
}
}