feat(firstleg): add indivisible coload groups

This commit is contained in:
2026-08-29 10:51:50 +08:00
parent e6ee9cb442
commit ac6d3289a6
13 changed files with 1494 additions and 0 deletions
@@ -0,0 +1,58 @@
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.model.ColoadGroupModels.BatchQueryRequest;
import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.CancelRequest;
import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.CreateRequest;
import com.baiyue.firstleg.loading.coload.port.ColoadGroupAssignmentPort;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.lang.reflect.Method;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
class ColoadGroupContractTest {
@Test
void platformApiReusesSmartLoadingAssignPermissionWithoutMemberEditEndpoint() throws Exception {
RequestMapping base = PlatformColoadGroupController.class.getAnnotation(RequestMapping.class);
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("batchQuery", "/batch-query", AuthenticatedUser.class, BatchQueryRequest.class);
assertThat(Arrays.stream(PlatformColoadGroupController.class.getDeclaredMethods())
.map(Method::getName)).doesNotContain("addMember", "removeMember", "updateMember");
}
@Test
void backendSixPortFreezesWholeGroupAssignRemoveAndCompletionMethods() throws Exception {
assertThat(ColoadGroupAssignmentPort.class.getDeclaredMethod(
"findByInbound", long.class, long.class)).isNotNull();
assertThat(ColoadGroupAssignmentPort.class.getDeclaredMethod(
"lockWholeGroupForAssignment", long.class, long.class, long.class,
String.class, long.class, String.class)).isNotNull();
assertThat(ColoadGroupAssignmentPort.class.getDeclaredMethod(
"unlockWholeGroupAfterRemoval", long.class, long.class, String.class,
long.class, String.class)).isNotNull();
assertThat(ColoadGroupAssignmentPort.class.getDeclaredMethod(
"consumeWholeGroup", long.class, long.class, String.class,
long.class, String.class)).isNotNull();
}
private void assertEndpoint(String methodName, String path, Class<?>... parameterTypes) throws Exception {
Method method = PlatformColoadGroupController.class.getDeclaredMethod(methodName, parameterTypes);
if (path.isEmpty()) {
assertThat(method.getAnnotation(PostMapping.class).value()).isEmpty();
} else {
assertThat(method.getAnnotation(PostMapping.class).value()).containsExactly(path);
}
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:firstleg:smart-loading:assign')");
}
}
@@ -0,0 +1,37 @@
package com.baiyue.firstleg.loading.coload;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class ColoadGroupOpenApiContractTest {
@SuppressWarnings("unchecked")
@Test
void publishesBoundedLongIdGroupContractWithoutNewPermission() throws Exception {
String source = Files.readString(Path.of("src/main/resources/openapi/coload-groups-v1.yaml"));
Map<String, Object> yaml = new Yaml().load(source);
Map<String, Object> paths = (Map<String, Object>) yaml.get("paths");
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/batch-query");
assertThat(source)
.contains("minItems: 2")
.contains("maxItems: 100")
.contains("maxItems: 200")
.contains("uniqueItems: true")
.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")
.doesNotContain("platform:firstleg:coload");
}
}
@@ -0,0 +1,217 @@
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.InboundCandidateRow;
import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.MemberRow;
import com.baiyue.firstleg.loading.coload.service.ColoadGroupService;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
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.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(ColoadGroupService.class)
class ColoadGroupServiceTest {
@MockBean
private ColoadGroupMapper mapper;
@Resource
private ColoadGroupService service;
@Test
void createsCrossCustomerGroupAndFreezesEveryMembersFullPackageCount() {
when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(List.of(
candidate(101L, 11L, 1001L, 30), candidate(202L, 12L, 1002L, 50)));
when(mapper.insertGroup(anyLong(), anyString(), eq(9L), eq("CH"), eq("FBA"),
eq(88L), eq("ONT8"), eq(7L), eq("operator"))).thenReturn(1);
when(mapper.insertMember(anyLong(), anyLong(), anyLong(), anyLong(), anyLong(), anyInt()))
.thenReturn(1);
when(mapper.insertHistory(anyLong(), anyLong(), anyString(), any(), anyString(), any(),
anyString(), any(), anyLong(), any())).thenReturn(1);
when(mapper.insertCommand(anyLong(), eq(7L), eq("CREATE"), eq("create-key"), anyString()))
.thenReturn(1);
when(mapper.completeCommand(anyLong(), anyLong())).thenReturn(1);
when(mapper.group(anyLong())).thenReturn(group(500L, "ACTIVE", null, 0));
when(mapper.members(anyLong())).thenReturn(List.of(
member(101L, 11L, 1001L, 30), member(202L, 12L, 1002L, 50)));
var result = service.create(List.of(12L, 11L), "create-key", 7L, "operator");
assertThat(result.members()).extracting("tenantId", "orderId", "inboundId", "fullPackageCount")
.containsExactly(tuple(101L, 11L, 1001L, 30), tuple(202L, 12L, 1002L, 50));
assertThat(result.fullPackageCount()).isEqualTo(80);
ArgumentCaptor<Long> groupId = ArgumentCaptor.forClass(Long.class);
verify(mapper).insertGroup(groupId.capture(), anyString(), eq(9L), eq("CH"), eq("FBA"),
eq(88L), eq("ONT8"), eq(7L), eq("operator"));
verify(mapper).insertMember(anyLong(), eq(groupId.getValue()), eq(101L), eq(11L), eq(1001L), eq(30));
verify(mapper).insertMember(anyLong(), eq(groupId.getValue()), eq(202L), eq(12L), eq(1002L), eq(50));
}
@Test
void rejectsSingleOrderBeforeAnyDatabaseWrite() {
assertThatThrownBy(() -> service.create(List.of(11L), "key", 7L, "operator"))
.isInstanceOf(BizException.class)
.hasMessageContaining("orderIds");
verify(mapper, never()).insertCommand(anyLong(), anyLong(), anyString(), anyString(), anyString());
}
@Test
void rejectsMemberThatIsAllocatedSplitOrAlreadyGrouped() {
InboundCandidateRow invalid = candidate(101L, 11L, 1001L, 30);
invalid.setActiveAllocationCount(1);
invalid.setChildCount(1);
invalid.setSplitBatchCount(1);
invalid.setActiveGroupId(900L);
when(mapper.insertCommand(anyLong(), eq(7L), eq("CREATE"), eq("key"), anyString())).thenReturn(1);
when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(List.of(
invalid, candidate(202L, 12L, 1002L, 50)));
assertThatThrownBy(() -> service.create(List.of(11L, 12L), "key", 7L, "operator"))
.isInstanceOf(BizException.class)
.hasMessageContaining("唯一可创建");
verify(mapper, never()).insertGroup(anyLong(), anyString(), anyLong(), anyString(), anyString(),
any(), any(), anyLong(), any());
}
@Test
void rejectsDifferentWarehouseChannelOrDestination() {
InboundCandidateRow second = candidate(202L, 12L, 1002L, 50);
second.setDestinationWarehouseId(99L);
second.setDestinationCode("LAX9");
when(mapper.insertCommand(anyLong(), eq(7L), eq("CREATE"), eq("route-key"), anyString()))
.thenReturn(1);
when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(List.of(
candidate(101L, 11L, 1001L, 30), second));
assertThatThrownBy(() -> service.create(List.of(11L, 12L), "route-key", 7L, "operator"))
.isInstanceOf(BizException.class)
.hasMessageContaining("同一国内仓、渠道和目的仓港");
}
@Test
void lockedGroupCannotBeCancelled() {
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"))
.isInstanceOf(BizException.class)
.hasMessageContaining("未锁定");
verify(mapper, never()).cancel(anyLong(), anyInt(), anyLong(), any());
}
@Test
void activeGroupCancelsAsAWholeAndReleasesEveryMembership() {
when(mapper.insertCommand(anyLong(), eq(7L), eq("CANCEL"), eq("cancel-all"), anyString()))
.thenReturn(1);
when(mapper.lockGroup(500L)).thenReturn(group(500L, "ACTIVE", null, 2));
when(mapper.cancel(500L, 2, 7L, "operator")).thenReturn(1);
when(mapper.closeMembers(500L)).thenReturn(2);
when(mapper.insertHistory(anyLong(), eq(500L), eq("CANCEL"), eq("ACTIVE"),
eq("CANCELLED"), any(), eq("cancel-all"), eq("rebuild"), eq(7L), eq("operator")))
.thenReturn(1);
when(mapper.completeCommand(anyLong(), eq(500L))).thenReturn(1);
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.cancel(500L, 2, "rebuild", "cancel-all", 7L, "operator");
assertThat(result.status()).isEqualTo("CANCELLED");
verify(mapper).closeMembers(500L);
}
@Test
void backendPortLocksAllFrozenMembersToOnePlan() {
when(mapper.activeGroupByInbound(101L, 1001L)).thenReturn(group(500L, "ACTIVE", null, 0));
when(mapper.insertCommand(anyLong(), eq(7L), eq("LOCK"), eq("assign-whole"), anyString()))
.thenReturn(1);
when(mapper.lockGroup(500L)).thenReturn(group(500L, "ACTIVE", null, 0));
when(mapper.members(500L)).thenReturn(List.of(
member(101L, 11L, 1001L, 30), member(202L, 12L, 1002L, 50)));
InboundCandidateRow first = candidate(101L, 11L, 1001L, 30); first.setActiveGroupId(500L);
InboundCandidateRow second = candidate(202L, 12L, 1002L, 50); second.setActiveGroupId(500L);
when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(List.of(first, second));
when(mapper.lockToPlan(500L, 700L, 7L, "operator")).thenReturn(1);
when(mapper.completeCommand(anyLong(), eq(500L))).thenReturn(1);
when(mapper.group(500L)).thenReturn(group(500L, "LOCKED", 700L, 1));
var result = service.lockWholeGroupForAssignment(
101L, 1001L, 700L, "assign-whole", 7L, "operator");
assertThat(result.status()).isEqualTo("LOCKED");
assertThat(result.lockedPlanId()).isEqualTo(700L);
assertThat(result.members()).hasSize(2);
assertThat(result.fullPackageCount()).isEqualTo(80);
}
@Test
void completionConsumesLockedGroupAndClosesMemberships() {
when(mapper.insertCommand(anyLong(), eq(7L), eq("CONSUME"), eq("consume-whole"), anyString()))
.thenReturn(1);
when(mapper.lockGroup(500L)).thenReturn(group(500L, "LOCKED", 700L, 1));
when(mapper.consume(500L, 700L, 7L, "operator")).thenReturn(1);
when(mapper.closeMembers(500L)).thenReturn(2);
when(mapper.insertHistory(anyLong(), eq(500L), eq("CONSUME"), eq("LOCKED"),
eq("CONSUMED"), eq(700L), eq("consume-whole"), any(), eq(7L), eq("operator")))
.thenReturn(1);
when(mapper.completeCommand(anyLong(), eq(500L))).thenReturn(1);
when(mapper.group(500L)).thenReturn(group(500L, "CONSUMED", 700L, 2));
when(mapper.members(500L)).thenReturn(List.of(
member(101L, 11L, 1001L, 30), member(202L, 12L, 1002L, 50)));
var result = service.consumeWholeGroup(500L, 700L, "consume-whole", 7L, "operator");
assertThat(result.status()).isEqualTo("CONSUMED");
verify(mapper).closeMembers(500L);
}
private static InboundCandidateRow candidate(long tenantId, long orderId, long inboundId, int packages) {
InboundCandidateRow value = new InboundCandidateRow();
value.setTenantId(tenantId); value.setOrderId(orderId); value.setInboundId(inboundId);
value.setProcessStatus("WAIT_SPLIT"); value.setInboundRole("MASTER");
value.setSplitStatus("NOT_SPLIT"); value.setSplitPurpose("MASTER");
value.setWarehouseId(9L); value.setChannelCode("CH"); value.setDestinationType("FBA");
value.setDestinationWarehouseId(88L); value.setDestinationCode("ONT8");
value.setFullPackageCount(packages); value.setActiveAllocationCount(0);
value.setChildCount(0); value.setSplitBatchCount(0);
return value;
}
private static GroupRow group(long id, String status, Long planId, int version) {
GroupRow value = new GroupRow();
value.setId(id); value.setGroupNo("CLG-" + id); value.setGroupStatus(status);
value.setWarehouseId(9L); value.setChannelCode("CH"); value.setDestinationType("FBA");
value.setDestinationWarehouseId(88L); value.setDestinationCode("ONT8");
value.setLockedPlanId(planId); value.setVersion(version);
value.setCreatedAt(LocalDateTime.of(2026, 8, 29, 9, 0));
value.setUpdatedAt(value.getCreatedAt());
return value;
}
private static MemberRow member(long tenantId, long orderId, long inboundId, int packages) {
MemberRow value = new MemberRow();
value.setTenantId(tenantId); value.setOrderId(orderId); value.setInboundId(inboundId);
value.setFullPackageCount(packages);
return value;
}
private static org.assertj.core.groups.Tuple tuple(Object... values) {
return org.assertj.core.groups.Tuple.tuple(values);
}
}
@@ -0,0 +1,48 @@
package com.baiyue.firstleg.loading.coload;
import com.baiyue.firstleg.loading.coload.persistence.ColoadGroupMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class ColoadGroupSqlContractTest {
@Test
void baselineAndUnifiedSqlFreezeAggregateMemberHistoryAndIdempotencyTables() throws Exception {
String baseline = Files.readString(Path.of("src/main/resources/db/phase3-coload-group-schema.sql"));
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
for (String source : new String[]{baseline, unified}) {
assertThat(source)
.contains("CREATE TABLE IF NOT EXISTS fl_coload_group (")
.contains("CREATE TABLE IF NOT EXISTS fl_coload_group_member (")
.contains("CREATE TABLE IF NOT EXISTS fl_coload_group_history (")
.contains("CREATE TABLE IF NOT EXISTS fl_coload_command (")
.contains("ACTIVE','LOCKED','CONSUMED','CANCELLED")
.contains("uk_coload_active_inbound")
.contains("full_package_count")
.contains("uk_coload_command")
.contains("locked_plan_id");
}
}
@Test
void mapperLocksAuthoritativeInboundAndRejectsAllocationChildrenAndSplitFacts() throws Exception {
assertThat(ColoadGroupMapper.class.getAnnotation(Mapper.class)).isNotNull();
Method method = ColoadGroupMapper.class.getDeclaredMethod("lockCandidates", java.util.List.class);
String sql = String.join(" ", method.getAnnotation(Select.class).value());
assertThat(sql)
.contains("FROM fl_inbound_order o")
.contains("FROM fl_loading_plan_item item")
.contains("item.deleted=0")
.contains("child.parent_inbound_id=o.id")
.contains("FROM fl_inbound_split_batch batch")
.contains("batch.status IN ('PREPARED','APPLIED')")
.contains("member.active_flag=1")
.contains("FOR UPDATE");
}
}