二期内容提交
This commit is contained in:
+67
@@ -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, "无效的内部服务凭据");
|
||||
}
|
||||
}
|
||||
}
|
||||
+178
@@ -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();
|
||||
}
|
||||
}
|
||||
+103
@@ -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());
|
||||
}
|
||||
}
|
||||
+155
@@ -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) {
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -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='已确认的一托一库位占用事实记录';
|
||||
|
||||
-- 头程入库单属于其他数据库,不建立跨库外键。
|
||||
+41
@@ -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();
|
||||
}
|
||||
}
|
||||
+38
@@ -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);
|
||||
}
|
||||
}
|
||||
+37
@@ -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");
|
||||
|
||||
+99
@@ -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;
|
||||
}
|
||||
}
|
||||
+202
@@ -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;
|
||||
}
|
||||
}
|
||||
+40
@@ -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;
|
||||
}
|
||||
}
|
||||
+108
@@ -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);
|
||||
}
|
||||
}
|
||||
+3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user