feat: complete overseas pda warehouse operations
This commit is contained in:
+30
@@ -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));
|
||||
}
|
||||
}
|
||||
+13
@@ -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
|
||||
) { }
|
||||
+26
@@ -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);
|
||||
}
|
||||
}
|
||||
+35
@@ -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;
|
||||
}
|
||||
+25
@@ -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);
|
||||
}
|
||||
+192
@@ -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); }
|
||||
}
|
||||
Reference in New Issue
Block a user