feat: complete overseas pda warehouse operations

This commit is contained in:
2026-08-25 11:20:36 +08:00
parent 45a3b9adfd
commit 042d0b738a
69 changed files with 4679 additions and 4 deletions
@@ -0,0 +1,39 @@
package com.baiyue.warehouse.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.warehouse.dto.PalletTransitionRequests;
import com.baiyue.warehouse.dto.PalletTransitionResponse;
import com.baiyue.warehouse.security.InternalServiceTokenGuard;
import com.baiyue.warehouse.service.WarehousePalletTransitionService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PathVariable;
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;
@RestController
@RequestMapping("/internal/v1/pda-pallet-transitions")
public class InternalPdaPalletTransitionController {
@Resource private InternalServiceTokenGuard tokenGuard;
@Resource private WarehousePalletTransitionService service;
@PostMapping("/{palletId}")
public ApiResponse<PalletTransitionResponse> apply(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long palletId, @Valid @RequestBody PalletTransitionRequests.Apply request) {
tokenGuard.verify(token);
return ApiResponse.success(service.apply(palletId, request));
}
@PostMapping("/{palletId}/{operationKey}/compensate")
public ApiResponse<PalletTransitionResponse> compensate(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long palletId, @PathVariable String operationKey,
@Valid @RequestBody PalletTransitionRequests.Compensate request) {
tokenGuard.verify(token);
return ApiResponse.success(service.compensate(palletId, operationKey, request));
}
}
@@ -31,4 +31,5 @@ public class InternalWarehousePalletController {
tokenGuard.verify(token);
return ApiResponse.success(service.resolvePublicPallet(palletCode));
}
}
@@ -0,0 +1,20 @@
package com.baiyue.warehouse.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
public final class PalletTransitionRequests {
private PalletTransitionRequests() { }
public record Apply(@Positive long warehouseId,
@PositiveOrZero int expectedVersion,
@NotBlank @Size(max = 20) String targetStatus,
@NotNull @Positive Long targetLocationId,
@NotBlank @Size(max = 128) String operationKey,
@Size(max = 500) String reason) { }
public record Compensate(@Positive long warehouseId) { }
}
@@ -0,0 +1,16 @@
package com.baiyue.warehouse.dto;
public record PalletTransitionResponse(
String operationKey,
long palletId,
String palletCode,
long warehouseId,
String beforeStatus,
String afterStatus,
long beforeLocationId,
long afterLocationId,
int beforeVersion,
int afterVersion,
String operationStatus,
boolean replayed
) { }
@@ -0,0 +1,29 @@
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.Data;
import java.time.LocalDateTime;
@Data
@TableName("wh_pallet_operation")
public class WarehousePalletOperationEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String operationKey;
private String requestHash;
private Long warehouseId;
private Long palletId;
private String palletCode;
private String beforeStatus;
private String afterStatus;
private Long beforeLocationId;
private Long afterLocationId;
private Integer beforeVersion;
private Integer afterVersion;
private String operationStatus;
private String reason;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -3,5 +3,4 @@ package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.entity.WarehousePalletEntity;
public interface WarehousePalletMapper extends BaseMapper<WarehousePalletEntity> {
}
public interface WarehousePalletMapper extends BaseMapper<WarehousePalletEntity> { }
@@ -0,0 +1,32 @@
package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.entity.WarehousePalletEntity;
import com.baiyue.warehouse.entity.WarehousePalletOperationEntity;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
public interface WarehousePalletTransitionMapper extends BaseMapper<WarehousePalletOperationEntity> {
@Select("SELECT * FROM wh_pallet_operation WHERE operation_key=#{operationKey} LIMIT 1")
WarehousePalletOperationEntity findOperation(@Param("operationKey") String operationKey);
@Select("SELECT * FROM wh_pallet_operation WHERE operation_key=#{operationKey} LIMIT 1 FOR UPDATE")
WarehousePalletOperationEntity lockOperation(@Param("operationKey") String operationKey);
@Select("SELECT * FROM wh_pallet WHERE id=#{palletId} AND customer_id=0 " +
"AND warehouse_id=#{warehouseId} AND deleted=0 LIMIT 1 FOR UPDATE")
WarehousePalletEntity lockPublicPallet(@Param("warehouseId") long warehouseId,
@Param("palletId") long palletId);
@Select("SELECT COUNT(*) FROM wh_location WHERE id=#{locationId} AND customer_id=0 " +
"AND warehouse_id=#{warehouseId} AND status&lt;&gt;'INACTIVE' AND deleted=0")
long countActiveLocation(@Param("warehouseId") long warehouseId, @Param("locationId") long locationId);
@Update("UPDATE wh_pallet SET status=#{targetStatus},location_id=#{targetLocationId},version=version+1," +
"updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{palletId} AND customer_id=0 " +
"AND warehouse_id=#{warehouseId} AND version=#{expectedVersion} AND status&lt;&gt;'SCRAPPED' AND deleted=0")
int transition(@Param("warehouseId") long warehouseId, @Param("palletId") long palletId,
@Param("targetStatus") String targetStatus, @Param("targetLocationId") long targetLocationId,
@Param("expectedVersion") int expectedVersion);
}
@@ -0,0 +1,106 @@
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.PalletTransitionRequests;
import com.baiyue.warehouse.dto.PalletTransitionResponse;
import com.baiyue.warehouse.entity.WarehousePalletEntity;
import com.baiyue.warehouse.entity.WarehousePalletOperationEntity;
import com.baiyue.warehouse.mapper.WarehousePalletTransitionMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.Set;
@Service
public class WarehousePalletTransitionService {
private static final Set<String> MUTABLE_STATUSES = Set.of("IDLE", "OCCUPIED");
@Resource private WarehousePalletTransitionMapper mapper;
@Transactional
public PalletTransitionResponse apply(long palletId, PalletTransitionRequests.Apply request) {
String key = request.operationKey().trim();
String status = request.targetStatus().trim().toUpperCase();
if (!MUTABLE_STATUSES.contains(status)) {
throw new BizException(ResultCode.BAD_REQUEST, "托盘作业状态只允许 IDLE 或 OCCUPIED");
}
String requestHash = hash(request.warehouseId() + "|" + palletId + "|" + request.expectedVersion()
+ "|" + status + "|" + request.targetLocationId());
WarehousePalletOperationEntity existing = mapper.findOperation(key);
if (existing != null) return replay(existing, requestHash);
WarehousePalletEntity pallet = mapper.lockPublicPallet(request.warehouseId(), palletId);
if (pallet == null) throw new BizException(ResultCode.NOT_FOUND, "当前冻结仓内托盘不存在");
int version = pallet.getVersion() == null ? 0 : pallet.getVersion();
if (version != request.expectedVersion()) {
throw new BizException(ResultCode.CONFLICT, "托盘版本已变化,请重新扫码");
}
if (mapper.countActiveLocation(request.warehouseId(), request.targetLocationId()) != 1) {
throw new BizException(ResultCode.CONFLICT, "目标库位无效或已停用");
}
if (pallet.getLocationId() == null) {
throw new BizException(ResultCode.CONFLICT, "托盘没有有效库位,不能执行库内作业");
}
if (mapper.transition(request.warehouseId(), palletId, status, request.targetLocationId(), version) != 1) {
throw new BizException(ResultCode.CONFLICT, "托盘版本已变化,请重新扫码");
}
WarehousePalletOperationEntity operation = new WarehousePalletOperationEntity();
operation.setId(IdWorker.getId()); operation.setOperationKey(key); operation.setRequestHash(requestHash);
operation.setWarehouseId(request.warehouseId()); operation.setPalletId(palletId);
operation.setPalletCode(pallet.getPalletCode()); operation.setBeforeStatus(pallet.getStatus());
operation.setAfterStatus(status); operation.setBeforeLocationId(pallet.getLocationId());
operation.setAfterLocationId(request.targetLocationId()); operation.setBeforeVersion(version);
operation.setAfterVersion(version + 1); operation.setOperationStatus("APPLIED");
operation.setReason(trim(request.reason())); operation.setCreatedAt(LocalDateTime.now());
operation.setUpdatedAt(operation.getCreatedAt()); mapper.insert(operation);
return response(operation, false);
}
@Transactional
public PalletTransitionResponse compensate(long palletId, String operationKey,
PalletTransitionRequests.Compensate request) {
WarehousePalletOperationEntity operation = mapper.lockOperation(operationKey);
if (operation == null || !Long.valueOf(palletId).equals(operation.getPalletId())
|| !Long.valueOf(request.warehouseId()).equals(operation.getWarehouseId())) {
throw new BizException(ResultCode.NOT_FOUND, "托盘状态变更记录不存在");
}
if ("COMPENSATED".equals(operation.getOperationStatus())) return response(operation, true);
if (!"APPLIED".equals(operation.getOperationStatus())) {
throw new BizException(ResultCode.CONFLICT, "托盘状态变更不可补偿");
}
if (mapper.transition(request.warehouseId(), palletId, operation.getBeforeStatus(),
operation.getBeforeLocationId(), operation.getAfterVersion()) != 1) {
throw new BizException(ResultCode.CONFLICT, "托盘已被后续作业修改,自动补偿失败");
}
operation.setOperationStatus("COMPENSATED"); operation.setUpdatedAt(LocalDateTime.now());
mapper.updateById(operation);
return response(operation, false);
}
private PalletTransitionResponse replay(WarehousePalletOperationEntity operation, String hash) {
if (!hash.equals(operation.getRequestHash())) {
throw new BizException(ResultCode.CONFLICT, "操作幂等键已被不同托盘请求使用");
}
return response(operation, true);
}
private static PalletTransitionResponse response(WarehousePalletOperationEntity value, boolean replayed) {
return new PalletTransitionResponse(value.getOperationKey(), value.getPalletId(), value.getPalletCode(),
value.getWarehouseId(), value.getBeforeStatus(), value.getAfterStatus(), value.getBeforeLocationId(),
value.getAfterLocationId(), value.getBeforeVersion(), value.getAfterVersion(),
value.getOperationStatus(), replayed);
}
private static String hash(String value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); }
}
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
}