优化
This commit is contained in:
@@ -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,
|
||||
|
||||
+1
-1
@@ -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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user