This commit is contained in:
2026-08-14 17:37:59 +08:00
parent 87e919f3c4
commit feced8eb92
40 changed files with 1142 additions and 244 deletions
+4 -3
View File
@@ -2,7 +2,7 @@
## 职责与边界
- 负责国内收货仓、地址/能力、往来单位、快递公司、平台公共供应商、国内整柜库存/出入库,以及托盘、库位、预占和占用。
- 负责国内收货仓、地址/能力、往来单位、快递公司、平台公共供应商、国内整柜库存/出入库,以及托盘主数据、库位预占和占用。
- 入库单、测量和入库状态归 `firstleg/`;订单只通过内部接口校验并保存快照。
- 公共资源统一 `tenant_id=0`,不得绑定客户或要求客户筛选。
@@ -28,10 +28,11 @@
## 三期托盘、库位与 A0 规则
- 普通库位保持一托一位;A0 是系统创建的共享临时溢出位,可被多个托盘占用,但每个托盘占用仍必须通过 `reservation_id`、`occupancy_id` 独立识别。
- 托盘是可复用标识和称重主数据,不参与 `AVAILABLE → RESERVED → OCCUPIED → AVAILABLE`;同一托盘主数据可被多条分配复用。
- 普通库位保持单活动预占/占用;A0 是系统创建的共享临时溢出位,可承载多条占用,但每条库位占用仍必须通过 `reservation_id`、`occupancy_id` 独立识别。
- 头程 PDA 结算接口 `/internal/warehouse/v1/pallet-locations/settlements` 是释放、子单转移和全部释放后重新分配的原子边界。不得将其拆成多个可独立提交的远程操作。
- 部分拣货的资源操作必须按 `reservationId` 定位,不能按 `locationId` 定位;A0 多托盘共享时,按 `locationId` 会误释放或合并资源。
- 当父单全部资源释放但子单仍有剩余数量时,在同一仓库事务内优先分配自然 A-Z 顺序的普通空闲库位;普通库位不足时使用 A0,并保持托盘状态、预占、占用和子单归属一致。
- 当父单全部库位释放但子单仍有剩余数量时,在同一仓库事务内优先分配自然 A-Z 顺序的普通空闲库位;普通库位不足时使用 A0,并保持预占、占用和子单归属一致。
- A0 换绑入口包括内部接口和平台接口:平台人员使用 `/api/platform/v1/locations/temporary-occupancies/{occupancyId}/rebind`,需要 `platform:location:edit`。目标必须为同仓 `AVAILABLE` 的普通库位;A0 不允许人工编辑、停用或删除。
- 资源结算、释放、转移和换绑必须保存可重放的幂等记录或具有等价的状态幂等语义,并在测试中覆盖并发状态变更。
@@ -0,0 +1,67 @@
package com.baiyue.warehouse.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.PalletRequests;
import com.baiyue.warehouse.service.PalletApplicationService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import jakarta.validation.constraints.Min;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.List;
/** 内部托盘主数据只读接口。 */
@Validated
@RestController
@RequestMapping("/internal/warehouse/v1/pallets")
@Tag(name = "托盘内部接口", description = "内部服务令牌保护的托盘主数据快照")
public class InternalPalletController {
@Resource
private PalletApplicationService service;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
private byte[] expectedToken;
@PostConstruct
void initializeToken() {
expectedToken = internalToken.getBytes(StandardCharsets.UTF_8);
}
@GetMapping("/{palletId}/snapshot")
@Operation(summary = "查询托盘只读快照", description = "固定按平台公共作用域校验仓库归属,不返回动态状态或可用性")
public ApiResponse<PalletRequests.Snapshot> snapshot(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable @Min(1) long palletId, @RequestParam @Min(1) long warehouseId) {
verify(token);
return ApiResponse.success(service.snapshot(0L, warehouseId, palletId));
}
@GetMapping("/snapshots")
@Operation(summary = "查询仓库托盘快照列表", description = "返回该仓全部未删除托盘主数据,不按动态状态或占用过滤")
public ApiResponse<List<PalletRequests.Snapshot>> snapshots(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestParam @Min(1) long warehouseId) {
verify(token);
return ApiResponse.success(service.snapshots(0L, warehouseId));
}
private void verify(String token) {
byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8);
if (!MessageDigest.isEqual(expectedToken, supplied)) {
throw new BizException(ResultCode.FORBIDDEN, "无效的内部服务凭据");
}
}
}
@@ -41,7 +41,7 @@ public class InternalPalletLocationController {
}
@PostMapping("/reservations")
@Operation(summary = "拆分托盘并预占一托一位")
@Operation(summary = "按托盘主数据拆分并预占库位")
public ApiResponse<PalletAllocationDtos.BatchResult> reserve(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody PalletAllocationDtos.ReserveRequest request) {
verify(token);
@@ -49,7 +49,7 @@ public class InternalPalletLocationController {
}
@PostMapping("/confirm")
@Operation(summary = "确认托盘和库位占用", description = "只确认仓库资源,不推进头程入库单状态")
@Operation(summary = "确认库位占用", description = "托盘只保存主数据快照;本接口不推进头程入库单状态")
public ApiResponse<PalletAllocationDtos.BatchResult> confirm(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody PalletAllocationDtos.ConfirmRequest request) {
verify(token);
@@ -111,7 +111,7 @@ public class InternalPalletLocationController {
}
@PostMapping("/transfer")
@Operation(summary = "将未拣货库位转给子入库单", description = "仅更新业务归属,托盘和库位继续保持已占用")
@Operation(summary = "将未拣货库位转给子入库单", description = "仅更新库位业务归属,托盘主数据不变")
public ApiResponse<List<PalletAllocationDtos.TransferResult>> transfer(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody PalletAllocationDtos.TransferRequest request) {
@@ -89,7 +89,7 @@ public class PlatformPalletController {
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('platform:pallet:delete')")
@Operation(summary = "删除托盘", description = "仅可删除未预占、未占用的托盘")
@Operation(summary = "删除托盘", description = "托盘是可复用主数据,删除不受库位预占或占用状态限制")
public ApiResponse<Void> delete(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(0) long tenantId,
@RequestParam @Min(1) long warehouseId,
@@ -74,4 +74,14 @@ public final class PalletRequests {
LocalDateTime updatedAt
) {
}
@Schema(name = "InternalPalletSnapshot", description = "内部只读托盘主数据快照;不包含动态资源状态")
public record Snapshot(
@Schema(description = "托盘ID") long palletId,
@Schema(description = "托盘编码") String palletCode,
@Schema(description = "托盘类型") String palletType,
@Schema(description = "容量,单位m³") BigDecimal capacityCbm,
@Schema(description = "皮重,单位kg") BigDecimal tareWeightKg
) {
}
}
@@ -10,9 +10,7 @@ import lombok.Setter;
import java.math.BigDecimal;
/**
* 托盘主数据实体。状态仅供内部预占、占用流程使用,不属于平台维护字段。
*/
/** 托盘可复用主数据实体,不承载预占或占用状态。 */
@Getter
@Setter
@TableName("wh_pallet")
@@ -37,15 +35,12 @@ public class PalletEntity extends BaseEntity {
@Schema(description = "托盘容积,单位m³", example = "1.200000")
private BigDecimal capacityCbm;
@Schema(description = "托盘重量,单位kg", example = "8.000")
@Schema(description = "托盘皮重,单位kg", example = "8.000")
private BigDecimal weightKg;
@Schema(description = "托盘颜色", example = "黄色")
private String color;
@Schema(description = "资源状态", allowableValues = {"AVAILABLE", "RESERVED", "OCCUPIED", "DISABLED"})
private String status;
@Schema(description = "备注", example = "标准川字托盘")
private String remark;
}
@@ -12,4 +12,13 @@ public interface PalletMapper extends BaseMapper<PalletEntity> {
/** 编码序列必须包含逻辑删除记录,避免历史编码被复用。 */
@Select("SELECT pallet_code FROM wh_pallet WHERE tenant_id = #{tenantId} AND pallet_code LIKE 'TP-%'")
List<String> selectAllCodes(@Param("tenantId") long tenantId);
/** 既有库位分配需在主数据逻辑删除后继续读取其不可变属性。 */
@Select("""
SELECT * FROM wh_pallet
WHERE tenant_id = #{tenantId} AND warehouse_id = #{warehouseId} AND id = #{id}
""")
PalletEntity selectIncludingDeleted(@Param("tenantId") long tenantId,
@Param("warehouseId") long warehouseId,
@Param("id") long id);
}
@@ -11,12 +11,11 @@ import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import java.util.Collection;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** 托盘持久化仓储;平台分页支持跨租户筛选,资源操作始终包含租户和仓库边界。 */
/** 托盘主数据持久化仓储;平台分页支持跨作用域筛选。 */
@Repository
public class PalletRepository {
private static final Pattern PALLET_CODE = Pattern.compile("TP-(\\d+)");
@@ -43,6 +42,18 @@ public class PalletRepository {
.eq(PalletEntity::getId, id));
}
public PalletEntity findOwnedIncludingDeleted(long tenantId, long warehouseId, long id) {
return mapper.selectIncludingDeleted(tenantId, warehouseId, id);
}
public List<PalletEntity> findAllByWarehouse(long tenantId, long warehouseId) {
return mapper.selectList(new LambdaQueryWrapper<PalletEntity>()
.eq(PalletEntity::getTenantId, tenantId)
.eq(PalletEntity::getWarehouseId, warehouseId)
.orderByAsc(PalletEntity::getPalletCode)
.orderByAsc(PalletEntity::getId));
}
public boolean existsCode(long tenantId, long warehouseId, String code, Long excludedId) {
return mapper.selectCount(new LambdaQueryWrapper<PalletEntity>()
.eq(PalletEntity::getTenantId, tenantId)
@@ -51,16 +62,14 @@ public class PalletRepository {
.ne(excludedId != null, PalletEntity::getId, excludedId)) > 0;
}
public List<PalletEntity> findAvailable(long tenantId, long warehouseId, String palletType,
java.math.BigDecimal capacityCbm, int limit) {
public List<PalletEntity> findReusable(long tenantId, long warehouseId, String palletType,
java.math.BigDecimal capacityCbm) {
return mapper.selectList(new LambdaQueryWrapper<PalletEntity>()
.eq(PalletEntity::getTenantId, tenantId)
.eq(PalletEntity::getWarehouseId, warehouseId)
.eq(PalletEntity::getStatus, "AVAILABLE")
.eq(StringUtils.hasText(palletType), PalletEntity::getPalletType, palletType)
.eq(capacityCbm != null, PalletEntity::getCapacityCbm, capacityCbm)
.orderByAsc(PalletEntity::getPalletCode)
.last("LIMIT " + limit));
.orderByAsc(PalletEntity::getPalletCode));
}
/**
@@ -92,19 +101,7 @@ public class PalletRepository {
.eq(PalletEntity::getWarehouseId, warehouseId)
.eq(PalletEntity::getId, id)
.eq(PalletEntity::getVersion, version)
.in(PalletEntity::getStatus, List.of("AVAILABLE", "DISABLED"))
.set(PalletEntity::getDeleted, true)
.set(PalletEntity::getVersion, version + 1));
}
public int transitionStatus(long tenantId, long warehouseId, long id,
Collection<String> expectedStatuses, String targetStatus) {
return mapper.update(null, new LambdaUpdateWrapper<PalletEntity>()
.eq(PalletEntity::getTenantId, tenantId)
.eq(PalletEntity::getWarehouseId, warehouseId)
.eq(PalletEntity::getId, id)
.in(PalletEntity::getStatus, expectedStatuses)
.set(PalletEntity::getStatus, targetStatus)
.setSql("version = version + 1"));
}
}
@@ -13,6 +13,8 @@ import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/** 托盘主数据应用服务。 */
@Service
public class PalletApplicationService {
@@ -41,7 +43,6 @@ public class PalletApplicationService {
entity.setTenantId(tenantId);
entity.setWarehouseId(request.warehouseId());
copy(entity, request);
entity.setStatus("AVAILABLE");
String code = repository.nextCode(tenantId);
for (int attempt = 0; attempt < CODE_INSERT_ATTEMPTS; attempt++) {
entity.setPalletCode(code);
@@ -78,7 +79,7 @@ public class PalletApplicationService {
public void delete(long tenantId, long warehouseId, long id) {
PalletEntity entity = owned(tenantId, warehouseId, id);
if (repository.logicalDelete(tenantId, warehouseId, id, entity.getVersion()) != 1) {
throw new BizException(ResultCode.CONFLICT, "预占、占用或已被并发修改的托盘不能删除");
throw new BizException(ResultCode.CONFLICT, "托盘已被并发修改,请刷新后重试");
}
}
@@ -122,4 +123,18 @@ public class PalletApplicationService {
entity.getColor(), entity.getRemark(),
entity.getCreatedAt(), entity.getUpdatedAt());
}
public PalletRequests.Snapshot snapshot(long tenantId, long warehouseId, long palletId) {
PalletEntity entity = owned(tenantId, warehouseId, palletId);
return new PalletRequests.Snapshot(entity.getId(), entity.getPalletCode(), entity.getPalletType(),
entity.getCapacityCbm(), entity.getWeightKg());
}
public List<PalletRequests.Snapshot> snapshots(long tenantId, long warehouseId) {
requireWarehouse(tenantId, warehouseId, false);
return repository.findAllByWarehouse(tenantId, warehouseId).stream()
.map(entity -> new PalletRequests.Snapshot(entity.getId(), entity.getPalletCode(), entity.getPalletType(),
entity.getCapacityCbm(), entity.getWeightKg()))
.toList();
}
}
@@ -28,7 +28,7 @@ import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/**
* 托盘拆分和一托一位编排。库位资源操作全部委托给任务6的 {@link LocationResourceService}。
* 托盘主数据复用和库位分配编排。库位资源操作全部委托给 {@link LocationResourceService}。
*/
@Service
public class PalletLocationAllocationService {
@@ -54,7 +54,7 @@ public class PalletLocationAllocationService {
private ObjectMapper objectMapper;
@Transactional
@BusinessAudit(event = "PALLET_LOCATION_RESERVE", resourceType = "INBOUND", resourceId = "#request.businessId", description = "预占托盘和库位", captureBefore = false)
@BusinessAudit(event = "PALLET_LOCATION_RESERVE", resourceType = "INBOUND", resourceId = "#request.businessId", description = "复用托盘主数据并预占库位", captureBefore = false)
public PalletAllocationDtos.BatchResult reserve(PalletAllocationDtos.ReserveRequest request) {
if (!Boolean.TRUE.equals(request.measurementCompleted())) {
throw new BizException(ResultCode.CONFLICT, "只能使用已完成测量的总体积拆分托盘并占用库位");
@@ -67,19 +67,15 @@ public class PalletLocationAllocationService {
String palletType = org.springframework.util.StringUtils.hasText(request.palletType())
? request.palletType() : DEFAULT_PALLET_TYPE;
List<PalletEntity> pallets = palletRepository.findAvailable(request.tenantId(), request.warehouseId(),
palletType, request.palletCapacityCbm(), split.palletCount());
if (pallets.size() != split.palletCount()) {
throw new BizException(ResultCode.CONFLICT, "可用托盘数量不足");
List<PalletEntity> pallets = palletRepository.findReusable(request.tenantId(), request.warehouseId(),
palletType, request.palletCapacityCbm());
if (pallets.isEmpty()) {
throw new BizException(ResultCode.NOT_FOUND, "未配置匹配类型和容量的托盘主数据");
}
List<LocationDtos.ReservationTarget> targets = new ArrayList<>(pallets.size());
for (int index = 0; index < pallets.size(); index++) {
PalletEntity pallet = pallets.get(index);
if (palletRepository.transitionStatus(request.tenantId(), request.warehouseId(), pallet.getId(),
List.of("AVAILABLE"), "RESERVED") != 1) {
throw new BizException(ResultCode.CONFLICT, "托盘被并发预占,请重试");
}
List<LocationDtos.ReservationTarget> targets = new ArrayList<>(split.palletCount());
for (int index = 0; index < split.palletCount(); index++) {
PalletEntity pallet = pallets.get(index % pallets.size());
targets.add(new LocationDtos.ReservationTarget(pallet.getId(), pallet.getPalletCode(),
request.idempotencyKey() + ":" + (index + 1)));
}
@@ -101,7 +97,7 @@ public class PalletLocationAllocationService {
}
@Transactional
@BusinessAudit(event = "PALLET_LOCATION_CONFIRM", resourceType = "INBOUND", resourceId = "#request.businessId", description = "确认托盘和库位占用")
@BusinessAudit(event = "PALLET_LOCATION_CONFIRM", resourceType = "INBOUND", resourceId = "#request.businessId", description = "确认库位占用")
public PalletAllocationDtos.BatchResult confirm(PalletAllocationDtos.ConfirmRequest request) {
var split = splitter.split(request.totalVolumeCbm(), request.palletCapacityCbm());
List<LocationDtos.ActiveResource> active = active(request.tenantId(), request.sourceService(), request.businessId());
@@ -123,13 +119,6 @@ public class PalletLocationAllocationService {
List<LocationDtos.OccupancyResult> occupied = locationResourceService.confirmOccupied(
new LocationDtos.ConfirmCommand(request.tenantId(), request.warehouseId(), request.sourceService(),
request.businessId(), request.operatorId(), targets));
for (LocationDtos.ActiveResource resource : active) {
if ("RESERVED".equals(resource.state())
&& palletRepository.transitionStatus(request.tenantId(), request.warehouseId(), resource.palletId(),
List.of("RESERVED"), "OCCUPIED") != 1) {
throw new BizException(ResultCode.CONFLICT, "托盘确认占用发生并发冲突");
}
}
List<PalletAllocationDtos.AllocationResult> results = new ArrayList<>(occupied.size());
for (int index = 0; index < occupied.size(); index++) {
var item = occupied.get(index);
@@ -141,7 +130,7 @@ public class PalletLocationAllocationService {
}
@Transactional
@BusinessAudit(event = "PALLET_LOCATION_RELEASE", resourceType = "INBOUND", resourceId = "#request.businessId", description = "补偿释放托盘和库位预占")
@BusinessAudit(event = "PALLET_LOCATION_RELEASE", resourceType = "INBOUND", resourceId = "#request.businessId", description = "补偿释放库位预占")
public List<LocationDtos.ActiveResource> release(PalletAllocationDtos.ReleaseRequest request) {
List<LocationDtos.ActiveResource> active = active(request.tenantId(), request.sourceService(), request.businessId());
if (active.stream().anyMatch(item -> "OCCUPIED".equals(item.state()))) {
@@ -150,12 +139,6 @@ public class PalletLocationAllocationService {
List<LocationDtos.ActiveResource> released = locationResourceService.release(
new LocationDtos.ReleaseCommand(request.tenantId(), request.sourceService(), request.businessId(),
request.operatorId(), request.reason()));
for (LocationDtos.ActiveResource resource : released) {
if (palletRepository.transitionStatus(request.tenantId(), resource.warehouseId(), resource.palletId(),
List.of("RESERVED"), "AVAILABLE") != 1) {
throw new BizException(ResultCode.CONFLICT, "托盘释放发生并发冲突");
}
}
return released;
}
@@ -187,10 +170,6 @@ public class PalletLocationAllocationService {
request.operatorId(), request.reason(), request.locationIds()));
List<PalletAllocationDtos.SelectiveReleaseResult> result = new ArrayList<>(released.size());
for (LocationDtos.ExactSelectiveReleaseResult resource : released) {
if (palletRepository.transitionStatus(request.tenantId(), resource.warehouseId(), resource.palletId(),
List.of("OCCUPIED"), "AVAILABLE") != 1) {
throw new BizException(ResultCode.CONFLICT, "Pallet changed concurrently during selected release");
}
result.add(new PalletAllocationDtos.SelectiveReleaseResult(resource.reservationId(),
resource.occupancyId(), resource.allocationNo(), resource.palletId(), resource.locationId(),
resource.locationCode(), resource.allocatedPackageCount()));
@@ -269,10 +248,6 @@ public class PalletLocationAllocationService {
request.sourceBusinessId(), request.operatorId(), settlementReason(request), request.releasedReservationIds()));
List<PalletAllocationDtos.SelectiveReleaseResult> result = new ArrayList<>(released.size());
for (LocationDtos.ExactSelectiveReleaseResult resource : released) {
if (palletRepository.transitionStatus(PLATFORM_RESOURCE_TENANT_ID, resource.warehouseId(), resource.palletId(),
List.of("OCCUPIED"), "AVAILABLE") != 1) {
throw new BizException(ResultCode.CONFLICT, "Pallet changed concurrently during PDA settlement");
}
result.add(new PalletAllocationDtos.SelectiveReleaseResult(resource.reservationId(),
resource.occupancyId(), resource.allocationNo(), resource.palletId(), resource.locationId(),
resource.locationCode(), resource.allocatedPackageCount()));
@@ -307,7 +282,7 @@ public class PalletLocationAllocationService {
return sha256(canonical);
}
/** 将未拣货的库位和托盘分配转给二级子入库单,物理资源继续保持占用。 */
/** 将未拣货的库位分配转给二级子入库单,库位继续保持占用。 */
@Transactional
@BusinessAudit(event = "PALLET_LOCATION_TRANSFER", resourceType = "INBOUND",
resourceId = "#request.sourceBusinessId", description = "将未拣货库位转给子入库单")
@@ -352,9 +327,9 @@ public class PalletLocationAllocationService {
List<LocationDtos.TransferResult> transferred) {
List<PalletAllocationDtos.TransferResult> result = new ArrayList<>(transferred.size());
for (LocationDtos.TransferResult item : transferred) {
PalletEntity pallet = palletRepository.findOwned(tenantId, item.warehouseId(), item.palletId());
if (pallet == null || !"OCCUPIED".equals(pallet.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "转移托盘不存在或未处于占用状态");
PalletEntity pallet = palletRepository.findOwnedIncludingDeleted(tenantId, item.warehouseId(), item.palletId());
if (pallet == null) {
throw new BizException(ResultCode.NOT_FOUND, "转移记录引用的托盘主数据不存在");
}
result.add(new PalletAllocationDtos.TransferResult(item.reservationId(), item.reservationNo(),
item.occupancyId(), item.allocationNo(), tenantId, item.warehouseId(), item.palletId(),
@@ -20,9 +20,10 @@ public class WarehouseSchemaReadinessService {
"SELECT id, tenant_id, data_scope_id, partner_code, partner_name, partner_type, status, remark, deleted FROM wh_partner LIMIT 0",
"SELECT partner_id, tenant_id, data_scope_id, partner_type FROM wh_partner_type LIMIT 0",
"SELECT id, tenant_id, company_code, company_name, status, deleted FROM wh_express_company LIMIT 0",
"SELECT id, tenant_id, warehouse_id, pallet_code, pallet_type, capacity_cbm, weight_kg, deleted, version FROM wh_pallet LIMIT 0",
"SELECT id, tenant_id, warehouse_id, zone_code, zone_name, location_code, location_type, max_weight_kg, shared_overflow, status, deleted, version FROM wh_location LIMIT 0",
"SELECT id, tenant_id, warehouse_id, pallet_id, location_id, shared_overflow, reservation_status, expires_at, version FROM wh_location_reservation LIMIT 0",
"SELECT id, tenant_id, warehouse_id, pallet_id, location_id, shared_overflow, occupancy_status, active_location_occupancy, active_pallet_occupancy, version FROM wh_location_occupancy LIMIT 0",
"SELECT id, tenant_id, warehouse_id, pallet_id, location_id, shared_overflow, occupancy_status, active_location_occupancy, version FROM wh_location_occupancy LIMIT 0",
"SELECT id, tenant_id, customer_name, currency, version FROM wh_overseas_storage_fee_rule LIMIT 0",
"SELECT id, tenant_id, rule_id, start_day, end_day, unit_price, sort_no, version FROM wh_overseas_storage_fee_tier LIMIT 0",
"SELECT id, tenant_id, business_type, order_no, warehouse_id, container_no, box_count, status, version FROM wh_domestic_container_order LIMIT 0",
@@ -15,9 +15,8 @@ CREATE TABLE IF NOT EXISTS wh_pallet (
pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型',
pallet_name VARCHAR(100) NULL COMMENT '托盘名称',
capacity_cbm DECIMAL(18,6) NOT NULL COMMENT '托盘可用容积CBM',
weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '托盘重量kg',
weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '托盘皮重kg',
color VARCHAR(32) NOT NULL DEFAULT '' COMMENT '托盘颜色',
status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE' COMMENT '内部状态:AVAILABLE、RESERVED或OCCUPIED',
remark VARCHAR(500) NULL COMMENT '备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -27,11 +26,11 @@ CREATE TABLE IF NOT EXISTS wh_pallet (
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_pallet_tenant_code (tenant_id, pallet_code),
KEY idx_pallet_available (tenant_id, warehouse_id, status, deleted),
KEY idx_pallet_type (tenant_id, warehouse_id, pallet_type, status, deleted),
KEY idx_pallet_warehouse (tenant_id, warehouse_id, deleted, pallet_code),
KEY idx_pallet_type (tenant_id, warehouse_id, pallet_type, capacity_cbm, deleted),
CONSTRAINT fk_pallet_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id),
CONSTRAINT ck_pallet_capacity CHECK (capacity_cbm > 0)
) ENGINE=InnoDB COMMENT='二期托盘基础资料和物理托盘实例';
) ENGINE=InnoDB COMMENT='可复用托盘标识和称重主数据,不承载动态资源状态';
CREATE TABLE IF NOT EXISTS wh_location (
id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)',
@@ -84,19 +83,17 @@ CREATE TABLE IF NOT EXISTS wh_location_reservation (
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
active_reservation TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = 'RESERVED' AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED COMMENT '普通库位活动预占唯一标记,A0 返回 NULL',
active_pallet_reservation TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = 'RESERVED' THEN 1 ELSE NULL END) STORED COMMENT '托盘活动预占唯一标记(包含A0)',
PRIMARY KEY (id),
UNIQUE KEY uk_location_reservation_no (tenant_id, reservation_no),
UNIQUE KEY uk_location_reservation_idempotency (tenant_id, source_service, idempotency_key),
UNIQUE KEY uk_location_active_reservation (tenant_id, location_id, active_reservation),
UNIQUE KEY uk_pallet_active_reservation (tenant_id, pallet_id, active_pallet_reservation),
KEY idx_location_reservation_business (tenant_id, source_service, business_id, reservation_status),
KEY idx_location_reservation_expire (reservation_status, expires_at),
KEY idx_location_reservation_target (tenant_id, warehouse_id, location_id, reservation_status),
CONSTRAINT fk_reservation_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id),
CONSTRAINT fk_reservation_pallet FOREIGN KEY (pallet_id) REFERENCES wh_pallet (id),
CONSTRAINT fk_reservation_location FOREIGN KEY (location_id) REFERENCES wh_location (id)
) ENGINE=InnoDB COMMENT='跨服务入库Saga的托盘和库位临时预占';
) ENGINE=InnoDB COMMENT='跨服务入库Saga的库位临时预占,托盘字段仅为主数据快照';
CREATE TABLE IF NOT EXISTS wh_location_occupancy (
id BIGINT UNSIGNED NOT NULL COMMENT '库位占用ID',
@@ -123,12 +120,10 @@ CREATE TABLE IF NOT EXISTS wh_location_occupancy (
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
active_location_occupancy TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = 'OCCUPIED' AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED COMMENT '普通库位活动占用唯一标记',
active_pallet_occupancy TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = 'OCCUPIED' THEN 1 ELSE NULL END) STORED COMMENT '托盘活动占用唯一标记(包含A0)',
PRIMARY KEY (id),
UNIQUE KEY uk_location_occupancy_no (tenant_id, occupancy_no),
UNIQUE KEY uk_occupancy_reservation (tenant_id, reservation_id),
UNIQUE KEY uk_location_active_occupancy (tenant_id, location_id, active_location_occupancy),
UNIQUE KEY uk_pallet_active_occupancy (tenant_id, pallet_id, active_pallet_occupancy),
KEY idx_occupancy_business (tenant_id, source_service, business_id, occupancy_status),
KEY idx_occupancy_warehouse (tenant_id, warehouse_id, occupancy_status, occupied_at),
CONSTRAINT fk_occupancy_reservation FOREIGN KEY (reservation_id) REFERENCES wh_location_reservation (id),
@@ -136,6 +131,6 @@ CREATE TABLE IF NOT EXISTS wh_location_occupancy (
CONSTRAINT fk_occupancy_pallet FOREIGN KEY (pallet_id) REFERENCES wh_pallet (id),
CONSTRAINT fk_occupancy_location FOREIGN KEY (location_id) REFERENCES wh_location (id),
CONSTRAINT ck_occupancy_volume CHECK (allocated_volume_cbm > 0)
) ENGINE=InnoDB COMMENT='已确认的一托一库位占用事实记录';
) ENGINE=InnoDB COMMENT='已确认的库位占用事实记录,托盘字段仅为主数据快照';
-- 头程入库单属于其他数据库,不建立跨库外键。
@@ -2,11 +2,11 @@
SET NAMES utf8mb4;
USE baiyue_warehouse;
-- 编码由平台维护接口自动生成;两个国内作业仓各初始化一个可用托盘。
INSERT INTO wh_pallet(id,tenant_id,warehouse_id,pallet_code,pallet_type,capacity_cbm,weight_kg,color,status,remark,created_by,updated_by)
-- 编码由平台维护接口自动生成;两个国内作业仓各初始化一条可复用托盘主数据。
INSERT INTO wh_pallet(id,tenant_id,warehouse_id,pallet_code,pallet_type,capacity_cbm,weight_kg,color,remark,created_by,updated_by)
VALUES
(190000000000000101,0,190000000000000001,'TP-001','木托盘',1.200000,8.000,'黄色','AVAILABLE',NULL,0,0),
(190000000000000102,0,190000000000000002,'TP-002','胶托盘',2.400000,12.000,'黑色','AVAILABLE',NULL,0,0)
(190000000000000101,0,190000000000000001,'TP-001','木托盘',1.200000,8.000,'黄色',NULL,0,0),
(190000000000000102,0,190000000000000002,'TP-002','胶托盘',2.400000,12.000,'黑色',NULL,0,0)
ON DUPLICATE KEY UPDATE
tenant_id=0,
warehouse_id=VALUES(warehouse_id),
@@ -0,0 +1,72 @@
openapi: 3.0.3
info:
title: Warehouse Pallet API
version: 1.0.0
description: 托盘是可复用标识和称重主数据,不承载 AVAILABLE、RESERVED 或 OCCUPIED 动态状态。
paths:
/internal/warehouse/v1/pallets/{palletId}/snapshot:
get:
summary: 查询单条托盘主数据快照
description: 固定使用平台公共作用域 tenant_id=0,并按 warehouseId 校验归属;不返回状态或可用性。
parameters:
- $ref: '#/components/parameters/InternalToken'
- { in: path, name: palletId, required: true, schema: { type: integer, format: int64, minimum: 1 } }
- { in: query, name: warehouseId, required: true, schema: { type: integer, format: int64, minimum: 1 } }
responses:
'200':
description: 业务响应;不存在或归属不符时 body code 为 A0404
content: { application/json: { schema: { $ref: '#/components/schemas/PalletSnapshotResponse' } } }
/internal/warehouse/v1/pallets/snapshots:
get:
summary: 查询仓库全部托盘主数据快照
description: 固定使用平台公共作用域 tenant_id=0,返回该仓全部未删除主数据,不按状态或占用过滤。
parameters:
- $ref: '#/components/parameters/InternalToken'
- { in: query, name: warehouseId, required: true, schema: { type: integer, format: int64, minimum: 1 } }
responses:
'200':
description: 业务响应
content: { application/json: { schema: { $ref: '#/components/schemas/PalletSnapshotListResponse' } } }
components:
parameters:
InternalToken:
in: header
name: X-Internal-Service-Token
required: true
schema: { type: string }
schemas:
PalletSnapshot:
type: object
additionalProperties: false
required: [palletId, palletCode, palletType, capacityCbm, tareWeightKg]
properties:
palletId: { type: integer, format: int64 }
palletCode: { type: string }
palletType: { type: string }
capacityCbm: { type: number }
tareWeightKg: { type: number }
ApiEnvelope:
type: object
required: [code, message, timestamp]
properties:
code: { type: string, example: '200' }
message: { type: string }
requestId: { type: string, nullable: true }
traceId: { type: string, nullable: true }
timestamp: { type: string, format: date-time }
PalletSnapshotResponse:
allOf:
- { $ref: '#/components/schemas/ApiEnvelope' }
- type: object
required: [data]
properties:
data: { $ref: '#/components/schemas/PalletSnapshot' }
PalletSnapshotListResponse:
allOf:
- { $ref: '#/components/schemas/ApiEnvelope' }
- type: object
required: [data]
properties:
data:
type: array
items: { $ref: '#/components/schemas/PalletSnapshot' }
@@ -0,0 +1,59 @@
package com.baiyue.warehouse.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.PalletRequests;
import com.baiyue.warehouse.service.PalletApplicationService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class InternalPalletControllerTest {
private static final String TOKEN = "test-internal-token";
private final PalletApplicationService service = mock(PalletApplicationService.class);
private final InternalPalletController controller = new InternalPalletController();
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(controller, "service", service);
ReflectionTestUtils.setField(controller, "internalToken", TOKEN);
controller.initializeToken();
}
@Test
void readsSnapshotFromPlatformScope() {
PalletRequests.Snapshot expected = snapshot(30L, "TP-001");
when(service.snapshot(0L, 20L, 30L)).thenReturn(expected);
assertThat(controller.snapshot(TOKEN, 30L, 20L).data()).isEqualTo(expected);
verify(service).snapshot(0L, 20L, 30L);
}
@Test
void listsAllWarehouseSnapshotsFromPlatformScope() {
List<PalletRequests.Snapshot> expected = List.of(snapshot(30L, "TP-001"), snapshot(31L, "TP-002"));
when(service.snapshots(0L, 20L)).thenReturn(expected);
assertThat(controller.snapshots(TOKEN, 20L).data()).isEqualTo(expected);
verify(service).snapshots(0L, 20L);
}
@Test
void rejectsInvalidInternalToken() {
assertThatThrownBy(() -> controller.snapshots("invalid", 20L)).isInstanceOf(BizException.class);
verifyNoInteractions(service);
}
private static PalletRequests.Snapshot snapshot(long id, String code) {
return new PalletRequests.Snapshot(id, code, "STANDARD", new BigDecimal("1.2"), new BigDecimal("8"));
}
}
@@ -0,0 +1,25 @@
package com.baiyue.warehouse.controller;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class PalletOpenApiContractTest {
@Test
void freezesInternalSnapshotPathsAndFields() throws IOException {
String yaml = Files.readString(Path.of("src/main/resources/openapi/pallet-v1.yaml"));
assertThat(yaml)
.contains("/internal/warehouse/v1/pallets/{palletId}/snapshot:")
.contains("/internal/warehouse/v1/pallets/snapshots:")
.contains("required: [palletId, palletCode, palletType, capacityCbm, tareWeightKg]")
.contains("additionalProperties: false")
.doesNotContain("tenantId:")
.doesNotContain("available:")
.doesNotContain("status:");
}
}
@@ -10,7 +10,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class InitialWarehouseResourceSeedSqlContractTest {
@Test
void initializesAvailablePalletAndLocationsForBothDomesticWarehouses() throws Exception {
void initializesReusablePalletMasterDataAndAvailableLocationsForBothDomesticWarehouses() throws Exception {
assertModuleSeed(Files.readString(Path.of("src", "main", "resources", "db", "phase2-pallet-seed.sql"),
StandardCharsets.UTF_8));
assertUnifiedSeed(Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
@@ -19,6 +19,7 @@ class InitialWarehouseResourceSeedSqlContractTest {
private static void assertModuleSeed(String sql) {
assertSeed(sql, "190000000000000001", "190000000000000002", "190000000000000");
assertThat(sql).doesNotContain("color,status,remark");
}
private static void assertUnifiedSeed(String sql) {
@@ -44,21 +44,29 @@ class WarehouseSchemaContractTest {
void shouldHandleCase04() throws IOException {
String sql = read("phase2-pallet-location-schema.sql");
assertColumns(sql, "wh_pallet", "tenant_id", "warehouse_id", "pallet_code", "pallet_type",
"capacity_cbm", "weight_kg", "color", "status", "remark", "deleted", "version");
assertThat(sql).contains("uk_pallet_tenant_code (tenant_id, pallet_code)");
"capacity_cbm", "weight_kg", "color", "remark", "deleted", "version");
assertThat(tableDefinition(sql, "wh_pallet"))
.contains("uk_pallet_tenant_code (tenant_id, pallet_code)")
.contains("idx_pallet_warehouse (tenant_id, warehouse_id, deleted, pallet_code)")
.doesNotContain("status VARCHAR")
.doesNotContain("AVAILABLE")
.doesNotContain("RESERVED")
.doesNotContain("OCCUPIED");
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");
"active_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");
"active_location_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");
.doesNotContain("active_pallet_reservation")
.doesNotContain("active_pallet_occupancy")
.doesNotContain("uk_pallet_active_reservation")
.doesNotContain("uk_pallet_active_occupancy");
}
@Test
@@ -112,6 +120,24 @@ class WarehouseSchemaContractTest {
}
}
@Test
void unifiedSqlShouldConvergePalletsToReusableMasterData() throws IOException {
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"));
String palletDefinition = tableDefinition(unified, "wh_pallet");
assertThat(palletDefinition)
.contains("idx_pallet_warehouse (tenant_id, warehouse_id, deleted, pallet_code)")
.contains("idx_pallet_type (tenant_id, warehouse_id, pallet_type, capacity_cbm, deleted)")
.doesNotContain("status VARCHAR")
.doesNotContain("idx_pallet_available");
assertThat(unified)
.contains("ALTER TABLE wh_pallet DROP COLUMN status")
.contains("ALTER TABLE wh_location_reservation DROP COLUMN active_pallet_reservation")
.contains("ALTER TABLE wh_location_occupancy DROP COLUMN active_pallet_occupancy")
.contains("托盘动态资源状态或活动唯一约束仍存在")
.contains("库位活动预占或占用唯一约束不完整");
}
private static String read(String file) throws IOException {
return Files.readString(Path.of("src/main/resources/db", file));
}
@@ -133,4 +159,10 @@ class WarehouseSchemaContractTest {
int end = sql.indexOf(' ', start);
return sql.substring(start, end);
}
private static String tableDefinition(String sql, String table) {
int start = sql.indexOf("CREATE TABLE IF NOT EXISTS " + table + " (");
int end = sql.indexOf("ENGINE=InnoDB", start);
return sql.substring(start, end);
}
}
@@ -27,7 +27,7 @@ class PalletApplicationServiceTest {
@Test
void shouldHandleCase01() {
PalletEntity entity = pallet("OCCUPIED");
PalletEntity entity = pallet();
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);
@@ -41,7 +41,7 @@ class PalletApplicationServiceTest {
@Test
void pagesWithoutTenantFilter() {
PalletRequests.PageQuery query = new PalletRequests.PageQuery(1, 20, null, null, null);
when(repository.page(null, query)).thenReturn(new PageResult<>(java.util.List.of(pallet("AVAILABLE")),
when(repository.page(null, query)).thenReturn(new PageResult<>(java.util.List.of(pallet()),
1, 20, 1, 1));
org.assertj.core.api.Assertions.assertThat(service.page(null, query).records())
@@ -63,12 +63,29 @@ class PalletApplicationServiceTest {
.hasMessageContaining("仅支持国内仓");
}
private static PalletEntity pallet(String status) {
@Test
void returnsReusablePalletSnapshotsWithoutResourceStatus() {
WarehouseEntity warehouse = new WarehouseEntity();
warehouse.setWarehouseType("DOMESTIC");
when(warehouseRepository.findOwned(0L, 20L)).thenReturn(warehouse);
PalletEntity entity = pallet();
entity.setPalletCode("TP-001");
entity.setPalletType("STANDARD");
entity.setCapacityCbm(new BigDecimal("1.200000"));
entity.setWeightKg(new BigDecimal("8.000"));
when(repository.findAllByWarehouse(0L, 20L)).thenReturn(java.util.List.of(entity));
org.assertj.core.api.Assertions.assertThat(service.snapshots(0L, 20L)).singleElement().satisfies(snapshot -> {
org.assertj.core.api.Assertions.assertThat(snapshot.palletId()).isEqualTo(30L);
org.assertj.core.api.Assertions.assertThat(snapshot.tareWeightKg()).isEqualByComparingTo("8.000");
});
}
private static PalletEntity pallet() {
PalletEntity entity = new PalletEntity();
entity.setId(30L);
entity.setTenantId(10001L);
entity.setWarehouseId(20L);
entity.setStatus(status);
entity.setVersion(0);
return entity;
}
@@ -52,22 +52,35 @@ class PalletLocationAllocationServiceTest {
}
@Test
void shouldHandleCase01() {
void reusesOnePalletMasterForMultipleAllocations() {
when(locations.findActiveResources(10001, "FIRSTLEG", 88)).thenReturn(List.of());
when(pallets.findAvailable(10001, 20, "木托盘", new BigDecimal("2"), 1))
when(pallets.findReusable(10001, 20, "木托盘", new BigDecimal("2")))
.thenReturn(List.of(pallet(30, "TP-001")));
when(pallets.transitionStatus(10001, 20, 30, List.of("AVAILABLE"), "RESERVED")).thenReturn(0);
when(locations.reserveAvailable(any())).thenReturn(List.of(
new LocationDtos.ReservationResult(101, "LR101", 30, "TP-001", 40, "A-01", false,
LocalDateTime.now().plusMinutes(30)),
new LocationDtos.ReservationResult(102, "LR102", 30, "TP-001", 41, "A-02", false,
LocalDateTime.now().plusMinutes(30))));
assertThatThrownBy(() -> service.reserve(reserve(88))).isInstanceOf(BizException.class);
verify(locations, never()).reserveAvailable(any());
var request = new PalletAllocationDtos.ReserveRequest(10001, 20, "FIRSTLEG", 88, "RK-001", "measure-001",
9001, new BigDecimal("4"), new BigDecimal("2"), "木托盘", LocalDateTime.now().plusMinutes(30), true);
assertThat(service.reserve(request).allocations()).hasSize(2)
.extracting(PalletAllocationDtos.AllocationResult::palletId)
.containsOnly(30L);
org.mockito.ArgumentCaptor<LocationDtos.ReserveCommand> command =
org.mockito.ArgumentCaptor.forClass(LocationDtos.ReserveCommand.class);
verify(locations).reserveAvailable(command.capture());
assertThat(command.getValue().targets()).hasSize(2)
.extracting(LocationDtos.ReservationTarget::palletId)
.containsOnly(30L);
}
@Test
void shouldHandleCase02() {
when(locations.findActiveResources(10001, "FIRSTLEG", 99)).thenReturn(List.of());
when(pallets.findAvailable(10001, 20, "STANDARD", new BigDecimal("2"), 1))
when(pallets.findReusable(10001, 20, "STANDARD", new BigDecimal("2")))
.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))));
@@ -77,7 +90,7 @@ class PalletLocationAllocationServiceTest {
var result = service.reserve(request);
assertThat(result.allocations().get(0).locationCode()).isEqualTo("A0");
verify(pallets).findAvailable(10001, 20, "STANDARD", new BigDecimal("2"), 1);
verify(pallets).findReusable(10001, 20, "STANDARD", new BigDecimal("2"));
}
@Test
@@ -86,13 +99,12 @@ class PalletLocationAllocationServiceTest {
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");
verifyNoInteractions(pallets);
}
@Test
@@ -117,7 +129,7 @@ class PalletLocationAllocationServiceTest {
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));
verify(pallets, never()).findReusable(any(Long.class), any(Long.class), any(), any());
}
@Test
@@ -127,8 +139,6 @@ class PalletLocationAllocationServiceTest {
when(locations.findActiveResources(10001L, "FIRSTLEG", 88L)).thenReturn(List.of(resource));
when(locations.confirmOccupied(any())).thenReturn(List.of(new LocationDtos.OccupancyResult(
301L, "OC-301", 101L, 30L, 40L, "A-01", false)));
when(pallets.transitionStatus(10001L, 20L, 30L, List.of("RESERVED"), "OCCUPIED"))
.thenReturn(1);
service.confirm(new PalletAllocationDtos.ConfirmRequest(10001L, 20L, "FIRSTLEG", 88L,
9001L, new BigDecimal("1.0"), new BigDecimal("2.0"), 20));
@@ -153,8 +163,6 @@ class PalletLocationAllocationServiceTest {
"TP-" + index, locationId, "A-" + index, false, "RESERVED"));
occupancyResults.add(new LocationDtos.OccupancyResult(400L + index, "OC-" + index,
reservationId, palletId, locationId, "A-" + index, false));
when(pallets.transitionStatus(10001L, 20L, palletId, List.of("RESERVED"), "OCCUPIED"))
.thenReturn(1);
}
when(locations.findActiveResources(10001L, "FIRSTLEG", 89L)).thenReturn(resources);
when(locations.confirmOccupied(any())).thenReturn(occupancyResults);
@@ -198,8 +206,6 @@ class PalletLocationAllocationServiceTest {
});
when(releaseOperations.lockByKey(anyLong(), anyString(), anyLong(), anyString())).thenReturn(operation);
when(locations.releaseSelectedExactly(any())).thenReturn(List.of(resource));
when(pallets.transitionStatus(10001L, 20L, 30L, List.of("OCCUPIED"), "AVAILABLE"))
.thenReturn(1);
var result = service.releaseSelected(new PalletAllocationDtos.SelectiveReleaseRequest(
10001L, "FIRSTLEG", 88L, "pda-release-88-202", 9001L, "PDA partial pick", List.of(40L)));
@@ -209,7 +215,7 @@ class PalletLocationAllocationServiceTest {
assertThat(item.locationId()).isEqualTo(40L);
assertThat(item.allocatedPackageCount()).isEqualTo(4);
});
verify(pallets).transitionStatus(10001L, 20L, 30L, List.of("OCCUPIED"), "AVAILABLE");
verifyNoInteractions(pallets);
verify(releaseOperations).complete(org.mockito.ArgumentMatchers.eq(701L), anyString());
}
@@ -265,14 +271,12 @@ class PalletLocationAllocationServiceTest {
.thenReturn(operation);
when(locations.releaseSelectedReservations(any())).thenReturn(List.of(new LocationDtos.ExactSelectiveReleaseResult(
101L, 201L, 1, 20L, 30L, 40L, "A-01", 8)));
when(pallets.transitionStatus(0L, 20L, 30L, List.of("OCCUPIED"), "AVAILABLE")).thenReturn(1);
when(locations.transferToChildByReservation(any())).thenReturn(List.of(new LocationDtos.TransferResult(
102L, "LR102", 202L, 2, 20L, 31L, "TP-002", 41L, "A-02",
new BigDecimal("1.200000"), 12, "OCCUPIED")));
PalletEntity pallet = pallet(31L, "TP-002");
pallet.setStatus("OCCUPIED");
pallet.setCapacityCbm(new BigDecimal("1.500000"));
when(pallets.findOwned(0L, 20L, 31L)).thenReturn(pallet);
when(pallets.findOwnedIncludingDeleted(0L, 20L, 31L)).thenReturn(pallet);
var result = service.settle(settlementRequest());
@@ -407,9 +411,8 @@ class PalletLocationAllocationServiceTest {
101L, "LR101", 201L, 1, 20L, 30L, "TP-001", 40L, "A-01",
new BigDecimal("1.200000"), 5, "OCCUPIED")));
PalletEntity pallet = pallet(30L, "TP-001");
pallet.setStatus("OCCUPIED");
pallet.setCapacityCbm(new BigDecimal("1.500000"));
when(pallets.findOwned(10001L, 20L, 30L)).thenReturn(pallet);
when(pallets.findOwnedIncludingDeleted(10001L, 20L, 30L)).thenReturn(pallet);
var result = service.transfer(new PalletAllocationDtos.TransferRequest(
10001L, "FIRSTLEG", 88L, 89L, "RK-001-S1", "transfer-88-89", 9001L,
@@ -421,7 +424,6 @@ class PalletLocationAllocationServiceTest {
assertThat(item.remainingPackageCount()).isEqualTo(5);
assertThat(item.state()).isEqualTo("OCCUPIED");
});
verify(pallets, never()).transitionStatus(10001L, 20L, 30L, List.of("OCCUPIED"), "AVAILABLE");
verify(transferOperations).complete(org.mockito.ArgumentMatchers.eq(501L), anyString());
}