This commit is contained in:
2026-08-06 17:48:50 +08:00
parent c68eedd3f3
commit 913eca7b7d
21 changed files with 1767 additions and 77 deletions
@@ -2,7 +2,6 @@ package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.LoadingListDtos.AdvanceRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
@@ -27,8 +26,6 @@ class LoadingListControllerContractTest {
authority("preloadDetail", "platform:firstleg:preload:view", AuthenticatedUser.class, long.class);
authority("updateTransport", "platform:firstleg:preload:edit", AuthenticatedUser.class,
long.class, String.class, TransportUpdateRequest.class);
authority("complete", "platform:firstleg:preload:complete", AuthenticatedUser.class,
long.class, String.class, AdvanceRequest.class);
authority("confirmedPage", "platform:firstleg:loading-list:list", AuthenticatedUser.class,
ConfirmedQuery.class);
authority("confirmedDetail", "platform:firstleg:loading-list:view", AuthenticatedUser.class, long.class);
@@ -38,8 +35,6 @@ class LoadingListControllerContractTest {
void writesAudited() throws Exception {
audit("updateTransport", "PRELOAD_TRANSPORT_UPDATE", long.class, long.class, String.class,
String.class, TransportUpdateRequest.class);
audit("complete", "PRELOAD_COMPLETE", long.class, long.class, String.class,
String.class, AdvanceRequest.class);
}
@Test
@@ -69,6 +64,12 @@ class LoadingListControllerContractTest {
.doesNotContain("/items/{itemId}");
}
@Test
void doesNotExposeManualCompletion() {
assertThat(PlatformLoadingListController.class.getDeclaredMethods())
.noneMatch(method -> method.getName().equals("complete"));
}
@Test
void yamlParses() throws Exception {
String yaml = new ClassPathResource("openapi/loading-lists-v1.yaml")
@@ -0,0 +1,105 @@
package com.baiyue.firstleg.repository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("主子入仓单SQL契约")
class InboundParentChildSqlContractTest {
private final String sql = loadSql();
@Test
void definesParentChildRelation() {
assertThat(sql)
.contains("'fl_inbound_order', 'inbound_role'")
.contains("'fl_inbound_order', 'parent_inbound_id'")
.contains("'fl_inbound_order', 'root_inbound_id'")
.contains("'fl_inbound_order', 'package_count'")
.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");
}
@Test
void definesSplitLedger() {
assertThat(sql)
.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("package_count INT UNSIGNED NOT NULL")
.doesNotContain("quantity INT", "item_count INT", "carton_count INT");
}
@Test
void protectsOwnershipAndTotals() {
assertThat(sql)
.contains("子入仓单必须绑定同一客户的有效主单")
.contains("parent_order.package_count = 0")
.contains("parent_order.split_status = 'FULLY_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("拆分数量台账不可修改")
.contains("拆分数量台账不可删除")
.contains("拆分历史不可修改")
.contains("拆分历史不可删除");
}
@Test
void tracesMeasurements() {
assertThat(sql)
.contains("'fl_inbound_measurement', 'split_batch_id'")
.contains("'fl_inbound_measurement', 'source_measurement_id'")
.contains("'fl_inbound_measurement_item', 'split_item_id'")
.contains("'fl_inbound_measurement_item', 'source_measurement_item_id'")
.contains("source_measurement_id BIGINT UNSIGNED NULL")
.contains("fk_inbound_split_item_measurement")
.contains("fk_measure_split_batch")
.contains("fk_measure_source_batch")
.contains("fk_measure_item_split_item")
.contains("fk_measure_item_source_item")
.contains("idx_measure_split_source")
.contains("idx_measure_item_split_source");
}
@Test
void syncsUnifiedMigration() throws Exception {
String module = normalize(sql).trim();
String unified = normalize(Files.readString(unifiedPath(), StandardCharsets.UTF_8));
assertThat(unified)
.contains("-- 来源:firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql")
.contains(module);
}
private static String loadSql() {
try {
return new ClassPathResource("db/phase3-inbound-parent-child-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
private static Path unifiedPath() {
Path working = Path.of("").toAbsolutePath();
Path path = working.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql");
if (!Files.exists(path)) {
path = working.resolve("../deploy/sql/头程数据库统一增量-MySQL8.sql").normalize();
}
return path;
}
private static String normalize(String value) {
return value.replace("\r\n", "\n").replace("\uFEFF", "");
}
}
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
class LoadingPickItemMapperContractTest {
@Test
void completionCheckCoversAllActivePlanItems() throws Exception {
Method method = LoadingPickItemMapper.class.getDeclaredMethod("unfinishedCount", long.class);
String sql = method.getAnnotation(Select.class).value()[0];
assertThat(sql)
.contains("plan_item.loading_plan_id = #{planId}")
.contains("plan_item.deleted = 0")
.contains("pick_task.loading_plan_id = #{planId}")
.contains("pick_task.task_status = 'LOADING'")
.contains("pick_item.picked_package_count = plan_item.package_count");
}
}
@@ -20,6 +20,8 @@ class SmartLoadingCandidateMapperContractTest {
.contains("SELECT MAX(m2.id)")
.contains("m2.status='APPROVED'")
.contains("i.inbound_detail_id IS NOT NULL")
.contains("SELECT SUM(li.package_count)")
.contains("li.deleted=0")
.contains("ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC");
}
@@ -32,6 +34,8 @@ class SmartLoadingCandidateMapperContractTest {
assertThat(sql)
.contains("o.destination_warehouse_id destinationWarehouseId")
.contains("m.status='APPROVED'")
.contains("SELECT MAX(m2.id)");
.contains("SELECT MAX(m2.id)")
.contains("SELECT SUM(li.package_count)")
.contains("FOR UPDATE");
}
}
@@ -64,7 +64,8 @@ class SmartLoadingSqlContractTest {
assertThat(migration())
.contains("active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS")
.contains("CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END")
.contains("UNIQUE KEY uk_loading_item_active_detail (account_id, active_inbound_detail_id)")
.contains("UNIQUE KEY uk_loading_item_plan_detail (loading_plan_id, active_inbound_detail_id)")
.contains("KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id)")
.contains("UNIQUE KEY uk_container_booking_no (booking_no)")
.contains("UNIQUE KEY uk_container_booking_cabinet (cabinet_no)")
.contains("UNIQUE KEY uk_loading_plan_push_key (push_idempotency_key)")
@@ -3,13 +3,13 @@ package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.LoadingListDtos.AdvanceRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow;
import com.baiyue.firstleg.repository.LoadingListRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName;
@@ -36,6 +36,8 @@ import static org.mockito.Mockito.when;
class LoadingListServiceTest {
@MockBean
private LoadingListRepository repository;
@MockBean
private LoadingPickRepository pickRepository;
@Resource
private LoadingListService service;
@@ -95,19 +97,32 @@ class LoadingListServiceTest {
}
@Test
void shouldAdvanceOnlyLoadingToCompleted() {
void shouldCompleteLoadingAfterAllPdaItemsPicked() {
AggregateRow before = row("LOADING", 2);
AggregateRow after = row("COMPLETED", 3);
when(pickRepository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.history(anyString())).thenReturn(null);
when(repository.aggregate(10L)).thenReturn(before, after);
when(repository.aggregate(10L)).thenReturn(before);
when(repository.advance(10L, "LOADING", "COMPLETED", 2, 9L, "operator"))
.thenReturn(1);
when(repository.preloadDetail(after)).thenReturn(
new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of()));
service.complete(10L, 9L, "operator", "complete-key", new AdvanceRequest(2, null));
service.finishFromPda(10L, 21L, 9L, "operator");
verify(repository).advance(10L, "LOADING", "COMPLETED", 2, 9L, "operator");
verify(repository).appendHistory(eq(10L), eq("PDA-COMPLETE-10-21"),
eq("PDA_COMPLETE_LOADING"), eq("LOADING"), eq("COMPLETED"), anyString(), anyString(),
eq(9L), eq("operator"), eq("全部箱数已完成PDA拣货"));
}
@Test
void shouldRejectAutomaticCompletionWhenPdaItemsRemain() {
when(pickRepository.allPlanItemsPicked(10L)).thenReturn(false);
assertThatThrownBy(() -> service.finishFromPda(10L, 21L, 9L, "operator"))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
verify(repository, never()).advance(eq(10L), anyString(), anyString(),
org.mockito.ArgumentMatchers.anyInt(), eq(9L), anyString());
}
@Test
@@ -0,0 +1,85 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(LoadingPickService.class)
class LoadingPickServiceTest {
@MockBean
private LoadingPickRepository repository;
@MockBean
private LoadingListService loadingListService;
@Resource
private LoadingPickService service;
@Test
void keepsPlanLoadingWhenItemsRemain() {
LoadingPickTaskEntity task = task();
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.allPlanItemsPicked(10L)).thenReturn(false);
when(repository.items(20L)).thenReturn(List.of(item));
service.pick(20L, 30L, new PickRequest(2, List.of(), 0, "pick-1"), 9L, "operator");
verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator");
}
@Test
void completesPlanWhenLastItemIsPicked() {
LoadingPickTaskEntity task = task();
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.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.items(20L)).thenReturn(List.of(item));
service.pick(20L, 30L, new PickRequest(2, List.of(), 0, "pick-2"), 9L, "operator");
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
verify(repository).insert(any(LoadingPickHistoryEntity.class));
}
private static LoadingPickTaskEntity task() {
LoadingPickTaskEntity task = new LoadingPickTaskEntity();
task.setId(20L);
task.setLoadingPlanId(10L);
task.setContainerNo("CSLU1234567");
task.setTaskStatus("LOADING");
task.setVersion(0);
return task;
}
private static LoadingPickItemEntity item() {
LoadingPickItemEntity item = new LoadingPickItemEntity();
item.setId(30L);
item.setPickTaskId(20L);
item.setLoadingPlanItemId(40L);
item.setInboundId(50L);
item.setInboundDetailId(60L);
item.setPlannedPackageCount(2);
item.setPickedPackageCount(0);
item.setItemStatus("PENDING");
item.setVersion(0);
return item;
}
}
@@ -95,10 +95,10 @@ class SmartLoadingServiceTest {
LoadingPlanEntity plan = plan("EDITING", 0);
when(repository.plan(1)).thenReturn(plan);
when(repository.candidate(9, 10, 20)).thenReturn(candidate(5));
when(repository.activeItem(9, 20)).thenReturn(item(7, 9, 20, 4));
when(repository.activeItem(1L, 9L, 20L)).thenReturn(item(7, 9, 20, 4));
BizException error = catchThrowableOfType(() -> service.assign(1,
new AssignRequest(9L, 10L, 20L, 2, 0, "assign-1"), 7, "u"), BizException.class);
new AssignRequest(9L, 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());
@@ -297,7 +297,7 @@ class SmartLoadingServiceTest {
when(repository.booking(99)).thenReturn(booking());
when(repository.items(1)).thenReturn(List.of());
when(repository.candidate(9, 10, 20)).thenReturn(candidate(10));
when(repository.activeItem(9, 20)).thenReturn(null);
when(repository.activeItem(1L, 9L, 20L)).thenReturn(null);
when(repository.touch(1, 0, 7, "u")).thenReturn(1);
}
@@ -0,0 +1,80 @@
package com.baiyue.firstleg.split.service;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class SplitPreloadSqlContractTest {
@Test
void supportsPartialPackageLock() throws Exception {
assertPartialContract(moduleMigration());
}
@Test
void limitsSecondaryInboundDepth() throws Exception {
String sql = moduleMigration();
assertThat(sql)
.contains("parent_inbound_id")
.contains("split_level")
.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("已完成拆单不能回退");
}
@Test
void remainsRepeatable() throws Exception {
String sql = moduleMigration();
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_detail")
.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("拆单迁移失败:主子层级或部分锁定触发器未完整创建");
}
@Test
void syncsUnifiedMigration() throws Exception {
assertPartialContract(unifiedMigration());
}
private static void assertPartialContract(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("剩余箱数必须绑定主单唯一二级子入库单");
}
private static String moduleMigration() throws Exception {
return new ClassPathResource("db/phase3-split-preload-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
}
private static String unifiedMigration() throws Exception {
Path working = Path.of("").toAbsolutePath();
Path file = working.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql");
if (!Files.exists(file)) {
file = working.resolve("../deploy/sql/头程数据库统一增量-MySQL8.sql").normalize();
}
return Files.readString(file, StandardCharsets.UTF_8);
}
}