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,30 @@
package com.baiyue.lastleg.pda.move.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest;
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveResponse;
import com.baiyue.lastleg.pda.move.service.PdaInventoryMoveService;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/pda/v1/last-leg/inventory-moves")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')")
public class PdaInventoryMoveController {
@Resource private PdaInventoryMoveService service;
@PostMapping
public ApiResponse<PdaInventoryMoveResponse> move(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PdaInventoryMoveRequest request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.move(identity.overseasWarehouseId(), identity.operatorKey(), request));
}
}
@@ -0,0 +1,13 @@
package com.baiyue.lastleg.pda.move.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
public record PdaInventoryMoveRequest(
@NotBlank @Size(max = 128) String sourcePalletCode,
@NotBlank @Size(max = 128) String targetLocationCode,
@PositiveOrZero int expectedPalletVersion,
@NotBlank @Size(max = 128) String idempotencyKey,
@NotBlank @Size(max = 500) String reason
) { }
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.pda.move.dto;
import java.time.LocalDateTime;
public record PdaInventoryMoveResponse(
String moveNo,
long palletId,
String palletCode,
long sourceLocationId,
String sourceLocationCode,
long targetLocationId,
String targetLocationCode,
int customerCount,
int positionCount,
long packageCount,
int palletVersion,
String status,
LocalDateTime completedAt,
boolean replayed
) {
public PdaInventoryMoveResponse asReplay() {
return replayed ? this : new PdaInventoryMoveResponse(moveNo, palletId, palletCode, sourceLocationId,
sourceLocationCode, targetLocationId, targetLocationCode, customerCount, positionCount,
packageCount, palletVersion, status, completedAt, true);
}
}
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.pda.move.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("ll_pda_inventory_move")
public class PdaInventoryMove {
@TableId(type = IdType.INPUT) private Long id;
private String moveNo;
private Long overseasWarehouseId;
private Long palletId;
private String palletCode;
private Long sourceLocationId;
private String sourceLocationCode;
private Long targetLocationId;
private String targetLocationCode;
private Integer customerCount;
private Integer positionCount;
private Long packageCount;
private Integer palletVersionBefore;
private Integer palletVersionAfter;
private String operatorKey;
private String reason;
private String idempotencyKey;
private String requestHash;
private String status;
private String resultJson;
private LocalDateTime createdAt;
private LocalDateTime completedAt;
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.pda.move.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.pda.move.entity.PdaInventoryMove;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface PdaInventoryMoveMapper extends BaseMapper<PdaInventoryMove> {
@Select("SELECT * FROM ll_pda_inventory_move WHERE overseas_warehouse_id=#{warehouseId} " +
"AND operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1")
PdaInventoryMove find(@Param("warehouseId") long warehouseId,
@Param("operatorKey") String operatorKey, @Param("key") String key);
@Select("SELECT * FROM ll_inventory_position WHERE overseas_warehouse_id=#{warehouseId} " +
"AND pallet_id=#{palletId} AND " +
"(available_packages+hold_packages+allocated_packages+picked_packages+receiving_packages)>0 " +
"ORDER BY customer_id,id FOR UPDATE")
List<InventoryPosition> lockPositions(@Param("warehouseId") String warehouseId,
@Param("palletId") long palletId);
}
@@ -0,0 +1,192 @@
package com.baiyue.lastleg.pda.move.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest;
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveResponse;
import com.baiyue.lastleg.pda.move.entity.PdaInventoryMove;
import com.baiyue.lastleg.pda.move.mapper.PdaInventoryMoveMapper;
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.List;
import java.util.Set;
@Service
public class PdaInventoryMoveService {
@Resource private PdaInventoryMoveMapper moves;
@Resource private InventoryPositionMapper positions;
@Resource private InventoryBalanceMapper balances;
@Resource private InventoryLedgerMapper ledgers;
@Resource private WarehouseResourceGateway warehouse;
@Resource private PalletPhysicalGateway physical;
@Resource private PdaWarehouseService history;
@Resource private ObjectMapper objectMapper;
@Transactional
public PdaInventoryMoveResponse move(long warehouseId, String operatorKey, PdaInventoryMoveRequest request) {
String key = required(request.idempotencyKey(), "Idempotency key is required");
String requestHash = hash(request);
PdaInventoryMove existing = moves.find(warehouseId, operatorKey, key);
if (existing != null) return replay(existing, requestHash);
var pallet = warehouse.resolvePallet(required(request.sourcePalletCode(), "Source pallet code is required"));
if (pallet.warehouseId() != warehouseId) throw conflict("Source pallet belongs to another overseas warehouse");
var target = warehouse.resolveLocation(warehouseId,
required(request.targetLocationCode(), "Target location code is required"));
if (!Set.of("ACTIVE", "IDLE", "OCCUPIED").contains(target.status())) {
throw conflict("Target location is not available");
}
boolean recoveringRemoteSuccess = pallet.locationId() == target.locationId()
&& pallet.version() == request.expectedPalletVersion() + 1;
if (!recoveringRemoteSuccess && pallet.version() != request.expectedPalletVersion()) {
throw conflict("Source pallet version changed; rescan it");
}
List<InventoryPosition> inventory = moves.lockPositions(String.valueOf(warehouseId), pallet.palletId());
if (inventory.isEmpty()) throw conflict("Source pallet has no movable inventory");
Set<Long> inventoryLocations = new HashSet<>();
inventory.forEach(position -> inventoryLocations.add(position.getWarehouseLocationId()));
if (inventoryLocations.contains(null) || inventoryLocations.size() != 1) {
throw conflict("Inventory and warehouse pallet locations are inconsistent; reconcile before moving");
}
long sourceLocationId = inventoryLocations.iterator().next();
if (!recoveringRemoteSuccess && sourceLocationId != pallet.locationId()) {
throw conflict("Inventory and warehouse pallet locations are inconsistent; reconcile before moving");
}
if (sourceLocationId == target.locationId()) throw badRequest("Source and target locations must differ");
var source = warehouse.resolveLocation(warehouseId, sourceLocationId);
long packages = inventory.stream().mapToLong(PdaInventoryMoveService::physical).sum();
Set<String> customers = new HashSet<>();
inventory.forEach(position -> customers.add(position.getCustomerId()));
long moveId = IdWorker.getId();
PdaInventoryMove operation = new PdaInventoryMove();
operation.setId(moveId); operation.setMoveNo("MV-" + Long.toUnsignedString(moveId));
operation.setOverseasWarehouseId(warehouseId); operation.setPalletId(pallet.palletId());
operation.setPalletCode(pallet.palletNo()); operation.setSourceLocationId(source.locationId());
operation.setSourceLocationCode(source.locationCode()); operation.setTargetLocationId(target.locationId());
operation.setTargetLocationCode(target.locationCode()); operation.setCustomerCount(customers.size());
operation.setPositionCount(inventory.size()); operation.setPackageCount(packages);
operation.setPalletVersionBefore(pallet.version()); operation.setOperatorKey(operatorKey);
operation.setReason(request.reason().trim()); operation.setIdempotencyKey(key); operation.setRequestHash(requestHash);
operation.setStatus("PROCESSING"); operation.setCreatedAt(LocalDateTime.now());
try { moves.insert(operation); }
catch (DuplicateKeyException duplicate) {
PdaInventoryMove winner = moves.find(warehouseId, operatorKey, key);
if (winner == null) throw duplicate;
return replay(winner, requestHash);
}
String transitionKey = transitionKey(operatorKey, key, pallet.palletId());
var transitionSource = new PalletPhysicalGateway.PalletReference(pallet.palletId(), pallet.palletNo(),
warehouseId, source.locationId(), "OCCUPIED", pallet.fbaWarehouseCode(), request.expectedPalletVersion());
var relocated = physical.apply(warehouseId, transitionSource, "OCCUPIED", target.locationId(),
transitionKey, request.reason());
if (relocated.afterLocationId() != target.locationId()
|| !"APPLIED".equals(relocated.operationStatus())) {
throw new IllegalStateException("Warehouse relocation returned another target location");
}
LocalDateTime completedAt = LocalDateTime.now();
for (InventoryPosition position : inventory) {
InventoryBalance balance = balances.selectById(position.getBalanceId());
if (balance == null || !position.getCustomerId().equals(balance.getCustomerId())
|| !String.valueOf(warehouseId).equals(balance.getOverseasWarehouseId())) {
throw new IllegalStateException("Inventory balance scope is inconsistent during relocation");
}
position.setWarehouseLocationId(target.locationId()); position.setWarehouseLocationCode(target.locationCode());
position.setVersion(value(position.getVersion()) + 1); position.setUpdatedAt(completedAt);
positions.updateById(position);
ledger(operation, position, balance, operatorKey, key, request.reason(), source.locationCode(),
target.locationCode(), completedAt);
}
operation.setPalletVersionAfter(relocated.afterVersion()); operation.setStatus("COMPLETED");
operation.setCompletedAt(completedAt);
PdaInventoryMoveResponse response = response(operation, false);
operation.setResultJson(write(response)); moves.updateById(operation);
history.record(warehouseId, null, operatorKey, "INVENTORY_MOVE", operation.getMoveNo(), "COMPLETED",
"INVENTORY_MOVE:" + key, requestHash,
pallet.palletNo() + ":" + source.locationCode() + " → " + target.locationCode()
+ "," + packages + " 箱");
return response;
}
private PdaInventoryMoveResponse replay(PdaInventoryMove operation, String requestHash) {
if (!requestHash.equals(operation.getRequestHash())) {
throw conflict("Idempotency key was reused with a different inventory move");
}
if (!"COMPLETED".equals(operation.getStatus()) || operation.getResultJson() == null) {
throw conflict("Inventory move is still processing");
}
try { return objectMapper.readValue(operation.getResultJson(), PdaInventoryMoveResponse.class).asReplay(); }
catch (JsonProcessingException error) { throw new IllegalStateException("Cannot read inventory move replay", error); }
}
private PdaInventoryMoveResponse response(PdaInventoryMove value, boolean replayed) {
return new PdaInventoryMoveResponse(value.getMoveNo(), value.getPalletId(), value.getPalletCode(),
value.getSourceLocationId(), value.getSourceLocationCode(), value.getTargetLocationId(),
value.getTargetLocationCode(), value.getCustomerCount(), value.getPositionCount(),
value.getPackageCount(), value.getPalletVersionAfter(), value.getStatus(), value.getCompletedAt(), replayed);
}
private void ledger(PdaInventoryMove operation, InventoryPosition position, InventoryBalance balance,
String operator, String key, String reason, String source, String target,
LocalDateTime now) {
InventoryLedger row = new InventoryLedger(); row.setId(IdWorker.getId()); row.setCustomerId(position.getCustomerId());
row.setBalanceId(balance.getId()); row.setPositionId(position.getId());
row.setOverseasWarehouseId(String.valueOf(operation.getOverseasWarehouseId()));
row.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); row.setInboundOrderId(balance.getInboundOrderId());
row.setPalletId(position.getPalletId()); row.setPalletNo(position.getPalletNo()); row.setBatchNo(position.getBatchNo());
row.setSkuCode(balance.getSkuCode()); row.setWarehouseLocationCode(target); row.setBusinessType("INTERNAL_MOVE");
row.setBusinessOrderId(operation.getId()); row.setBusinessOrderNo(operation.getMoveNo());
row.setInboundCode(balance.getInboundCode()); row.setInboundOrderNo(balance.getInboundOrderNo());
row.setIdempotencyKey("MOVE:" + operation.getId() + ":" + position.getId());
row.setStatusFrom(source); row.setStatusTo(target); long packages = physical(position);
row.setBeforePackages(packages); row.setDeltaPackages(0L); row.setAfterPackages(packages);
row.setOperatorId(operator); row.setRemark(reason.trim()); row.setOccurredAt(now); row.setCreatedAt(now);
ledgers.insert(row);
}
private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(write(value).getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); }
}
private static String transitionKey(String operator, String key, long palletId) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(("INVENTORY_MOVE|" + operator + "|" + key + "|" + palletId)
.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); }
}
private String write(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException error) { throw new IllegalStateException("Cannot serialize inventory move", error); }
}
private static long physical(InventoryPosition value) { return value(value.getAvailablePackages()) + value(value.getHoldPackages())
+ value(value.getAllocatedPackages()) + value(value.getPickedPackages()) + value(value.getReceivingPackages()); }
private static long value(Long value) { return value == null ? 0 : value; }
private static String required(String value, String message) { if (value == null || value.isBlank()) throw badRequest(message); return value.trim(); }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}