From ea615b864a74820d1f324ea943e994b72b96d766 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 31 Aug 2026 14:53:44 +0800 Subject: [PATCH] fix(firstleg): cancel coload groups by inbound code --- .../PlatformColoadGroupController.java | 13 ++- .../coload/model/ColoadGroupModels.java | 3 +- .../coload/service/ColoadGroupService.java | 46 +++++++--- .../resources/openapi/coload-groups-v1.yaml | 12 +-- .../coload/ColoadGroupContractTest.java | 6 +- .../ColoadGroupOpenApiContractTest.java | 4 +- .../coload/ColoadGroupServiceTest.java | 92 ++++++++++++++++++- 7 files changed, 142 insertions(+), 34 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/controller/PlatformColoadGroupController.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/controller/PlatformColoadGroupController.java index 6e6c86bec..62e4cd0a4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/controller/PlatformColoadGroupController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/controller/PlatformColoadGroupController.java @@ -6,7 +6,7 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.BusinessSummary; -import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.CancelRequest; +import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.CancelByInboundCodeRequest; import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.CreateRequest; import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.GroupSnapshot; import com.baiyue.firstleg.loading.coload.service.ColoadGroupService; @@ -15,7 +15,6 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; -import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Size; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -52,19 +51,19 @@ public class PlatformColoadGroupController { user.userId(), user.username())); } - @PostMapping("/{groupId}/cancel") + @PostMapping("/by-inbound-code/{inboundCode}/cancel") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") @BusinessAudit(event = "COLOAD_GROUP_CANCEL", resourceType = "COLOAD_GROUP", - resourceId = "#groupId", description = "整组取消同柜不可拆组") + resourceId = "#inboundCode", description = "按入仓码整组取消同柜不可拆组") @Operation(summary = "整组取消同柜不可拆组", description = "仅 ACTIVE 组允许取消;不提供单成员编辑,修改成员必须取消后重建。") public ApiResponse cancel( @AuthenticationPrincipal AuthenticatedUser user, - @PathVariable @Min(1) long groupId, + @PathVariable @Size(min = 1, max = 40) String inboundCode, @RequestHeader("Idempotency-Key") String idempotencyKey, - @Valid @RequestBody CancelRequest request) { + @Valid @RequestBody CancelByInboundCodeRequest request) { requirePlatform(user); - return ApiResponse.success(service.cancel(groupId, request.expectedVersion(), request.reason(), + return ApiResponse.success(service.cancelByInboundCode(inboundCode, request.reason(), idempotencyKey, user.userId(), user.username())); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/model/ColoadGroupModels.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/model/ColoadGroupModels.java index 095379d89..e6e7375d5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/model/ColoadGroupModels.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/model/ColoadGroupModels.java @@ -18,8 +18,7 @@ public final class ColoadGroupModels { ) { } - public record CancelRequest( - @NotNull @Min(0) Integer expectedVersion, + public record CancelByInboundCodeRequest( @Size(max = 500) String reason ) { } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/service/ColoadGroupService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/service/ColoadGroupService.java index 6fd09103a..03c78c764 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/service/ColoadGroupService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/service/ColoadGroupService.java @@ -65,25 +65,30 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort { } @Transactional - public GroupSnapshot cancel(long groupId, int expectedVersion, String reason, - String idempotencyKey, long operatorId, String operatorName) { - if (groupId <= 0 || expectedVersion < 0) throw badRequest("组ID或版本不合法"); + public GroupSnapshot cancelByInboundCode(String inboundCode, String reason, + String idempotencyKey, long operatorId, + String operatorName) { + String code = normalizedInboundCode(inboundCode); String key = key(idempotencyKey); String normalizedReason = trim(reason); - String requestHash = sha256("CANCEL|" + groupId + '|' + expectedVersion + '|' + String requestHash = sha256("CANCEL_BY_INBOUND_CODE|" + code + '|' + Objects.toString(normalizedReason, "")); CommandClaim claim = claim(operatorId, "CANCEL", key, requestHash); if (claim.replayGroupId() != null) return requiredSnapshot(claim.replayGroupId()); + long groupId = requiredCurrentGroupId(code); GroupRow group = requiredLocked(groupId); if (!"ACTIVE".equals(group.getGroupStatus())) { throw conflict("只有未锁定的同柜不可拆组可以整组取消"); } - if (!Objects.equals(group.getVersion(), expectedVersion) - || mapper.cancel(groupId, expectedVersion, operatorId, trim(operatorName)) != 1) { + GroupSnapshot before = snapshot(group); + int expectedVersion = group.getVersion(); + if (mapper.cancel(groupId, expectedVersion, operatorId, trim(operatorName)) != 1) { throw conflict("组版本已变化,请刷新后重试"); } - mapper.closeMembers(groupId); + if (mapper.closeMembers(groupId) != before.members().size()) { + throw conflict("同柜不可拆组成员状态已变化,请刷新后重试"); + } history(groupId, "CANCEL", "ACTIVE", "CANCELLED", null, key, normalizedReason, operatorId, operatorName); complete(claim.commandId(), groupId); @@ -92,8 +97,7 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort { @Transactional(readOnly = true) public BusinessSummary queryByInboundCode(String inboundCode) { - String code = trim(inboundCode); - if (code == null || code.length() > 40) throw badRequest("inboundCode不能为空且不能超过40字符"); + String code = normalizedInboundCode(inboundCode); var groups = mapper.groupsByInboundCode(code).stream() .filter(row -> row.getGroupId() != null && CURRENT_GROUP_STATUSES.contains(row.getGroupStatus())) .toList(); @@ -118,6 +122,24 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort { return new BusinessSummary(status, members.size(), members); } + private long requiredCurrentGroupId(String inboundCode) { + var groups = mapper.groupsByInboundCode(inboundCode).stream() + .filter(row -> row.getGroupId() != null && CURRENT_GROUP_STATUSES.contains(row.getGroupStatus())) + .toList(); + long distinctGroups = groups.stream().map(row -> row.getGroupId()).distinct().limit(2).count(); + if (distinctGroups == 0) { + throw new BizException(ResultCode.NOT_FOUND, "入仓码未关联有效同柜不可拆组"); + } + if (distinctGroups > 1) throw conflict("入仓码关联多个有效同柜不可拆组"); + return groups.get(0).getGroupId(); + } + + private static String normalizedInboundCode(String inboundCode) { + String code = trim(inboundCode); + if (code == null || code.length() > 40) throw badRequest("inboundCode不能为空且不能超过40字符"); + return code; + } + @Transactional(readOnly = true) public List queryInternalByOrderIds(List requestedOrderIds) { List orderIds = normalizedOrderIds(requestedOrderIds, 1, 200); @@ -331,8 +353,10 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort { private void history(long groupId, String action, String fromStatus, String toStatus, Long planId, String operationId, String remark, long operatorId, String operatorName) { - mapper.insertHistory(IdWorker.getId(), groupId, action, fromStatus, toStatus, planId, - operationId, trim(remark), operatorId, trim(operatorName)); + if (mapper.insertHistory(IdWorker.getId(), groupId, action, fromStatus, toStatus, planId, + operationId, trim(remark), operatorId, trim(operatorName)) != 1) { + throw conflict("同柜不可拆组审计历史写入冲突"); + } } private static List normalizedOrderIds(List source, int minimum, int maximum) { diff --git a/firstleg/src/main/resources/openapi/coload-groups-v1.yaml b/firstleg/src/main/resources/openapi/coload-groups-v1.yaml index ef6ebc0a3..af59d801f 100644 --- a/firstleg/src/main/resources/openapi/coload-groups-v1.yaml +++ b/firstleg/src/main/resources/openapi/coload-groups-v1.yaml @@ -20,18 +20,18 @@ paths: responses: '200': {$ref: '#/components/responses/ColoadGroupResponse'} '403': {description: 缺少智能排柜分配权限} - /api/platform/v1/first-leg/coload-groups/{groupId}/cancel: + /api/platform/v1/first-leg/coload-groups/by-inbound-code/{inboundCode}/cancel: post: summary: 整组取消同柜不可拆组 - description: 仅 ACTIVE 组允许取消;不提供单成员编辑,修改成员必须取消后重建。 + description: 使用任一成员入仓码整组取消;仅 ACTIVE 组允许取消,不提供单成员编辑。 parameters: - $ref: '#/components/parameters/IdempotencyKey' - - {name: groupId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: inboundCode, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 40}} requestBody: required: true content: application/json: - schema: {$ref: '#/components/schemas/CancelColoadGroupRequest'} + schema: {$ref: '#/components/schemas/CancelColoadGroupByInboundCodeRequest'} responses: '200': {$ref: '#/components/responses/ColoadGroupResponse'} '403': {description: 缺少智能排柜分配权限} @@ -98,12 +98,10 @@ components: maxItems: 100 uniqueItems: true items: {type: string, pattern: '^[1-9][0-9]*$'} - CancelColoadGroupRequest: + CancelColoadGroupByInboundCodeRequest: type: object additionalProperties: false - required: [expectedVersion] properties: - expectedVersion: {type: integer, minimum: 0} reason: {type: string, maxLength: 500, nullable: true} ColoadGroupMember: type: object diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupContractTest.java index 628858041..2b9d51963 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupContractTest.java @@ -3,7 +3,7 @@ package com.baiyue.firstleg.loading.coload; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.loading.coload.controller.PlatformColoadGroupController; import com.baiyue.firstleg.loading.coload.controller.InternalColoadGroupController; -import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.CancelRequest; +import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.CancelByInboundCodeRequest; import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.CreateRequest; import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.InternalBatchQueryRequest; import com.baiyue.firstleg.loading.coload.port.ColoadGroupAssignmentPort; @@ -25,8 +25,8 @@ class ColoadGroupContractTest { assertThat(base.value()).containsExactly("/api/platform/v1/first-leg/coload-groups"); assertEndpoint("create", "", AuthenticatedUser.class, String.class, CreateRequest.class); - assertEndpoint("cancel", "/{groupId}/cancel", AuthenticatedUser.class, long.class, - String.class, CancelRequest.class); + assertEndpoint("cancel", "/by-inbound-code/{inboundCode}/cancel", AuthenticatedUser.class, + String.class, String.class, CancelByInboundCodeRequest.class); Method read = PlatformColoadGroupController.class.getDeclaredMethod( "byInboundCode", AuthenticatedUser.class, String.class); assertThat(read.getAnnotation(GetMapping.class).value()) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupOpenApiContractTest.java index 3312539cc..a722e1eea 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupOpenApiContractTest.java @@ -25,7 +25,7 @@ class ColoadGroupOpenApiContractTest { assertThat(paths).containsKeys( "/api/platform/v1/first-leg/coload-groups", - "/api/platform/v1/first-leg/coload-groups/{groupId}/cancel", + "/api/platform/v1/first-leg/coload-groups/by-inbound-code/{inboundCode}/cancel", "/api/platform/v1/first-leg/coload-groups/by-inbound-code/{inboundCode}"); assertThat(paths).doesNotContainKey("/api/platform/v1/first-leg/coload-groups/batch-query"); assertThat(source) @@ -35,7 +35,6 @@ class ColoadGroupOpenApiContractTest { .contains("pattern: '^[1-9][0-9]*$'") .contains("enum: [ACTIVE, LOCKED, CONSUMED, CANCELLED]") .contains("Idempotency-Key") - .contains("expectedVersion") .contains("fullPackageCount") .contains("platform:firstleg:smart-loading:assign") .contains("platform:firstleg:smart-loading:list") @@ -45,6 +44,7 @@ class ColoadGroupOpenApiContractTest { .doesNotContain("destinationType") .doesNotContain("destinationWarehouseId") .doesNotContain("destinationCode") + .doesNotContain("expectedVersion") .doesNotContain("platform:firstleg:coload"); assertThat(summaryProperties).containsOnlyKeys("status", "memberCount", "members"); assertThat(memberProperties).containsOnlyKeys("inboundCode"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupServiceTest.java index aa9e63e6b..6271b8555 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupServiceTest.java @@ -3,6 +3,7 @@ package com.baiyue.firstleg.loading.coload; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.loading.coload.persistence.ColoadGroupMapper; import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.GroupRow; +import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.CommandRow; import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.InboundCandidateRow; import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.MemberRow; import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.InboundGroupLookupRow; @@ -25,6 +26,7 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -192,11 +194,13 @@ class ColoadGroupServiceTest { @Test void lockedGroupCannotBeCancelled() { + when(mapper.groupsByInboundCode("IN-A")).thenReturn(List.of(lookup(500L, "LOCKED"))); when(mapper.insertCommand(anyLong(), eq(7L), eq("CANCEL"), eq("cancel-key"), anyString())) .thenReturn(1); when(mapper.lockGroup(500L)).thenReturn(group(500L, "LOCKED", 700L, 2)); - assertThatThrownBy(() -> service.cancel(500L, 2, "change", "cancel-key", 7L, "operator")) + assertThatThrownBy(() -> service.cancelByInboundCode( + "IN-A", "change", "cancel-key", 7L, "operator")) .isInstanceOf(BizException.class) .hasMessageContaining("未锁定"); verify(mapper, never()).cancel(anyLong(), anyInt(), anyLong(), any()); @@ -204,6 +208,7 @@ class ColoadGroupServiceTest { @Test void activeGroupCancelsAsAWholeAndReleasesEveryMembership() { + when(mapper.groupsByInboundCode("IN-A")).thenReturn(List.of(lookup(500L, "ACTIVE"))); when(mapper.insertCommand(anyLong(), eq(7L), eq("CANCEL"), eq("cancel-all"), anyString())) .thenReturn(1); when(mapper.lockGroup(500L)).thenReturn(group(500L, "ACTIVE", null, 2)); @@ -217,10 +222,73 @@ class ColoadGroupServiceTest { when(mapper.members(500L)).thenReturn(List.of( member(101L, 11L, 1001L, 30), member(202L, 12L, 1002L, 50))); - var result = service.cancel(500L, 2, "rebuild", "cancel-all", 7L, "operator"); + var result = service.cancelByInboundCode( + "IN-A", "rebuild", "cancel-all", 7L, "operator"); assertThat(result.status()).isEqualTo("CANCELLED"); verify(mapper).closeMembers(500L); + verify(mapper).insertHistory(anyLong(), eq(500L), eq("CANCEL"), eq("ACTIVE"), + eq("CANCELLED"), isNull(), eq("cancel-all"), eq("rebuild"), eq(7L), eq("operator")); + } + + @Test + void cancelReplayReturnsOriginalResultWithoutResolvingOrMutatingCurrentGroup() { + CommandRow command = command("SUCCESS", 500L); + when(mapper.command(7L, "CANCEL", "cancel-replay")).thenReturn(command); + when(mapper.group(500L)).thenReturn(group(500L, "CANCELLED", null, 3)); + when(mapper.members(500L)).thenReturn(List.of( + member(101L, 11L, 1001L, 30), member(202L, 12L, 1002L, 50))); + + var result = service.cancelByInboundCode( + " IN-A ", "rebuild", "cancel-replay", 7L, "operator"); + + assertThat(result.status()).isEqualTo("CANCELLED"); + verify(mapper, never()).groupsByInboundCode(anyString()); + verify(mapper, never()).cancel(anyLong(), anyInt(), anyLong(), any()); + verify(mapper, never()).insertHistory(anyLong(), anyLong(), anyString(), any(), anyString(), + any(), anyString(), any(), anyLong(), any()); + } + + @Test + void cancelReplayRejectsDifferentPayloadAndConcurrentProcessing() { + CommandRow differentPayload = command("SUCCESS", 500L); + differentPayload.setRequestHash("different-hash"); + when(mapper.command(7L, "CANCEL", "same-key")).thenReturn(differentPayload); + when(mapper.command(7L, "CANCEL", "processing-key")) + .thenReturn(command("PROCESSING", null)); + + assertThatThrownBy(() -> service.cancelByInboundCode( + "IN-A", "changed", "same-key", 7L, "operator")) + .isInstanceOf(BizException.class).hasMessageContaining("不同请求"); + assertThatThrownBy(() -> service.cancelByInboundCode( + "IN-A", "rebuild", "processing-key", 7L, "operator")) + .isInstanceOf(BizException.class).hasMessageContaining("正在处理中"); + verify(mapper, never()).cancel(anyLong(), anyInt(), anyLong(), any()); + } + + @Test + void concurrentStateChangeOrPartialMemberCloseRollsBackWithoutAuditCompletion() { + when(mapper.groupsByInboundCode("IN-A")).thenReturn(List.of(lookup(500L, "ACTIVE"))); + when(mapper.insertCommand(anyLong(), eq(7L), eq("CANCEL"), anyString(), anyString())) + .thenReturn(1); + when(mapper.lockGroup(500L)) + .thenReturn(group(500L, "CANCELLED", null, 3), group(500L, "ACTIVE", null, 2)); + + assertThatThrownBy(() -> service.cancelByInboundCode( + "IN-A", "rebuild", "lost-race", 7L, "operator")) + .isInstanceOf(BizException.class).hasMessageContaining("未锁定"); + + when(mapper.members(500L)).thenReturn(List.of( + member(101L, 11L, 1001L, 30), member(202L, 12L, 1002L, 50))); + when(mapper.cancel(500L, 2, 7L, "operator")).thenReturn(1); + when(mapper.closeMembers(500L)).thenReturn(1); + assertThatThrownBy(() -> service.cancelByInboundCode( + "IN-A", "rebuild", "partial-close", 7L, "operator")) + .isInstanceOf(BizException.class).hasMessageContaining("成员状态已变化"); + + verify(mapper, never()).insertHistory(anyLong(), anyLong(), eq("CANCEL"), any(), anyString(), + any(), anyString(), any(), anyLong(), any()); + verify(mapper, never()).completeCommand(anyLong(), anyLong()); } @Test @@ -237,6 +305,8 @@ class ColoadGroupServiceTest { InboundCandidateRow second = candidate(202L, 12L, 1002L, 50); second.setActiveGroupId(500L); when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(List.of(first, fragment, second)); when(mapper.lockToPlan(500L, 700L, 7L, "operator")).thenReturn(1); + when(mapper.insertHistory(anyLong(), eq(500L), eq("LOCK"), eq("ACTIVE"), eq("LOCKED"), + eq(700L), eq("assign-whole"), any(), eq(7L), eq("operator"))).thenReturn(1); when(mapper.completeCommand(anyLong(), eq(500L))).thenReturn(1); when(mapper.group(500L)).thenReturn(group(500L, "LOCKED", 700L, 1)); @@ -318,6 +388,24 @@ class ColoadGroupServiceTest { return value; } + private static CommandRow command(String status, Long resultGroupId) { + CommandRow value = new CommandRow(); + value.setId(900L); + value.setRequestHash(sha256("CANCEL_BY_INBOUND_CODE|IN-A|rebuild")); + value.setCommandStatus(status); + value.setResultGroupId(resultGroupId); + return value; + } + + private static String sha256(String value) { + try { + return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + } catch (java.security.NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } + private static org.assertj.core.groups.Tuple tuple(Object... values) { return org.assertj.core.groups.Tuple.tuple(values); }