fix(firstleg): cancel coload groups by inbound code

This commit is contained in:
2026-08-31 14:53:44 +08:00
parent 314ce09b5d
commit ea615b864a
7 changed files with 142 additions and 34 deletions
@@ -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<GroupSnapshot> 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()));
}
@@ -18,8 +18,7 @@ public final class ColoadGroupModels {
) {
}
public record CancelRequest(
@NotNull @Min(0) Integer expectedVersion,
public record CancelByInboundCodeRequest(
@Size(max = 500) String reason
) {
}
@@ -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<InternalOrderGroupInfo> queryInternalByOrderIds(List<Long> requestedOrderIds) {
List<Long> 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<Long> normalizedOrderIds(List<Long> source, int minimum, int maximum) {
@@ -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
@@ -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())
@@ -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");
@@ -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);
}