二期内容提交

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,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.LocationDtos;
import com.baiyue.warehouse.dto.PalletAllocationDtos;
import com.baiyue.warehouse.service.PalletLocationAllocationService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.List;
/** 头程入库 Saga 使用的托盘、库位内部接口。 */
@RestController
@RequestMapping("/internal/warehouse/v1/pallet-locations")
@Tag(name = "托盘库位内部接口", description = "内部服务令牌保护的预占、确认和补偿释放")
public class InternalPalletLocationController {
private final PalletLocationAllocationService service;
private final byte[] expectedToken;
public InternalPalletLocationController(PalletLocationAllocationService service,
@Value("${baiyue.security.internal-service-token}") String token) {
this.service = service;
this.expectedToken = token.getBytes(StandardCharsets.UTF_8);
}
@PostMapping("/reservations")
@Operation(summary = "拆分托盘并预占一托一位")
public ApiResponse<PalletAllocationDtos.BatchResult> reserve(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody PalletAllocationDtos.ReserveRequest request) {
verify(token);
return ApiResponse.success(service.reserve(request));
}
@PostMapping("/confirm")
@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);
return ApiResponse.success(service.confirm(request));
}
@PostMapping("/release")
@Operation(summary = "释放未确认预占", description = "Saga补偿只释放RESERVED,已确认OCCUPIED不得反向释放")
public ApiResponse<List<LocationDtos.ActiveResource>> release(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody PalletAllocationDtos.ReleaseRequest request) {
verify(token);
return ApiResponse.success(service.release(request));
}
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, "无效的内部服务凭据");
}
}
}
@@ -0,0 +1,178 @@
package com.baiyue.warehouse.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.warehouse.dto.LocationDtos.Detail;
import com.baiyue.warehouse.dto.LocationDtos.PageQuery;
import com.baiyue.warehouse.dto.LocationDtos.SaveRequest;
import com.baiyue.warehouse.dto.LocationDtos.StatusRequest;
import com.baiyue.warehouse.entity.LocationEntity;
import com.baiyue.warehouse.service.LocationApplicationService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
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.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** 平台库位管理接口;当前数据模型中企业边界即目标 tenantId。 */
@Validated
@RestController
@RequestMapping("/api/platform/v1/locations")
@Tag(name = "平台库位管理", description = "按企业租户和仓库数据范围管理库位基础资料")
public class PlatformLocationController {
private final LocationApplicationService service;
public PlatformLocationController(LocationApplicationService service) {
this.service = service;
}
@GetMapping
@PreAuthorize("hasAuthority('platform:location:list')")
@Operation(summary = "分页查询库位", description = "按目标企业租户、仓库、库区、编码、类型和状态分页查询")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:location:list 权限,且仓库须属于目标企业租户")
})
public ApiResponse<PageResult<LocationEntity>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@Valid @ParameterObject PageQuery query) {
requirePlatform(user);
return ApiResponse.success(service.page(tenantId, query));
}
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('platform:location:list')")
@Operation(summary = "查询库位详情", description = "详情查询同时校验企业租户和仓库归属")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:location:list 权限或仓库超出数据范围"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "库位不存在")
})
public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@RequestParam @Min(1) long warehouseId,
@PathVariable long id) {
requirePlatform(user);
return ApiResponse.success(service.detail(tenantId, warehouseId, id));
}
@GetMapping("/available")
@PreAuthorize("hasAuthority('platform:location:list')")
@Operation(summary = "查询可用库位", description = "仅返回启用仓库中状态为 AVAILABLE、未预占且未占用的库位")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:location:list 权限或仓库超出数据范围")
})
public ApiResponse<List<LocationEntity>> available(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@RequestParam @Min(1) long warehouseId,
@RequestParam(defaultValue = "20") @Min(1) @Max(200) int limit) {
requirePlatform(user);
return ApiResponse.success(service.findAvailable(tenantId, warehouseId, limit));
}
@PostMapping
@PreAuthorize("hasAuthority('platform:location:create')")
@Operation(summary = "新增库位", description = "在目标企业租户的指定仓库内新增唯一编码库位")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "新增成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:location:create 权限或仓库超出数据范围"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "库位编码重复")
})
public ApiResponse<Long> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@Valid @RequestBody SaveRequest request) {
requirePlatform(user);
return ApiResponse.success(service.create(tenantId, request));
}
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('platform:location:edit')")
@Operation(summary = "编辑库位", description = "按 version 乐观锁编辑;不能跨仓库移动或绕过启停接口修改状态")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "编辑成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:location:edit 权限或数据范围校验失败"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "版本冲突、编码重复或库位正在被使用")
})
public ApiResponse<Void> update(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@RequestParam @Min(1) long warehouseId,
@PathVariable long id,
@Valid @RequestBody SaveRequest request) {
requirePlatform(user);
service.update(tenantId, warehouseId, id, request);
return ApiResponse.success(null);
}
@PatchMapping("/{id}/status")
@PreAuthorize("hasAuthority('platform:location:status')")
@Operation(summary = "启用或停用库位", description = "按 version 条件更新;活动预占或占用中的库位不能停用")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "状态变更成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:location:status 权限或仓库超出数据范围"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "版本冲突或库位正在被使用")
})
public ApiResponse<Void> changeStatus(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@RequestParam @Min(1) long warehouseId,
@PathVariable long id,
@Valid @RequestBody StatusRequest request) {
requirePlatform(user);
service.changeStatus(tenantId, warehouseId, id, request.status(), request.version(), requireUserId(user));
return ApiResponse.success(null);
}
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('platform:location:delete')")
@Operation(summary = "删除库位", description = "仅允许按 version 逻辑删除已停用且无活动预占/占用的库位")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "删除成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:location:delete 权限或仓库超出数据范围"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "库位未停用、正在被使用或版本冲突")
})
public ApiResponse<Void> delete(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@RequestParam @Min(1) long warehouseId,
@PathVariable long id,
@Parameter(description = "乐观锁版本号", required = true)
@RequestParam @Min(0) int version) {
requirePlatform(user);
service.delete(tenantId, warehouseId, id, version, requireUserId(user));
return ApiResponse.success(null);
}
private static void requirePlatform(AuthenticatedUser user) {
if (user == null || !"PLATFORM".equals(user.appCode())) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
}
private static long requireUserId(AuthenticatedUser user) {
if (user.userId() == null || user.userId() <= 0) {
throw new BizException(ResultCode.UNAUTHORIZED, "登录用户标识缺失");
}
return user.userId();
}
}
@@ -0,0 +1,103 @@
package com.baiyue.warehouse.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
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.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** 平台托盘管理接口。 */
@Validated
@RestController
@RequestMapping("/api/platform/v1/pallets")
@Tag(name = "平台托盘管理", description = "按租户和仓库维护托盘主数据")
public class PlatformPalletController {
private final PalletApplicationService service;
public PlatformPalletController(PalletApplicationService service) {
this.service = service;
}
@GetMapping
@PreAuthorize("hasAuthority('platform:pallet:list')")
@Operation(summary = "分页查询托盘")
@ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要 platform:pallet:list 权限"))
public ApiResponse<PageResult<PalletRequests.View>> page(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@Valid @ParameterObject PalletRequests.PageQuery query) {
requirePlatform(user);
return ApiResponse.success(service.page(tenantId, query));
}
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('platform:pallet:list')")
@Operation(summary = "查询托盘详情")
public ApiResponse<PalletRequests.View> detail(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@RequestParam @Min(1) long warehouseId,
@PathVariable long id) {
requirePlatform(user);
return ApiResponse.success(service.detail(tenantId, warehouseId, id));
}
@PostMapping
@PreAuthorize("hasAuthority('platform:pallet:create')")
@Operation(summary = "新增托盘")
public ApiResponse<Long> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@Valid @RequestBody PalletRequests.SaveRequest request) {
requirePlatform(user);
return ApiResponse.success(service.create(tenantId, request));
}
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('platform:pallet:edit')")
@Operation(summary = "编辑托盘")
public ApiResponse<Void> update(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@RequestParam @Min(1) long warehouseId,
@PathVariable long id,
@Valid @RequestBody PalletRequests.SaveRequest request) {
requirePlatform(user);
service.update(tenantId, warehouseId, id, request);
return ApiResponse.success(null);
}
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('platform:pallet:delete')")
@Operation(summary = "删除托盘", description = "仅可删除未预占、未占用的托盘")
public ApiResponse<Void> delete(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@RequestParam @Min(1) long warehouseId,
@PathVariable long id) {
requirePlatform(user);
service.delete(tenantId, warehouseId, id);
return ApiResponse.success(null);
}
private static void requirePlatform(AuthenticatedUser user) {
if (user == null || !"PLATFORM".equals(user.appCode())) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
}
}
@@ -0,0 +1,153 @@
package com.baiyue.warehouse.dto;
import com.baiyue.warehouse.entity.LocationEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/** 库位管理及任务7资源编排共用 DTO。 */
public final class LocationDtos {
private LocationDtos() {
}
@Schema(name = "LocationPageQuery", description = "库位分页查询条件")
public record PageQuery(
@Min(1) long page,
@Min(1) @Max(200) long size,
@NotNull @Min(1) Long warehouseId,
@Size(max = 50) String zoneCode,
@Size(max = 50) String locationCode,
@Pattern(regexp = "^(SHELF|PALLET|FLOOR|FLOW_RACK)$") String locationType,
@Pattern(regexp = "^(AVAILABLE|RESERVED|OCCUPIED|DISABLED)$") String status
) {
public PageQuery {
if (page == 0) {
page = 1;
}
if (size == 0) {
size = 20;
}
}
}
@Schema(name = "LocationSaveRequest", description = "库位新增或编辑请求")
public record SaveRequest(
@NotNull @Min(1) Long warehouseId,
@NotBlank @Size(max = 50) String zoneCode,
@NotBlank @Size(max = 100) String zoneName,
@NotBlank @Size(max = 50) String locationCode,
@Size(max = 100) String locationName,
@NotBlank @Pattern(regexp = "^(SHELF|PALLET|FLOOR|FLOW_RACK)$") String locationType,
@DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg,
@NotBlank @Pattern(regexp = "^(AVAILABLE|DISABLED)$") String status,
@Size(max = 500) String remark,
@Min(0) Integer version
) {
}
@Schema(name = "LocationStatusRequest", description = "库位启停请求")
public record StatusRequest(
@NotBlank @Pattern(regexp = "^(AVAILABLE|DISABLED)$") String status,
@NotNull @Min(0) Integer version
) {
}
@Schema(name = "LocationDetail", description = "带仓库名称的库位详情")
public record Detail(LocationEntity location, String warehouseCode, String warehouseName) {
}
/** 单个托盘的幂等预占目标。 */
public record ReservationTarget(
@NotNull @Min(1) Long palletId,
@NotBlank @Size(max = 50) String palletCode,
@NotBlank @Size(max = 128) String idempotencyKey
) {
}
/** 原子选择并预占 N 个可用库位的命令。 */
public record ReserveCommand(
@Min(1) long tenantId,
@Min(1) long warehouseId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long businessId,
@NotBlank @Size(max = 40) String inboundCode,
@Min(1) long operatorId,
@NotNull LocalDateTime expiresAt,
@NotEmpty @Size(max = 200) List<@Valid ReservationTarget> targets
) {
}
public record ReservationResult(
long reservationId,
String reservationNo,
long palletId,
String palletCode,
long locationId,
String locationCode,
boolean sharedOverflow,
LocalDateTime expiresAt
) {
}
/** 确认占用时补齐本托盘分配汇总。 */
public record OccupancyTarget(
@Min(1) long reservationId,
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal allocatedVolumeCbm,
@Min(1) Integer allocatedPackageCount
) {
}
public record ConfirmCommand(
@Min(1) long tenantId,
@Min(1) long warehouseId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long businessId,
@Min(1) long operatorId,
@NotEmpty @Size(max = 200) List<@Valid OccupancyTarget> targets
) {
}
public record OccupancyResult(
long occupancyId,
String occupancyNo,
long reservationId,
long palletId,
long locationId,
String locationCode,
boolean sharedOverflow
) {
}
public record ReleaseCommand(
@Min(1) long tenantId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long businessId,
@Min(1) long operatorId,
@NotBlank @Size(max = 500) String reason
) {
}
public record ActiveResource(
long reservationId,
Long occupancyId,
long warehouseId,
long palletId,
String palletCode,
long locationId,
String locationCode,
boolean sharedOverflow,
String state
) {
}
}
@@ -0,0 +1,70 @@
package com.baiyue.warehouse.dto;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Digits;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/** 头程调用仓库托盘、库位编排的内部契约。 */
public final class PalletAllocationDtos {
private PalletAllocationDtos() {
}
public record ReserveRequest(
@Min(1) long tenantId,
@Min(1) long warehouseId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long businessId,
@NotBlank @Size(max = 40) String inboundCode,
@NotBlank @Size(max = 128) String idempotencyKey,
@Min(1) long operatorId,
@NotNull @DecimalMin(value = "0", inclusive = false) @Digits(integer = 12, fraction = 6) BigDecimal totalVolumeCbm,
@NotNull @DecimalMin(value = "0", inclusive = false) @Digits(integer = 12, fraction = 6) BigDecimal palletCapacityCbm,
@Size(max = 32) String palletType,
@NotNull LocalDateTime expiresAt,
@NotNull Boolean measurementCompleted
) {
}
public record ConfirmRequest(
@Min(1) long tenantId,
@Min(1) long warehouseId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long businessId,
@Min(1) long operatorId,
@NotNull @DecimalMin(value = "0", inclusive = false) @Digits(integer = 12, fraction = 6) BigDecimal totalVolumeCbm,
@NotNull @DecimalMin(value = "0", inclusive = false) @Digits(integer = 12, fraction = 6) BigDecimal palletCapacityCbm
) {
}
public record ReleaseRequest(
@Min(1) long tenantId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long businessId,
@Min(1) long operatorId,
@NotBlank @Size(max = 500) String reason
) {
}
public record AllocationResult(
long reservationId,
String reservationNo,
int allocationNo,
long palletId,
String palletCode,
long locationId,
String locationCode,
BigDecimal allocatedVolumeCbm,
String state
) {
}
public record BatchResult(int palletCount, List<AllocationResult> allocations) {
}
}
@@ -0,0 +1,72 @@
package com.baiyue.warehouse.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Digits;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 托盘管理请求契约。 */
public final class PalletRequests {
private PalletRequests() {
}
@Schema(name = "PalletSaveRequest", description = "托盘新增或编辑请求")
public record SaveRequest(
@NotNull @Min(1)
@Schema(description = "所属仓库ID", example = "190000000000000002", requiredMode = Schema.RequiredMode.REQUIRED)
Long warehouseId,
@NotBlank @Size(max = 50)
@Schema(description = "仓库内唯一托盘编码", example = "TP-001", requiredMode = Schema.RequiredMode.REQUIRED)
String palletCode,
@NotBlank @Size(max = 32)
@Schema(description = "托盘类型", example = "木托盘", requiredMode = Schema.RequiredMode.REQUIRED)
String palletType,
@NotNull @DecimalMin(value = "0", inclusive = false) @Digits(integer = 12, fraction = 6)
@Schema(description = "托盘容积,单位m³", example = "1.200000", requiredMode = Schema.RequiredMode.REQUIRED)
BigDecimal capacityCbm,
@Size(max = 500)
@Schema(description = "备注", example = "标准川字托盘")
String remark
) {
}
@Schema(name = "PalletPageQuery", description = "托盘分页查询条件")
public record PageQuery(
@Min(1) @Schema(description = "页码", defaultValue = "1") long page,
@Min(1) @Max(200) @Schema(description = "每页条数", defaultValue = "20") long size,
@Min(1) @Schema(description = "仓库ID") Long warehouseId,
@Size(max = 50) @Schema(description = "托盘编码,支持模糊查询") String palletCode,
@Size(max = 32) @Schema(description = "托盘类型") String palletType
) {
public PageQuery {
if (page == 0) {
page = 1;
}
if (size == 0) {
size = 20;
}
}
}
/** 平台托盘视图不暴露内部预占、占用状态。 */
@Schema(name = "PalletView", description = "托盘主数据视图")
public record View(
Long id,
Long tenantId,
Long warehouseId,
String palletCode,
String palletType,
BigDecimal capacityCbm,
String remark,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
}
}
@@ -0,0 +1,51 @@
package com.baiyue.warehouse.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.common.core.model.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
/** 库位基础资料实体,对应二期共用表 {@code wh_location}。 */
@Getter
@Setter
@TableName("wh_location")
@Schema(name = "Location", description = "库位基础资料")
public class LocationEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
@Schema(description = "库位 ID", example = "190000000000000001")
private Long id;
@Schema(description = "所属企业租户 ID", example = "10001")
private Long tenantId;
@Schema(description = "所属仓库 ID", example = "190000000000000101")
private Long warehouseId;
@TableField(exist = false)
@Schema(description = "仓库编码", example = "US-LAX-01")
private String warehouseCode;
@TableField(exist = false)
@Schema(description = "仓库名称", example = "洛杉矶长滩海外仓")
private String warehouseName;
@Schema(description = "库区编码", example = "NORMAL")
private String zoneCode;
@Schema(description = "库区名称", example = "常规货")
private String zoneName;
@Schema(description = "租户仓库内唯一库位编码", example = "A1-01")
private String locationCode;
@Schema(description = "库位名称", example = "A1 区 01 位")
private String locationName;
@Schema(description = "库位类型", allowableValues = {"SHELF", "PALLET", "FLOOR", "FLOW_RACK"})
private String locationType;
@Schema(description = "最大承重 kg,仅展示,不参与二期容量拦截", example = "500.000")
private BigDecimal maxWeightKg;
@Schema(description = "是否为系统共享溢出位 A0", example = "false")
private Boolean sharedOverflow;
@Schema(description = "资源状态", allowableValues = {"AVAILABLE", "RESERVED", "OCCUPIED", "DISABLED"})
private String status;
@Schema(description = "备注", example = "靠近入库口")
private String remark;
}
@@ -0,0 +1,41 @@
package com.baiyue.warehouse.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 库位占用事实实体,仅由 {@code LocationResourceService} 维护。 */
@Getter
@Setter
@TableName("wh_location_occupancy")
public class LocationOccupancyEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String occupancyNo;
private Long reservationId;
private String sourceService;
private Long businessId;
private String inboundCode;
private Long warehouseId;
private Long palletId;
private String palletCode;
private Long locationId;
private String locationCode;
private Boolean sharedOverflow;
private BigDecimal allocatedVolumeCbm;
private Integer allocatedPackageCount;
private String occupancyStatus;
private LocalDateTime occupiedAt;
private LocalDateTime releasedAt;
private String releaseReason;
private Long createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private Integer version;
}
@@ -0,0 +1,39 @@
package com.baiyue.warehouse.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** 库位活动预占实体,仅由 {@code LocationResourceService} 维护。 */
@Getter
@Setter
@TableName("wh_location_reservation")
public class LocationReservationEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String reservationNo;
private String idempotencyKey;
private String sourceService;
private Long businessId;
private String inboundCode;
private Long warehouseId;
private Long palletId;
private String palletCode;
private Long locationId;
private String locationCode;
private Boolean sharedOverflow;
private String reservationStatus;
private LocalDateTime expiresAt;
private LocalDateTime confirmedAt;
private LocalDateTime releasedAt;
private String releaseReason;
private Long createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private Integer version;
}
@@ -0,0 +1,45 @@
package com.baiyue.warehouse.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.common.core.model.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
/**
* 托盘主数据实体。状态同时承载可用、预占、占用和停用四种资源状态。
*/
@Getter
@Setter
@TableName("wh_pallet")
@Schema(name = "Pallet", description = "托盘主数据")
public class PalletEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
@Schema(description = "托盘ID", example = "190000000000000001")
private Long id;
@Schema(description = "所属租户ID", example = "10001")
private Long tenantId;
@Schema(description = "所属仓库ID", example = "190000000000000002")
private Long warehouseId;
@Schema(description = "托盘编码", example = "TP-001")
private String palletCode;
@Schema(description = "托盘类型", example = "木托盘")
private String palletType;
@Schema(description = "托盘容积,单位m³", example = "1.200000")
private BigDecimal capacityCbm;
@Schema(description = "资源状态", allowableValues = {"AVAILABLE", "RESERVED", "OCCUPIED", "DISABLED"})
private String status;
@Schema(description = "备注", example = "标准川字托盘")
private String remark;
}
@@ -0,0 +1,162 @@
package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.warehouse.entity.LocationEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
/** 库位基础资料及并发状态条件更新 Mapper。 */
@Mapper
public interface LocationMapper extends BaseMapper<LocationEntity> {
@Select("""
<script>
SELECT l.*, w.warehouse_code, w.warehouse_name
FROM wh_location l
JOIN wh_warehouse w
ON w.id = l.warehouse_id AND w.tenant_id = l.tenant_id AND w.deleted = 0
WHERE l.tenant_id = #{tenantId}
AND l.warehouse_id = #{warehouseId}
AND l.deleted = 0
<if test="zoneCode != null and zoneCode != ''">
AND l.zone_code = #{zoneCode}
</if>
<if test="locationCode != null and locationCode != ''">
AND l.location_code LIKE CONCAT('%', #{locationCode}, '%')
</if>
<if test="locationType != null and locationType != ''">
AND l.location_type = #{locationType}
</if>
<if test="status != null and status != ''">
AND l.status = #{status}
</if>
ORDER BY l.created_at DESC, l.id DESC
</script>
""")
Page<LocationEntity> selectLocationPage(Page<LocationEntity> page,
@Param("tenantId") long tenantId,
@Param("warehouseId") long warehouseId,
@Param("zoneCode") String zoneCode,
@Param("locationCode") String locationCode,
@Param("locationType") String locationType,
@Param("status") String status);
@Select("""
SELECT l.*, w.warehouse_code, w.warehouse_name
FROM wh_location l
JOIN wh_warehouse w
ON w.id = l.warehouse_id AND w.tenant_id = l.tenant_id
AND w.status = 'ACTIVE' AND w.deleted = 0
WHERE l.tenant_id = #{tenantId}
AND l.warehouse_id = #{warehouseId}
AND l.status = 'AVAILABLE' AND l.shared_overflow = 0 AND l.deleted = 0
AND NOT EXISTS (
SELECT 1 FROM wh_location_reservation r
WHERE r.tenant_id = l.tenant_id AND r.location_id = l.id
AND r.reservation_status = 'RESERVED'
)
AND NOT EXISTS (
SELECT 1 FROM wh_location_occupancy o
WHERE o.tenant_id = l.tenant_id AND o.location_id = l.id
AND o.occupancy_status = 'OCCUPIED'
)
ORDER BY l.zone_code,
REGEXP_REPLACE(l.location_code, '[0-9]+$', ''),
CAST(COALESCE(REGEXP_SUBSTR(l.location_code, '[0-9]+$'), '0') AS UNSIGNED),
l.location_code, l.id
LIMIT #{limit}
""")
List<LocationEntity> selectAvailable(@Param("tenantId") long tenantId,
@Param("warehouseId") long warehouseId,
@Param("limit") int limit);
@Select("""
SELECT l.*
FROM wh_location l
JOIN wh_warehouse w
ON w.id = l.warehouse_id AND w.tenant_id = l.tenant_id
AND w.status = 'ACTIVE' AND w.deleted = 0
WHERE l.tenant_id = #{tenantId}
AND l.warehouse_id = #{warehouseId}
AND l.status = 'AVAILABLE' AND l.shared_overflow = 0 AND l.deleted = 0
AND NOT EXISTS (
SELECT 1 FROM wh_location_reservation r
WHERE r.tenant_id = l.tenant_id AND r.location_id = l.id
AND r.reservation_status = 'RESERVED'
)
AND NOT EXISTS (
SELECT 1 FROM wh_location_occupancy o
WHERE o.tenant_id = l.tenant_id AND o.location_id = l.id
AND o.occupancy_status = 'OCCUPIED'
)
ORDER BY l.zone_code,
REGEXP_REPLACE(l.location_code, '[0-9]+$', ''),
CAST(COALESCE(REGEXP_SUBSTR(l.location_code, '[0-9]+$'), '0') AS UNSIGNED),
l.location_code, l.id
LIMIT #{limit}
FOR UPDATE SKIP LOCKED
""")
List<LocationEntity> lockAvailable(@Param("tenantId") long tenantId,
@Param("warehouseId") long warehouseId,
@Param("limit") int limit);
@Insert("""
INSERT INTO wh_location (
id, tenant_id, warehouse_id, zone_code, zone_name, location_code, location_name,
location_type, max_weight_kg, shared_overflow, status, remark,
created_by, created_at, updated_by, updated_at, deleted, version
) VALUES (
#{id}, #{tenantId}, #{warehouseId}, 'OVERFLOW', '共享溢出区', 'A0', '默认共享溢出位',
'FLOOR', NULL, 1, 'AVAILABLE', '系统自动创建;普通库位不足时共享使用',
#{operatorId}, CURRENT_TIMESTAMP(3), #{operatorId}, CURRENT_TIMESTAMP(3), 0, 0
)
ON DUPLICATE KEY UPDATE id = id
""")
int insertSharedOverflowIfAbsent(@Param("id") long id,
@Param("tenantId") long tenantId,
@Param("warehouseId") long warehouseId,
@Param("operatorId") long operatorId);
@Select("""
SELECT * FROM wh_location
WHERE tenant_id = #{tenantId} AND warehouse_id = #{warehouseId}
AND location_code = 'A0' AND shared_overflow = 1
AND status = 'AVAILABLE' AND deleted = 0
FOR UPDATE
""")
LocationEntity lockSharedOverflow(@Param("tenantId") long tenantId,
@Param("warehouseId") long warehouseId);
@Update("""
UPDATE wh_location
SET status = #{targetStatus}, version = version + 1,
updated_by = #{operatorId}, updated_at = CURRENT_TIMESTAMP(3)
WHERE id = #{id} AND tenant_id = #{tenantId} AND warehouse_id = #{warehouseId}
AND status = #{expectedStatus} AND version = #{version} AND deleted = 0
""")
int updateStatusConditionally(@Param("tenantId") long tenantId,
@Param("warehouseId") long warehouseId,
@Param("id") long id,
@Param("expectedStatus") String expectedStatus,
@Param("targetStatus") String targetStatus,
@Param("version") int version,
@Param("operatorId") long operatorId);
@Update("""
UPDATE wh_location
SET deleted = 1, version = version + 1,
updated_by = #{operatorId}, updated_at = CURRENT_TIMESTAMP(3)
WHERE id = #{id} AND tenant_id = #{tenantId} AND warehouse_id = #{warehouseId}
AND status = 'DISABLED' AND version = #{version} AND deleted = 0
""")
int deleteDisabledConditionally(@Param("tenantId") long tenantId,
@Param("warehouseId") long warehouseId,
@Param("id") long id,
@Param("version") int version,
@Param("operatorId") long operatorId);
}
@@ -0,0 +1,29 @@
package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.entity.LocationOccupancyEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
/** 库位占用事实 Mapper。 */
@Mapper
public interface LocationOccupancyMapper extends BaseMapper<LocationOccupancyEntity> {
@Select("""
SELECT COUNT(1) FROM wh_location_occupancy
WHERE tenant_id = #{tenantId} AND location_id = #{locationId}
AND occupancy_status = 'OCCUPIED'
""")
int countActiveByLocation(@Param("tenantId") long tenantId, @Param("locationId") long locationId);
@Update("""
UPDATE wh_location_occupancy
SET occupancy_status = 'RELEASED', released_at = CURRENT_TIMESTAMP(3),
release_reason = #{reason}, updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{id} AND tenant_id = #{tenantId}
AND occupancy_status = 'OCCUPIED' AND version = #{version}
""")
int release(@Param("tenantId") long tenantId, @Param("id") long id,
@Param("version") int version, @Param("reason") String reason);
}
@@ -0,0 +1,45 @@
package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.entity.LocationReservationEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
/** 库位预占 Mapper。 */
@Mapper
public interface LocationReservationMapper extends BaseMapper<LocationReservationEntity> {
@Select("""
SELECT COUNT(1) FROM wh_location_reservation
WHERE tenant_id = #{tenantId} AND location_id = #{locationId}
AND reservation_status = 'RESERVED'
""")
int countActiveByLocation(@Param("tenantId") long tenantId, @Param("locationId") long locationId);
@Select("""
SELECT * FROM wh_location_reservation
WHERE id = #{id} AND tenant_id = #{tenantId}
FOR UPDATE
""")
LocationReservationEntity lockById(@Param("tenantId") long tenantId, @Param("id") long id);
@Update("""
UPDATE wh_location_reservation
SET reservation_status = 'CONFIRMED', confirmed_at = CURRENT_TIMESTAMP(3),
updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{id} AND tenant_id = #{tenantId}
AND reservation_status = 'RESERVED' AND version = #{version}
""")
int confirm(@Param("tenantId") long tenantId, @Param("id") long id, @Param("version") int version);
@Update("""
UPDATE wh_location_reservation
SET reservation_status = 'RELEASED', released_at = CURRENT_TIMESTAMP(3),
release_reason = #{reason}, updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{id} AND tenant_id = #{tenantId}
AND reservation_status = 'RESERVED' AND version = #{version}
""")
int release(@Param("tenantId") long tenantId, @Param("id") long id,
@Param("version") int version, @Param("reason") String reason);
}
@@ -0,0 +1,8 @@
package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.entity.PalletEntity;
/** 托盘主数据 Mapper。 */
public interface PalletMapper extends BaseMapper<PalletEntity> {
}
@@ -0,0 +1,156 @@
package com.baiyue.warehouse.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.warehouse.dto.LocationDtos.PageQuery;
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.mapper.LocationMapper;
import com.baiyue.warehouse.mapper.LocationOccupancyMapper;
import com.baiyue.warehouse.mapper.LocationReservationMapper;
import com.baiyue.warehouse.mapper.WarehouseMapper;
import org.springframework.stereotype.Repository;
import java.util.Collection;
import java.util.List;
/** 强制使用租户、仓库联合条件的库位持久化边界。 */
@Repository
public class LocationRepository {
private final LocationMapper locations;
private final LocationReservationMapper reservations;
private final LocationOccupancyMapper occupancies;
private final WarehouseMapper warehouses;
public LocationRepository(LocationMapper locations, LocationReservationMapper reservations,
LocationOccupancyMapper occupancies, WarehouseMapper warehouses) {
this.locations = locations;
this.reservations = reservations;
this.occupancies = occupancies;
this.warehouses = warehouses;
}
public WarehouseEntity findWarehouse(long tenantId, long warehouseId) {
return warehouses.selectOne(new LambdaQueryWrapper<WarehouseEntity>()
.eq(WarehouseEntity::getTenantId, tenantId)
.eq(WarehouseEntity::getId, warehouseId));
}
public LocationEntity findOwned(long tenantId, long warehouseId, long id) {
return locations.selectOne(new LambdaQueryWrapper<LocationEntity>()
.eq(LocationEntity::getTenantId, tenantId)
.eq(LocationEntity::getWarehouseId, warehouseId)
.eq(LocationEntity::getId, id));
}
public PageResult<LocationEntity> page(long tenantId, PageQuery query) {
Page<LocationEntity> result = locations.selectLocationPage(new Page<>(query.page(), query.size()),
tenantId, query.warehouseId(), query.zoneCode(), query.locationCode(), query.locationType(), query.status());
return PageResult.from(result);
}
public boolean existsCode(long tenantId, long warehouseId, String code, Long excludedId) {
return locations.selectCount(new LambdaQueryWrapper<LocationEntity>()
.eq(LocationEntity::getTenantId, tenantId)
.eq(LocationEntity::getWarehouseId, warehouseId)
.eq(LocationEntity::getLocationCode, code)
.ne(excludedId != null, LocationEntity::getId, excludedId)) > 0;
}
public void insert(LocationEntity entity) {
locations.insert(entity);
}
public int save(LocationEntity entity) {
return locations.updateById(entity);
}
public List<LocationEntity> findAvailable(long tenantId, long warehouseId, int limit) {
return locations.selectAvailable(tenantId, warehouseId, limit);
}
public List<LocationEntity> lockAvailable(long tenantId, long warehouseId, int limit) {
return locations.lockAvailable(tenantId, warehouseId, limit);
}
/** 依靠租户仓库编码唯一键并发创建或复用系统共享 A0。 */
public LocationEntity lockOrCreateSharedOverflow(long tenantId, long warehouseId, long operatorId) {
locations.insertSharedOverflowIfAbsent(IdWorker.getId(), tenantId, warehouseId, operatorId);
return locations.lockSharedOverflow(tenantId, warehouseId);
}
public int changeStatus(long tenantId, long warehouseId, long id, String expected, String target,
int version, long operatorId) {
return locations.updateStatusConditionally(tenantId, warehouseId, id, expected, target, version, operatorId);
}
public int deleteDisabled(long tenantId, long warehouseId, long id, int version, long operatorId) {
return locations.deleteDisabledConditionally(tenantId, warehouseId, id, version, operatorId);
}
public boolean hasActiveResource(long tenantId, long locationId) {
return reservations.countActiveByLocation(tenantId, locationId) > 0
|| occupancies.countActiveByLocation(tenantId, locationId) > 0;
}
public List<LocationReservationEntity> findReservationsByKeys(long tenantId, String sourceService,
Collection<String> keys) {
if (keys.isEmpty()) {
return List.of();
}
return reservations.selectList(new LambdaQueryWrapper<LocationReservationEntity>()
.eq(LocationReservationEntity::getTenantId, tenantId)
.eq(LocationReservationEntity::getSourceService, sourceService)
.in(LocationReservationEntity::getIdempotencyKey, keys));
}
public List<LocationReservationEntity> findReservationsByBusiness(long tenantId, String sourceService,
long businessId) {
return reservations.selectList(new LambdaQueryWrapper<LocationReservationEntity>()
.eq(LocationReservationEntity::getTenantId, tenantId)
.eq(LocationReservationEntity::getSourceService, sourceService)
.eq(LocationReservationEntity::getBusinessId, businessId));
}
public LocationReservationEntity lockReservation(long tenantId, long id) {
return reservations.lockById(tenantId, id);
}
public void insertReservation(LocationReservationEntity entity) {
reservations.insert(entity);
}
public int confirmReservation(LocationReservationEntity entity) {
return reservations.confirm(entity.getTenantId(), entity.getId(), entity.getVersion());
}
public int releaseReservation(LocationReservationEntity entity, String reason) {
return reservations.release(entity.getTenantId(), entity.getId(), entity.getVersion(), reason);
}
public List<LocationOccupancyEntity> findOccupanciesByBusiness(long tenantId, String sourceService,
long businessId) {
return occupancies.selectList(new LambdaQueryWrapper<LocationOccupancyEntity>()
.eq(LocationOccupancyEntity::getTenantId, tenantId)
.eq(LocationOccupancyEntity::getSourceService, sourceService)
.eq(LocationOccupancyEntity::getBusinessId, businessId));
}
public LocationOccupancyEntity findOccupancyByReservation(long tenantId, long reservationId) {
return occupancies.selectOne(new LambdaQueryWrapper<LocationOccupancyEntity>()
.eq(LocationOccupancyEntity::getTenantId, tenantId)
.eq(LocationOccupancyEntity::getReservationId, reservationId));
}
public void insertOccupancy(LocationOccupancyEntity entity) {
occupancies.insert(entity);
}
public int releaseOccupancy(LocationOccupancyEntity entity, String reason) {
return occupancies.release(entity.getTenantId(), entity.getId(), entity.getVersion(), reason);
}
}
@@ -0,0 +1,93 @@
package com.baiyue.warehouse.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.warehouse.dto.PalletRequests;
import com.baiyue.warehouse.entity.PalletEntity;
import com.baiyue.warehouse.mapper.PalletMapper;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import java.util.Collection;
import java.util.List;
/** 托盘持久化仓储,所有业务查询均显式包含租户和仓库边界。 */
@Repository
public class PalletRepository {
private final PalletMapper mapper;
public PalletRepository(PalletMapper mapper) {
this.mapper = mapper;
}
public PageResult<PalletEntity> page(long tenantId, PalletRequests.PageQuery query) {
LambdaQueryWrapper<PalletEntity> wrapper = new LambdaQueryWrapper<PalletEntity>()
.eq(PalletEntity::getTenantId, tenantId)
.eq(query.warehouseId() != null, PalletEntity::getWarehouseId, query.warehouseId())
.like(StringUtils.hasText(query.palletCode()), PalletEntity::getPalletCode, query.palletCode())
.eq(StringUtils.hasText(query.palletType()), PalletEntity::getPalletType, query.palletType())
.orderByAsc(PalletEntity::getWarehouseId)
.orderByAsc(PalletEntity::getPalletCode)
.orderByAsc(PalletEntity::getId);
return PageResult.from(mapper.selectPage(new Page<>(query.page(), query.size()), wrapper));
}
public PalletEntity findOwned(long tenantId, long warehouseId, long id) {
return mapper.selectOne(new LambdaQueryWrapper<PalletEntity>()
.eq(PalletEntity::getTenantId, tenantId)
.eq(PalletEntity::getWarehouseId, warehouseId)
.eq(PalletEntity::getId, id));
}
public boolean existsCode(long tenantId, long warehouseId, String code, Long excludedId) {
return mapper.selectCount(new LambdaQueryWrapper<PalletEntity>()
.eq(PalletEntity::getTenantId, tenantId)
.eq(PalletEntity::getWarehouseId, warehouseId)
.eq(PalletEntity::getPalletCode, code)
.ne(excludedId != null, PalletEntity::getId, excludedId)) > 0;
}
public List<PalletEntity> findAvailable(long tenantId, long warehouseId, String palletType,
java.math.BigDecimal capacityCbm, int limit) {
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));
}
public void insert(PalletEntity entity) {
mapper.insert(entity);
}
public int save(PalletEntity entity) {
return mapper.updateById(entity);
}
public int logicalDelete(long tenantId, long warehouseId, long id, int version) {
return mapper.update(null, new LambdaUpdateWrapper<PalletEntity>()
.eq(PalletEntity::getTenantId, tenantId)
.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"));
}
}
@@ -0,0 +1,206 @@
package com.baiyue.warehouse.service;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.LocationDtos.Detail;
import com.baiyue.warehouse.dto.LocationDtos.PageQuery;
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.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/** 库位基础资料应用服务,负责 CRUD、租户仓库隔离和启停/删除约束。 */
@Service
public class LocationApplicationService {
private final LocationRepository repository;
public LocationApplicationService(LocationRepository repository) {
this.repository = repository;
}
public PageResult<LocationEntity> page(long tenantId, PageQuery query) {
requireWarehouse(tenantId, query.warehouseId(), false);
return repository.page(tenantId, query);
}
public Detail detail(long tenantId, long warehouseId, long id) {
LocationEntity location = owned(tenantId, warehouseId, id);
WarehouseEntity warehouse = requireWarehouse(tenantId, warehouseId, false);
location.setWarehouseCode(warehouse.getWarehouseCode());
location.setWarehouseName(warehouse.getWarehouseName());
return new Detail(location, warehouse.getWarehouseCode(), warehouse.getWarehouseName());
}
@Transactional
@BusinessAudit(event = "LOCATION_CREATE", resourceType = "LOCATION",
resourceId = "#request.locationCode", description = "新增库位", captureBefore = false)
public Long create(long tenantId, SaveRequest request) {
requireUserManagedCode(request.locationCode());
requireWarehouse(tenantId, request.warehouseId(), "AVAILABLE".equals(request.status()));
ensureCodeUnique(tenantId, request.warehouseId(), request.locationCode(), null);
LocationEntity entity = toEntity(tenantId, request);
try {
repository.insert(entity);
} catch (DataIntegrityViolationException exception) {
throw new BizException(ResultCode.CONFLICT, "库位编码在当前企业仓库内已存在");
}
return entity.getId();
}
@Transactional
@BusinessAudit(event = "LOCATION_UPDATE", resourceType = "LOCATION",
resourceId = "#id", description = "编辑库位")
public void update(long tenantId, long warehouseId, long id, SaveRequest request) {
if (request.version() == null) {
throw new BizException(ResultCode.BAD_REQUEST, "编辑库位必须携带 version");
}
LocationEntity old = owned(tenantId, warehouseId, id);
requireUserManaged(old);
requireUserManagedCode(request.locationCode());
if (!warehouseIdEquals(warehouseId, request.warehouseId())) {
throw new BizException(ResultCode.FORBIDDEN, "禁止通过编辑跨仓库移动库位");
}
if (!old.getStatus().equals(request.status())) {
throw new BizException(ResultCode.FORBIDDEN, "库位状态只能通过启停接口变更");
}
if ("RESERVED".equals(old.getStatus()) || "OCCUPIED".equals(old.getStatus())
|| repository.hasActiveResource(tenantId, id)) {
throw new BizException(ResultCode.CONFLICT, "预占或占用中的库位不能编辑");
}
requireWarehouse(tenantId, warehouseId, "AVAILABLE".equals(old.getStatus()));
ensureCodeUnique(tenantId, warehouseId, request.locationCode(), id);
LocationEntity entity = toEntity(tenantId, request);
entity.setId(id);
entity.setVersion(request.version());
try {
if (repository.save(entity) != 1) {
throw optimisticConflict();
}
} catch (DataIntegrityViolationException exception) {
throw new BizException(ResultCode.CONFLICT, "库位编码在当前企业仓库内已存在");
}
}
@Transactional
@BusinessAudit(event = "LOCATION_STATUS_CHANGE", resourceType = "LOCATION",
resourceId = "#id", description = "启用或停用库位")
public void changeStatus(long tenantId, long warehouseId, long id, String status, int version, long operatorId) {
LocationEntity location = owned(tenantId, warehouseId, id);
requireUserManaged(location);
if (location.getStatus().equals(status)) {
if (!Integer.valueOf(version).equals(location.getVersion())) {
throw optimisticConflict();
}
return;
}
if ("AVAILABLE".equals(status)) {
requireWarehouse(tenantId, warehouseId, true);
if (!"DISABLED".equals(location.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "只有已停用库位可以重新启用");
}
} else if ("DISABLED".equals(status)) {
if (!"AVAILABLE".equals(location.getStatus()) || repository.hasActiveResource(tenantId, id)) {
throw new BizException(ResultCode.CONFLICT, "预占或占用中的库位不能停用");
}
} else {
throw new BizException(ResultCode.BAD_REQUEST, "无效库位状态");
}
if (repository.changeStatus(tenantId, warehouseId, id, location.getStatus(), status,
version, operatorId) != 1) {
throw optimisticConflict();
}
}
@Transactional
@BusinessAudit(event = "LOCATION_DELETE", resourceType = "LOCATION",
resourceId = "#id", description = "删除库位")
public void delete(long tenantId, long warehouseId, long id, int version, long operatorId) {
LocationEntity location = owned(tenantId, warehouseId, id);
requireUserManaged(location);
if (!"DISABLED".equals(location.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "库位必须先停用再删除");
}
if (repository.hasActiveResource(tenantId, id)) {
throw new BizException(ResultCode.CONFLICT, "存在活动预占或占用的库位不能删除");
}
if (repository.deleteDisabled(tenantId, warehouseId, id, version, operatorId) != 1) {
throw optimisticConflict();
}
}
public List<LocationEntity> findAvailable(long tenantId, long warehouseId, int limit) {
if (limit < 1 || limit > 200) {
throw new BizException(ResultCode.BAD_REQUEST, "可用库位查询数量必须在 1 到 200 之间");
}
requireWarehouse(tenantId, warehouseId, true);
return repository.findAvailable(tenantId, warehouseId, limit);
}
private WarehouseEntity requireWarehouse(long tenantId, long warehouseId, boolean active) {
WarehouseEntity warehouse = repository.findWarehouse(tenantId, warehouseId);
if (warehouse == null) {
throw new BizException(ResultCode.FORBIDDEN, "仓库不属于当前企业租户或不在数据范围内");
}
if (active && !"ACTIVE".equals(warehouse.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "仓库已停用");
}
return warehouse;
}
private LocationEntity owned(long tenantId, long warehouseId, long id) {
LocationEntity entity = repository.findOwned(tenantId, warehouseId, id);
if (entity == null) {
throw new BizException(ResultCode.NOT_FOUND, "库位不存在或超出当前企业仓库数据范围");
}
return entity;
}
private void ensureCodeUnique(long tenantId, long warehouseId, String code, Long excludedId) {
if (repository.existsCode(tenantId, warehouseId, code, excludedId)) {
throw new BizException(ResultCode.CONFLICT, "库位编码在当前企业仓库内已存在");
}
}
private LocationEntity toEntity(long tenantId, SaveRequest request) {
LocationEntity entity = new LocationEntity();
entity.setTenantId(tenantId);
entity.setWarehouseId(request.warehouseId());
entity.setZoneCode(request.zoneCode());
entity.setZoneName(request.zoneName());
entity.setLocationCode(request.locationCode());
entity.setLocationName(request.locationName());
entity.setLocationType(request.locationType());
entity.setMaxWeightKg(request.maxWeightKg());
entity.setSharedOverflow(false);
entity.setStatus(request.status());
entity.setRemark(request.remark());
return entity;
}
private static boolean warehouseIdEquals(long expected, Long actual) {
return actual != null && expected == actual;
}
private static void requireUserManaged(LocationEntity location) {
if (Boolean.TRUE.equals(location.getSharedOverflow())) {
throw new BizException(ResultCode.CONFLICT, "系统共享溢出位 A0 不能编辑、停用或删除");
}
}
private static void requireUserManagedCode(String locationCode) {
if (locationCode != null && "A0".equalsIgnoreCase(locationCode.trim())) {
throw new BizException(ResultCode.CONFLICT, "A0 为系统保留的共享溢出位编码");
}
}
private static BizException optimisticConflict() {
return new BizException(ResultCode.CONFLICT, "库位已被其他操作修改,请刷新后重试");
}
}
@@ -0,0 +1,339 @@
package com.baiyue.warehouse.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.LocationDtos.ActiveResource;
import com.baiyue.warehouse.dto.LocationDtos.ConfirmCommand;
import com.baiyue.warehouse.dto.LocationDtos.OccupancyResult;
import com.baiyue.warehouse.dto.LocationDtos.OccupancyTarget;
import com.baiyue.warehouse.dto.LocationDtos.ReleaseCommand;
import com.baiyue.warehouse.dto.LocationDtos.ReservationResult;
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.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 任务7可直接复用的库位资源服务。
* 所有批量状态变更均在单事务内完成;普通库位不足时共享 A0,并发抢占或唯一键冲突会整批回滚。
*/
@Service
@Validated
public class LocationResourceService {
private final LocationRepository repository;
public LocationResourceService(LocationRepository repository) {
this.repository = repository;
}
public List<LocationEntity> findAvailable(@Min(1) long tenantId, @Min(1) long warehouseId,
@Min(1) @Max(200) int limit) {
requireLimit(limit);
requireActiveWarehouse(tenantId, warehouseId);
return repository.findAvailable(tenantId, warehouseId, limit);
}
@Transactional
public List<ReservationResult> reserveAvailable(@Valid ReserveCommand command) {
int quantity = command.targets().size();
requireLimit(quantity);
requireActiveWarehouse(command.tenantId(), command.warehouseId());
if (!command.expiresAt().isAfter(LocalDateTime.now())) {
throw new BizException(ResultCode.BAD_REQUEST, "库位预占过期时间必须晚于当前时间");
}
validateUniqueTargets(command.targets());
Map<String, LocationReservationEntity> existing = new LinkedHashMap<>();
repository.findReservationsByKeys(command.tenantId(), command.sourceService(),
command.targets().stream().map(ReservationTarget::idempotencyKey).toList())
.forEach(item -> existing.put(item.getIdempotencyKey(), item));
if (!existing.isEmpty()) {
if (existing.size() != quantity) {
throw new BizException(ResultCode.CONFLICT, "同批库位预占幂等键只完成了一部分,请核对请求");
}
return command.targets().stream()
.map(target -> existingReservation(command, target, existing.get(target.idempotencyKey())))
.toList();
}
List<LocationEntity> selected = repository.lockAvailable(command.tenantId(), command.warehouseId(), quantity);
LocationEntity overflow = selected.size() < quantity
? repository.lockOrCreateSharedOverflow(command.tenantId(), command.warehouseId(), command.operatorId())
: null;
if (selected.size() < quantity && overflow == null) {
throw new BizException(ResultCode.CONFLICT, "系统共享溢出位 A0 创建或读取失败");
}
List<ReservationResult> result = new ArrayList<>(quantity);
try {
for (int index = 0; index < quantity; index++) {
LocationEntity location = index < selected.size() ? selected.get(index) : overflow;
ReservationTarget target = command.targets().get(index);
if (!Boolean.TRUE.equals(location.getSharedOverflow())
&& repository.changeStatus(command.tenantId(), command.warehouseId(), location.getId(),
"AVAILABLE", "RESERVED", location.getVersion(), command.operatorId()) != 1) {
throw new BizException(ResultCode.CONFLICT, "库位已被并发抢占");
}
LocationReservationEntity reservation = reservation(command, target, location);
repository.insertReservation(reservation);
result.add(toReservationResult(reservation));
}
} catch (DataIntegrityViolationException exception) {
throw new BizException(ResultCode.CONFLICT, "库位预占幂等键、托盘或库位已被占用");
}
return List.copyOf(result);
}
@Transactional
public List<OccupancyResult> confirmOccupied(@Valid ConfirmCommand command) {
requireLimit(command.targets().size());
validateUniqueReservationIds(command.targets());
requireActiveWarehouse(command.tenantId(), command.warehouseId());
List<OccupancyResult> result = new ArrayList<>(command.targets().size());
try {
for (OccupancyTarget target : command.targets()) {
LocationReservationEntity reservation = repository.lockReservation(command.tenantId(), target.reservationId());
requireReservationOwnership(command, reservation);
LocationOccupancyEntity existing = repository.findOccupancyByReservation(command.tenantId(), target.reservationId());
if ("CONFIRMED".equals(reservation.getReservationStatus())) {
if (existing == null) {
throw new BizException(ResultCode.CONFLICT, "预占已确认但占用事实缺失");
}
result.add(toOccupancyResult(existing));
continue;
}
if (!"RESERVED".equals(reservation.getReservationStatus())) {
throw new BizException(ResultCode.CONFLICT, "库位预占已释放或已过期");
}
if (!reservation.getExpiresAt().isAfter(LocalDateTime.now())) {
throw new BizException(ResultCode.CONFLICT, "库位预占已过期");
}
LocationEntity location = ownedLocation(reservation);
if (!Boolean.TRUE.equals(reservation.getSharedOverflow())
&& repository.changeStatus(command.tenantId(), command.warehouseId(), location.getId(),
"RESERVED", "OCCUPIED", location.getVersion(), command.operatorId()) != 1) {
throw new BizException(ResultCode.CONFLICT, "库位状态已被并发修改");
}
LocationOccupancyEntity occupancy = occupancy(command, target, reservation);
repository.insertOccupancy(occupancy);
if (repository.confirmReservation(reservation) != 1) {
throw new BizException(ResultCode.CONFLICT, "库位预占已被并发修改");
}
result.add(toOccupancyResult(occupancy));
}
} catch (DataIntegrityViolationException exception) {
throw new BizException(ResultCode.CONFLICT, "库位或托盘已存在活动占用");
}
return List.copyOf(result);
}
@Transactional
public List<ActiveResource> release(@Valid ReleaseCommand command) {
List<ActiveResource> released = new ArrayList<>();
for (LocationOccupancyEntity occupancy : repository.findOccupanciesByBusiness(
command.tenantId(), command.sourceService(), command.businessId())) {
if (!"OCCUPIED".equals(occupancy.getOccupancyStatus())) {
continue;
}
LocationEntity location = repository.findOwned(command.tenantId(), occupancy.getWarehouseId(), occupancy.getLocationId());
if (location == null || repository.releaseOccupancy(occupancy, command.reason()) != 1
|| (!Boolean.TRUE.equals(occupancy.getSharedOverflow())
&& repository.changeStatus(command.tenantId(), occupancy.getWarehouseId(), occupancy.getLocationId(),
"OCCUPIED", "AVAILABLE", location.getVersion(), command.operatorId()) != 1)) {
throw new BizException(ResultCode.CONFLICT, "释放占用时库位状态已被并发修改");
}
released.add(active(occupancy, "RELEASED"));
}
for (LocationReservationEntity reservation : repository.findReservationsByBusiness(
command.tenantId(), command.sourceService(), command.businessId())) {
if (!"RESERVED".equals(reservation.getReservationStatus())) {
continue;
}
LocationEntity location = ownedLocation(reservation);
if (repository.releaseReservation(reservation, command.reason()) != 1
|| (!Boolean.TRUE.equals(reservation.getSharedOverflow())
&& repository.changeStatus(command.tenantId(), reservation.getWarehouseId(), reservation.getLocationId(),
"RESERVED", "AVAILABLE", location.getVersion(), command.operatorId()) != 1)) {
throw new BizException(ResultCode.CONFLICT, "释放预占时库位状态已被并发修改");
}
released.add(active(reservation, "RELEASED"));
}
return List.copyOf(released);
}
@Transactional(readOnly = true)
public List<ActiveResource> findActiveResources(@Min(1) long tenantId,
@NotBlank String sourceService,
@Min(1) long businessId) {
List<ActiveResource> result = new ArrayList<>();
repository.findReservationsByBusiness(tenantId, sourceService, businessId).stream()
.filter(item -> "RESERVED".equals(item.getReservationStatus()))
.map(item -> active(item, "RESERVED"))
.forEach(result::add);
repository.findOccupanciesByBusiness(tenantId, sourceService, businessId).stream()
.filter(item -> "OCCUPIED".equals(item.getOccupancyStatus()))
.map(item -> active(item, "OCCUPIED"))
.forEach(result::add);
return List.copyOf(result);
}
private LocationReservationEntity reservation(ReserveCommand command, ReservationTarget target,
LocationEntity location) {
long id = IdWorker.getId();
LocationReservationEntity entity = new LocationReservationEntity();
entity.setId(id);
entity.setTenantId(command.tenantId());
entity.setReservationNo("LR" + id);
entity.setIdempotencyKey(target.idempotencyKey());
entity.setSourceService(command.sourceService());
entity.setBusinessId(command.businessId());
entity.setInboundCode(command.inboundCode());
entity.setWarehouseId(command.warehouseId());
entity.setPalletId(target.palletId());
entity.setPalletCode(target.palletCode());
entity.setLocationId(location.getId());
entity.setLocationCode(location.getLocationCode());
entity.setSharedOverflow(Boolean.TRUE.equals(location.getSharedOverflow()));
entity.setReservationStatus("RESERVED");
entity.setExpiresAt(command.expiresAt());
entity.setCreatedBy(command.operatorId());
entity.setVersion(0);
return entity;
}
private LocationOccupancyEntity occupancy(ConfirmCommand command, OccupancyTarget target,
LocationReservationEntity reservation) {
long id = IdWorker.getId();
LocationOccupancyEntity entity = new LocationOccupancyEntity();
entity.setId(id);
entity.setTenantId(command.tenantId());
entity.setOccupancyNo("LO" + id);
entity.setReservationId(reservation.getId());
entity.setSourceService(reservation.getSourceService());
entity.setBusinessId(reservation.getBusinessId());
entity.setInboundCode(reservation.getInboundCode());
entity.setWarehouseId(reservation.getWarehouseId());
entity.setPalletId(reservation.getPalletId());
entity.setPalletCode(reservation.getPalletCode());
entity.setLocationId(reservation.getLocationId());
entity.setLocationCode(reservation.getLocationCode());
entity.setSharedOverflow(Boolean.TRUE.equals(reservation.getSharedOverflow()));
entity.setAllocatedVolumeCbm(target.allocatedVolumeCbm());
entity.setAllocatedPackageCount(target.allocatedPackageCount());
entity.setOccupancyStatus("OCCUPIED");
entity.setCreatedBy(command.operatorId());
entity.setVersion(0);
return entity;
}
private ReservationResult existingReservation(ReserveCommand command, ReservationTarget target,
LocationReservationEntity reservation) {
if (reservation == null || reservation.getBusinessId() != command.businessId()
|| reservation.getWarehouseId() != command.warehouseId()
|| reservation.getPalletId() != target.palletId()
|| !reservation.getPalletCode().equals(target.palletCode())) {
throw new BizException(ResultCode.CONFLICT, "库位预占幂等键已用于其他业务或托盘");
}
if ("RELEASED".equals(reservation.getReservationStatus()) || "EXPIRED".equals(reservation.getReservationStatus())) {
throw new BizException(ResultCode.CONFLICT, "库位预占幂等请求已释放或过期");
}
return toReservationResult(reservation);
}
private void requireReservationOwnership(ConfirmCommand command, LocationReservationEntity reservation) {
if (reservation == null || reservation.getWarehouseId() != command.warehouseId()
|| reservation.getBusinessId() != command.businessId()
|| !reservation.getSourceService().equals(command.sourceService())) {
throw new BizException(ResultCode.FORBIDDEN, "库位预占不属于当前租户、仓库或业务");
}
}
private LocationEntity ownedLocation(LocationReservationEntity reservation) {
LocationEntity location = repository.findOwned(reservation.getTenantId(), reservation.getWarehouseId(),
reservation.getLocationId());
if (location == null) {
throw new BizException(ResultCode.CONFLICT, "预占关联库位不存在");
}
return location;
}
private void requireActiveWarehouse(long tenantId, long warehouseId) {
WarehouseEntity warehouse = repository.findWarehouse(tenantId, warehouseId);
if (warehouse == null) {
throw new BizException(ResultCode.FORBIDDEN, "仓库不属于当前企业租户或不在数据范围内");
}
if (!"ACTIVE".equals(warehouse.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "仓库已停用");
}
}
private static void validateUniqueTargets(List<ReservationTarget> targets) {
Set<Long> pallets = new HashSet<>();
Set<String> keys = new HashSet<>();
for (ReservationTarget target : targets) {
if (!pallets.add(target.palletId()) || !keys.add(target.idempotencyKey())) {
throw new BizException(ResultCode.BAD_REQUEST, "同批预占的托盘和幂等键不得重复");
}
}
}
private static void validateUniqueReservationIds(List<OccupancyTarget> targets) {
Set<Long> ids = new HashSet<>();
for (OccupancyTarget target : targets) {
if (!ids.add(target.reservationId())) {
throw new BizException(ResultCode.BAD_REQUEST, "同批确认的预占 ID 不得重复");
}
}
}
private static void requireLimit(int limit) {
if (limit < 1 || limit > 200) {
throw new BizException(ResultCode.BAD_REQUEST, "库位数量必须在 1 到 200 之间");
}
}
private static ReservationResult toReservationResult(LocationReservationEntity entity) {
return new ReservationResult(entity.getId(), entity.getReservationNo(), entity.getPalletId(),
entity.getPalletCode(), entity.getLocationId(), entity.getLocationCode(),
Boolean.TRUE.equals(entity.getSharedOverflow()), entity.getExpiresAt());
}
private static OccupancyResult toOccupancyResult(LocationOccupancyEntity entity) {
return new OccupancyResult(entity.getId(), entity.getOccupancyNo(), entity.getReservationId(),
entity.getPalletId(), entity.getLocationId(), entity.getLocationCode(),
Boolean.TRUE.equals(entity.getSharedOverflow()));
}
private static ActiveResource active(LocationReservationEntity entity, String state) {
return new ActiveResource(entity.getId(), null, entity.getWarehouseId(), entity.getPalletId(),
entity.getPalletCode(), entity.getLocationId(), entity.getLocationCode(),
Boolean.TRUE.equals(entity.getSharedOverflow()), state);
}
private static ActiveResource active(LocationOccupancyEntity entity, String state) {
return new ActiveResource(entity.getReservationId(), entity.getId(), entity.getWarehouseId(),
entity.getPalletId(), entity.getPalletCode(), entity.getLocationId(), entity.getLocationCode(),
Boolean.TRUE.equals(entity.getSharedOverflow()), state);
}
}
@@ -0,0 +1,113 @@
package com.baiyue.warehouse.service;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.PalletRequests;
import com.baiyue.warehouse.entity.PalletEntity;
import com.baiyue.warehouse.repository.PalletRepository;
import com.baiyue.warehouse.repository.WarehouseRepository;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** 托盘主数据应用服务。 */
@Service
public class PalletApplicationService {
private final PalletRepository repository;
private final WarehouseRepository warehouseRepository;
public PalletApplicationService(PalletRepository repository, WarehouseRepository warehouseRepository) {
this.repository = repository;
this.warehouseRepository = warehouseRepository;
}
public PageResult<PalletRequests.View> page(long tenantId, PalletRequests.PageQuery query) {
PageResult<PalletEntity> source = repository.page(tenantId, query);
return new PageResult<>(source.records().stream().map(PalletApplicationService::view).toList(),
source.page(), source.size(), source.total(), source.pages());
}
public PalletRequests.View detail(long tenantId, long warehouseId, long id) {
return view(owned(tenantId, warehouseId, id));
}
@Transactional
@BusinessAudit(event = "PALLET_CREATE", resourceType = "PALLET", description = "新增托盘", captureBefore = false)
public Long create(long tenantId, PalletRequests.SaveRequest request) {
requireWarehouse(tenantId, request.warehouseId());
ensureCodeUnique(tenantId, request.warehouseId(), request.palletCode(), null);
PalletEntity entity = new PalletEntity();
entity.setTenantId(tenantId);
entity.setWarehouseId(request.warehouseId());
copy(entity, request);
entity.setStatus("AVAILABLE");
try {
repository.insert(entity);
} catch (DataIntegrityViolationException exception) {
throw new BizException(ResultCode.CONFLICT, "托盘编码已存在");
}
return entity.getId();
}
@Transactional
@BusinessAudit(event = "PALLET_UPDATE", resourceType = "PALLET", resourceId = "#id", description = "编辑托盘")
public void update(long tenantId, long warehouseId, long id, PalletRequests.SaveRequest request) {
if (warehouseId != request.warehouseId()) {
throw new BizException(ResultCode.FORBIDDEN, "请求仓库与托盘所属仓库不一致");
}
PalletEntity entity = owned(tenantId, warehouseId, id);
ensureCodeUnique(tenantId, warehouseId, request.palletCode(), id);
copy(entity, request);
try {
if (repository.save(entity) != 1) {
throw new BizException(ResultCode.CONFLICT, "托盘已被并发修改,请刷新后重试");
}
} catch (DataIntegrityViolationException exception) {
throw new BizException(ResultCode.CONFLICT, "托盘编码已存在");
}
}
@Transactional
@BusinessAudit(event = "PALLET_DELETE", resourceType = "PALLET", resourceId = "#id", description = "删除托盘")
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, "预占、占用或已被并发修改的托盘不能删除");
}
}
private PalletEntity owned(long tenantId, long warehouseId, long id) {
PalletEntity entity = repository.findOwned(tenantId, warehouseId, id);
if (entity == null) {
throw new BizException(ResultCode.NOT_FOUND, "托盘不存在或不属于当前租户和仓库");
}
return entity;
}
private void requireWarehouse(long tenantId, long warehouseId) {
if (warehouseRepository.findOwned(tenantId, warehouseId) == null) {
throw new BizException(ResultCode.FORBIDDEN, "仓库不存在或不属于目标租户");
}
}
private void ensureCodeUnique(long tenantId, long warehouseId, String code, Long excludedId) {
if (repository.existsCode(tenantId, warehouseId, code, excludedId)) {
throw new BizException(ResultCode.CONFLICT, "托盘编码已存在");
}
}
private static void copy(PalletEntity entity, PalletRequests.SaveRequest request) {
entity.setPalletCode(request.palletCode());
entity.setPalletType(request.palletType());
entity.setCapacityCbm(request.capacityCbm());
entity.setRemark(request.remark());
}
private static PalletRequests.View view(PalletEntity entity) {
return new PalletRequests.View(entity.getId(), entity.getTenantId(), entity.getWarehouseId(),
entity.getPalletCode(), entity.getPalletType(), entity.getCapacityCbm(), entity.getRemark(),
entity.getCreatedAt(), entity.getUpdatedAt());
}
}
@@ -0,0 +1,155 @@
package com.baiyue.warehouse.service;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ResultCode;
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.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/**
* 托盘拆分和一托一位编排。库位资源操作全部委托给任务6的 {@link LocationResourceService}。
*/
@Service
public class PalletLocationAllocationService {
/** 测量完成后默认推荐的普通托盘类型编码。 */
public static final String DEFAULT_PALLET_TYPE = "STANDARD";
private final PalletRepository palletRepository;
private final PalletVolumeSplitter splitter;
private final LocationResourceService locationResourceService;
public PalletLocationAllocationService(PalletRepository palletRepository,
PalletVolumeSplitter splitter,
LocationResourceService locationResourceService) {
this.palletRepository = palletRepository;
this.splitter = splitter;
this.locationResourceService = locationResourceService;
}
@Transactional
@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, "只能使用已完成测量的总体积拆分托盘并占用库位");
}
var split = splitter.split(request.totalVolumeCbm(), request.palletCapacityCbm());
List<LocationDtos.ActiveResource> existing = active(request.tenantId(), request.sourceService(), request.businessId());
if (!existing.isEmpty()) {
return fromActive(existing, split);
}
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<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, "托盘被并发预占,请重试");
}
targets.add(new LocationDtos.ReservationTarget(pallet.getId(), pallet.getPalletCode(),
request.idempotencyKey() + ":" + (index + 1)));
}
List<LocationDtos.ReservationResult> reservations = locationResourceService.reserveAvailable(
new LocationDtos.ReserveCommand(request.tenantId(), request.warehouseId(), request.sourceService(),
request.businessId(), request.inboundCode(), request.operatorId(), request.expiresAt(), targets));
if (reservations.size() != split.palletCount()) {
throw new BizException(ResultCode.CONFLICT, "库位预占结果数量不一致");
}
List<PalletAllocationDtos.AllocationResult> results = new ArrayList<>(reservations.size());
for (int index = 0; index < reservations.size(); index++) {
var item = reservations.get(index);
results.add(new PalletAllocationDtos.AllocationResult(item.reservationId(), item.reservationNo(), index + 1,
item.palletId(), item.palletCode(), item.locationId(), item.locationCode(),
split.allocations().get(index).allocatedVolumeCbm(), "RESERVED"));
}
return new PalletAllocationDtos.BatchResult(split.palletCount(), List.copyOf(results));
}
@Transactional
@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());
if (active.size() != split.palletCount()) {
throw new BizException(ResultCode.CONFLICT, "活动预占数量与托盘拆分数量不一致");
}
active.sort(Comparator.comparingLong(LocationDtos.ActiveResource::reservationId));
List<LocationDtos.OccupancyTarget> targets = new ArrayList<>(active.size());
for (int index = 0; index < active.size(); index++) {
targets.add(new LocationDtos.OccupancyTarget(active.get(index).reservationId(),
split.allocations().get(index).allocatedVolumeCbm(), null));
}
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);
results.add(new PalletAllocationDtos.AllocationResult(item.reservationId(), null, index + 1,
item.palletId(), active.get(index).palletCode(), item.locationId(), item.locationCode(),
split.allocations().get(index).allocatedVolumeCbm(), "OCCUPIED"));
}
return new PalletAllocationDtos.BatchResult(split.palletCount(), List.copyOf(results));
}
@Transactional
@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()))) {
throw new BizException(ResultCode.CONFLICT, "已确认实物入库的占用不能通过补偿接口释放");
}
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;
}
private List<LocationDtos.ActiveResource> active(long tenantId, String sourceService, long businessId) {
return new ArrayList<>(locationResourceService.findActiveResources(tenantId, sourceService, businessId));
}
private static PalletAllocationDtos.BatchResult fromActive(List<LocationDtos.ActiveResource> active,
PalletVolumeSplitter.SplitResult split) {
if (active.size() != split.palletCount()) {
throw new BizException(ResultCode.CONFLICT, "幂等请求与已有托盘拆分结果不一致");
}
active.sort(Comparator.comparingLong(LocationDtos.ActiveResource::reservationId));
List<PalletAllocationDtos.AllocationResult> results = new ArrayList<>(active.size());
for (int index = 0; index < active.size(); index++) {
var item = active.get(index);
results.add(new PalletAllocationDtos.AllocationResult(item.reservationId(), null, index + 1,
item.palletId(), item.palletCode(), item.locationId(), item.locationCode(),
split.allocations().get(index).allocatedVolumeCbm(), item.state()));
}
return new PalletAllocationDtos.BatchResult(split.palletCount(), List.copyOf(results));
}
}
@@ -0,0 +1,52 @@
package com.baiyue.warehouse.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.List;
/** 按托盘容积向上拆分入库总体积,不涉及SKU库存。 */
@Component
public class PalletVolumeSplitter {
static final int MAX_PALLET_COUNT = 200;
public SplitResult split(BigDecimal totalVolumeCbm, BigDecimal palletCapacityCbm) {
requirePositive(totalVolumeCbm, "总体积必须大于0");
requirePositive(palletCapacityCbm, "托盘容积必须大于0");
final int palletCount;
try {
palletCount = totalVolumeCbm.divide(palletCapacityCbm, 0, RoundingMode.CEILING).intValueExact();
} catch (ArithmeticException exception) {
throw new BizException(ResultCode.VALIDATION_FAILED, "托盘拆分数量超出系统处理范围");
}
if (palletCount > MAX_PALLET_COUNT) {
throw new BizException(ResultCode.VALIDATION_FAILED, "单次最多拆分200个托盘");
}
List<Allocation> allocations = new ArrayList<>(palletCount);
for (int index = 1; index <= palletCount; index++) {
BigDecimal allocated = index == palletCount
? totalVolumeCbm.subtract(palletCapacityCbm.multiply(BigDecimal.valueOf(palletCount - 1L)))
: palletCapacityCbm;
allocations.add(new Allocation(index, allocated));
}
return new SplitResult(totalVolumeCbm, palletCapacityCbm, palletCount, List.copyOf(allocations));
}
private static void requirePositive(BigDecimal value, String message) {
if (value == null || value.signum() <= 0) {
throw new BizException(ResultCode.VALIDATION_FAILED, message);
}
}
public record Allocation(int allocationNo, BigDecimal allocatedVolumeCbm) {
}
public record SplitResult(BigDecimal totalVolumeCbm, BigDecimal palletCapacityCbm,
int palletCount, List<Allocation> allocations) {
}
}
@@ -18,7 +18,10 @@ public class WarehouseSchemaReadinessService {
"SELECT id, tenant_id, warehouse_id, capability_code, capability_name, enabled, deleted FROM wh_service_capability LIMIT 0",
"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, company_code, company_name, status, deleted FROM wh_express_company 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"
);
private final JdbcTemplate jdbcTemplate;
@@ -32,7 +35,7 @@ public class WarehouseSchemaReadinessService {
PROBES.forEach(jdbcTemplate::execute);
return new Readiness(true, "UP", "仓储数据库表结构已就绪");
} catch (DataAccessException exception) {
return new Readiness(false, "DEGRADED", "仓储数据库表结构未就绪,请执行一期迁移脚本");
return new Readiness(false, "DEGRADED", "仓储数据库表结构未就绪,请执行一期及二期库位迁移脚本");
}
}
@@ -0,0 +1,137 @@
-- 二期库位共享溢出位 A0 增量迁移。
-- 执行顺序:phase2-pallet-location-schema.sql、本文件。
-- 普通库位仍保持一托一位;同一租户仓库只有一个 A0,且 A0 可承载多个活动资源。
SET NAMES utf8mb4;
USE baiyue_warehouse;
DROP PROCEDURE IF EXISTS add_location_overflow_column_if_missing;
DELIMITER $$
CREATE PROCEDURE add_location_overflow_column_if_missing(
IN p_table_name VARCHAR(64),
IN p_column_name VARCHAR(64),
IN p_column_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = p_table_name
AND column_name = p_column_name
) THEN
SET @column_sql = CONCAT(
'ALTER TABLE `', REPLACE(p_table_name, '`', '``'),
'` ADD COLUMN `', REPLACE(p_column_name, '`', '``'), '` ', p_column_definition
);
PREPARE column_statement FROM @column_sql;
EXECUTE column_statement;
DEALLOCATE PREPARE column_statement;
END IF;
END$$
DELIMITER ;
CALL add_location_overflow_column_if_missing(
'wh_location', 'shared_overflow',
'TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否为系统共享溢出位 A0'' AFTER max_weight_kg'
);
CALL add_location_overflow_column_if_missing(
'wh_location_reservation', 'shared_overflow',
'TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否使用共享溢出位 A0'' AFTER location_code'
);
CALL add_location_overflow_column_if_missing(
'wh_location_occupancy', 'shared_overflow',
'TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否使用共享溢出位 A0'' AFTER location_code'
);
-- 兼容迁移前人工或旧流程已创建的 A0,并将资源快照回填一致。
UPDATE wh_location
SET shared_overflow = 1,
status = 'AVAILABLE'
WHERE location_code = 'A0' AND deleted = 0;
UPDATE wh_location_reservation r
JOIN wh_location l
ON l.tenant_id = r.tenant_id AND l.id = r.location_id
SET r.shared_overflow = l.shared_overflow;
UPDATE wh_location_occupancy o
JOIN wh_location l
ON l.tenant_id = o.tenant_id AND l.id = o.location_id
SET o.shared_overflow = l.shared_overflow;
DROP PROCEDURE IF EXISTS add_location_overflow_column_if_missing;
-- 先移除依赖旧生成列的唯一索引,再按普通库位/A0 的不同语义重建。
SET @schema_name = DATABASE();
SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND index_name = 'uk_location_active_reservation') > 0,
'ALTER TABLE wh_location_reservation DROP INDEX uk_location_active_reservation', 'SELECT 1');
PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND index_name = 'uk_pallet_active_reservation') > 0,
'ALTER TABLE wh_location_reservation DROP INDEX uk_pallet_active_reservation', 'SELECT 1');
PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement;
ALTER TABLE wh_location_reservation
MODIFY COLUMN active_reservation TINYINT
GENERATED ALWAYS AS (
CASE WHEN reservation_status = 'RESERVED' AND shared_overflow = 0 THEN 1 ELSE NULL END
) STORED COMMENT '普通库位活动预占唯一标记,A0 返回 NULL';
SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND index_name = 'uk_location_active_reservation') = 0,
'ALTER TABLE wh_location_reservation ADD UNIQUE KEY uk_location_active_reservation (tenant_id, location_id, active_reservation)', 'SELECT 1');
PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_location_active_occupancy') > 0,
'ALTER TABLE wh_location_occupancy DROP INDEX uk_location_active_occupancy', 'SELECT 1');
PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_pallet_active_occupancy') > 0,
'ALTER TABLE wh_location_occupancy DROP INDEX uk_pallet_active_occupancy', 'SELECT 1');
PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement;
DROP PROCEDURE IF EXISTS add_location_overflow_column_if_missing;
DELIMITER $$
CREATE PROCEDURE add_location_overflow_column_if_missing(
IN p_table_name VARCHAR(64),
IN p_column_name VARCHAR(64),
IN p_column_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = p_table_name
AND column_name = p_column_name
) THEN
SET @column_sql = CONCAT(
'ALTER TABLE `', REPLACE(p_table_name, '`', '``'),
'` ADD COLUMN `', REPLACE(p_column_name, '`', '``'), '` ', p_column_definition
);
PREPARE column_statement FROM @column_sql;
EXECUTE column_statement;
DEALLOCATE PREPARE column_statement;
END IF;
END$$
DELIMITER ;
CALL add_location_overflow_column_if_missing(
'wh_location_reservation', 'active_pallet_reservation',
'TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = ''RESERVED'' THEN 1 ELSE NULL END) STORED COMMENT ''托盘活动预占唯一标记(包含 A0)'''
);
CALL add_location_overflow_column_if_missing(
'wh_location_occupancy', 'active_location_occupancy',
'TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = ''OCCUPIED'' AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED COMMENT ''普通库位活动占用唯一标记'''
);
CALL add_location_overflow_column_if_missing(
'wh_location_occupancy', 'active_pallet_occupancy',
'TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = ''OCCUPIED'' THEN 1 ELSE NULL END) STORED COMMENT ''托盘活动占用唯一标记(包含 A0)'''
);
DROP PROCEDURE IF EXISTS add_location_overflow_column_if_missing;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND index_name = 'uk_pallet_active_reservation') = 0,
'ALTER TABLE wh_location_reservation ADD UNIQUE KEY uk_pallet_active_reservation (tenant_id, pallet_id, active_pallet_reservation)', 'SELECT 1');
PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_location_active_occupancy') = 0,
'ALTER TABLE wh_location_occupancy ADD UNIQUE KEY uk_location_active_occupancy (tenant_id, location_id, active_location_occupancy)', 'SELECT 1');
PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_pallet_active_occupancy') = 0,
'ALTER TABLE wh_location_occupancy ADD UNIQUE KEY uk_pallet_active_occupancy (tenant_id, pallet_id, active_pallet_occupancy)', 'SELECT 1');
PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement;
@@ -0,0 +1,46 @@
-- 二期托盘、库位表的可重复执行增量迁移。
-- 执行顺序:phase2-pallet-location-schema.sql 之后。
SET NAMES utf8mb4;
USE baiyue_warehouse;
-- 活动预占必须同时保证“一托一位”;A0 共享只放宽库位,不放宽托盘唯一性。
SET @schema_name = DATABASE();
DROP PROCEDURE IF EXISTS add_pallet_reservation_column_if_missing;
DELIMITER $$
CREATE PROCEDURE add_pallet_reservation_column_if_missing()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'wh_location_reservation'
AND column_name = 'active_pallet_reservation'
) THEN
ALTER TABLE wh_location_reservation
ADD COLUMN active_pallet_reservation TINYINT
GENERATED ALWAYS AS (CASE WHEN reservation_status = 'RESERVED' THEN 1 ELSE NULL END)
STORED COMMENT '托盘活动预占唯一标记(包含A0)';
END IF;
END$$
DELIMITER ;
CALL add_pallet_reservation_column_if_missing();
DROP PROCEDURE IF EXISTS add_pallet_reservation_column_if_missing;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'wh_location_reservation'
AND index_name = 'uk_pallet_active_reservation') > 0,
'ALTER TABLE wh_location_reservation DROP INDEX uk_pallet_active_reservation',
'SELECT 1');
PREPARE statement FROM @sql;
EXECUTE statement;
DEALLOCATE PREPARE statement;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'wh_location_reservation'
AND index_name = 'uk_pallet_active_reservation') = 0,
'ALTER TABLE wh_location_reservation ADD UNIQUE KEY uk_pallet_active_reservation (tenant_id, pallet_id, active_pallet_reservation)',
'SELECT 1');
PREPARE statement FROM @sql;
EXECUTE statement;
DEALLOCATE PREPARE statement;
@@ -0,0 +1,139 @@
-- 二期托盘、库位、预占与占用表结构。
-- 本脚本只新增表、字段、索引和约束,不包含业务数据及流程实现。
-- 可在 warehouse.sql 和一期增量脚本之后执行;所有建表均可重复执行。
SET NAMES utf8mb4;
CREATE DATABASE IF NOT EXISTS baiyue_warehouse
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE baiyue_warehouse;
CREATE TABLE IF NOT EXISTS wh_pallet (
id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID',
pallet_code VARCHAR(50) NOT NULL COMMENT '租户仓库内唯一托盘编码',
pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型',
pallet_name VARCHAR(100) NULL COMMENT '托盘名称',
capacity_cbm DECIMAL(18,6) NOT NULL COMMENT '托盘可用容积CBM',
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 '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_pallet_tenant_warehouse_code (tenant_id, warehouse_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),
CONSTRAINT fk_pallet_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id),
CONSTRAINT ck_pallet_capacity CHECK (capacity_cbm > 0)
) ENGINE=InnoDB COMMENT='二期托盘基础资料和物理托盘实例';
CREATE TABLE IF NOT EXISTS wh_location (
id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID',
zone_code VARCHAR(50) NULL COMMENT '库区编码',
zone_name VARCHAR(100) NULL COMMENT '库区名称',
location_code VARCHAR(50) NOT NULL COMMENT '租户仓库内唯一库位编码',
location_name VARCHAR(100) NULL COMMENT '库位名称',
location_type VARCHAR(32) NOT NULL DEFAULT 'PALLET' COMMENT '库位类型,二期为PALLET',
max_weight_kg DECIMAL(18,3) NULL COMMENT '最大承重展示值,二期不作容量拦截',
shared_overflow TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为系统共享溢出位A0',
status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE' COMMENT 'AVAILABLE、RESERVED、OCCUPIED或DISABLED',
remark VARCHAR(500) NULL COMMENT '备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_location_tenant_warehouse_code (tenant_id, warehouse_id, location_code),
KEY idx_location_available (tenant_id, warehouse_id, status, deleted),
KEY idx_location_zone (tenant_id, warehouse_id, zone_code, status, deleted),
CONSTRAINT fk_location_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id),
CONSTRAINT ck_location_max_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0)
) ENGINE=InnoDB COMMENT='二期库位基础资料,默认一托盘对应一个库位且不作容量校验';
CREATE TABLE IF NOT EXISTS wh_location_reservation (
id BIGINT UNSIGNED NOT NULL COMMENT '库位预占ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
reservation_no VARCHAR(40) NOT NULL COMMENT '租户内唯一预占业务号',
idempotency_key VARCHAR(128) NOT NULL COMMENT '调用方幂等键',
source_service VARCHAR(40) NOT NULL DEFAULT 'FIRSTLEG' COMMENT '来源服务',
business_id BIGINT UNSIGNED NOT NULL COMMENT '来源服务入库单ID',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID',
pallet_id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID',
pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照',
location_id BIGINT UNSIGNED NOT NULL COMMENT '库位ID',
location_code VARCHAR(50) NOT NULL COMMENT '库位编码快照',
shared_overflow TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否使用共享溢出位A0',
reservation_status VARCHAR(20) NOT NULL DEFAULT 'RESERVED' COMMENT 'RESERVED、CONFIRMED、RELEASED或EXPIRED',
expires_at DATETIME(3) NOT NULL COMMENT '预占过期时间',
confirmed_at DATETIME(3) NULL COMMENT '确认占用时间',
released_at DATETIME(3) NULL COMMENT '释放时间',
release_reason VARCHAR(500) NULL COMMENT '释放或过期原因',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人或系统主体ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
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 '普通库位活动预占唯一标记',
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的托盘和库位临时预占';
CREATE TABLE IF NOT EXISTS wh_location_occupancy (
id BIGINT UNSIGNED NOT NULL COMMENT '库位占用ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
occupancy_no VARCHAR(40) NOT NULL COMMENT '租户内唯一占用业务号',
reservation_id BIGINT UNSIGNED NOT NULL COMMENT '来源预占ID',
source_service VARCHAR(40) NOT NULL DEFAULT 'FIRSTLEG' COMMENT '来源服务',
business_id BIGINT UNSIGNED NOT NULL COMMENT '来源服务入库单ID',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID',
pallet_id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID',
pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照',
location_id BIGINT UNSIGNED NOT NULL COMMENT '库位ID',
location_code VARCHAR(50) NOT NULL COMMENT '库位编码快照',
shared_overflow TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否使用共享溢出位A0',
allocated_volume_cbm DECIMAL(18,6) NOT NULL COMMENT '本托盘分配体积CBM',
allocated_package_count INT UNSIGNED NULL COMMENT '本托盘分配箱数',
occupancy_status VARCHAR(20) NOT NULL DEFAULT 'OCCUPIED' COMMENT 'OCCUPIED或RELEASED',
occupied_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '占用开始时间',
released_at DATETIME(3) NULL COMMENT '释放时间',
release_reason VARCHAR(500) NULL COMMENT '释放原因',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人或系统主体ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
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),
CONSTRAINT fk_occupancy_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id),
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='已确认的一托一库位占用事实记录';
-- 头程入库单属于其他数据库,不建立跨库外键。
@@ -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