feat(firstleg): add indivisible coload groups
This commit is contained in:
+58
@@ -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')");
|
||||
}
|
||||
}
|
||||
+37
@@ -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");
|
||||
}
|
||||
}
|
||||
+217
@@ -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);
|
||||
}
|
||||
}
|
||||
+48
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user