Merge branch 'test-02huogui' into test

# Conflicts:
#	firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java
#	firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java
This commit is contained in:
2026-08-07 23:46:44 +08:00
135 changed files with 9649 additions and 370 deletions
@@ -0,0 +1,51 @@
package com.baiyue.firstleg.controller;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.core.io.ClassPathResource;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("货柜基础资料接口契约")
class ContainerControllerContractTest {
@Test
@DisplayName("查询、新增和编辑按钮权限彼此独立")
void separatesButtonPermissions() {
assertPermission("page", "platform:firstleg:container:list");
assertPermission("detail", "platform:firstleg:container:view");
assertPermission("create", "platform:firstleg:container:create");
assertPermission("updateDraft", "platform:firstleg:container:edit");
assertPermission("updateTransport", "platform:firstleg:container:edit");
}
@Test
@DisplayName("OpenAPI声明平台级货柜路径和冻结规则")
void documentsContainerContract() throws Exception {
String openapi = new ClassPathResource("openapi/firstleg-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
String section = openapi.substring(openapi.indexOf(" /api/platform/v1/containers:"),
openapi.indexOf(" /openapi/v1/inbound-measurements:"));
assertThat(section)
.contains("/api/platform/v1/containers/{id}/transport:")
.contains("platform:firstleg:container:list")
.contains("EDITING")
.contains("LOADING")
.contains("COMPLETED")
.doesNotContain("tenantId")
.doesNotContain("accountId");
}
private static void assertPermission(String methodName, String authority) {
Method method = Arrays.stream(PlatformContainerController.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals(methodName))
.findFirst().orElseThrow();
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + authority + "')");
}
}
@@ -48,7 +48,7 @@ class FbaPricingContractTest {
.contains("/api/platform/v1/inbounds/{inboundId}/fees/send:")
.contains("/api/platform/v1/inbounds/{inboundId}/fees/published:")
.contains("Idempotency-Key")
.contains("主流程已到 WAIT_SPLIT,也可独立试算且不回退主流程状态")
.contains("主流程已到 INBOUNDED,也可独立试算且不回退主流程状态")
.contains("该动作涉及后续仓储流程,本轮二期验收不调用")
.contains("不回退 INBOUNDED 状态")
.doesNotContain("应收")
@@ -54,7 +54,7 @@ class InternalInboundOrderReferenceControllerTest {
@Test
void unifiedQueryRequiresInternalTokenAndForwardsBatchRequest() {
UnifiedInboundQuery request = new UnifiedInboundQuery(
10001L, null, null, "WAIT_SPLIT", null, null, null,
10001L, null, null, "INBOUNDED", null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null,
null, null, 1, 20);
when(unifiedQueryService.page(request)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
@@ -0,0 +1,121 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.security.AuthenticatedUser;
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.service.LoadingListService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.access.prepost.PreAuthorize;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("预装与装柜清单控制器契约")
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, long.class);
authority("updateTransport", "platform:firstleg:preload:edit", AuthenticatedUser.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, long.class);
}
@Test
void writesAudited() throws Exception {
audit("updateTransport", "PRELOAD_TRANSPORT_UPDATE", long.class, long.class, long.class, String.class,
String.class, TransportUpdateRequest.class);
}
@Test
void openApiComplete() throws Exception {
String yaml = new ClassPathResource("openapi/loading-lists-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(yaml)
.contains("EDITING 到 LOADING 再到 COMPLETED")
.contains("Idempotency-Key")
.contains("expectedVersion")
.contains("createdFrom")
.contains("createdTo")
.contains("platform:firstleg:loading-list:view")
.contains("PreloadPageEnvelope")
.contains("ConfirmedPageEnvelope")
.contains("PreloadSummary")
.contains("ConfirmedSummary")
.contains("LoadingItem")
.contains("OperationHistory")
.contains("customers")
.contains("productQuantity")
.contains("beforeSnapshot")
.contains("containerNo")
.contains("voyageNo")
.doesNotContain("rollback")
.doesNotContain("delete:")
.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")
.getContentAsString(StandardCharsets.UTF_8);
Map<String, Object> document = stringMap(new Yaml().load(yaml));
assertThat(document.get("openapi")).isEqualTo("3.0.3");
assertThat((Map<?, ?>) document.get("paths")).hasSize(6);
Map<String, Object> components = stringMap(document.get("components"));
Map<String, Object> schemas = stringMap(components.get("schemas"));
assertThat(schemas).containsKeys(
"LongId", "PreloadPage", "ConfirmedPage", "Customer", "PreloadSummary",
"ConfirmedSummary", "LoadingItem", "OperationHistory", "PreloadDetail",
"ConfirmedDetail", "TransportUpdateRequest", "AdvanceRequest");
assertThat(stringMap(schemas.get("LongId"))).containsEntry("type", "string");
assertThat(properties(schemas, "PreloadPage")).containsKeys("records", "page", "size", "total", "pages");
assertThat(properties(schemas, "PreloadSummary")).containsKeys("planId", "customers", "skuCount");
assertThat(properties(schemas, "LoadingItem")).containsKeys(
"itemId", "accountId", "inboundId", "inboundDetailId", "skuCode",
"destinationWarehouse", "productQuantity", "frozenAt");
assertThat(properties(schemas, "OperationHistory")).containsKeys(
"historyId", "itemId", "actionType", "fromStatus", "toStatus",
"beforeSnapshot", "afterSnapshot", "operatorId", "occurredAt");
assertThat(properties(schemas, "TransportUpdateRequest")).containsOnlyKeys(
"containerNo", "voyageNo", "expectedVersion", "operationReason");
}
private static Map<String, Object> properties(Map<String, Object> schemas, String schemaName) {
return stringMap(stringMap(schemas.get(schemaName)).get("properties"));
}
@SuppressWarnings("unchecked")
private static Map<String, Object> stringMap(Object value) {
return (Map<String, Object>) value;
}
private static void authority(String name, String permission, Class<?>... parameterTypes) throws Exception {
Method method = PlatformLoadingListController.class.getDeclaredMethod(name, parameterTypes);
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + permission + "')");
}
private static void audit(String name, String event, Class<?>... parameterTypes) throws Exception {
Method method = LoadingListService.class.getDeclaredMethod(name, parameterTypes);
BusinessAudit audit = method.getAnnotation(BusinessAudit.class);
assertThat(audit.event()).isEqualTo(event);
assertThat(audit.resourceType()).isEqualTo("LOADING_PLAN");
assertThat(audit.resourceId()).isEqualTo("#planId");
}
}
@@ -0,0 +1,23 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.SmartLoadingDtos.*;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
class PlatformSmartLoadingControllerContractTest {
@Test void draftQueriesUseListPermission() throws Exception {
authority("draftByBooking","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class);
authority("detail","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class);
}
@Test void everyMutationDeclaresDedicatedPermission() throws Exception {
authority("assign","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignRequest.class);
authority("recommend","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,RecommendRequest.class);
authority("adjust","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,long.class,AdjustRequest.class);
authority("delete","platform:firstleg:smart-loading:delete",AuthenticatedUser.class,long.class,long.class,DeleteRequest.class);
authority("push","platform:firstleg:smart-loading:push",AuthenticatedUser.class,long.class,String.class,PushRequest.class);
}
private void authority(String method,String permission,Class<?>...types)throws Exception{Method m=PlatformSmartLoadingController.class.getDeclaredMethod(method,types);assertThat(m.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('"+permission+"')");}
}
@@ -0,0 +1,46 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.mapper.ContainerBookingMapper;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("货柜数据库映射契约")
class ContainerBookingMapperContractTest {
@Test
@DisplayName("货柜映射平台级最终表且状态只从装柜计划读取")
void mapsFinalBookingContract() throws Exception {
assertThat(ContainerBookingEntity.class.getAnnotation(TableName.class).value())
.isEqualTo("fl_container_booking");
String select = String.join("\n", ContainerBookingMapper.class
.getDeclaredMethod("selectBooking", long.class).getAnnotation(Select.class).value());
assertThat(select)
.contains("fl_loading_plan")
.contains("p.id AS plan_id")
.contains("plan_status")
.doesNotContain("tenant_id")
.doesNotContain("account_id");
}
@Test
@DisplayName("草稿完整更新和预装运输白名单都带版本与状态门禁")
void guardsUpdatesInSql() {
String allSql = Arrays.stream(ContainerBookingMapper.class.getDeclaredMethods())
.map(method -> method.getAnnotation(Update.class))
.filter(annotation -> annotation != null)
.flatMap(annotation -> Arrays.stream(annotation.value()))
.reduce("", (left, right) -> left + "\n" + right);
assertThat(allSql)
.contains("capacity_cbm")
.contains("p.plan_status&lt;&gt;'EDITING'")
.contains("p.plan_status='COMPLETED'")
.contains("version=#{expectedVersion}");
}
}
@@ -0,0 +1,121 @@
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_one_child")
.contains("DROP INDEX 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("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 = '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("拆分数量台账不可修改")
.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)
.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,35 @@
package com.baiyue.firstleg.repository;
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 InboundStatusUnificationSqlContractTest {
@Test
void syncsStatusUnificationMigrationToUnifiedDeploymentScript() throws Exception {
String module = normalize(new ClassPathResource("db/phase3-inbounded-status-unification.sql")
.getContentAsString(StandardCharsets.UTF_8)).trim();
String unified = normalize(Files.readString(unifiedPath(), StandardCharsets.UTF_8));
assertThat(unified)
.contains("-- 来源:firstleg/src/main/resources/db/phase3-inbounded-status-unification.sql")
.contains(module);
}
private static Path unifiedPath() {
Path path = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql");
if (Files.exists(path)) {
return path;
}
return Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql").normalize();
}
private static String normalize(String value) {
return value.replace("\r\n", "\n").replace("\uFEFF", "");
}
}
@@ -0,0 +1,93 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("清单持久化SQL契约")
class LoadingListQueryMapperContractTest {
@Test
void annotationScriptsShouldParseAsMyBatisStatements() {
Configuration configuration = new Configuration();
configuration.addMapper(LoadingListQueryMapper.class);
assertThat(configuration.hasStatement(
LoadingListQueryMapper.class.getName() + ".selectPreloadPage")).isTrue();
assertThat(configuration.hasStatement(
LoadingListQueryMapper.class.getName() + ".advance")).isTrue();
}
@Test
void bothListsShouldProjectTheSameLoadingPlanAggregate() throws Exception {
String preload = sql("selectPreloadPage", Select.class);
String confirmed = sql("selectConfirmedPage", Select.class);
assertThat(preload)
.contains("FROM fl_loading_plan p")
.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");
}
@Test
void mutationsShouldUseStatusAndOptimisticVersionConditions() throws Exception {
String touch = sql("touchEditable", Update.class);
String transport = sql("updateTransport", Update.class);
String advance = sql("advance", Update.class);
assertThat(touch)
.contains("plan_status = 'LOADING'")
.contains("version = #{expectedVersion}")
.contains("version = version + 1");
assertThat(transport)
.contains("SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo}")
.doesNotContain("DELETE")
.doesNotContain("fl_loading_plan_item");
assertThat(advance)
.contains("plan_status = #{fromStatus}")
.contains("plan_status = #{toStatus}")
.contains("version = #{expectedVersion}")
.doesNotContain("ROLLED_BACK");
}
@Test
void everyWriteShouldAppendAnImmutableHistoryOperation() throws Exception {
String insert = sql("insertHistory", Insert.class);
assertThat(insert)
.contains("INSERT INTO fl_loading_plan_history")
.contains("operation_id")
.contains("before_snapshot")
.contains("after_snapshot")
.contains("operator_id");
}
private static String sql(String methodName, Class<?> annotationType) throws Exception {
Method method = java.util.Arrays.stream(LoadingListQueryMapper.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals(methodName))
.findFirst().orElseThrow();
if (annotationType == Select.class) {
return String.join("\n", method.getAnnotation(Select.class).value());
}
if (annotationType == Update.class) {
return String.join("\n", method.getAnnotation(Update.class).value());
}
return String.join("\n", method.getAnnotation(Insert.class).value());
}
}
@@ -0,0 +1,49 @@
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;
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");
}
@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");
}
}
@@ -0,0 +1,45 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper;
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 SmartLoadingCandidateMapperContractTest {
@Test
void selectsLatestApprovedBatch() throws Exception {
Method list = SmartLoadingCandidateMapper.class.getDeclaredMethod("candidates", long.class);
String sql = String.join(" ", list.getAnnotation(Select.class).value());
assertThat(sql)
.contains("o.destination_warehouse_id destinationWarehouseId")
.contains("m.status='APPROVED'")
.contains("SELECT MAX(m2.id)")
.contains("m2.status='APPROVED'")
.contains("i.inbound_detail_id IS NOT NULL")
.contains("o.warehouse_id=#{warehouseId}")
.contains("o.process_status='INBOUNDED'")
.contains("SELECT SUM(li.package_count)")
.contains("li.deleted=0")
.contains("ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC");
}
@Test
void detailUsesSameBatchRule() throws Exception {
Method detail = SmartLoadingCandidateMapper.class.getDeclaredMethod(
"candidate", long.class, long.class, long.class);
String sql = String.join(" ", detail.getAnnotation(Select.class).value());
assertThat(sql)
.contains("o.destination_warehouse_id destinationWarehouseId")
.contains("m.status='APPROVED'")
.contains("SELECT MAX(m2.id)")
.contains("o.warehouse_id=#{warehouseId}")
.contains("o.process_status='INBOUNDED'")
.contains("SELECT SUM(li.package_count)")
.contains("FOR UPDATE");
}
}
@@ -0,0 +1,177 @@
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 java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("智能装柜SQL契约")
class SmartLoadingSqlContractTest {
private static final Pattern PERMISSION_PATTERN = Pattern.compile("'((?:platform:firstleg:)[^']+)'");
private static final Set<String> PHASE3_PERMISSIONS = Set.of(
"platform:firstleg:container:list", "platform:firstleg:container:view",
"platform:firstleg:container:create", "platform:firstleg:container:edit",
"platform:firstleg:smart-loading:list", "platform:firstleg:smart-loading:create",
"platform:firstleg:smart-loading:assign", "platform:firstleg:smart-loading:delete",
"platform:firstleg:smart-loading:push", "platform:firstleg:preload:list",
"platform:firstleg:preload:view", "platform:firstleg:preload:edit",
"platform:firstleg:preload:complete", "platform:firstleg:preload:confirm",
"platform:firstleg:loading-list:list", "platform:firstleg:loading-list:view");
@Test
@DisplayName("平台聚合与客户明细边界及原型快照字段完整")
void shouldProvidePlatformAggregatesAndAccountScopedSnapshots() throws Exception {
assertThat(migration())
.contains("CREATE TABLE IF NOT EXISTS fl_container_booking")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_plan")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_plan_item")
.contains("CREATE TABLE IF NOT EXISTS fl_container_document")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_plan_history")
.contains("account_id BIGINT UNSIGNED NOT NULL")
.contains("customer_name VARCHAR(128) NOT NULL")
.contains("warehouse_id BIGINT UNSIGNED NOT NULL")
.contains("cabinet_no VARCHAR(64) NOT NULL")
.contains("bill_of_lading_no VARCHAR(128)")
.contains("destination_warehouse_name VARCHAR(128)")
.contains("product_name_zh VARCHAR(255)")
.contains("product_name_en VARCHAR(255)")
.contains("product_image_file_id BIGINT UNSIGNED")
.contains("pallet_count INT UNSIGNED")
.contains("net_weight_kg DECIMAL(18,3)")
.contains("dimensional_weight_kg DECIMAL(18,3)")
.contains("product_quantity INT UNSIGNED")
.contains("units_per_carton INT UNSIGNED")
.contains("fba_tracking_no VARCHAR(128)")
.contains("shipment_no VARCHAR(128)")
.contains("shipping_mark VARCHAR(500)")
.contains("internal_remark VARCHAR(1000)")
.contains("customer_remark VARCHAR(1000)")
.contains("warehouse_remark VARCHAR(1000)");
}
@Test
@DisplayName("活动明细唯一锁、乐观锁和双层幂等字段完整")
void shouldProvideConcurrencyAndIdempotencyBoundaries() throws Exception {
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_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)")
.contains("UNIQUE KEY uk_loading_history_operation (operation_id)")
.contains("version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'");
}
@Test
@DisplayName("平台菜单按钮权限使用统一前缀并授权运营角色")
void shouldProvidePlatformPermissions() throws Exception {
assertThat(migration())
.contains("platform:firstleg:container:view")
.contains("platform:firstleg:smart-loading:push")
.contains("platform:firstleg:preload:confirm")
.contains("platform:firstleg:loading-list:view")
.contains("role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')")
.contains("智能装柜迁移失败:平台权限资源未完整创建")
.contains("智能装柜迁移失败:平台角色授权未完整写入");
}
@Test
@DisplayName("模块脚本和统一脚本均校验16项权限与两个角色32项授权")
void shouldAssertCompletePermissionGrants() throws Exception {
assertPermissionContract(migration());
assertPermissionContract(unifiedMigration());
assertThat(unifiedMigration()).contains(migration().trim());
}
@Test
@DisplayName("计划状态只允许编辑中、装柜中和已完成单向推进")
void shouldEnforceOneWayStatusAndPermanentFreeze() throws Exception {
assertThat(migration())
.contains("CHECK (plan_status IN ('EDITING', 'LOADING', 'COMPLETED'))")
.contains("OLD.plan_status = 'EDITING' AND NEW.plan_status IN ('EDITING', 'LOADING')")
.contains("NEW.plan_status IN ('LOADING', 'COMPLETED')")
.contains("SET MESSAGE_TEXT = '装柜状态只允许单向推进'")
.contains("IF OLD.plan_status = 'EDITING' AND NEW.plan_status = 'LOADING' THEN")
.contains("ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN")
.contains("LEFT JOIN fl_loading_pick_item pick_item")
.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("装柜完成后的明细永久冻结")
.contains("装柜中仅允许修改集装箱号与航次")
.contains("装柜完成后货柜全部只读,不允许修改")
.doesNotContain("preload_status")
.doesNotContain("PRELOAD_PUSHED")
.doesNotContain("PRELOADING")
.doesNotContain("PRELOAD_COMPLETED")
.doesNotContain("CONFIRMED")
.doesNotContain("ROLLED_BACK")
.doesNotContain("WITHDRAWN");
}
@Test
@DisplayName("PDA三表、唯一索引、向上取整和不可变审计契约完整")
void shouldProvidePdaContract() throws Exception {
assertThat(migration())
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_task")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_item")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_history")
.contains("UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id)")
.contains("UNIQUE KEY uk_loading_pick_item_plan_item (loading_plan_item_id)")
.contains("UNIQUE KEY uk_loading_pick_history_operation (operation_id)")
.contains("上层拆分按业务规则向上取整")
.contains("CONSTRAINT chk_loading_pick_item_planned CHECK (planned_package_count > 0)")
.contains("trg_loading_pick_task_before_update")
.contains("trg_loading_pick_item_before_update")
.contains("trg_loading_pick_item_before_delete")
.contains("trg_loading_pick_history_before_update")
.contains("trg_loading_pick_history_before_delete")
.contains("PDA唯一索引缺失")
.contains(")) <> 16 THEN");
}
private static String migration() throws Exception {
return new ClassPathResource("db/phase3-smart-loading-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);
}
private static void assertPermissionContract(String sql) {
int message = sql.indexOf("智能装柜迁移失败:平台角色授权未完整写入");
assertThat(message).isGreaterThan(0);
int grantStart = sql.lastIndexOf("INSERT IGNORE INTO iam_role_permission", message);
int grantEnd = sql.indexOf(';', grantStart);
assertThat(permissions(sql.substring(grantStart, grantEnd))).isEqualTo(PHASE3_PERMISSIONS);
int assertionStart = sql.lastIndexOf("IF (SELECT COUNT(DISTINCT CONCAT", message);
String assertion = sql.substring(assertionStart, message);
assertThat(permissions(assertion)).isEqualTo(PHASE3_PERMISSIONS);
assertThat(assertion).contains("<> 32 THEN");
}
private static Set<String> permissions(String sql) {
Matcher matcher = PERMISSION_PATTERN.matcher(sql);
return matcher.results().map(result -> result.group(1)).collect(Collectors.toSet());
}
}
@@ -0,0 +1,205 @@
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;
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;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
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;
@DisplayName("货柜基础资料服务")
@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() {
ContainerBookingEntity created = service.create(9L, "admin", request("柜-001"));
ArgumentCaptor<ContainerBookingEntity> captor = ArgumentCaptor.forClass(ContainerBookingEntity.class);
verify(repository).insert(captor.capture());
ContainerBookingEntity saved = captor.getValue();
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");
}
@Test
@DisplayName("智能装柜草稿阶段允许完整编辑")
void updatesAllFieldsWhileEditing() {
ContainerBookingEntity current = booking("EDITING", 2);
when(repository.find(11L)).thenReturn(current);
when(repository.updateDraft(any(), org.mockito.ArgumentMatchers.eq(2),
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq("admin"))).thenReturn(1);
service.updateDraft(11L, 9L, "admin", new FullUpdateRequest(2, request("柜-NEW")));
assertThat(current.getCabinetNo()).isEqualTo("柜-NEW");
verify(repository).updateDraft(current, 2, 9L, "admin");
}
@Test
@DisplayName("进入预装柜中后拒绝完整编辑")
void rejectsFullUpdateAfterPreloadPush() {
when(repository.find(11L)).thenReturn(booking("LOADING", 2));
assertThatThrownBy(() -> service.updateDraft(
11L, 9L, "admin", new FullUpdateRequest(2, request("柜-NEW"))))
.isInstanceOf(BizException.class)
.hasMessageContaining("只能修改集装箱号和航次");
verify(repository, never()).updateDraft(any(), any(Integer.class),
any(Long.class), any(String.class));
}
@Test
@DisplayName("预装柜中和预装柜完成只更新集装箱号与航次")
void updatesTransportWhitelistAfterPush() {
for (String status : new String[] {"LOADING"}) {
ContainerBookingEntity current = booking(status, 2);
when(repository.find(11L)).thenReturn(current);
when(repository.updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin"))
.thenReturn(1);
service.updateTransport(11L, 9L, "admin",
new TransportUpdateRequest("MSCU1234567", "V002", 2));
}
verify(repository, org.mockito.Mockito.times(1))
.updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin");
}
@Test
@DisplayName("已确认货柜全部只读")
void rejectsConfirmedUpdate() {
when(repository.find(11L)).thenReturn(booking("COMPLETED", 2));
assertThatThrownBy(() -> service.updateTransport(11L, 9L, "admin",
new TransportUpdateRequest("MSCU1234567", "V002", 2)))
.isInstanceOf(BizException.class)
.hasMessageContaining("全部只读");
verify(repository, never()).updateTransport(any(Long.class), any(), any(),
any(Integer.class), any(Long.class), any());
}
@Test
@DisplayName("旧版本编辑请求触发乐观锁冲突")
void rejectsStaleVersion() {
when(repository.find(11L)).thenReturn(booking("EDITING", 3));
assertThatThrownBy(() -> service.updateDraft(
11L, 9L, "admin", new FullUpdateRequest(2, request("柜-NEW"))))
.isInstanceOf(BizException.class)
.hasMessageContaining("刷新后重试");
}
@Test
@DisplayName("新增时校验重复集装箱号和内部柜号")
void rejectsDuplicateNumbers() {
when(repository.containerNoExists("MSCU1234567", null)).thenReturn(true);
assertThatThrownBy(() -> service.create(9L, "admin", request("柜-001")))
.isInstanceOf(BizException.class)
.hasMessageContaining("集装箱号已存在");
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);
value.setContainerNo("MSCU1234567");
value.setCabinetNo("柜-001");
value.setPlanStatus(status);
value.setVersion(version);
return value;
}
private static SaveRequest request(String cabinetNo) {
return new SaveRequest("MSCU1234567", cabinetNo, 101L, "上海仓", "BOL-001", "柏悦轮",
"V001", "40HQ", 201L, "船司A", "CNSHA", "上海港", "USLAX", "洛杉矶港",
"ONT8", "ONT8", new BigDecimal("68.0000"), new BigDecimal("26000.000"), "拖车公司A",
LocalDateTime.of(2026, 8, 10, 8, 0), LocalDateTime.of(2026, 8, 12, 8, 0), null,
LocalDateTime.of(2026, 8, 25, 8, 0), null,
LocalDateTime.of(2026, 8, 27, 8, 0), null);
}
}
@@ -113,7 +113,9 @@ class FbaPricingServiceTest {
void calculatesAtWaitSplitWithoutRollingBackProcessStatus() {
readyRepository();
InboundPricingEntity inbound = inbound("100.000", "1.0000");
inbound.setProcessStatus("WAIT_SPLIT");
inbound.setProcessStatus("INBOUNDED");
CustomerChargeRuleEntity rule = rule("KG", "167.000000", 3, "CEILING");
CarrierFbaRateEntity rate = rate("KG", "2.000000", "0.00");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(priceBookEstimator.estimate(any())).thenReturn(
estimate("KG", "167.000", "2.000000", "334.00", "0.00"));
@@ -121,8 +123,8 @@ class FbaPricingServiceTest {
FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "wait-split");
assertThat(response.feeStatus()).isEqualTo("CALCULATED");
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
verify(repository).markInboundCalculated(TENANT, INBOUND, "WAIT_SPLIT", "KG",
assertThat(response.nextProcessStatus()).isEqualTo("INBOUNDED");
verify(repository).markInboundCalculated(TENANT, INBOUND, "INBOUNDED", "KG",
new BigDecimal("167.000"));
}
@@ -130,7 +132,7 @@ class FbaPricingServiceTest {
@DisplayName("待分柜边界确认费用后仍保持主流程状态")
void confirmsAtWaitSplitWithoutRollingBackProcessStatus() {
InboundPricingEntity inbound = inbound("10.000", "1.0000");
inbound.setProcessStatus("WAIT_SPLIT");
inbound.setProcessStatus("INBOUNDED");
inbound.setFeeStatus("CALCULATED");
InboundFeeEntity fee = savedFee("CALCULATED");
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(fee);
@@ -147,7 +149,7 @@ class FbaPricingServiceTest {
FeeSnapshotResponse response = service.confirm(
TENANT, OPERATOR, INBOUND, "confirm-wait-split");
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
assertThat(response.nextProcessStatus()).isEqualTo("INBOUNDED");
verify(repository).markInboundConfirmed(TENANT, INBOUND);
}
@@ -184,7 +186,7 @@ class FbaPricingServiceTest {
void returnsOriginalSnapshotForRepeatedCalculation() {
InboundPricingIdempotencyEntity completed = completed("FEE_CALCULATE", 501L);
InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("WAIT_SPLIT");
inbound.setProcessStatus("INBOUNDED");
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_CALCULATE", "same")).thenReturn(completed);
when(repository.findFee(TENANT, 501L)).thenReturn(savedFee("CALCULATED"));
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
@@ -194,8 +196,8 @@ class FbaPricingServiceTest {
FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "same");
assertThat(response.feeId()).isEqualTo(501L);
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
verify(priceBookEstimator, never()).estimate(any());
assertThat(response.nextProcessStatus()).isEqualTo("INBOUNDED");
verify(repository, never()).matchRules(any(), any());
verify(repository, never()).insertFee(any());
}
@@ -2,8 +2,11 @@ package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.client.MeasurementImageClient;
import com.baiyue.firstleg.dto.InboundMeasurementRequests;
import com.baiyue.firstleg.dto.MeasurementBatchCommand;
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
@@ -31,6 +34,7 @@ import static org.assertj.core.api.Assertions.tuple;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
@@ -44,6 +48,7 @@ import static org.mockito.Mockito.when;
class InboundMeasurementServiceTest {
@MockBean private InboundMeasurementRepository repository;
@MockBean private MeasurementBatchService batchService;
@MockBean private OrderCargoClient orderCargoClient;
@MockBean private MeasurementImageClient imageClient;
@Resource private InboundMeasurementService service;
@@ -480,7 +485,7 @@ class InboundMeasurementServiceTest {
@Test
void editingAndResubmittingInboundMeasurementDoesNotRollBackMainStatus() {
InboundOrderEntity inbound = inbound("WAIT_SPLIT");
InboundOrderEntity inbound = inbound("INBOUNDED");
inbound.setMeasureReviewStatus("APPROVED");
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(30L);
@@ -498,13 +503,40 @@ class InboundMeasurementServiceTest {
InboundMeasurementResponses.MeasurementSubmitted result = service.submit(1L, 10L, 9L,
new InboundMeasurementRequests.SubmitRequest(30L, 2));
assertThat(result.processStatus()).isEqualTo("WAIT_SPLIT");
assertThat(result.processStatus()).isEqualTo("INBOUNDED");
assertThat(result.measureReviewStatus()).isEqualTo("APPROVED");
assertThat(measurement.getStatus()).isEqualTo("APPROVED");
verify(repository, never()).updateMeasurement(any());
verify(repository).updateInbound(inbound);
}
@Test
void savingDraftValidatesCargoAndPersistsInboundDetailBinding() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
inbound.setOrderId(80L);
InboundMeasurementEntity saved = new InboundMeasurementEntity();
saved.setId(30L);
saved.setVersion(0);
saved.setStatus("DRAFT");
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(orderCargoClient.detail(eq(""), eq(1L), eq(80L), eq(80001L))).thenReturn(
ApiResponse.success(new OrderCargoClient.CargoBinding(80001L, 80L, 1, "Test cargo",
2, new BigDecimal("6.500"), new BigDecimal("0.0480"))));
when(batchService.saveBatch(any())).thenReturn(saved);
InboundMeasurementResponses.DraftSaved result = service.saveDraft(1L, 10L, 9L,
new InboundMeasurementRequests.SaveDraftRequest(null, 0, null, null, null, null,
List.of(new InboundMeasurementRequests.ItemRequest(80001L, "001", "A", 2,
new BigDecimal("40"), new BigDecimal("30"), new BigDecimal("20"),
new BigDecimal("3.250"), null, null, null, null, null, null))));
assertThat(result.measurementId()).isEqualTo(30L);
ArgumentCaptor<MeasurementBatchCommand> command = ArgumentCaptor.forClass(MeasurementBatchCommand.class);
verify(batchService).saveBatch(command.capture());
assertThat(command.getValue().items().get(0).inboundDetailId()).isEqualTo(80001L);
verify(orderCargoClient).detail("", 1L, 80L, 80001L);
}
private InboundOrderEntity inbound(String status) {
InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setId(10L);
@@ -0,0 +1,188 @@
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.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;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
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.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@DisplayName("预装与装柜清单状态服务")
@SpringJUnitConfig(classes = {LoadingListService.class, LoadingListServiceTest.Config.class})
class LoadingListServiceTest {
@MockBean
private LoadingListRepository repository;
@MockBean
private LoadingPickRepository pickRepository;
@Resource
private LoadingListService service;
@TestConfiguration
static class Config {
@Bean
ObjectMapper objectMapper() {
return new ObjectMapper();
}
}
@Test
void shouldRejectEditingStatusFromPreloadList() {
PreloadQuery query = new PreloadQuery(null, "EDITING", 100L, null, null, 1, 20);
assertThatThrownBy(() -> service.preloadPage(query))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.BAD_REQUEST);
}
@Test
void shouldOnlyUpdateTransportFieldsWithOptimisticLockAndHistory() {
AggregateRow before = row("LOADING", 3);
before.setContainerNo("OLD");
before.setVoyageNo("001E");
AggregateRow after = row("LOADING", 4);
after.setContainerNo("CSLU1234567");
after.setVoyageNo("023E");
when(repository.history(anyString())).thenReturn(null);
when(repository.aggregate(10L)).thenReturn(before, after);
when(repository.updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator"))
.thenReturn(1);
when(repository.preloadDetail(after)).thenReturn(
new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of()));
service.updateTransport(10L, 100L, 9L, "operator", "key-1",
new TransportUpdateRequest(" CSLU1234567 ", "023E", 3, "补录箱号"));
verify(repository).updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator");
verify(repository).appendHistory(eq(10L), anyString(), eq("UPDATE_PRELOAD_TRANSPORT"),
eq("LOADING"), eq("LOADING"), anyString(), anyString(), eq(9L),
eq("operator"), eq("补录箱号"));
}
@Test
void shouldRejectTransportUpdateAfterConfirmation() {
when(repository.history(anyString())).thenReturn(null);
when(repository.aggregate(10L)).thenReturn(row("COMPLETED", 5));
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "key-1",
new TransportUpdateRequest("CSLU1234567", "023E", 5, null)))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
verify(repository, never()).updateTransport(eq(10L), eq(5), anyString(), anyString(), eq(9L), anyString());
}
@Test
void shouldCompleteLoadingAfterAllPdaItemsPicked() {
AggregateRow before = row("LOADING", 2);
when(pickRepository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.history(anyString())).thenReturn(null);
when(repository.aggregate(10L)).thenReturn(before);
when(repository.advance(10L, "LOADING", "COMPLETED", 2, 9L, "operator"))
.thenReturn(1);
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
void shouldReplaySameTransportRequestWithoutSecondMutation() {
AggregateRow before = row("LOADING", 3);
AggregateRow after = row("LOADING", 4);
after.setContainerNo("CSLU1234567");
after.setVoyageNo("023E");
when(repository.history(anyString())).thenReturn(null);
when(repository.aggregate(10L)).thenReturn(before, after);
when(repository.updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator"))
.thenReturn(1);
when(repository.preloadDetail(after)).thenReturn(
new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of()));
ArgumentCaptor<String> snapshot = ArgumentCaptor.forClass(String.class);
TransportUpdateRequest request = new TransportUpdateRequest("CSLU1234567", "023E", 3, null);
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));
HistoryRow replay = new HistoryRow();
replay.setAfterSnapshot(snapshot.getValue());
when(repository.history(anyString())).thenReturn(replay);
when(repository.aggregate(10L)).thenReturn(after);
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 ", 100L, null, null, 1, 20);
when(repository.confirmedPage(org.mockito.ArgumentMatchers.any())).thenReturn(
new PageResult<>(List.of(), 1, 20, 0, 0));
service.confirmedPage(query);
verify(repository).confirmedPage(org.mockito.ArgumentMatchers.argThat(value ->
"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;
}
}
@@ -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, 101L)).thenReturn(item);
when(repository.pick(30L, 2, 0, 101L)).thenReturn(1);
when(repository.allPlanItemsPicked(10L)).thenReturn(false);
when(repository.items(20L, 101L)).thenReturn(List.of(item));
service.pick(20L, 30L, new PickRequest(101L, 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, 101L)).thenReturn(item);
when(repository.pick(30L, 2, 0, 101L)).thenReturn(1);
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.items(20L, 101L)).thenReturn(List.of(item));
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));
}
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;
}
}
@@ -79,6 +79,7 @@ class MeasurementBatchServiceTest {
ArgumentCaptor<List<InboundMeasurementItemEntity>> items = ArgumentCaptor.forClass(List.class);
verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L),
org.mockito.ArgumentMatchers.eq(100L), items.capture());
assertThat(items.getValue().get(0).getInboundDetailId()).isEqualTo(80001L);
assertThat(items.getValue().get(0).getTotalWeightKg()).isEqualByComparingTo("6.500");
}
@@ -131,7 +132,7 @@ class MeasurementBatchServiceTest {
private MeasurementBatchCommand.Item item(BigDecimal unitWeight) {
return new MeasurementBatchCommand.Item(
"001", "A", 2, new BigDecimal("40"), new BigDecimal("30"),
80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"),
new BigDecimal("20"), unitWeight, 500L, null,
new BigDecimal("41"), new BigDecimal("31"), new BigDecimal("21"), "备注");
}
@@ -72,6 +72,7 @@ class MeasurementLocationOccupationServiceTest {
org.mockito.ArgumentMatchers.eq(WAREHOUSE),
org.mockito.ArgumentMatchers.eq(PLATFORM_RESOURCE_TENANT),
org.mockito.ArgumentMatchers.eq(new BigDecimal("1.200000")),
org.mockito.ArgumentMatchers.eq(20),
org.mockito.ArgumentMatchers.any());
}
@@ -102,6 +103,7 @@ class MeasurementLocationOccupationServiceTest {
org.mockito.ArgumentMatchers.eq(WAREHOUSE),
org.mockito.ArgumentMatchers.eq(PLATFORM_RESOURCE_TENANT),
org.mockito.ArgumentMatchers.eq(new BigDecimal("1.200000")),
org.mockito.ArgumentMatchers.eq(20),
org.mockito.ArgumentMatchers.any());
}
@@ -126,7 +128,7 @@ class MeasurementLocationOccupationServiceTest {
assertThat(release.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT);
assertThat(release.getValue().businessId()).isEqualTo(INBOUND);
verify(allocations).replace(BUSINESS_TENANT, INBOUND, WAREHOUSE, PLATFORM_RESOURCE_TENANT,
new BigDecimal("1.200000"), batch("RESERVED"));
new BigDecimal("1.200000"), 20, batch("RESERVED"));
}
private static InboundProperties properties() {
@@ -150,6 +152,7 @@ class MeasurementLocationOccupationServiceTest {
value.setWarehouseId(WAREHOUSE);
value.setInboundCode("RK-001");
value.setActualVolumeCbm(new BigDecimal("1.0000"));
value.setTotalPackageCount(20);
return value;
}
@@ -77,7 +77,7 @@ class MeasurementReviewServiceTest {
ArgumentCaptor.forClass(InboundStatusHistoryEntity.class);
verify(fixture.repository).insertHistory(history.capture());
assertThat(history.getValue().getFromStatus()).isEqualTo("MEASURE_PENDING");
assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_SPLIT");
assertThat(history.getValue().getToStatus()).isEqualTo("INBOUNDED");
}
@Test
@@ -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;
}
}
@@ -0,0 +1,354 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.dto.SmartLoadingDtos.AssignRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate;
import com.baiyue.firstleg.dto.SmartLoadingDtos.DeleteRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Plan;
import com.baiyue.firstleg.dto.SmartLoadingDtos.PushRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.RecommendRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowableOfType;
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.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(SmartLoadingServiceTest.Config.class)
class SmartLoadingServiceTest {
@MockBean private SmartLoadingRepository repository;
@MockBean private OrderCargoClient cargoClient;
@MockBean private LoadingPickService loadingPickService;
@Resource private SmartLoadingService service;
@TestConfiguration
static class Config {
@Bean SmartLoadingService smartLoadingService() { return new SmartLoadingService(); }
@Bean ObjectMapper objectMapper() { return new ObjectMapper(); }
}
@BeforeEach
void setUp() {
when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(
ApiResponse.success(new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 5,
new BigDecimal("10"), new BigDecimal("2"))));
}
@Test
void restoresDrafts() {
LoadingPlanEntity plan = plan("EDITING", 2);
when(repository.booking(99L)).thenReturn(booking());
when(repository.planByBooking(99L)).thenReturn(plan);
when(repository.plan(1L)).thenReturn(plan);
when(repository.items(1L)).thenReturn(List.of(item(8, 9, 20, 1)));
assertThat(service.draftByBooking(99L).id()).isEqualTo(1L);
assertThat(service.detail(1L).bookingId()).isEqualTo(99L);
}
@Test
void returnsExistingDraftOnCreate() {
LoadingPlanEntity plan = plan("EDITING", 2);
when(repository.booking(99L)).thenReturn(booking());
when(repository.planByBooking(99L)).thenReturn(plan);
when(repository.items(1L)).thenReturn(List.of());
Plan result = service.create(99L, "备注", 7L, "u");
assertThat(result.id()).isEqualTo(1L);
verify(repository, never()).insert(any(LoadingPlanEntity.class));
}
@Test
void rejectsExcessBoxes() {
LoadingPlanEntity plan = plan("EDITING", 0);
when(repository.plan(1)).thenReturn(plan);
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(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());
}
@Test
void snapshotsCargoFields() {
stubAssignment();
when(cargoClient.detail(anyString(), eq(9L), eq(30L), eq(20L))).thenReturn(ApiResponse.success(
new OrderCargoClient.CargoBinding(20L, 30L, 1, "中文品名", 10,
new BigDecimal("20"), new BigDecimal("2"), "SKU-1", "English name",
4, 100, new BigDecimal("10"), "货物备注")));
doAnswer(invocation -> {
Object value = invocation.getArgument(0);
if (value instanceof LoadingPlanItemEntity item) item.setId(88L);
return null;
}).when(repository).insert(any(LoadingPlanItemEntity.class));
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());
LoadingPlanItemEntity saved = itemCaptor.getValue();
assertThat(saved.getDestinationWarehouseId()).isEqualTo(200L);
assertThat(saved.getSkuCode()).isEqualTo("SKU-1");
assertThat(saved.getProductNameEn()).isEqualTo("English name");
assertThat(saved.getUnitsPerCarton()).isEqualTo(4);
assertThat(saved.getProductQuantity()).isEqualTo(20);
assertThat(saved.getNetWeightKg()).isEqualByComparingTo("5");
assertThat(saved.getInternalRemark()).isEqualTo("货物备注");
ArgumentCaptor<LoadingPlanHistoryEntity> historyCaptor = ArgumentCaptor.forClass(LoadingPlanHistoryEntity.class);
verify(repository).insert(historyCaptor.capture());
assertThat(historyCaptor.getValue().getLoadingPlanItemId()).isEqualTo(88L);
}
@Test
void roundsQuantityWithoutLoss() {
stubAssignment();
when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(ApiResponse.success(
new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 3,
BigDecimal.ONE, BigDecimal.ONE, null, null, null, 100, null, null)));
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());
assertThat(captor.getValue().getProductQuantity()).isEqualTo(67);
}
@Test
void replaysBeforeVersionCheck() {
AssignRequest request = new AssignRequest(10L, 20L, 2, 0, "assign-replay");
LoadingPlanHistoryEntity replay = history(1, null, "ADD_ITEM",
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);
when(repository.booking(99)).thenReturn(booking());
when(repository.items(1)).thenReturn(List.of());
Plan result = service.assign(1, request, 7, "u");
assertThat(result.status()).isEqualTo("LOADING");
verify(repository, never()).candidate(anyLong(), anyLong(), anyLong());
verify(cargoClient, never()).detail(anyString(), anyLong(), anyLong(), anyLong());
}
@Test
void rejectsReusedOperation() {
AssignRequest request = new AssignRequest(10L, 20L, 2, 0, "shared-op");
when(repository.operation("shared-op")).thenReturn(history(2, null, "ADD_ITEM",
sha256("ASSIGN|1|10|20|2|0")));
BizException error = catchThrowableOfType(() -> service.assign(1, request, 7, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
}
@Test
void replaysRecommendation() {
RecommendRequest request = new RecommendRequest(0, "recommend-replay");
when(repository.operation("recommend-replay")).thenReturn(history(1, null,
"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());
Plan result = service.recommend(1, request, 7, "u");
assertThat(result.status()).isEqualTo("LOADING");
verify(repository, never()).candidates(anyLong());
}
@Test
void replaysDeleteBeforeVersionCheck() {
DeleteRequest request = new DeleteRequest(2, 0, "delete-replay");
when(repository.operation("delete-replay")).thenReturn(history(1, 8L,
"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());
Plan result = service.delete(1, 8, request, 7, "u");
assertThat(result.status()).isEqualTo("LOADING");
verify(repository, never()).item(anyLong(), anyLong());
verify(repository, never()).remove(anyLong(), anyLong(), anyInt(), anyLong(), any());
}
@Test
void deleteTouchesPlan() {
LoadingPlanEntity plan = plan("EDITING", 2);
LoadingPlanItemEntity item = item(8, 9, 20, 1);
when(repository.plan(1)).thenReturn(plan, plan("EDITING", 3));
when(repository.item(1, 8)).thenReturn(item);
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());
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");
}
@Test
void preservesDownstreamCode() {
stubAssignment();
when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(
ApiResponse.failure(ResultCode.NOT_FOUND, "货物不存在", null, null));
BizException error = catchThrowableOfType(() -> service.assign(1,
new AssignRequest(10L, 20L, 2, 0, "assign-not-found"), 7, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.NOT_FOUND);
}
@Test
void mapsFeignFailure() {
stubAssignment();
when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong()))
.thenThrow(mock(FeignException.class));
BizException error = catchThrowableOfType(() -> service.assign(1,
new AssignRequest(10L, 20L, 2, 0, "assign-down"), 7, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
}
@Test
void rejectsCargoMismatch() {
stubAssignment();
when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(ApiResponse.success(
new OrderCargoClient.CargoBinding(99L, 30L, 1, "货物", 5, BigDecimal.ONE, BigDecimal.ONE)));
BizException error = catchThrowableOfType(() -> service.assign(1,
new AssignRequest(10L, 20L, 2, 0, "assign-mismatch"), 7, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
}
@Test
void deletesCustomerItemInThePlanWarehouse() {
LoadingPlanEntity plan = plan("EDITING", 2);
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());
Plan result = service.delete(1, 8,
new DeleteRequest(2, 0, "delete-owner"), 7, "u");
assertThat(result.version()).isEqualTo(3);
verify(repository).remove(1, 8, 0, 7, "u");
}
@Test
void pushesIdempotently() {
LoadingPlanEntity editing = plan("EDITING", 3);
LoadingPlanEntity pushed = plan("LOADING", 4);
pushed.setPushIdempotencyKey("key");
when(repository.plan(1)).thenReturn(editing, pushed);
when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1)));
when(repository.booking(99)).thenReturn(booking());
when(repository.push(1, "key", 3, 7, "u")).thenReturn(1);
Plan result = service.pushPreload(1, "key", new PushRequest(3), 7, "u");
assertThat(result.status()).isEqualTo("LOADING");
verify(repository).push(1, "key", 3, 7, "u");
}
private void stubAssignment() {
LoadingPlanEntity plan = plan("EDITING", 0);
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(100, 10, 20)).thenReturn(candidate(10));
when(repository.activeItem(1L, 9L, 20L)).thenReturn(null);
when(repository.touch(1, 0, 7, "u")).thenReturn(1);
}
private static LoadingPlanHistoryEntity history(long planId, Long itemId, String action, String hash) {
LoadingPlanHistoryEntity value = new LoadingPlanHistoryEntity();
value.setLoadingPlanId(planId);
value.setLoadingPlanItemId(itemId);
value.setActionType(action);
value.setAfterSnapshot("{\"requestHash\":\"" + hash + "\"}");
return value;
}
private static String sha256(String value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (Exception exception) {
throw new IllegalStateException(exception);
}
}
private static LoadingPlanEntity plan(String status, int version) {
LoadingPlanEntity value = new LoadingPlanEntity();
value.setId(1L); value.setContainerBookingId(99L); value.setPlanNo("LP1");
value.setPlanStatus(status); value.setVersion(version);
return value;
}
private static LoadingPlanItemEntity item(long id, long account, long detail, int boxes) {
LoadingPlanItemEntity value = new LoadingPlanItemEntity();
value.setId(id); value.setLoadingPlanId(1L); value.setAccountId(account);
value.setInboundDetailId(detail); value.setPackageCount(boxes);
value.setVersion(0); value.setDeleted(false);
return value;
}
private static Candidate candidate(int boxes) {
return new Candidate(9L, 10L, 20L, 30L, "O", "I", 100L, 200L,
"FBA1", "FBA仓", "客户", "货物", boxes, new BigDecimal("2"), new BigDecimal("0.5"));
}
private static ContainerBookingEntity booking() {
ContainerBookingEntity value = new ContainerBookingEntity();
value.setId(99L); value.setWarehouseId(100L); value.setDestinationWarehouseCode("FBA1");
value.setContainerNo("MSCU1234567");
value.setCapacityCbm(new BigDecimal("60")); value.setMaxWeightKg(new BigDecimal("20000"));
return value;
}
}
@@ -0,0 +1,19 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.domain.SmartLoadingStatus;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class SmartLoadingStatusTest {
@Test
void statusCanOnlyAdvanceOneWay() {
assertThat(SmartLoadingStatus.EDITING.canAdvanceTo(SmartLoadingStatus.LOADING)).isTrue();
assertThat(SmartLoadingStatus.LOADING.canAdvanceTo(SmartLoadingStatus.COMPLETED)).isTrue();
assertThat(SmartLoadingStatus.COMPLETED.canAdvanceTo(SmartLoadingStatus.LOADING)).isFalse();
assertThat(SmartLoadingStatus.EDITING.mutable()).isTrue();
assertThat(SmartLoadingStatus.LOADING.mutable()).isTrue();
assertThat(SmartLoadingStatus.LOADING.allowsTransportUpdate()).isTrue();
assertThat(SmartLoadingStatus.COMPLETED.allowsTransportUpdate()).isFalse();
}
}
@@ -14,9 +14,10 @@ class UnifiedInboundQueryServiceTest {
assertThat(UnifiedInboundQueryService.mainStatus("MEASURING")).isEqualTo("WAIT_MEASURE");
assertThat(UnifiedInboundQueryService.mainStatus("MEASURE_PENDING")).isEqualTo("WAIT_MEASURE");
assertThat(UnifiedInboundQueryService.mainStatus("MEASURE_REJECTED")).isEqualTo("WAIT_MEASURE");
assertThat(UnifiedInboundQueryService.mainStatus("WAIT_PRICING")).isEqualTo("WAIT_SPLIT");
assertThat(UnifiedInboundQueryService.mainStatus("FEE_CONFIRMING")).isEqualTo("WAIT_SPLIT");
assertThat(UnifiedInboundQueryService.mainStatus("INBOUNDING")).isEqualTo("WAIT_SPLIT");
assertThat(UnifiedInboundQueryService.mainStatus("WAIT_PRICING")).isEqualTo("INBOUNDED");
assertThat(UnifiedInboundQueryService.mainStatus("FEE_CONFIRMING")).isEqualTo("INBOUNDED");
assertThat(UnifiedInboundQueryService.mainStatus("INBOUNDING")).isEqualTo("INBOUNDED");
assertThat(UnifiedInboundQueryService.mainStatus("WAIT_SPLIT")).isEqualTo("INBOUNDED");
assertThat(UnifiedInboundQueryService.mainStatus("INBOUNDED")).isEqualTo("INBOUNDED");
assertThat(UnifiedInboundQueryService.mainStatus("LOADING")).isEqualTo("LOADING");
assertThat(UnifiedInboundQueryService.mainStatus("LOADED")).isEqualTo("LOADED");
@@ -29,13 +30,14 @@ class UnifiedInboundQueryServiceTest {
assertThat(UnifiedInboundQueryService.nextAction("WAIT_MEASURE", "MEASURING", null)).isNull();
assertThat(UnifiedInboundQueryService.nextAction("WAIT_MEASURE", "MEASURE_PENDING", null))
.isEqualTo("CONFIRM_MEASUREMENT");
assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "WAIT_SPLIT", null))
assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDED", null))
.isEqualTo("SMART_SPLIT");
assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "WAIT_SPLIT", "PENDING"))
assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDED", "PENDING"))
.isEqualTo("CONFIRM_PRELOAD");
assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "INBOUNDING", null)).isNull();
assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "WAIT_SPLIT", "CONFIRMED")).isNull();
assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDED", null)).isNull();
assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDING", null)).isNull();
assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDED", "CONFIRMED")).isNull();
assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "WAIT_SPLIT", null))
.isEqualTo("SMART_SPLIT");
assertThat(UnifiedInboundQueryService.nextAction("LOADED", "LOADED", null))
.isEqualTo("COMPLETE_DELIVERY");
assertThat(UnifiedInboundQueryService.nextAction("DELIVERED", "DELIVERED", null)).isNull();
@@ -44,19 +44,19 @@ class SplitPreloadServiceTest {
@Test
void smartSplitCreatesEditablePendingPlan() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 3));
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "INBOUNDED", 3));
when(repository.findPlan(10, 20)).thenReturn(null);
SmartSplitRequest request = new SmartSplitRequest(0,
List.of(new PreloadItem("C1", "A", 2, BigDecimal.ONE, null)), "r");
SplitActionResponse result = service.smartSplit(10, 20, 7, "admin", "k2", request);
assertThat(result.mainStatus()).isEqualTo("WAIT_SPLIT");
assertThat(result.mainStatus()).isEqualTo("INBOUNDED");
verify(repository).createPlan(any(PreloadPlanEntity.class));
}
@Test
void confirmsPlanAndMovesInboundToLoading() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 4));
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "INBOUNDED", 4));
PreloadPlanEntity plan = new PreloadPlanEntity();
plan.setTenantId(10L); plan.setInboundId(20L); plan.setStatus("CONFIRMING"); plan.setVersion(1);
plan.setItemsJson("[]");
@@ -0,0 +1,83 @@
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 {
assertCurrentContract(moduleMigration());
}
@Test
void limitsSecondaryInboundDepth() throws Exception {
String sql = moduleMigration();
assertThat(sql)
.contains("parent_inbound_id")
.contains("inbound_role")
.contains("root_inbound_id")
.contains("split_status")
.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_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_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 {
assertCurrentContract(unifiedMigration());
}
private static void assertCurrentContract(String sql) {
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("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-inbound-parent-child-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);
}
}