diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index c2240a6c0..d6e427cc7 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -165,7 +165,7 @@ node --test scripts/admin-test-env-modules/suppliers-contract.test.mjs ## 国内整柜与客户运营统计 -- `domestic-container.mjs`:整柜入库、出库、库存列表和详情;静态契约为 `domestic-container-contract.test.mjs`。 +- `domestic-container.mjs`:整柜入库、出库、库存列表和详情,并覆盖字段边界、组合筛选与分页汇总、柜号标准化、同柜追加入库、货名冲突、完成出库强制库存版本、并发版本竞争和幂等重放;静态契约为 `domestic-container-contract.test.mjs`。 - `customer-operating-statistics.mjs`:客户详情在途柜量/TEU 运营指标,复用 `platform:firstleg:container:list` 权限。 - 两者由总入口 `admin-test-env-acceptance.mjs` 的默认模块列表编排;接口验收会写入测试数据,静态合同可离线执行。 diff --git a/scripts/admin-test-env-modules/domestic-container-contract.test.mjs b/scripts/admin-test-env-modules/domestic-container-contract.test.mjs index 510f78a85..229acd3dc 100644 --- a/scripts/admin-test-env-modules/domestic-container-contract.test.mjs +++ b/scripts/admin-test-env-modules/domestic-container-contract.test.mjs @@ -28,8 +28,9 @@ test('覆盖国内仓、待单、完成入库、部分和全部出库及列表 test('在创建测试数据前覆盖无筛选整柜入库列表', () => { const unfilteredList = '`${BASE}/inbounds?page=1&size=20`' + const runSource = moduleSource.slice(moduleSource.indexOf('export async function run(context)')) assert.match(moduleSource, /无筛选查询整柜入库单列表/) - assert.ok(moduleSource.indexOf(unfilteredList) < moduleSource.indexOf("api(context, 'POST', `${BASE}/inbounds`")) + assert.ok(runSource.indexOf(unfilteredList) < runSource.indexOf("api(context, 'POST', `${BASE}/inbounds`")) }) test('覆盖库存不足、版本冲突及两类幂等行为', () => { @@ -46,3 +47,20 @@ test('覆盖权限拒绝与 requestId traceId', () => { assert.match(moduleSource, /Boolean\(response\?\.requestId\).*Boolean\(response\?\.traceId\)/s) assert.match(moduleSource, /status: 'DISABLED'/) }) + +test('扩展覆盖字段边界、组合筛选、标准化、分页汇总和详情不存在', () => { + for (const token of ['集装箱号格式非法被拒绝', '零箱入库被拒绝', '零体积入库被拒绝', + '非法入库状态被拒绝', '空幂等键被拒绝', '超长备注被拒绝', '非法入库分页大小被拒绝', + '非法库存状态筛选被拒绝', '同仓同柜同货名追加入库', '同仓同柜不同货名禁止合并库存', + '筛选汇总不受分页大小影响且柜号已标准化', '入库单筛选总数与分页记录正确', + '不存在的整柜库存详情返回未找到']) assert.match(moduleSource, new RegExp(token)) +}) + +test('扩展覆盖强制库存版本、并发竞争和成功请求幂等重放', () => { + assert.match(moduleSource, /完成出库必须携带库存版本/) + assert.match(moduleSource, /缺失库存版本的请求不得扣减库存/) + assert.match(moduleSource, /Promise\.all\(raceBodies\.map/) + assert.match(moduleSource, /同一库存版本并发出库仅一个成功/) + assert.match(moduleSource, /并发出库只扣减一次并只新增一条流水/) + assert.match(moduleSource, /并发成功请求重放不重复扣减/) +}) diff --git a/scripts/admin-test-env-modules/domestic-container.mjs b/scripts/admin-test-env-modules/domestic-container.mjs index 15bfbaacf..b058909db 100644 --- a/scripts/admin-test-env-modules/domestic-container.mjs +++ b/scripts/admin-test-env-modules/domestic-container.mjs @@ -20,6 +20,102 @@ async function api(context, method, path, body, token = context.token) { return response } +async function verifyValidationBoundaries(context, warehouseId, marker, now) { + const valid = { + warehouseId, cargoName: marker, containerNo: `DCV${marker}`.slice(0, 32).toUpperCase(), boxCount: 1, + volumeCbm: 1, inboundTime: now, status: 'COMPLETED', remark: marker, + } + const cases = [ + ['集装箱号格式非法被拒绝', { ...valid, containerNo: 'bad!', idempotencyKey: `${marker}-invalid-container` }], + ['零箱入库被拒绝', { ...valid, boxCount: 0, idempotencyKey: `${marker}-zero-boxes` }], + ['零体积入库被拒绝', { ...valid, volumeCbm: 0, idempotencyKey: `${marker}-zero-volume` }], + ['非法入库状态被拒绝', { ...valid, status: 'UNKNOWN', idempotencyKey: `${marker}-invalid-status` }], + ['空幂等键被拒绝', { ...valid, idempotencyKey: ' ' }], + ['超长备注被拒绝', { ...valid, remark: 'R'.repeat(501), idempotencyKey: `${marker}-long-remark` }], + ] + for (const [label, body] of cases) { + const response = await api(context, 'POST', `${BASE}/inbounds`, body) + expectCode(context, label, response, 'A0422') + } + expectCode(context, '非法入库分页大小被拒绝', + await api(context, 'GET', `${BASE}/inbounds?page=1&size=201`), 'A0422') + expectCode(context, '非法库存状态筛选被拒绝', + await api(context, 'GET', `${BASE}/inventories?page=1&size=20&status=UNKNOWN`), 'A0422') +} + +async function verifyExtendedInventoryScenarios(context, warehouseId, marker, now) { + const containerNo = `DCX${marker}`.slice(0, 32).toUpperCase() + const cargoName = `${marker}-extended`.slice(0, 120) + const firstBody = { warehouseId, cargoName, containerNo, boxCount: 40, volumeCbm: 20, + inboundTime: now, status: 'COMPLETED', remark: `${marker}-extended-1`, idempotencyKey: `${marker}-extended-1` } + context.apiSuccess(MODULE, '扩展场景首次完成入库', await api(context, 'POST', `${BASE}/inbounds`, firstBody)) + context.apiSuccess(MODULE, '同仓同柜同货名追加入库', await api(context, 'POST', `${BASE}/inbounds`, { + ...firstBody, cargoName: ` ${cargoName} `, containerNo: containerNo.toLowerCase(), boxCount: 10, volumeCbm: 5, + remark: `${marker}-extended-2`, idempotencyKey: `${marker}-extended-2`, + })) + expectCode(context, '同仓同柜不同货名禁止合并库存', await api(context, 'POST', `${BASE}/inbounds`, { + ...firstBody, cargoName: `${cargoName}-other`, boxCount: 1, volumeCbm: 1, + idempotencyKey: `${marker}-cargo-conflict`, + }), 'A0409') + + const filteredResponse = await api(context, 'GET', `${BASE}/inventories?page=1&size=1&warehouseId=${warehouseId}&containerNo=${containerNo.toLowerCase()}&cargoName=${encodeURIComponent(cargoName)}&status=IN_STOCK`) + const filtered = context.apiSuccess(MODULE, '组合条件筛选扩展库存', filteredResponse) + const inventory = filtered?.records?.[0] + context.assert(MODULE, '筛选汇总不受分页大小影响且柜号已标准化', filteredResponse, + Number(filtered?.total) === 1 && filtered?.records?.length === 1 && inventory?.containerNo === containerNo + && Number(filtered?.summary?.inboundBoxes) === 50 && Number(filtered?.summary?.availableBoxes) === 50 + && Number(filtered?.summary?.inboundVolumeCbm) === 25, + `total=${filtered?.total}, records=${filtered?.records?.length}, containerNo=${inventory?.containerNo}, summary=${JSON.stringify(filtered?.summary)}`) + if (!inventory?.id) throw new Error('扩展库存未创建成功') + + const inboundListResponse = await api(context, 'GET', `${BASE}/inbounds?page=1&size=1&warehouseId=${warehouseId}&containerNo=${containerNo}&cargoName=${encodeURIComponent(cargoName)}&status=COMPLETED`) + const inboundList = context.apiSuccess(MODULE, '组合条件筛选扩展入库单', inboundListResponse) + context.assert(MODULE, '入库单筛选总数与分页记录正确', inboundListResponse, + Number(inboundList?.total) === 2 && inboundList?.records?.length === 1 + && inboundList.records[0]?.status === 'COMPLETED' && inboundList.records[0]?.businessType === 'INBOUND', + `total=${inboundList?.total}, records=${inboundList?.records?.length}, status=${inboundList?.records?.[0]?.status}`) + + const beforeMissingVersion = await api(context, 'GET', `${BASE}/inventories/${inventory.id}`) + const beforeDetail = context.apiSuccess(MODULE, '缺失版本测试前读取库存详情', beforeMissingVersion) + expectCode(context, '完成出库必须携带库存版本', await api(context, 'POST', `${BASE}/outbounds`, { + warehouseId, containerNo, boxCount: 1, outboundTime: now, status: 'COMPLETED', + idempotencyKey: `${marker}-missing-version`, + }), 'A0422') + const afterMissingVersion = context.apiSuccess(MODULE, '缺失版本请求后回查库存', + await api(context, 'GET', `${BASE}/inventories/${inventory.id}`)) + context.assert(MODULE, '缺失库存版本的请求不得扣减库存', beforeMissingVersion, + Number(afterMissingVersion?.inventory?.availableBoxes) === Number(beforeDetail?.inventory?.availableBoxes), + `before=${beforeDetail?.inventory?.availableBoxes}, after=${afterMissingVersion?.inventory?.availableBoxes}`) + + const currentVersion = Number(afterMissingVersion?.inventory?.version) + const availableBeforeRace = Number(afterMissingVersion?.inventory?.availableBoxes) + const raceBodies = [1, 2].map((sequence) => ({ warehouseId, containerNo, boxCount: 7, outboundTime: now, + status: 'COMPLETED', inventoryVersion: currentVersion, idempotencyKey: `${marker}-race-${sequence}` })) + const raceResponses = await Promise.all(raceBodies.map((body) => context.request('POST', `${BASE}/outbounds`, body, context.token))) + raceResponses.forEach((response, index) => traced(context, `并发出库请求${index + 1}`, response)) + const winners = raceResponses.filter(succeeded) + const conflicts = raceResponses.filter((response) => response?.http === 200 && String(response?.body?.code) === 'A0409') + context.assert(MODULE, '同一库存版本并发出库仅一个成功', raceResponses[0], winners.length === 1 && conflicts.length === 1, + `success=${winners.length}, conflict=${conflicts.length}`) + const afterRaceResponse = await api(context, 'GET', `${BASE}/inventories/${inventory.id}`) + const afterRace = context.apiSuccess(MODULE, '并发出库后回查库存', afterRaceResponse) + context.assert(MODULE, '并发出库只扣减一次并只新增一条流水', afterRaceResponse, + Number(afterRace?.inventory?.availableBoxes) === availableBeforeRace - 7 + && afterRace?.transactions?.length === 3 && Number(afterRace?.outboundOrderCount) === 1, + `before=${availableBeforeRace}, after=${afterRace?.inventory?.availableBoxes}, transactions=${afterRace?.transactions?.length}, outboundOrders=${afterRace?.outboundOrderCount}`) + const winnerIndex = raceResponses.findIndex(succeeded) + if (winnerIndex >= 0) { + const firstWinner = raceResponses[winnerIndex]?.body?.data + const replay = await api(context, 'POST', `${BASE}/outbounds`, raceBodies[winnerIndex]) + const replayData = context.apiSuccess(MODULE, '并发成功请求使用原幂等键重放', replay) + context.assert(MODULE, '并发成功请求重放不重复扣减', replay, + replayData?.replayed === true && id(replayData?.id) === id(firstWinner?.id), + `replayed=${replayData?.replayed}, firstId=${id(firstWinner?.id)}, replayId=${id(replayData?.id)}`) + } + expectCode(context, '不存在的整柜库存详情返回未找到', + await api(context, 'GET', `${BASE}/inventories/1`), 'A0404') +} + export async function run(context) { const marker = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-18) const initialInboundsResponse = await api(context, 'GET', `${BASE}/inbounds?page=1&size=20`) @@ -37,6 +133,8 @@ export async function run(context) { const containerNo = `DCA${marker}`.slice(0, 32).toUpperCase() const now = new Date().toISOString().slice(0, 19) + await verifyValidationBoundaries(context, warehouseId, marker, now) + const invalidWarehouse = await api(context, 'POST', `${BASE}/inbounds`, { warehouseId: '1', cargoName: marker, containerNo: `${containerNo}X`, boxCount: 1, volumeCbm: 1, inboundTime: now, status: 'COMPLETED', remark: marker, idempotencyKey: `${marker}-invalid-warehouse`, @@ -122,6 +220,8 @@ export async function run(context) { inboundRows?.records?.length === 2 && outboundRows?.records?.length === 3, `inbounds=${inboundRows?.records?.length}, outbounds=${outboundRows?.records?.length}`) + await verifyExtendedInventoryScenarios(context, warehouseId, marker, now) + await verifyPermissionDenial(context, marker, warehouseId, containerNo) context.createdResources.domesticContainer = { warehouseId, inventoryId, containerNo, permissions: PERMISSIONS } } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/service/DomesticContainerService.java b/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/service/DomesticContainerService.java index edef96c24..2f692993d 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/service/DomesticContainerService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/service/DomesticContainerService.java @@ -81,7 +81,8 @@ public class DomesticContainerService { DomesticContainerInventoryEntity inventory = repository.lockInventory(warehouse.getId(), normalize(request.containerNo())); if (inventory == null) throw new BizException(ResultCode.NOT_FOUND, "整柜库存不存在"); if ("COMPLETED".equals(request.status())) { - if (request.inventoryVersion() != null && !request.inventoryVersion().equals(inventory.getVersion())) throw new BizException(ResultCode.CONFLICT, "库存已被修改,请刷新后重试"); + if (request.inventoryVersion() == null) throw new BizException(ResultCode.VALIDATION_FAILED, "完成出库必须提供库存版本"); + if (!request.inventoryVersion().equals(inventory.getVersion())) throw new BizException(ResultCode.CONFLICT, "库存已被修改,请刷新后重试"); if (request.boxCount() > inventory.getAvailableBoxes()) throw new BizException(ResultCode.CONFLICT, "出库箱数超过可用库存"); } DomesticContainerOrderEntity order = new DomesticContainerOrderEntity(); order.setId(IdWorker.getId()); order.setTenantId(PLATFORM_TENANT); order.setBusinessType("OUTBOUND"); diff --git a/warehouse/src/main/resources/openapi/domestic-container-v1.yaml b/warehouse/src/main/resources/openapi/domestic-container-v1.yaml index a8a85fae5..d3d8de103 100644 --- a/warehouse/src/main/resources/openapi/domestic-container-v1.yaml +++ b/warehouse/src/main/resources/openapi/domestic-container-v1.yaml @@ -74,7 +74,7 @@ components: status: { type: string, enum: [PENDING, COMPLETED] } remark: { type: string, maxLength: 500 } idempotencyKey: { type: string, maxLength: 128 } - inventoryVersion: { type: integer, minimum: 0 } + inventoryVersion: { type: integer, minimum: 0, description: 完成出库时必填;待出库不扣减库存时可不传 } ApiEnvelope: type: object required: [code, message, timestamp] diff --git a/warehouse/src/test/java/com/baiyue/warehouse/domesticcontainer/service/DomesticContainerServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/domesticcontainer/service/DomesticContainerServiceTest.java index 7fcd2dc56..bbaba8358 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/domesticcontainer/service/DomesticContainerServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/domesticcontainer/service/DomesticContainerServiceTest.java @@ -1,6 +1,7 @@ package com.baiyue.warehouse.domesticcontainer.service; import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.api.ResultCode; import com.baiyue.warehouse.domesticcontainer.dto.DomesticContainerDtos; import com.baiyue.warehouse.domesticcontainer.entity.DomesticContainerIdempotencyEntity; import com.baiyue.warehouse.domesticcontainer.entity.DomesticContainerInventoryEntity; @@ -76,6 +77,24 @@ class DomesticContainerServiceTest { verify(repository, never()).insertOrder(any()); } + @Test + void completedOutboundRequiresInventoryVersionBeforeWriting() { + when(warehouses.findActiveOwned(0L, 10L)).thenReturn(warehouse()); + when(repository.claim(anyLong(), eq("OUTBOUND"), eq("out-no-version"), any())).thenReturn(1); + DomesticContainerInventoryEntity inventory = inventory(120, 0, 120, 3); + when(repository.lockInventory(10L, "MSCU1234567")).thenReturn(inventory); + + assertThatThrownBy(() -> service.createOutbound(new DomesticContainerDtos.CreateOutboundRequest(10L, + "MSCU1234567", 1, LocalDateTime.now(), "COMPLETED", null, "out-no-version", null))) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.VALIDATION_FAILED); + assertThat(inventory.getAvailableBoxes()).isEqualTo(120); + verify(repository, never()).insertOrder(any()); + verify(repository, never()).updateInventory(any()); + verify(repository, never()).insertTransaction(any()); + } + @Test void replaysCompletedIdempotentRequestWithoutWriting() { DomesticContainerIdempotencyEntity replay = new DomesticContainerIdempotencyEntity();