This commit is contained in:
2026-08-07 10:57:06 +08:00
parent 913eca7b7d
commit ebea3a1813
45 changed files with 1271 additions and 582 deletions
@@ -23,17 +23,17 @@ class LoadingListControllerContractTest {
@Test
void permissionsMatch() throws Exception {
authority("preloadPage", "platform:firstleg:preload:list", AuthenticatedUser.class, PreloadQuery.class);
authority("preloadDetail", "platform:firstleg:preload:view", AuthenticatedUser.class, long.class);
authority("preloadDetail", "platform:firstleg:preload:view", AuthenticatedUser.class, long.class, long.class);
authority("updateTransport", "platform:firstleg:preload:edit", AuthenticatedUser.class,
long.class, String.class, TransportUpdateRequest.class);
long.class, long.class, String.class, TransportUpdateRequest.class);
authority("confirmedPage", "platform:firstleg:loading-list:list", AuthenticatedUser.class,
ConfirmedQuery.class);
authority("confirmedDetail", "platform:firstleg:loading-list:view", AuthenticatedUser.class, long.class);
authority("confirmedDetail", "platform:firstleg:loading-list:view", AuthenticatedUser.class, long.class, long.class);
}
@Test
void writesAudited() throws Exception {
audit("updateTransport", "PRELOAD_TRANSPORT_UPDATE", long.class, long.class, String.class,
audit("updateTransport", "PRELOAD_TRANSPORT_UPDATE", long.class, long.class, long.class, String.class,
String.class, TransportUpdateRequest.class);
}
@@ -25,7 +25,8 @@ class InboundParentChildSqlContractTest {
.contains("GENERATED ALWAYS AS (CASE WHEN inbound_role = 'MASTER'")
.contains("DROP INDEX uk_inbound_tenant_order")
.contains("uk_inbound_master_order")
.contains("uk_inbound_parent_sequence");
.contains("uk_inbound_one_child")
.contains("DROP INDEX uk_inbound_parent_sequence");
}
@Test
@@ -34,27 +35,42 @@ class InboundParentChildSqlContractTest {
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_batch")
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_item")
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_history")
.contains("line_no INT UNSIGNED NOT NULL")
.contains("uk_inbound_split_batch_line")
.contains("uk_inbound_split_batch_source_item")
.contains("package_count INT UNSIGNED NOT NULL")
.doesNotContain("quantity INT", "item_count INT", "carton_count INT");
}
@org.junit.jupiter.api.Disabled("旧拆分口径已由一主单一子单的批次汇总校验取代")
@Test
void protectsOwnershipAndTotals() {
assertThat(sql)
.contains("子入仓单必须绑定同一客户的有效主单")
.contains("parent_order.package_count = 0")
.contains("parent_order.split_status = 'FULLY_SPLIT'")
.contains("parent_order.package_count > 0")
.contains("parent_order.split_status = 'PARTIALLY_SPLIT'")
.contains("parent_order.version > NEW.expected_parent_version")
.contains("主单可用箱数未归零或子单箱数合计不一致")
.contains("SUM(split_item.package_count) > source_item.package_count")
.contains("子单承接箱数超过来源测量箱规箱数")
.contains("child_order.package_count = NEW.package_count")
.contains("child_order.package_count >= NEW.package_count")
.contains("拆分数量台账不可修改")
.contains("拆分数量台账不可删除")
.contains("拆分历史不可修改")
.contains("拆分历史不可删除");
}
@Test
void validatesOneChildAndMultiSkuTotals() {
assertThat(sql)
.contains("uk_inbound_one_child")
.contains("uk_inbound_split_batch_line")
.contains("uk_inbound_split_batch_source_item")
.contains("COUNT(DISTINCT item.child_inbound_id)")
.contains("child_order.package_count <> SUM(item.package_count)")
.contains("parent_order.split_status = 'PARTIALLY_SPLIT'");
}
@Test
void tracesMeasurements() {
assertThat(sql)
@@ -35,10 +35,14 @@ class LoadingListQueryMapperContractTest {
.contains("JOIN fl_container_booking b")
.contains("LEFT JOIN fl_loading_plan_item i")
.contains("p.plan_status = 'LOADING'")
.contains("b.warehouse_id = #{query.warehouseId}")
.doesNotContain("query.accountId")
.doesNotContain("PRELOAD_PUSHED");
assertThat(confirmed)
.contains("FROM fl_loading_plan p")
.contains("p.plan_status = 'COMPLETED'")
.contains("b.warehouse_id = #{query.warehouseId}")
.doesNotContain("query.accountId")
.doesNotContain("fl_loading_list");
}
@@ -2,6 +2,7 @@ package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
@@ -21,4 +22,28 @@ class LoadingPickItemMapperContractTest {
.contains("pick_task.task_status = 'LOADING'")
.contains("pick_item.picked_package_count = plan_item.package_count");
}
@Test
void terminalStatesAreDefinedInSchema() throws Exception {
String sql = new org.springframework.core.io.ClassPathResource("db/phase3-smart-loading-schema.sql")
.getContentAsString(java.nio.charset.StandardCharsets.UTF_8);
assertThat(sql)
.contains("'PENDING', 'PICKING', 'PICKED', 'PARTIAL', 'SKIPPED'")
.contains("action_type VARCHAR(24) NOT NULL DEFAULT 'PICK'")
.contains("operation_reason VARCHAR(500) NULL");
}
@Test
void pdaWritesAndReadsRequireWarehouseScope() throws Exception {
Method list = LoadingPickItemMapper.class.getDeclaredMethod("selectTaskItemsWithAccount", long.class, long.class);
Method pick = LoadingPickItemMapper.class.getDeclaredMethod("pick", long.class, int.class, int.class, long.class);
assertThat(list.getAnnotation(Select.class).value()[0])
.contains("booking.warehouse_id = #{warehouseId}")
.contains("plan_item.deleted = 0");
assertThat(pick.getAnnotation(Update.class).value()[0])
.contains("booking.warehouse_id = #{warehouseId}")
.contains("plan_item.deleted = 0");
}
}
@@ -0,0 +1,37 @@
package com.baiyue.firstleg.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class PdaSplitSettlementSqlContractTest {
@Test
void protectsOneChildAndPartialSettlement() throws Exception {
String sql = new ClassPathResource("db/phase3-inbound-parent-child-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql)
.contains("uk_inbound_one_child")
.contains("uk_inbound_split_batch_line")
.contains("uk_inbound_split_batch_source_item")
.contains("child_order.package_count <> SUM(item.package_count)")
.contains("parent_order.split_status = 'PARTIALLY_SPLIT'")
.contains("SUM(split_item.package_count) > source_item.package_count")
.contains("CONSTRAINT chk_inbound_split_item_package CHECK (package_count > 0)");
}
@Test
void protectsPdaTerminalStates() throws Exception {
String sql = new ClassPathResource("db/phase3-smart-loading-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql)
.contains("'PICKED', 'PARTIAL', 'SKIPPED'")
.contains("NEW.item_status = 'PARTIAL'")
.contains("NEW.item_status = 'SKIPPED'")
.contains("NEW.picked_package_count = OLD.picked_package_count");
}
}
@@ -20,6 +20,7 @@ class SmartLoadingCandidateMapperContractTest {
.contains("SELECT MAX(m2.id)")
.contains("m2.status='APPROVED'")
.contains("i.inbound_detail_id IS NOT NULL")
.contains("o.warehouse_id=#{warehouseId}")
.contains("SELECT SUM(li.package_count)")
.contains("li.deleted=0")
.contains("ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC");
@@ -35,6 +36,7 @@ class SmartLoadingCandidateMapperContractTest {
.contains("o.destination_warehouse_id destinationWarehouseId")
.contains("m.status='APPROVED'")
.contains("SELECT MAX(m2.id)")
.contains("o.warehouse_id=#{warehouseId}")
.contains("SELECT SUM(li.package_count)")
.contains("FOR UPDATE");
}
@@ -108,7 +108,7 @@ class SmartLoadingSqlContractTest {
.contains("pick_item.picked_package_count <> pick_item.planned_package_count")
.contains("IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN")
.contains("SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3))")
.contains("已有PDA拣货记录的装柜明细不可修改或删除")
.contains("已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数")
.contains("装柜完成后的明细永久冻结")
.contains("装柜中仅允许修改集装箱号与航次")
.contains("装柜完成后货柜全部只读,不允许修改")
@@ -1,6 +1,8 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
@@ -8,6 +10,7 @@ import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.repository.ContainerRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
@@ -19,6 +22,8 @@ import java.time.LocalDateTime;
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.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -27,8 +32,15 @@ import static org.mockito.Mockito.when;
@SpringJUnitConfig(ContainerService.class)
class ContainerServiceTest {
@MockBean private ContainerRepository repository;
@MockBean private WarehouseMasterDataClient warehouses;
@Resource private ContainerService service;
@BeforeEach
void setUpWarehouse() {
when(warehouses.active(anyString(), anyLong(), anyLong())).thenAnswer(invocation ->
activeWarehouse(invocation.getArgument(2, Long.class), "DOMESTIC", "上海中转仓"));
}
@Test
@DisplayName("新增货柜保存最终数据库契约字段并生成平台预订单号")
void createsContainerBooking() {
@@ -40,6 +52,8 @@ class ContainerServiceTest {
assertThat(created).isSameAs(saved);
assertThat(saved.getBookingNo()).startsWith("CB");
assertThat(saved.getCabinetNo()).isEqualTo("柜-001");
assertThat(saved.getWarehouseId()).isEqualTo(101L);
assertThat(saved.getWarehouseName()).isEqualTo("上海中转仓");
assertThat(saved.getDeparturePortCode()).isEqualTo("CNSHA");
assertThat(saved.getCapacityCbm()).isEqualByComparingTo("68.0000");
assertThat(saved.getPlanStatus()).isEqualTo("EDITING");
@@ -127,6 +141,49 @@ class ContainerServiceTest {
verify(repository, never()).insert(any());
}
@Test
@DisplayName("虚拟柜拒绝选择非国内中转仓")
void rejectsNonDomesticWarehouse() {
when(warehouses.active(anyString(), anyLong(), anyLong()))
.thenReturn(activeWarehouse(101L, "FBA", "美国目的仓"));
assertThatThrownBy(() -> service.create(9L, "admin", request("柜-001")))
.isInstanceOf(BizException.class)
.hasMessageContaining("国内中转仓");
verify(repository, never()).insert(any());
}
@Test
@DisplayName("虚拟柜仓库名称以仓库主数据为准")
void replacesRequestedWarehouseName() {
service.create(9L, "admin", request("柜-001"));
ArgumentCaptor<ContainerBookingEntity> captor = ArgumentCaptor.forClass(ContainerBookingEntity.class);
verify(repository).insert(captor.capture());
assertThat(captor.getValue().getWarehouseName()).isEqualTo("上海中转仓");
}
@Test
@DisplayName("仓库主数据服务不可用时拒绝创建虚拟柜")
void rejectsUnavailableWarehouseService() {
when(warehouses.active(anyString(), anyLong(), anyLong()))
.thenThrow(new IllegalStateException("warehouse service unavailable"));
assertThatThrownBy(() -> service.create(9L, "admin", request("柜-001")))
.isInstanceOf(BizException.class)
.hasMessageContaining("暂时不可用");
verify(repository, never()).insert(any());
}
private static ApiResponse<WarehouseMasterDataClient.Detail> activeWarehouse(long warehouseId,
String type,
String name) {
return ApiResponse.success(new WarehouseMasterDataClient.Detail(
new WarehouseMasterDataClient.Warehouse(warehouseId, 0L, "CN-SHA-01", name, type, "ACTIVE")));
}
private static ContainerBookingEntity booking(String status, int version) {
ContainerBookingEntity value = new ContainerBookingEntity();
value.setId(11L);
@@ -51,7 +51,7 @@ class LoadingListServiceTest {
@Test
void shouldRejectEditingStatusFromPreloadList() {
PreloadQuery query = new PreloadQuery(null, "EDITING", null, null, null, null, 1, 20);
PreloadQuery query = new PreloadQuery(null, "EDITING", 100L, null, null, 1, 20);
assertThatThrownBy(() -> service.preloadPage(query))
.isInstanceOf(BizException.class)
@@ -74,7 +74,7 @@ class LoadingListServiceTest {
when(repository.preloadDetail(after)).thenReturn(
new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of()));
service.updateTransport(10L, 9L, "operator", "key-1",
service.updateTransport(10L, 100L, 9L, "operator", "key-1",
new TransportUpdateRequest(" CSLU1234567 ", "023E", 3, "补录箱号"));
verify(repository).updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator");
@@ -88,7 +88,7 @@ class LoadingListServiceTest {
when(repository.history(anyString())).thenReturn(null);
when(repository.aggregate(10L)).thenReturn(row("COMPLETED", 5));
assertThatThrownBy(() -> service.updateTransport(10L, 9L, "operator", "key-1",
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "key-1",
new TransportUpdateRequest("CSLU1234567", "023E", 5, null)))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
@@ -140,7 +140,7 @@ class LoadingListServiceTest {
ArgumentCaptor<String> snapshot = ArgumentCaptor.forClass(String.class);
TransportUpdateRequest request = new TransportUpdateRequest("CSLU1234567", "023E", 3, null);
service.updateTransport(10L, 9L, "operator", "key-1", request);
service.updateTransport(10L, 100L, 9L, "operator", "key-1", request);
verify(repository).appendHistory(eq(10L), anyString(), eq("UPDATE_PRELOAD_TRANSPORT"),
eq("LOADING"), eq("LOADING"), anyString(), snapshot.capture(), eq(9L),
eq("operator"), eq(null));
@@ -149,14 +149,14 @@ class LoadingListServiceTest {
when(repository.history(anyString())).thenReturn(replay);
when(repository.aggregate(10L)).thenReturn(after);
service.updateTransport(10L, 9L, "operator", "key-1", request);
service.updateTransport(10L, 100L, 9L, "operator", "key-1", request);
verify(repository, times(1)).updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator");
}
@Test
void confirmedPageAlwaysUsesRepositoryConfirmedProjection() {
ConfirmedQuery query = new ConfirmedQuery(" plan ", null, null, null, null, 1, 20);
ConfirmedQuery query = new ConfirmedQuery(" plan ", 100L, null, null, 1, 20);
when(repository.confirmedPage(org.mockito.ArgumentMatchers.any())).thenReturn(
new PageResult<>(List.of(), 1, 20, 0, 0));
@@ -166,11 +166,22 @@ class LoadingListServiceTest {
"plan".equals(value.keyword())));
}
@Test
void rejectsPlanOutsideRequestedWarehouse() {
when(repository.aggregate(10L)).thenReturn(row("LOADING", 1));
assertThatThrownBy(() -> service.preloadDetail(10L, 200L))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.NOT_FOUND);
}
private static AggregateRow row(String status, int version) {
AggregateRow row = new AggregateRow();
row.setPlanId(10L);
row.setPlanNo("PL-10");
row.setPlanStatus(status);
row.setWarehouseId(100L);
row.setVersion(version);
return row;
}
@@ -32,12 +32,12 @@ class LoadingPickServiceTest {
LoadingPickItemEntity item = item();
when(repository.history("pick-1")).thenReturn(null);
when(repository.task(20L)).thenReturn(task);
when(repository.item(20L, 30L)).thenReturn(item);
when(repository.pick(30L, 2, 0)).thenReturn(1);
when(repository.item(20L, 30L, 101L)).thenReturn(item);
when(repository.pick(30L, 2, 0, 101L)).thenReturn(1);
when(repository.allPlanItemsPicked(10L)).thenReturn(false);
when(repository.items(20L)).thenReturn(List.of(item));
when(repository.items(20L, 101L)).thenReturn(List.of(item));
service.pick(20L, 30L, new PickRequest(2, List.of(), 0, "pick-1"), 9L, "operator");
service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-1"), 9L, "operator");
verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator");
}
@@ -48,12 +48,12 @@ class LoadingPickServiceTest {
LoadingPickItemEntity item = item();
when(repository.history("pick-2")).thenReturn(null);
when(repository.task(20L)).thenReturn(task);
when(repository.item(20L, 30L)).thenReturn(item);
when(repository.pick(30L, 2, 0)).thenReturn(1);
when(repository.item(20L, 30L, 101L)).thenReturn(item);
when(repository.pick(30L, 2, 0, 101L)).thenReturn(1);
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.items(20L)).thenReturn(List.of(item));
when(repository.items(20L, 101L)).thenReturn(List.of(item));
service.pick(20L, 30L, new PickRequest(2, List.of(), 0, "pick-2"), 9L, "operator");
service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-2"), 9L, "operator");
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
verify(repository).insert(any(LoadingPickHistoryEntity.class));
@@ -0,0 +1,53 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class PdaPickSettlementPlanTest {
@Test
void groupsPartialItemsByInbound() {
var resolution = PdaPickSettlementPlan.resolve(List.of(
item(1L, 11L, 101L, 1001L, 10, 6, "PARTIAL"),
item(2L, 12L, 101L, 1002L, 8, 3, "PARTIAL"),
item(3L, 13L, 102L, 2001L, 4, 0, "SKIPPED"),
item(4L, 14L, 103L, 3001L, 5, 5, "PICKED")));
assertThat(resolution.partialByInbound()).containsOnlyKeys(101L);
assertThat(resolution.partialByInbound().get(101L))
.extracting(PdaPickSettlementPlan.SplitLine::remainingPackageCount)
.containsExactlyInAnyOrder(4, 5);
assertThat(resolution.skippedPlanItemIds()).containsExactly(13L);
}
@Test
void rejectsUnfinishedItem() {
assertThatThrownBy(() -> PdaPickSettlementPlan.resolve(List.of(
item(1L, 11L, 101L, 1001L, 10, 2, "PICKING"))))
.hasMessageContaining("未结束");
}
@Test
void rejectsInvalidPartialCount() {
assertThatThrownBy(() -> PdaPickSettlementPlan.resolve(List.of(
item(1L, 11L, 101L, 1001L, 10, 10, "PARTIAL"))))
.hasMessageContaining("不完整或箱数不合法");
}
private static LoadingPickItemEntity item(long id, long planItemId, long inboundId, long detailId,
int planned, int picked, String status) {
LoadingPickItemEntity item = new LoadingPickItemEntity();
item.setId(id);
item.setLoadingPlanItemId(planItemId);
item.setInboundId(inboundId);
item.setInboundDetailId(detailId);
item.setPlannedPackageCount(planned);
item.setPickedPackageCount(picked);
item.setItemStatus(status);
return item;
}
}
@@ -94,11 +94,12 @@ class SmartLoadingServiceTest {
void rejectsExcessBoxes() {
LoadingPlanEntity plan = plan("EDITING", 0);
when(repository.plan(1)).thenReturn(plan);
when(repository.candidate(9, 10, 20)).thenReturn(candidate(5));
when(repository.booking(99L)).thenReturn(booking());
when(repository.candidate(100, 10, 20)).thenReturn(candidate(5));
when(repository.activeItem(1L, 9L, 20L)).thenReturn(item(7, 9, 20, 4));
BizException error = catchThrowableOfType(() -> service.assign(1,
new AssignRequest(9L, 10L, 20L, 6, 0, "assign-1"), 7, "u"), BizException.class);
new AssignRequest(10L, 20L, 6, 0, "assign-1"), 7, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED);
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any());
@@ -117,7 +118,7 @@ class SmartLoadingServiceTest {
return null;
}).when(repository).insert(any(LoadingPlanItemEntity.class));
service.assign(1, new AssignRequest(9L, 10L, 20L, 5, 0, "assign-snapshot"), 7, "u");
service.assign(1, new AssignRequest(10L, 20L, 5, 0, "assign-snapshot"), 7, "u");
ArgumentCaptor<LoadingPlanItemEntity> itemCaptor = ArgumentCaptor.forClass(LoadingPlanItemEntity.class);
verify(repository).insert(itemCaptor.capture());
@@ -142,7 +143,7 @@ class SmartLoadingServiceTest {
new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 3,
BigDecimal.ONE, BigDecimal.ONE, null, null, null, 100, null, null)));
service.assign(1, new AssignRequest(9L, 10L, 20L, 2, 0, "assign-round"), 7, "u");
service.assign(1, new AssignRequest(10L, 20L, 2, 0, "assign-round"), 7, "u");
ArgumentCaptor<LoadingPlanItemEntity> captor = ArgumentCaptor.forClass(LoadingPlanItemEntity.class);
verify(repository).insert(captor.capture());
@@ -151,9 +152,9 @@ class SmartLoadingServiceTest {
@Test
void replaysBeforeVersionCheck() {
AssignRequest request = new AssignRequest(9L, 10L, 20L, 2, 0, "assign-replay");
AssignRequest request = new AssignRequest(10L, 20L, 2, 0, "assign-replay");
LoadingPlanHistoryEntity replay = history(1, null, "ADD_ITEM",
sha256("ASSIGN|1|9|10|20|2|0"));
sha256("ASSIGN|1|10|20|2|0"));
LoadingPlanEntity frozen = plan("LOADING", 9);
when(repository.operation("assign-replay")).thenReturn(replay);
when(repository.plan(1)).thenReturn(frozen);
@@ -169,9 +170,9 @@ class SmartLoadingServiceTest {
@Test
void rejectsReusedOperation() {
AssignRequest request = new AssignRequest(9L, 10L, 20L, 2, 0, "shared-op");
AssignRequest request = new AssignRequest(10L, 20L, 2, 0, "shared-op");
when(repository.operation("shared-op")).thenReturn(history(2, null, "ADD_ITEM",
sha256("ASSIGN|1|9|10|20|2|0")));
sha256("ASSIGN|1|10|20|2|0")));
BizException error = catchThrowableOfType(() -> service.assign(1, request, 7, "u"), BizException.class);
@@ -180,9 +181,9 @@ class SmartLoadingServiceTest {
@Test
void replaysRecommendation() {
RecommendRequest request = new RecommendRequest(9L, 0, "recommend-replay");
RecommendRequest request = new RecommendRequest(0, "recommend-replay");
when(repository.operation("recommend-replay")).thenReturn(history(1, null,
"SMART_RECOMMEND", sha256("RECOMMEND|1|9|0")));
"SMART_RECOMMEND", sha256("RECOMMEND|1|0")));
when(repository.plan(1)).thenReturn(plan("LOADING", 8));
when(repository.booking(99)).thenReturn(booking());
when(repository.items(1)).thenReturn(List.of());
@@ -195,9 +196,9 @@ class SmartLoadingServiceTest {
@Test
void replaysDeleteBeforeVersionCheck() {
DeleteRequest request = new DeleteRequest(9L, 2, 0, "delete-replay");
DeleteRequest request = new DeleteRequest(2, 0, "delete-replay");
when(repository.operation("delete-replay")).thenReturn(history(1, 8L,
"REMOVE_ITEM", sha256("DELETE|1|8|9|2|0")));
"REMOVE_ITEM", sha256("DELETE|1|8|2|0")));
when(repository.plan(1)).thenReturn(plan("LOADING", 7));
when(repository.booking(99)).thenReturn(booking());
when(repository.items(1)).thenReturn(List.of());
@@ -220,7 +221,7 @@ class SmartLoadingServiceTest {
when(repository.booking(99)).thenReturn(booking());
when(repository.items(1)).thenReturn(List.of());
Plan result = service.delete(1, 8, new DeleteRequest(9L, 2, 0, "delete-1"), 7, "u");
Plan result = service.delete(1, 8, new DeleteRequest(2, 0, "delete-1"), 7, "u");
assertThat(result.version()).isEqualTo(3);
verify(repository).touch(1, 2, 7, "u");
@@ -233,7 +234,7 @@ class SmartLoadingServiceTest {
ApiResponse.failure(ResultCode.NOT_FOUND, "货物不存在", null, null));
BizException error = catchThrowableOfType(() -> service.assign(1,
new AssignRequest(9L, 10L, 20L, 2, 0, "assign-not-found"), 7, "u"), BizException.class);
new AssignRequest(10L, 20L, 2, 0, "assign-not-found"), 7, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.NOT_FOUND);
}
@@ -245,7 +246,7 @@ class SmartLoadingServiceTest {
.thenThrow(mock(FeignException.class));
BizException error = catchThrowableOfType(() -> service.assign(1,
new AssignRequest(9L, 10L, 20L, 2, 0, "assign-down"), 7, "u"), BizException.class);
new AssignRequest(10L, 20L, 2, 0, "assign-down"), 7, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
}
@@ -257,22 +258,26 @@ class SmartLoadingServiceTest {
new OrderCargoClient.CargoBinding(99L, 30L, 1, "货物", 5, BigDecimal.ONE, BigDecimal.ONE)));
BizException error = catchThrowableOfType(() -> service.assign(1,
new AssignRequest(9L, 10L, 20L, 2, 0, "assign-mismatch"), 7, "u"), BizException.class);
new AssignRequest(10L, 20L, 2, 0, "assign-mismatch"), 7, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
}
@Test
void rejectsWrongAccountDelete() {
void deletesCustomerItemInThePlanWarehouse() {
LoadingPlanEntity plan = plan("EDITING", 2);
when(repository.plan(1)).thenReturn(plan);
when(repository.plan(1)).thenReturn(plan, plan("EDITING", 3));
when(repository.item(1, 8)).thenReturn(item(8, 22, 20, 1));
when(repository.touch(1, 2, 7, "u")).thenReturn(1);
when(repository.remove(1, 8, 0, 7, "u")).thenReturn(1);
when(repository.booking(99)).thenReturn(booking());
when(repository.items(1)).thenReturn(List.of());
BizException error = catchThrowableOfType(() -> service.delete(1, 8,
new DeleteRequest(9L, 2, 0, "delete-owner"), 7, "u"), BizException.class);
Plan result = service.delete(1, 8,
new DeleteRequest(2, 0, "delete-owner"), 7, "u");
assertThat(error.getResultCode()).isEqualTo(ResultCode.FORBIDDEN);
verify(repository, never()).remove(anyLong(), anyLong(), anyInt(), anyLong(), any());
assertThat(result.version()).isEqualTo(3);
verify(repository).remove(1, 8, 0, 7, "u");
}
@Test
@@ -296,7 +301,7 @@ class SmartLoadingServiceTest {
when(repository.plan(1)).thenReturn(plan, plan("EDITING", 1));
when(repository.booking(99)).thenReturn(booking());
when(repository.items(1)).thenReturn(List.of());
when(repository.candidate(9, 10, 20)).thenReturn(candidate(10));
when(repository.candidate(100, 10, 20)).thenReturn(candidate(10));
when(repository.activeItem(1L, 9L, 20L)).thenReturn(null);
when(repository.touch(1, 0, 7, "u")).thenReturn(1);
}
@@ -12,7 +12,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class SplitPreloadSqlContractTest {
@Test
void supportsPartialPackageLock() throws Exception {
assertPartialContract(moduleMigration());
assertCurrentContract(moduleMigration());
}
@Test
@@ -20,52 +20,55 @@ class SplitPreloadSqlContractTest {
String sql = moduleMigration();
assertThat(sql)
.contains("parent_inbound_id")
.contains("split_level")
.contains("inbound_role")
.contains("root_inbound_id")
.contains("split_status")
.contains("UNIQUE KEY uk_inbound_one_secondary (tenant_id, parent_inbound_id)")
.contains("CONSTRAINT fk_inbound_parent FOREIGN KEY (parent_inbound_id)")
.contains("二级子入库单不能继续拆分")
.contains("入库单主子关系创建后不可修改")
.contains("已完成拆单不能回退");
.contains("uk_inbound_one_child")
.contains("fk_inbound_parent_order")
.contains("子入仓单不可继续拆分")
.contains("入仓单主子归属创建后不可修改");
}
@Test
void remainsRepeatable() throws Exception {
String sql = moduleMigration();
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_detail")
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_batch")
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_item")
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_history")
.contains("information_schema.columns")
.contains("information_schema.statistics")
.contains("information_schema.table_constraints")
.contains("DROP TRIGGER IF EXISTS trg_inbound_split_before_insert")
.contains("DROP TRIGGER IF EXISTS trg_inbound_split_detail_before_delete")
.contains("CREATE PROCEDURE phase3_split_assert_contract()")
.contains("拆单迁移失败:唯一子单或部分锁定幂等索引缺失")
.contains("拆单迁移失败:主子层级或部分锁定触发器未完整创建");
.contains("DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_insert")
.contains("DROP TRIGGER IF EXISTS trg_inbound_split_item_before_delete")
.contains("CREATE PROCEDURE phase3_assert_inbound_parent_child_schema()")
.contains("主子入仓单拆分台账未完整创建")
.contains("主子入仓单数据库保护触发器未完整创建");
}
@Test
void syncsUnifiedMigration() throws Exception {
assertPartialContract(unifiedMigration());
assertCurrentContract(unifiedMigration());
}
private static void assertPartialContract(String sql) {
private static void assertCurrentContract(String sql) {
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_detail")
.contains("remaining_package_count INT UNSIGNED GENERATED ALWAYS AS")
.contains("source_package_count - locked_package_count")
.contains("locked_package_count < source_package_count")
.contains("status = 'LOCKED' AND child_inbound_id IS NULL")
.contains("status = 'COMPLETED' AND child_inbound_id IS NOT NULL")
.contains("UNIQUE KEY uk_inbound_split_parent_detail")
.contains("UNIQUE KEY uk_inbound_split_operation (operation_id)")
.contains("部分锁定的归属和箱数创建后不可修改")
.contains("部分锁定与拆单明细不可删除")
.contains("剩余箱数必须绑定主单唯一二级子入库单");
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_batch")
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_item")
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_history")
.contains("uk_inbound_one_child")
.contains("uk_inbound_split_operation")
.contains("uk_inbound_split_idempotency")
.contains("uk_inbound_split_batch_line")
.contains("COUNT(DISTINCT item.child_inbound_id)")
.contains("child_order.package_count <> SUM(item.package_count)")
.contains("HAVING SUM(split_item.package_count) > source_item.package_count")
.contains("CREATE TRIGGER trg_inbound_split_item_before_update")
.contains("CREATE TRIGGER trg_inbound_split_item_before_delete");
}
private static String moduleMigration() throws Exception {
return new ClassPathResource("db/phase3-split-preload-schema.sql")
return new ClassPathResource("db/phase3-inbound-parent-child-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
}