This commit is contained in:
2026-08-06 15:48:21 +08:00
parent 4df8b8c355
commit c3a978b497
71 changed files with 6268 additions and 13 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 + "')");
}
}
@@ -0,0 +1,120 @@
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;
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);
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);
}
@Test
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
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 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,89 @@
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'")
.doesNotContain("PRELOAD_PUSHED");
assertThat(confirmed)
.contains("FROM fl_loading_plan p")
.contains("p.plan_status = 'COMPLETED'")
.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,37 @@
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("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)");
}
}
@@ -0,0 +1,176 @@
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_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,148 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
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.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.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@DisplayName("货柜基础资料服务")
@SpringJUnitConfig(ContainerService.class)
class ContainerServiceTest {
@MockBean private ContainerRepository repository;
@Resource private ContainerService service;
@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.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());
}
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);
}
}
@@ -0,0 +1,162 @@
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.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;
@Resource
private LoadingListService service;
@TestConfiguration
static class Config {
@Bean
ObjectMapper objectMapper() {
return new ObjectMapper();
}
}
@Test
void shouldRejectEditingStatusFromPreloadList() {
PreloadQuery query = new PreloadQuery(null, "EDITING", null, null, 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, 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, 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 shouldAdvanceOnlyLoadingToCompleted() {
AggregateRow before = row("LOADING", 2);
AggregateRow after = row("COMPLETED", 3);
when(repository.history(anyString())).thenReturn(null);
when(repository.aggregate(10L)).thenReturn(before, after);
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));
verify(repository).advance(10L, "LOADING", "COMPLETED", 2, 9L, "operator");
}
@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, 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, 9L, "operator", "key-1", request);
verify(repository, times(1)).updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator");
}
@Test
void confirmedPageAlwaysUsesRepositoryConfirmedProjection() {
ConfirmedQuery query = new ConfirmedQuery(" plan ", null, null, null, null, 1, 20);
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())));
}
private static AggregateRow row(String status, int version) {
AggregateRow row = new AggregateRow();
row.setPlanId(10L);
row.setPlanNo("PL-10");
row.setPlanStatus(status);
row.setVersion(version);
return row;
}
}
@@ -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;
}
@@ -0,0 +1,349 @@
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.candidate(9, 10, 20)).thenReturn(candidate(5));
when(repository.activeItem(9, 20)).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);
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(9L, 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(9L, 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(9L, 10L, 20L, 2, 0, "assign-replay");
LoadingPlanHistoryEntity replay = history(1, null, "ADD_ITEM",
sha256("ASSIGN|1|9|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(9L, 10L, 20L, 2, 0, "shared-op");
when(repository.operation("shared-op")).thenReturn(history(2, null, "ADD_ITEM",
sha256("ASSIGN|1|9|10|20|2|0")));
BizException error = catchThrowableOfType(() -> service.assign(1, request, 7, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
}
@Test
void replaysRecommendation() {
RecommendRequest request = new RecommendRequest(9L, 0, "recommend-replay");
when(repository.operation("recommend-replay")).thenReturn(history(1, null,
"SMART_RECOMMEND", sha256("RECOMMEND|1|9|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(9L, 2, 0, "delete-replay");
when(repository.operation("delete-replay")).thenReturn(history(1, 8L,
"REMOVE_ITEM", sha256("DELETE|1|8|9|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(9L, 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(9L, 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(9L, 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(9L, 10L, 20L, 2, 0, "assign-mismatch"), 7, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
}
@Test
void rejectsWrongAccountDelete() {
LoadingPlanEntity plan = plan("EDITING", 2);
when(repository.plan(1)).thenReturn(plan);
when(repository.item(1, 8)).thenReturn(item(8, 22, 20, 1));
BizException error = catchThrowableOfType(() -> service.delete(1, 8,
new DeleteRequest(9L, 2, 0, "delete-owner"), 7, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.FORBIDDEN);
verify(repository, never()).remove(anyLong(), anyLong(), anyInt(), anyLong(), any());
}
@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(9, 10, 20)).thenReturn(candidate(10));
when(repository.activeItem(9, 20)).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();
}
}