From 575cee7a866a04fadb504ae7fee9e05ea8433f5b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 11:38:53 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PlatformContainerController.java | 3 +- .../ContainerTransportHistoryEntity.java | 2 + .../ContainerTransportHistoryMapper.java | 9 ++- .../firstleg/service/ContainerService.java | 68 +++++++++++++++++-- .../inbound-measurement.mjs | 18 +---- .../it-test-wait-split-bulk-contract.test.mjs | 8 +++ 6 files changed, 85 insertions(+), 23 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java index cfbb44d48..dc5c82047 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java @@ -114,9 +114,10 @@ public class PlatformContainerController { @Operation(summary="修改集装箱号和航次", description="PDA拣货中仅开放此白名单;装柜完成后全部只读") public ApiResponse updateTransport(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, + @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody TransportUpdateRequest request) { FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.updateTransport(id, user.userId(), user.username(), request)); + return ApiResponse.success(service.updateTransport(id, user.userId(), user.username(), key, request)); } @GetMapping("/{id}/clearance") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerTransportHistoryEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerTransportHistoryEntity.java index d27b4782b..4b98dfa1f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerTransportHistoryEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerTransportHistoryEntity.java @@ -16,6 +16,8 @@ public class ContainerTransportHistoryEntity { private String afterContainerNo; private String beforeVoyageNo; private String afterVoyageNo; + private String operationId; + private String requestHash; private String reason; private Long operatorId; private String operatorName; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerTransportHistoryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerTransportHistoryMapper.java index 45d31755f..380fefbf7 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerTransportHistoryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerTransportHistoryMapper.java @@ -3,6 +3,13 @@ package com.baiyue.firstleg.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.ContainerTransportHistoryEntity; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; @Mapper -public interface ContainerTransportHistoryMapper extends BaseMapper {} +public interface ContainerTransportHistoryMapper extends BaseMapper { + @Select("SELECT * FROM fl_container_transport_history WHERE container_booking_id=#{bookingId} " + + "AND operator_id=#{operatorId} AND operation_id=#{operationId} LIMIT 1") + ContainerTransportHistoryEntity selectOperation(@Param("bookingId") long bookingId, + @Param("operatorId") long operatorId, @Param("operationId") String operationId); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java index 8a9d5571e..7ecca193f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java @@ -36,6 +36,10 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Objects; @Service public class ContainerService { @@ -151,31 +155,83 @@ public class ContainerService { @Transactional public ContainerBookingEntity updateTransport(long id, long operatorId, String operatorName, - TransportUpdateRequest request) { - ContainerBookingEntity current = required(id); + String idempotencyKey, TransportUpdateRequest request) { + String operationId = transportOperationId(id, idempotencyKey); + String containerNo = trim(request.containerNo()); + String voyageNo = trim(request.voyageNo()); + String requestHash = sha256("TRANSPORT|" + id + '|' + request.version() + '|' + + Objects.toString(containerNo, "") + '|' + Objects.toString(voyageNo, "") + '|' + + request.reason().trim()); + ContainerTransportHistoryEntity replay = transportHistories == null ? null + : transportHistories.selectOperation(id, operatorId, operationId); + if (replay != null) { + if (!requestHash.equals(replay.getRequestHash())) { + throw conflict("Idempotency-Key已用于不同运输字段请求"); + } + return required(id); + } + ContainerBookingEntity current = repository.findForUpdate(id); + if (current == null || !"ACTIVE".equals(current.getRecordStatus())) { + throw new BizException(ResultCode.NOT_FOUND, "货柜不存在"); + } requireVersion(current, request.version()); if (status(current) == SmartLoadingStatus.COMPLETED) throw conflict("装柜完成后货柜全部只读,不能修改"); - String containerNo = trim(request.containerNo()); + replay = transportHistories == null ? null + : transportHistories.selectOperation(id, operatorId, operationId); + if (replay != null) { + if (!requestHash.equals(replay.getRequestHash())) { + throw conflict("Idempotency-Key已用于不同运输字段请求"); + } + return current; + } + if (Objects.equals(current.getContainerNo(), containerNo) + && Objects.equals(current.getVoyageNo(), voyageNo)) { + return current; + } LoadingPlanEntity plan = smartLoadingRepository.planByBooking(id); if (containerNo == null && plan != null && loadingPickRepository.taskByPlan(plan.getId()) != null) { throw conflict("PDA拣货任务已创建,不能清空真实集装箱号"); } if (repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在"); try { - if (repository.updateTransport(id, containerNo, trim(request.voyageNo()), request.version(), + if (repository.updateTransport(id, containerNo, voyageNo, request.version(), operatorId, operatorName) != 1) throw conflict("货柜状态或版本已变化,请刷新后重试"); } catch (DuplicateKeyException ex) { throw conflict("集装箱号已存在"); } ContainerTransportHistoryEntity history = new ContainerTransportHistoryEntity(); history.setContainerBookingId(id); history.setBeforeContainerNo(current.getContainerNo()); history.setAfterContainerNo(containerNo); history.setBeforeVoyageNo(current.getVoyageNo()); - history.setAfterVoyageNo(trim(request.voyageNo())); history.setReason(request.reason().trim()); + history.setAfterVoyageNo(voyageNo); history.setOperationId(operationId); + history.setRequestHash(requestHash); history.setReason(request.reason().trim()); history.setOperatorId(operatorId); history.setOperatorName(operatorName); - if (transportHistories != null) transportHistories.insert(history); + try { + if (transportHistories != null) transportHistories.insert(history); + } catch (DuplicateKeyException exception) { + ContainerTransportHistoryEntity concurrent = transportHistories.selectOperation(id, operatorId, operationId); + if (concurrent == null || !requestHash.equals(concurrent.getRequestHash())) { + throw conflict("Idempotency-Key已被并发用于不同运输字段请求"); + } + } ContainerBookingEntity updated = required(id); loadingPickService.beginIfReady(updated.getId(), operatorId, operatorName); return updated; } + private static String transportOperationId(long id, String value) { + String key = trim(value); + if (key == null) throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key不能为空"); + if (key.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key长度不能超过128"); + return sha256("CONTAINER_TRANSPORT|" + id + '|' + key); + } + + private static String sha256(String value) { + try { + return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256不可用", exception); + } + } + private ContainerBookingEntity required(long id) { ContainerBookingEntity value = repository.find(id); if (value == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在"); diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index f83dd6933..1df6b2f87 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -71,7 +71,6 @@ async function waitForCompletedMeasurement(context, measurementPath, attempts = async function provisionWarehouseResources(context, { inboundId, warehouseId, palletCount }) { if (!warehouseId || !Number.isInteger(palletCount) || palletCount < 1) return null - const suffix = `${String(context.marker).replace(/\D/g, '').slice(-8)}${String(inboundId).slice(-6)}` const resources = [] const candidates = await reusablePallets(context, warehouseId) if (!candidates) return null @@ -82,20 +81,9 @@ async function provisionWarehouseResources(context, { inboundId, warehouseId, pa context.assert(MODULE, `随机复用原有称重托盘 ${index}/${palletCount}`, null, REUSABLE_PALLET_CODES.includes(candidates.find((item) => item.id === palletId)?.code), `warehouseId=${warehouseId}, palletId=${palletId}`, OPTIONS) - const resourceSuffix = `${suffix}${String(index).padStart(2, '0')}` - let locationId = null - if (context.context?.phase3LoadingRequireSharedOverflow !== true) { - const locationResponse = await context.request('POST', '/api/platform/v1/locations?tenantId=0', { - warehouseId, zoneCode: `E2E-${String(context.marker).slice(-8)}`, zoneName: '自动化验收区', - locationCode: `E2E-${resourceSuffix}`, locationName: `测量验收库位-${index}`, - locationType: 'PALLET', maxWeightKg: 1000, status: 'AVAILABLE', - remark: `${context.marker}-INBOUND-${inboundId}`, - }) - locationId = asId(context.apiSuccess(MODULE, `创建测量占用库位 ${index}/${palletCount}`, - locationResponse, `warehouseId=${warehouseId}`, OPTIONS)) - } - if (context.context?.phase3LoadingRequireSharedOverflow !== true && !locationId) return null - resources.push({ palletId, locationId }) + // 库位由 Warehouse 在测量审核时按正式规则分配;普通位不足会复用唯一的 A0 共享溢出位。 + // 验收脚本不得为了制造容量而新增普通库位。 + resources.push({ palletId, locationId: null }) } context.createdResources.inboundWarehouseResources ??= {} diff --git a/scripts/admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs b/scripts/admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs index 56f67e3fe..c070bb330 100644 --- a/scripts/admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs +++ b/scripts/admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs @@ -6,6 +6,7 @@ const acceptance = readFileSync(new URL('../admin-test-env-acceptance.mjs', impo const orders = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8') const measurement = readFileSync(new URL('./inbound-measurement.mjs', import.meta.url), 'utf8') const runner = readFileSync(new URL('../it-test-wait-split-bulk.mjs', import.meta.url), 'utf8') +const warehouseLocationResources = readFileSync(new URL('../../warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java', import.meta.url), 'utf8') test('单票随机箱数严格分配到各 SKU 并限制为1到1000', () => { assert.match(acceptance, /--wait-split-package-count /) @@ -35,6 +36,13 @@ test('称重只随机复用义乌仓原有两个托盘且禁止按订单新增', assert.doesNotMatch(measurement, /创建称重托盘主数据/) }) +test('称重脚本不新增库位且容量不足由仓库服务复用 A0 公共位', () => { + assert.doesNotMatch(measurement, /context\.request\('POST', '\/api\/platform\/v1\/locations/) + assert.doesNotMatch(measurement, /创建测量占用库位/) + assert.match(measurement, /普通位不足会复用唯一的 A0 共享溢出位/) + assert.match(warehouseLocationResources, /selected\.size\(\) < quantity[\s\S]*lockOrCreateSharedOverflow/) +}) + test('八千票执行器逐票记录且失败后继续', () => { assert.match(runner, /positiveInteger\(values\.get\('count'\), 8000/) assert.match(runner, /positiveInteger\(values\.get\('box-max'\), 300/)