装柜
This commit is contained in:
+4
-4
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+20
-4
@@ -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)
|
||||
|
||||
+4
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
+25
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+37
@@ -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");
|
||||
}
|
||||
}
|
||||
+2
@@ -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");
|
||||
}
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
|
||||
+30
-27
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user