二期内容提交

This commit is contained in:
2026-08-04 10:07:45 +08:00
parent b2b4ff429d
commit 57b54a5236
184 changed files with 13195 additions and 5 deletions
@@ -0,0 +1,41 @@
package com.baiyue.warehouse.controller;
import io.swagger.v3.oas.annotations.Operation;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
class PlatformLocationControllerContractTest {
@Test
void 库位接口路径权限和Swagger说明完整() {
assertThat(PlatformLocationController.class.getAnnotation(RequestMapping.class).value())
.containsExactly("/api/platform/v1/locations");
assertMethod("page", GetMapping.class, "platform:location:list");
assertMethod("detail", GetMapping.class, "platform:location:list");
assertMethod("available", GetMapping.class, "platform:location:list");
assertMethod("create", PostMapping.class, "platform:location:create");
assertMethod("update", PutMapping.class, "platform:location:edit");
assertMethod("changeStatus", PatchMapping.class, "platform:location:status");
assertMethod("delete", DeleteMapping.class, "platform:location:delete");
}
private static void assertMethod(String name, Class<? extends Annotation> mapping, String permission) {
Method method = java.util.Arrays.stream(PlatformLocationController.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals(name))
.findFirst().orElseThrow();
assertThat(method.getAnnotation(mapping)).isNotNull();
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + permission + "')");
assertThat(method.getAnnotation(Operation.class).summary()).isNotBlank();
}
}
@@ -0,0 +1,38 @@
package com.baiyue.warehouse.controller;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
class PlatformPalletControllerContractTest {
@Test
void 托盘接口路径和按钮权限完整() {
assertThat(PlatformPalletController.class.getAnnotation(RequestMapping.class).value())
.containsExactly("/api/platform/v1/pallets");
assertMethod("page", GetMapping.class, "platform:pallet:list");
assertMethod("detail", GetMapping.class, "platform:pallet:list");
assertMethod("create", PostMapping.class, "platform:pallet:create");
assertMethod("update", PutMapping.class, "platform:pallet:edit");
assertMethod("delete", DeleteMapping.class, "platform:pallet:delete");
assertThat(Arrays.stream(PlatformPalletController.class.getDeclaredMethods())
.noneMatch(method -> method.getName().equals("status"))).isTrue();
}
private static void assertMethod(String name, Class<? extends Annotation> mapping, String authority) {
Method method = Arrays.stream(PlatformPalletController.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals(name)).findFirst().orElseThrow();
assertThat(method.getAnnotation(mapping)).isNotNull();
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + authority + "')");
}
}
@@ -0,0 +1,53 @@
package com.baiyue.warehouse.mapper;
import org.apache.ibatis.annotations.Insert;
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 LocationMapperContractTest {
@Test
void 可用库位按库区和库位数字自然顺序临近分配() throws NoSuchMethodException {
String selectSql = sql(LocationMapper.class.getMethod(
"selectAvailable", long.class, long.class, int.class).getAnnotation(Select.class).value());
String lockSql = sql(LocationMapper.class.getMethod(
"lockAvailable", long.class, long.class, int.class).getAnnotation(Select.class).value());
assertNearbyOrder(selectSql);
assertNearbyOrder(lockSql);
assertThat(lockSql).contains("FOR UPDATE SKIP LOCKED");
}
@Test
void A0依靠唯一编码并发创建后加锁复用() throws NoSuchMethodException {
Method insert = LocationMapper.class.getMethod(
"insertSharedOverflowIfAbsent", long.class, long.class, long.class, long.class);
Method lock = LocationMapper.class.getMethod("lockSharedOverflow", long.class, long.class);
assertThat(sql(insert.getAnnotation(Insert.class).value()))
.contains("location_code, location_name")
.contains("'A0'")
.contains("shared_overflow")
.contains("ON DUPLICATE KEY UPDATE id = id");
assertThat(sql(lock.getAnnotation(Select.class).value()))
.contains("tenant_id = #{tenantId}")
.contains("warehouse_id = #{warehouseId}")
.contains("location_code = 'A0'")
.contains("FOR UPDATE");
}
private static void assertNearbyOrder(String sql) {
assertThat(sql).contains("shared_overflow = 0")
.contains("ORDER BY l.zone_code")
.contains("REGEXP_REPLACE(l.location_code, '[0-9]+$', '')")
.contains("REGEXP_SUBSTR(l.location_code, '[0-9]+$')")
.contains("AS UNSIGNED");
}
private static String sql(String[] fragments) {
return String.join(" ", fragments);
}
}
@@ -40,6 +40,43 @@ class WarehouseSchemaContractTest {
assertBaseColumns(sql);
}
@Test
void 二期库位共用表覆盖基础资料预占占用和并发约束() throws IOException {
String sql = read("phase2-pallet-location-schema.sql");
assertColumns(sql, "wh_location", "tenant_id", "warehouse_id", "zone_code", "zone_name",
"location_code", "location_type", "max_weight_kg", "shared_overflow", "status", "remark", "deleted", "version");
assertColumns(sql, "wh_location_reservation", "idempotency_key", "business_id", "pallet_id",
"location_id", "shared_overflow", "reservation_status", "expires_at",
"active_reservation", "active_pallet_reservation", "version");
assertColumns(sql, "wh_location_occupancy", "reservation_id", "business_id", "pallet_id",
"location_id", "shared_overflow", "occupancy_status",
"active_location_occupancy", "active_pallet_occupancy", "version");
assertThat(sql).contains("uk_location_tenant_warehouse_code")
.contains("uk_location_active_reservation")
.contains("uk_pallet_active_reservation (tenant_id, pallet_id, active_pallet_reservation)")
.contains("uk_location_active_occupancy")
.contains("uk_pallet_active_occupancy");
}
@Test
void A0迁移允许共享库位多占用但仍限制托盘唯一活动占用() throws IOException {
String sql = read("phase2-location-overflow-migration.sql");
assertThat(sql).contains("'wh_location', 'shared_overflow'")
.contains("'wh_location_reservation', 'shared_overflow'")
.contains("'wh_location_occupancy', 'shared_overflow'")
.contains("reservation_status = 'RESERVED' AND shared_overflow = 0")
.contains("occupancy_status = ''OCCUPIED'' AND shared_overflow = 0")
.contains("occupancy_status = ''OCCUPIED'' THEN 1")
.contains("active_location_occupancy")
.contains("active_pallet_occupancy")
.contains("active_pallet_reservation")
.contains("uk_pallet_active_reservation (tenant_id, pallet_id, active_pallet_reservation)")
.contains("uk_location_active_occupancy (tenant_id, location_id, active_location_occupancy)")
.contains("uk_pallet_active_occupancy (tenant_id, pallet_id, active_pallet_occupancy)")
.contains("FROM information_schema.columns")
.doesNotContain("ADD COLUMN IF NOT EXISTS");
}
@Test
void 既有库增量迁移覆盖全部仓储实体表和列表索引() throws IOException {
String sql = read("phase1-warehouse-schema-migration.sql");
@@ -0,0 +1,99 @@
package com.baiyue.warehouse.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.LocationDtos.SaveRequest;
import com.baiyue.warehouse.entity.LocationEntity;
import com.baiyue.warehouse.entity.WarehouseEntity;
import com.baiyue.warehouse.repository.LocationRepository;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class LocationApplicationServiceTest {
@Test
void 新增时仓库必须属于当前企业租户() {
LocationRepository repository = mock(LocationRepository.class);
LocationApplicationService service = new LocationApplicationService(repository);
assertThatThrownBy(() -> service.create(10001L, request("AVAILABLE", null)))
.isInstanceOf(BizException.class).hasMessageContaining("企业租户");
verify(repository, never()).insert(any());
}
@Test
void 同一租户仓库的库位编码必须唯一() {
LocationRepository repository = mock(LocationRepository.class);
when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse("ACTIVE"));
when(repository.existsCode(10001L, 88L, "A1-01", null)).thenReturn(true);
LocationApplicationService service = new LocationApplicationService(repository);
assertThatThrownBy(() -> service.create(10001L, request("AVAILABLE", null)))
.isInstanceOf(BizException.class).hasMessageContaining("编码");
}
@Test
void 活动预占或占用库位不能停用() {
LocationRepository repository = mock(LocationRepository.class);
LocationEntity location = location("AVAILABLE", 3);
when(repository.findOwned(10001L, 88L, 9L)).thenReturn(location);
when(repository.hasActiveResource(10001L, 9L)).thenReturn(true);
LocationApplicationService service = new LocationApplicationService(repository);
assertThatThrownBy(() -> service.changeStatus(10001L, 88L, 9L, "DISABLED", 3, 7L))
.isInstanceOf(BizException.class).hasMessageContaining("不能停用");
verify(repository, never()).changeStatus(10001L, 88L, 9L, "AVAILABLE", "DISABLED", 3, 7L);
}
@Test
void 删除前必须先停用且没有活动资源() {
LocationRepository repository = mock(LocationRepository.class);
when(repository.findOwned(10001L, 88L, 9L)).thenReturn(location("AVAILABLE", 3));
LocationApplicationService service = new LocationApplicationService(repository);
assertThatThrownBy(() -> service.delete(10001L, 88L, 9L, 3, 7L))
.isInstanceOf(BizException.class).hasMessageContaining("先停用");
verify(repository, never()).deleteDisabled(10001L, 88L, 9L, 3, 7L);
}
@Test
void 条件更新失败统一转换为乐观锁冲突() {
LocationRepository repository = mock(LocationRepository.class);
when(repository.findOwned(10001L, 88L, 9L)).thenReturn(location("DISABLED", 3));
when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse("ACTIVE"));
when(repository.changeStatus(10001L, 88L, 9L, "DISABLED", "AVAILABLE", 3, 7L)).thenReturn(0);
LocationApplicationService service = new LocationApplicationService(repository);
assertThatThrownBy(() -> service.changeStatus(10001L, 88L, 9L, "AVAILABLE", 3, 7L))
.isInstanceOf(BizException.class).hasMessageContaining("刷新");
}
private static SaveRequest request(String status, Integer version) {
return new SaveRequest(88L, "NORMAL", "常规货", "A1-01", "A1 区 01 位",
"PALLET", new BigDecimal("500"), status, "备注", version);
}
private static WarehouseEntity warehouse(String status) {
WarehouseEntity warehouse = new WarehouseEntity();
warehouse.setId(88L);
warehouse.setTenantId(10001L);
warehouse.setStatus(status);
return warehouse;
}
private static LocationEntity location(String status, int version) {
LocationEntity location = new LocationEntity();
location.setId(9L);
location.setTenantId(10001L);
location.setWarehouseId(88L);
location.setStatus(status);
location.setVersion(version);
return location;
}
}
@@ -0,0 +1,202 @@
package com.baiyue.warehouse.service;
import com.baiyue.warehouse.dto.LocationDtos.ConfirmCommand;
import com.baiyue.warehouse.dto.LocationDtos.OccupancyTarget;
import com.baiyue.warehouse.dto.LocationDtos.ReservationTarget;
import com.baiyue.warehouse.dto.LocationDtos.ReserveCommand;
import com.baiyue.warehouse.entity.LocationEntity;
import com.baiyue.warehouse.entity.LocationOccupancyEntity;
import com.baiyue.warehouse.entity.LocationReservationEntity;
import com.baiyue.warehouse.entity.WarehouseEntity;
import com.baiyue.warehouse.repository.LocationRepository;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class LocationResourceServiceTest {
@Test
void 普通库位不足时优先分配普通位并将剩余托盘共享到A0() {
LocationRepository repository = mock(LocationRepository.class);
when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse());
when(repository.lockAvailable(10001L, 88L, 2)).thenReturn(List.of(location(9L, "A1")));
when(repository.lockOrCreateSharedOverflow(10001L, 88L, 7L)).thenReturn(overflow());
when(repository.changeStatus(10001L, 88L, 9L, "AVAILABLE", "RESERVED", 0, 7L)).thenReturn(1);
LocationResourceService service = new LocationResourceService(repository);
var result = service.reserveAvailable(reserveCommand());
assertThat(result).extracting(item -> item.locationCode()).containsExactly("A1", "A0");
assertThat(result).extracting(item -> item.sharedOverflow()).containsExactly(false, true);
verify(repository).changeStatus(10001L, 88L, 9L, "AVAILABLE", "RESERVED", 0, 7L);
verify(repository, never()).changeStatus(10001L, 88L, 99L, "AVAILABLE", "RESERVED", 0, 7L);
verify(repository, times(2)).insertReservation(any());
}
@Test
void 无普通库位时同一请求的所有不足目标复用同一个A0() {
LocationRepository repository = mock(LocationRepository.class);
when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse());
when(repository.lockAvailable(10001L, 88L, 2)).thenReturn(List.of());
when(repository.lockOrCreateSharedOverflow(10001L, 88L, 7L)).thenReturn(overflow());
LocationResourceService service = new LocationResourceService(repository);
var result = service.reserveAvailable(reserveCommand());
assertThat(result).extracting(item -> item.locationId()).containsExactly(99L, 99L);
assertThat(result).extracting(item -> item.locationCode()).containsExactly("A0", "A0");
verify(repository, never()).changeStatus(anyLong(), anyLong(), anyLong(),
any(String.class), any(String.class), anyInt(), anyLong());
}
@Test
void 单事务按托盘顺序锁定并条件预占库位() {
LocationRepository repository = mock(LocationRepository.class);
when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse());
when(repository.lockAvailable(10001L, 88L, 2))
.thenReturn(List.of(location(9L, "A1"), location(10L, "A2")));
when(repository.changeStatus(10001L, 88L, 9L, "AVAILABLE", "RESERVED", 0, 7L)).thenReturn(1);
when(repository.changeStatus(10001L, 88L, 10L, "AVAILABLE", "RESERVED", 0, 7L)).thenReturn(1);
LocationResourceService service = new LocationResourceService(repository);
var result = service.reserveAvailable(reserveCommand());
assertThat(result).extracting(item -> item.locationCode()).containsExactly("A1", "A2");
ArgumentCaptor<LocationReservationEntity> captor = ArgumentCaptor.forClass(LocationReservationEntity.class);
verify(repository, org.mockito.Mockito.times(2)).insertReservation(captor.capture());
assertThat(captor.getAllValues()).extracting(LocationReservationEntity::getIdempotencyKey)
.containsExactly("inbound-1-p1", "inbound-1-p2");
}
@Test
void 已确认预占按占用事实幂等返回() {
LocationRepository repository = mock(LocationRepository.class);
when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse());
LocationReservationEntity reservation = reservation("CONFIRMED");
when(repository.lockReservation(10001L, 101L)).thenReturn(reservation);
LocationOccupancyEntity occupancy = new LocationOccupancyEntity();
occupancy.setId(201L);
occupancy.setOccupancyNo("LO201");
occupancy.setReservationId(101L);
occupancy.setPalletId(501L);
occupancy.setLocationId(9L);
occupancy.setLocationCode("A1");
when(repository.findOccupancyByReservation(10001L, 101L)).thenReturn(occupancy);
LocationResourceService service = new LocationResourceService(repository);
var result = service.confirmOccupied(confirmCommand());
assertThat(result).singleElement().extracting(item -> item.occupancyId()).isEqualTo(201L);
verify(repository, never()).insertOccupancy(any());
}
@Test
void 确认占用使用预占归属和库位版本条件更新() {
LocationRepository repository = mock(LocationRepository.class);
when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse());
LocationReservationEntity reservation = reservation("RESERVED");
when(repository.lockReservation(10001L, 101L)).thenReturn(reservation);
when(repository.findOwned(10001L, 88L, 9L)).thenReturn(location(9L, "A1"));
when(repository.changeStatus(10001L, 88L, 9L, "RESERVED", "OCCUPIED", 0, 7L)).thenReturn(1);
when(repository.confirmReservation(reservation)).thenReturn(1);
LocationResourceService service = new LocationResourceService(repository);
var result = service.confirmOccupied(confirmCommand());
assertThat(result).hasSize(1);
verify(repository).insertOccupancy(any(LocationOccupancyEntity.class));
verify(repository).confirmReservation(reservation);
}
@Test
void 确认A0占用时保持共享库位AVAILABLE() {
LocationRepository repository = mock(LocationRepository.class);
when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse());
LocationReservationEntity reservation = reservation("RESERVED");
reservation.setLocationId(99L);
reservation.setLocationCode("A0");
reservation.setSharedOverflow(true);
when(repository.lockReservation(10001L, 101L)).thenReturn(reservation);
when(repository.findOwned(10001L, 88L, 99L)).thenReturn(overflow());
when(repository.confirmReservation(reservation)).thenReturn(1);
LocationResourceService service = new LocationResourceService(repository);
var result = service.confirmOccupied(confirmCommand());
assertThat(result).singleElement().satisfies(item -> {
assertThat(item.locationCode()).isEqualTo("A0");
assertThat(item.sharedOverflow()).isTrue();
});
verify(repository, never()).changeStatus(anyLong(), anyLong(), anyLong(),
any(String.class), any(String.class), anyInt(), anyLong());
verify(repository).insertOccupancy(any(LocationOccupancyEntity.class));
}
private static ReserveCommand reserveCommand() {
return new ReserveCommand(10001L, 88L, "FIRSTLEG", 301L, "RK001", 7L,
LocalDateTime.now().plusMinutes(30), List.of(
new ReservationTarget(501L, "P1", "inbound-1-p1"),
new ReservationTarget(502L, "P2", "inbound-1-p2")));
}
private static ConfirmCommand confirmCommand() {
return new ConfirmCommand(10001L, 88L, "FIRSTLEG", 301L, 7L,
List.of(new OccupancyTarget(101L, new BigDecimal("1.200000"), 10)));
}
private static WarehouseEntity warehouse() {
WarehouseEntity warehouse = new WarehouseEntity();
warehouse.setId(88L);
warehouse.setTenantId(10001L);
warehouse.setStatus("ACTIVE");
return warehouse;
}
private static LocationEntity location(long id, String code) {
LocationEntity location = new LocationEntity();
location.setId(id);
location.setTenantId(10001L);
location.setWarehouseId(88L);
location.setLocationCode(code);
location.setStatus("AVAILABLE");
location.setSharedOverflow(false);
location.setVersion(0);
return location;
}
private static LocationEntity overflow() {
LocationEntity location = location(99L, "A0");
location.setSharedOverflow(true);
return location;
}
private static LocationReservationEntity reservation(String status) {
LocationReservationEntity reservation = new LocationReservationEntity();
reservation.setId(101L);
reservation.setTenantId(10001L);
reservation.setWarehouseId(88L);
reservation.setSourceService("FIRSTLEG");
reservation.setBusinessId(301L);
reservation.setInboundCode("RK001");
reservation.setPalletId(501L);
reservation.setPalletCode("P1");
reservation.setLocationId(9L);
reservation.setLocationCode("A1");
reservation.setReservationStatus(status);
reservation.setExpiresAt(LocalDateTime.now().plusMinutes(10));
reservation.setVersion(0);
return reservation;
}
}
@@ -0,0 +1,40 @@
package com.baiyue.warehouse.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.entity.PalletEntity;
import com.baiyue.warehouse.repository.PalletRepository;
import com.baiyue.warehouse.repository.WarehouseRepository;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class PalletApplicationServiceTest {
private final PalletRepository repository = mock(PalletRepository.class);
private final WarehouseRepository warehouseRepository = mock(WarehouseRepository.class);
private final PalletApplicationService service = new PalletApplicationService(repository, warehouseRepository);
@Test
void 平台详情不暴露内部状态() {
PalletEntity entity = pallet("OCCUPIED");
when(repository.findOwned(10001, 20, 30)).thenReturn(entity);
org.assertj.core.api.Assertions.assertThat(service.detail(10001, 20, 30))
.extracting("id", "palletCode").containsExactly(30L, null);
}
@Test
void 跨仓库托盘不可见() {
assertThatThrownBy(() -> service.detail(10001, 21, 30)).isInstanceOf(BizException.class);
}
private static PalletEntity pallet(String status) {
PalletEntity entity = new PalletEntity();
entity.setId(30L);
entity.setTenantId(10001L);
entity.setWarehouseId(20L);
entity.setStatus(status);
entity.setVersion(0);
return entity;
}
}
@@ -0,0 +1,108 @@
package com.baiyue.warehouse.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.LocationDtos;
import com.baiyue.warehouse.dto.PalletAllocationDtos;
import com.baiyue.warehouse.entity.PalletEntity;
import com.baiyue.warehouse.repository.PalletRepository;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
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.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class PalletLocationAllocationServiceTest {
private final PalletRepository pallets = mock(PalletRepository.class);
private final LocationResourceService locations = mock(LocationResourceService.class);
private final PalletLocationAllocationService service =
new PalletLocationAllocationService(pallets, new PalletVolumeSplitter(), locations);
@Test
void 并发抢占托盘时整批失败且不预占库位() {
when(locations.findActiveResources(10001, "FIRSTLEG", 88)).thenReturn(List.of());
when(pallets.findAvailable(10001, 20, "木托盘", new BigDecimal("2"), 1))
.thenReturn(List.of(pallet(30, "TP-001")));
when(pallets.transitionStatus(10001, 20, 30, List.of("AVAILABLE"), "RESERVED")).thenReturn(0);
assertThatThrownBy(() -> service.reserve(reserve(88))).isInstanceOf(BizException.class);
verify(locations, never()).reserveAvailable(any());
}
@Test
void 未指定类型时默认推荐普通托盘() {
when(locations.findActiveResources(10001, "FIRSTLEG", 99)).thenReturn(List.of());
when(pallets.findAvailable(10001, 20, "STANDARD", new BigDecimal("2"), 1))
.thenReturn(List.of(pallet(30, "TP-001")));
when(pallets.transitionStatus(10001, 20, 30, List.of("AVAILABLE"), "RESERVED")).thenReturn(1);
when(locations.reserveAvailable(any())).thenReturn(List.of(
new LocationDtos.ReservationResult(101, "LR101", 30, "TP-001", 40, "A0", true,
LocalDateTime.now().plusMinutes(30))));
var request = new PalletAllocationDtos.ReserveRequest(10001, 20, "FIRSTLEG", 99, "RK-001", "measure-99",
9001, new BigDecimal("2"), new BigDecimal("2"), null, LocalDateTime.now().plusMinutes(30), true);
var result = service.reserve(request);
assertThat(result.allocations().get(0).locationCode()).isEqualTo("A0");
verify(pallets).findAvailable(10001, 20, "STANDARD", new BigDecimal("2"), 1);
}
@Test
void 释放预占后托盘恢复可用并可再次预占() {
LocationDtos.ActiveResource resource = new LocationDtos.ActiveResource(
101, null, 20, 30, "TP-001", 40, "A-01", false, "RESERVED");
when(locations.findActiveResources(10001, "FIRSTLEG", 88)).thenReturn(List.of(resource));
when(locations.release(any())).thenReturn(List.of(resource));
when(pallets.transitionStatus(10001, 20, 30, List.of("RESERVED"), "AVAILABLE")).thenReturn(1);
List<LocationDtos.ActiveResource> released = service.release(
new PalletAllocationDtos.ReleaseRequest(10001, "FIRSTLEG", 88, 9001, "入库补偿"));
assertThat(released).containsExactly(resource);
verify(pallets).transitionStatus(10001, 20, 30, List.of("RESERVED"), "AVAILABLE");
}
@Test
void 已确认占用禁止补偿释放避免回滚入库事实() {
LocationDtos.ActiveResource resource = new LocationDtos.ActiveResource(
101, 201L, 20, 30, "TP-001", 40, "A-01", false, "OCCUPIED");
when(locations.findActiveResources(10001, "FIRSTLEG", 88)).thenReturn(List.of(resource));
assertThatThrownBy(() -> service.release(
new PalletAllocationDtos.ReleaseRequest(10001, "FIRSTLEG", 88, 9001, "错误补偿")))
.isInstanceOf(BizException.class);
verify(locations, never()).release(any());
}
@Test
void 相同业务幂等重试直接返回已有预占() {
LocationDtos.ActiveResource resource = new LocationDtos.ActiveResource(
101, null, 20, 30, "TP-001", 40, "A-01", false, "RESERVED");
when(locations.findActiveResources(10001, "FIRSTLEG", 88)).thenReturn(List.of(resource));
var result = service.reserve(reserve(88));
assertThat(result.palletCount()).isEqualTo(1);
assertThat(result.allocations().get(0).state()).isEqualTo("RESERVED");
verify(pallets, never()).findAvailable(any(Long.class), any(Long.class), any(), any(), any(Integer.class));
}
private static PalletAllocationDtos.ReserveRequest reserve(long businessId) {
return new PalletAllocationDtos.ReserveRequest(10001, 20, "FIRSTLEG", businessId, "RK-001", "measure-001",
9001, new BigDecimal("2"), new BigDecimal("2"), "木托盘", LocalDateTime.now().plusMinutes(30), true);
}
private static PalletEntity pallet(long id, String code) {
PalletEntity entity = new PalletEntity();
entity.setId(id);
entity.setPalletCode(code);
return entity;
}
}
@@ -0,0 +1,37 @@
package com.baiyue.warehouse.service;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class PalletVolumeSplitterTest {
private final PalletVolumeSplitter splitter = new PalletVolumeSplitter();
@Test
void 整除时不多拆托盘() {
var result = splitter.split(new BigDecimal("20"), new BigDecimal("2"));
assertThat(result.palletCount()).isEqualTo(10);
assertThat(result.allocations()).allMatch(item -> item.allocatedVolumeCbm().compareTo(new BigDecimal("2")) == 0);
}
@Test
void 非整除时向上拆分并保留尾盘体积() {
var result = splitter.split(new BigDecimal("20.1"), new BigDecimal("2"));
assertThat(result.palletCount()).isEqualTo(11);
assertThat(result.allocations().get(10).allocatedVolumeCbm()).isEqualByComparingTo("0.1");
assertThat(result.allocations()).extracting(PalletVolumeSplitter.Allocation::allocationNo)
.containsExactly(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11);
}
@Test
void 拒绝空值零值负值和超大拆分() {
assertThatThrownBy(() -> splitter.split(null, BigDecimal.ONE)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> splitter.split(BigDecimal.ZERO, BigDecimal.ONE)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> splitter.split(BigDecimal.ONE, BigDecimal.ZERO)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> splitter.split(new BigDecimal("10001"), BigDecimal.ONE)).isInstanceOf(BizException.class);
}
}
@@ -22,6 +22,9 @@ class WarehouseSchemaReadinessServiceTest {
assertThat(readiness.ready()).isTrue();
assertThat(readiness.status()).isEqualTo("UP");
verify(jdbcTemplate).execute(org.mockito.ArgumentMatchers.contains("wh_express_company"));
verify(jdbcTemplate).execute(org.mockito.ArgumentMatchers.contains("wh_location_reservation"));
verify(jdbcTemplate).execute(org.mockito.ArgumentMatchers.contains("wh_location_occupancy"));
verify(jdbcTemplate).execute(org.mockito.ArgumentMatchers.contains("active_location_occupancy"));
}
@Test