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); }
}
@@ -0,0 +1,61 @@
package com.baiyue.lastleg.pda.stocktake.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses;
import com.baiyue.lastleg.pda.stocktake.service.PdaStocktakeService;
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.GetMapping;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/pda/v1/last-leg/stocktakes")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')")
public class PdaStocktakeController {
@Resource private PdaStocktakeService service;
@GetMapping
public ApiResponse<PageResult<PdaStocktakeResponses.Summary>> page(
@AuthenticationPrincipal AuthenticatedUser user, @Valid PdaStocktakeRequests.TaskQuery query) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.page(identity.overseasWarehouseId(), query));
}
@PostMapping
public ApiResponse<PdaStocktakeResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PdaStocktakeRequests.Create request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.create(identity.overseasWarehouseId(), identity.operatorKey(), request));
}
@GetMapping("/{taskNo}")
public ApiResponse<PdaStocktakeResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String taskNo) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.detail(identity.overseasWarehouseId(), taskNo));
}
@PostMapping("/{taskNo}/counts")
public ApiResponse<PdaStocktakeResponses.Item> count(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String taskNo, @Valid @RequestBody PdaStocktakeRequests.Count request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.count(identity.overseasWarehouseId(), identity.operatorKey(), taskNo, request));
}
@PostMapping("/{taskNo}/submit")
public ApiResponse<PdaStocktakeResponses.Detail> submit(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String taskNo, @Valid @RequestBody PdaStocktakeRequests.Submit request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.submit(identity.overseasWarehouseId(), identity.operatorKey(), taskNo, request));
}
}
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.pda.stocktake.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
public final class PdaStocktakeRequests {
private PdaStocktakeRequests() { }
public record TaskQuery(@Min(1) long page, @Min(1) @Max(100) long size, String status) {
public TaskQuery { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record Create(@NotBlank @Size(max = 16) String scopeType,
@NotBlank @Size(max = 128) String scopeCode,
@NotBlank @Size(max = 128) String idempotencyKey) { }
public record Count(@NotNull Long positionId, @PositiveOrZero long actualPackages,
@PositiveOrZero long expectedVersion,
@NotBlank @Size(max = 128) String idempotencyKey) { }
public record Submit(@PositiveOrZero long expectedVersion,
@NotBlank @Size(max = 500) String reason,
@NotBlank @Size(max = 128) String idempotencyKey) { }
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.pda.stocktake.dto;
import java.time.LocalDateTime;
import java.util.List;
public final class PdaStocktakeResponses {
private PdaStocktakeResponses() { }
public record Summary(String taskNo, String scopeType, String scopeCode, String status,
int positionCount, int countedCount, int differenceCount, long version,
LocalDateTime createdAt, LocalDateTime submittedAt) { }
public record Item(long itemId, String customerId, long balanceId, long positionId,
String skuCode, String productName, String inboundCode,
long palletId, String palletCode, long locationId, String locationCode,
long bookPackages, Long actualPackages, Long differencePackages,
String status, long positionVersion, long version) { }
public record Detail(Summary task, List<Item> items) { }
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.pda.stocktake.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_stocktake_action")
public class PdaStocktakeAction {
@TableId(type = IdType.INPUT) private Long id;
private Long overseasWarehouseId;
private String operatorKey;
private String idempotencyKey;
private String actionType;
private Long taskId;
private String requestHash;
private String status;
private String resultJson;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.pda.stocktake.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_stocktake_item")
public class PdaStocktakeItem {
@TableId(type = IdType.INPUT) private Long id;
private Long taskId;
private String customerId;
private Long balanceId;
private Long positionId;
private String skuCode;
private String productName;
private String inboundCode;
private Long palletId;
private String palletCode;
private Long locationId;
private String locationCode;
private Long bookPackages;
private Long actualPackages;
private Long differencePackages;
private String status;
private Long positionVersion;
private String countedBy;
private Long version;
private LocalDateTime countedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.pda.stocktake.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_stocktake_task")
public class PdaStocktakeTask {
@TableId(type = IdType.INPUT) private Long id;
private String taskNo;
private Long overseasWarehouseId;
private String scopeType;
private Long scopeResourceId;
private String scopeCode;
private String status;
private Integer positionCount;
private Integer countedCount;
private Integer differenceCount;
private String createdBy;
private String submittedBy;
private String submitReason;
private String idempotencyKey;
private String requestHash;
private Long version;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private LocalDateTime submittedAt;
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.pda.stocktake.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeAction;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface PdaStocktakeActionMapper extends BaseMapper<PdaStocktakeAction> {
@Select("SELECT * FROM ll_pda_stocktake_action WHERE overseas_warehouse_id=#{warehouseId} " +
"AND operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1")
PdaStocktakeAction find(@Param("warehouseId") long warehouseId,
@Param("operatorKey") String operatorKey, @Param("key") String key);
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.pda.stocktake.mapper;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface PdaStocktakeInventoryMapper {
@Select("SELECT * FROM ll_inventory_position WHERE id=#{positionId} AND customer_id=#{customerId} " +
"AND overseas_warehouse_id=#{warehouseId} LIMIT 1 FOR UPDATE")
InventoryPosition lockPosition(@Param("positionId") long positionId, @Param("customerId") String customerId,
@Param("warehouseId") String warehouseId);
@Select("SELECT * FROM ll_inventory_balance WHERE id=#{balanceId} AND customer_id=#{customerId} " +
"AND overseas_warehouse_id=#{warehouseId} LIMIT 1 FOR UPDATE")
InventoryBalance lockBalance(@Param("balanceId") long balanceId, @Param("customerId") String customerId,
@Param("warehouseId") String warehouseId);
}
@@ -0,0 +1,66 @@
package com.baiyue.lastleg.pda.stocktake.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses;
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeItem;
import com.baiyue.lastleg.pda.stocktake.model.StocktakeCandidate;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
public interface PdaStocktakeItemMapper extends BaseMapper<PdaStocktakeItem> {
String CANDIDATE = "SELECT p.customer_id,b.id balance_id,p.id position_id,b.sku_code," +
"COALESCE(NULLIF(b.product_name_zh,''),b.product_name_en) product_name,b.inbound_code," +
"p.pallet_id,p.pallet_no pallet_code,p.warehouse_location_id location_id," +
"p.warehouse_location_code location_code," +
"(p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages) book_packages," +
"p.version position_version FROM ll_inventory_position p JOIN ll_inventory_balance b " +
"ON b.id=p.balance_id AND b.customer_id=p.customer_id WHERE p.overseas_warehouse_id=#{warehouseId} ";
@Select(CANDIDATE + "AND p.pallet_id=#{palletId} AND " +
"(p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " +
"ORDER BY p.customer_id,p.id")
List<StocktakeCandidate> candidatesByPallet(@Param("warehouseId") String warehouseId,
@Param("palletId") long palletId);
@Select(CANDIDATE + "AND (p.warehouse_location_id=#{locationId} OR p.warehouse_location_code=#{locationCode}) " +
"AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " +
"ORDER BY p.customer_id,p.pallet_no,p.id")
List<StocktakeCandidate> candidatesByLocation(@Param("warehouseId") String warehouseId,
@Param("locationId") long locationId,
@Param("locationCode") String locationCode);
@Select("SELECT * FROM ll_pda_stocktake_item WHERE task_id=#{taskId} ORDER BY location_code,pallet_code,position_id")
List<PdaStocktakeItem> list(@Param("taskId") long taskId);
@Select("SELECT * FROM ll_pda_stocktake_item WHERE task_id=#{taskId} ORDER BY id FOR UPDATE")
List<PdaStocktakeItem> lockAll(@Param("taskId") long taskId);
@Select("SELECT * FROM ll_pda_stocktake_item WHERE task_id=#{taskId} AND position_id=#{positionId} LIMIT 1 FOR UPDATE")
PdaStocktakeItem lockItem(@Param("taskId") long taskId, @Param("positionId") long positionId);
@Update("UPDATE ll_pda_stocktake_item SET actual_packages=#{actualPackages}," +
"difference_packages=#{differencePackages},status=#{status},counted_by=#{operatorKey}," +
"counted_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),version=version+1 " +
"WHERE id=#{itemId} AND version=#{expectedVersion} AND status IN ('PENDING','COUNTED')")
int countItem(@Param("itemId") long itemId, @Param("expectedVersion") long expectedVersion,
@Param("actualPackages") long actualPackages, @Param("differencePackages") long differencePackages,
@Param("status") String status, @Param("operatorKey") String operatorKey);
@Select("SELECT COUNT(*) FROM ll_pda_stocktake_item WHERE task_id=#{taskId} AND actual_packages IS NOT NULL")
int counted(@Param("taskId") long taskId);
@Select("SELECT COUNT(*) FROM ll_pda_stocktake_item WHERE task_id=#{taskId} " +
"AND actual_packages IS NOT NULL AND difference_packages&lt;&gt;0")
int differences(@Param("taskId") long taskId);
@Select("SELECT id item_id,customer_id,balance_id,position_id,sku_code,product_name,inbound_code," +
"pallet_id,pallet_code,location_id,location_code,book_packages,actual_packages,difference_packages," +
"status,position_version,version FROM ll_pda_stocktake_item WHERE task_id=#{taskId} " +
"ORDER BY location_code,pallet_code,position_id")
List<PdaStocktakeResponses.Item> views(@Param("taskId") long taskId);
}
@@ -0,0 +1,51 @@
package com.baiyue.lastleg.pda.stocktake.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses;
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeTask;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
public interface PdaStocktakeTaskMapper extends BaseMapper<PdaStocktakeTask> {
@Select("SELECT * FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " +
"AND created_by=#{operatorKey} AND idempotency_key=#{key} LIMIT 1")
PdaStocktakeTask findCreate(@Param("warehouseId") long warehouseId,
@Param("operatorKey") String operatorKey, @Param("key") String key);
@Select("SELECT * FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " +
"AND task_no=#{taskNo} LIMIT 1")
PdaStocktakeTask find(@Param("warehouseId") long warehouseId, @Param("taskNo") String taskNo);
@Select("SELECT * FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " +
"AND task_no=#{taskNo} LIMIT 1 FOR UPDATE")
PdaStocktakeTask lock(@Param("warehouseId") long warehouseId, @Param("taskNo") String taskNo);
@Select("<script>SELECT task_no,scope_type,scope_code,status,position_count,counted_count,difference_count," +
"version,created_at,submitted_at FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " +
"<if test='status != null and status != \"\"'>AND status=#{status} </if>" +
"ORDER BY created_at DESC,id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<PdaStocktakeResponses.Summary> page(@Param("warehouseId") long warehouseId,
@Param("status") String status,
@Param("offset") long offset, @Param("limit") long limit);
@Select("<script>SELECT COUNT(*) FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " +
"<if test='status != null and status != \"\"'>AND status=#{status}</if></script>")
long count(@Param("warehouseId") long warehouseId, @Param("status") String status);
@Update("UPDATE ll_pda_stocktake_task SET counted_count=#{countedCount},difference_count=#{differenceCount}," +
"version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{taskId} AND version=#{expectedVersion} " +
"AND status='IN_PROGRESS'")
int refreshCounts(@Param("taskId") long taskId, @Param("expectedVersion") long expectedVersion,
@Param("countedCount") int countedCount, @Param("differenceCount") int differenceCount);
@Update("UPDATE ll_pda_stocktake_task SET status='SUBMITTED',submitted_by=#{operatorKey}," +
"submit_reason=#{reason},submitted_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3)," +
"version=version+1 WHERE id=#{taskId} AND version=#{expectedVersion} AND status='IN_PROGRESS'")
int submit(@Param("taskId") long taskId, @Param("expectedVersion") long expectedVersion,
@Param("operatorKey") String operatorKey, @Param("reason") String reason);
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.pda.stocktake.model;
import lombok.Data;
@Data
public class StocktakeCandidate {
private String customerId;
private Long balanceId;
private Long positionId;
private String skuCode;
private String productName;
private String inboundCode;
private Long palletId;
private String palletCode;
private Long locationId;
private String locationCode;
private Long bookPackages;
private Long positionVersion;
}
@@ -0,0 +1,323 @@
package com.baiyue.lastleg.pda.stocktake.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
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.pda.stocktake.dto.PdaStocktakeRequests;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses;
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeAction;
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeItem;
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeTask;
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeActionMapper;
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeInventoryMapper;
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeItemMapper;
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeTaskMapper;
import com.baiyue.lastleg.pda.stocktake.model.StocktakeCandidate;
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.HashMap;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
@Service
public class PdaStocktakeService {
@Resource private PdaStocktakeTaskMapper tasks;
@Resource private PdaStocktakeItemMapper items;
@Resource private PdaStocktakeActionMapper actions;
@Resource private PdaStocktakeInventoryMapper inventory;
@Resource private InventoryPositionMapper positions;
@Resource private InventoryBalanceMapper balances;
@Resource private InventoryLedgerMapper ledgers;
@Resource private WarehouseResourceGateway warehouse;
@Resource private PdaWarehouseService history;
@Resource private ObjectMapper objectMapper;
public PageResult<PdaStocktakeResponses.Summary> page(long warehouseId, PdaStocktakeRequests.TaskQuery query) {
String status = status(query.status());
long offset = (query.page() - 1) * query.size();
List<PdaStocktakeResponses.Summary> rows = tasks.page(warehouseId, status, offset, query.size());
long total = tasks.count(warehouseId, status);
return new PageResult<>(rows, query.page(), query.size(), total,
total == 0 ? 0 : (total + query.size() - 1) / query.size());
}
public PdaStocktakeResponses.Detail detail(long warehouseId, String taskNo) {
PdaStocktakeTask task = requireTask(warehouseId, taskNo, false);
return detail(task);
}
@Transactional
public PdaStocktakeResponses.Detail create(long warehouseId, String operatorKey,
PdaStocktakeRequests.Create request) {
String key = required(request.idempotencyKey(), "Idempotency key is required");
String requestHash = hash(request);
PdaStocktakeTask replay = tasks.findCreate(warehouseId, operatorKey, key);
if (replay != null) {
if (!requestHash.equals(replay.getRequestHash())) throw conflict("Idempotency key was reused with another stocktake");
return detail(replay);
}
String scopeType = required(request.scopeType(), "Scope type is required").toUpperCase();
String scopeCode = required(request.scopeCode(), "Scope code is required");
long resourceId;
List<StocktakeCandidate> candidates;
if ("PALLET".equals(scopeType)) {
var pallet = warehouse.resolvePallet(scopeCode);
if (pallet.warehouseId() != warehouseId) throw conflict("Pallet belongs to another overseas warehouse");
resourceId = pallet.palletId();
scopeCode = pallet.palletNo();
candidates = items.candidatesByPallet(String.valueOf(warehouseId), resourceId);
} else if ("LOCATION".equals(scopeType)) {
var location = warehouse.resolveLocation(warehouseId, scopeCode);
if (!List.of("ACTIVE", "IDLE", "OCCUPIED").contains(location.status())) {
throw conflict("Stocktake location is not active");
}
resourceId = location.locationId();
scopeCode = location.locationCode();
candidates = items.candidatesByLocation(String.valueOf(warehouseId), resourceId, scopeCode);
} else {
throw badRequest("scopeType must be LOCATION or PALLET");
}
if (candidates.isEmpty()) throw conflict("No inventory is available in the scanned scope");
long taskId = IdWorker.getId();
LocalDateTime now = LocalDateTime.now();
PdaStocktakeTask task = new PdaStocktakeTask();
task.setId(taskId); task.setTaskNo("CC-" + Long.toUnsignedString(taskId));
task.setOverseasWarehouseId(warehouseId); task.setScopeType(scopeType); task.setScopeResourceId(resourceId);
task.setScopeCode(scopeCode); task.setStatus("IN_PROGRESS"); task.setPositionCount(candidates.size());
task.setCountedCount(0); task.setDifferenceCount(0); task.setCreatedBy(operatorKey);
task.setIdempotencyKey(key); task.setRequestHash(requestHash); task.setVersion(0L);
task.setCreatedAt(now); task.setUpdatedAt(now);
try {
tasks.insert(task);
} catch (DuplicateKeyException duplicate) {
PdaStocktakeTask winner = tasks.findCreate(warehouseId, operatorKey, key);
if (winner == null || !requestHash.equals(winner.getRequestHash())) throw duplicate;
return detail(winner);
}
for (StocktakeCandidate candidate : candidates) {
PdaStocktakeItem item = new PdaStocktakeItem();
item.setId(IdWorker.getId()); item.setTaskId(taskId); item.setCustomerId(candidate.getCustomerId());
item.setBalanceId(candidate.getBalanceId()); item.setPositionId(candidate.getPositionId());
item.setSkuCode(candidate.getSkuCode()); item.setProductName(candidate.getProductName());
item.setInboundCode(candidate.getInboundCode()); item.setPalletId(candidate.getPalletId());
item.setPalletCode(candidate.getPalletCode()); item.setLocationId(candidate.getLocationId());
item.setLocationCode(candidate.getLocationCode()); item.setBookPackages(value(candidate.getBookPackages()));
item.setStatus("PENDING"); item.setPositionVersion(value(candidate.getPositionVersion()));
item.setVersion(0L); item.setCreatedAt(now); item.setUpdatedAt(now); items.insert(item);
}
history.record(warehouseId, null, operatorKey, "STOCKTAKE_CREATE", task.getTaskNo(), "IN_PROGRESS",
"STOCKTAKE_CREATE:" + key, requestHash,
scopeType + " " + scopeCode + ",共 " + candidates.size() + " 个库存位置");
return detail(task);
}
@Transactional
public PdaStocktakeResponses.Item count(long warehouseId, String operatorKey, String taskNo,
PdaStocktakeRequests.Count request) {
String key = required(request.idempotencyKey(), "Idempotency key is required");
String requestHash = hash(request);
PdaStocktakeAction action = beginAction(warehouseId, operatorKey, key, "COUNT", requestHash, taskNo);
if ("COMPLETED".equals(action.getStatus())) return read(action, PdaStocktakeResponses.Item.class);
PdaStocktakeTask task = requireTask(warehouseId, taskNo, true);
if (!"IN_PROGRESS".equals(task.getStatus())) throw conflict("Submitted stocktake cannot be changed");
PdaStocktakeItem item = items.lockItem(task.getId(), request.positionId());
if (item == null) throw notFound("Stocktake position was not found in this task");
long difference = request.actualPackages() - value(item.getBookPackages());
if (items.countItem(item.getId(), request.expectedVersion(), request.actualPackages(), difference,
difference == 0 ? "MATCHED" : "DIFFERENCE", operatorKey) != 1) {
throw conflict("Stocktake item version changed; refresh and retry");
}
int counted = items.counted(task.getId());
int differences = items.differences(task.getId());
if (tasks.refreshCounts(task.getId(), value(task.getVersion()), counted, differences) != 1) {
throw conflict("Stocktake task version changed; refresh and retry");
}
PdaStocktakeResponses.Item response = items.views(task.getId()).stream()
.filter(row -> row.positionId() == request.positionId()).findFirst().orElseThrow();
complete(action, response);
return response;
}
@Transactional
public PdaStocktakeResponses.Detail submit(long warehouseId, String operatorKey, String taskNo,
PdaStocktakeRequests.Submit request) {
String key = required(request.idempotencyKey(), "Idempotency key is required");
String requestHash = hash(request);
PdaStocktakeAction action = beginAction(warehouseId, operatorKey, key, "SUBMIT", requestHash, taskNo);
if ("COMPLETED".equals(action.getStatus())) return read(action, PdaStocktakeResponses.Detail.class);
PdaStocktakeTask task = requireTask(warehouseId, taskNo, true);
if (!"IN_PROGRESS".equals(task.getStatus())) throw conflict("Stocktake was already submitted");
if (value(task.getVersion()) != request.expectedVersion()) throw conflict("Stocktake task version changed; refresh and retry");
List<PdaStocktakeItem> taskItems = items.lockAll(task.getId());
if (taskItems.isEmpty() || taskItems.stream().anyMatch(item -> item.getActualPackages() == null)) {
throw conflict("Every stocktake position must be counted before submission");
}
Map<Long, InventoryBalance> changedBalances = new HashMap<>();
LocalDateTime now = LocalDateTime.now();
for (PdaStocktakeItem item : taskItems) {
InventoryPosition position = inventory.lockPosition(item.getPositionId(), item.getCustomerId(),
String.valueOf(warehouseId));
if (position == null || value(position.getVersion()) != value(item.getPositionVersion())) {
throw conflict("Inventory changed after counting; recreate the stocktake task");
}
long before = physical(position);
if (before != value(item.getBookPackages())) {
throw conflict("Inventory package total changed after counting; recreate the stocktake task");
}
long fixed = value(position.getHoldPackages()) + value(position.getAllocatedPackages())
+ value(position.getPickedPackages()) + value(position.getReceivingPackages());
if (value(item.getActualPackages()) < fixed) {
throw conflict("Actual packages cannot be lower than reserved or processing packages");
}
long newAvailable = value(item.getActualPackages()) - fixed;
long delta = newAvailable - value(position.getAvailablePackages());
InventoryBalance balance = changedBalances.get(item.getBalanceId());
if (balance == null) {
balance = inventory.lockBalance(item.getBalanceId(), item.getCustomerId(), String.valueOf(warehouseId));
if (balance == null) throw new IllegalStateException("Stocktake inventory balance is missing");
changedBalances.put(item.getBalanceId(), balance);
}
long balanceAfter = value(balance.getAvailablePackages()) + delta;
if (balanceAfter < 0) throw conflict("Stocktake adjustment would make inventory negative");
position.setAvailablePackages(newAvailable); position.setStatus(physicalAfter(position, newAvailable) == 0 ? "EMPTY" : "OCCUPIED");
position.setVersion(value(position.getVersion()) + 1); position.setUpdatedAt(now); positions.updateById(position);
balance.setAvailablePackages(balanceAfter); balance.setVersion(value(balance.getVersion()) + 1); balance.setUpdatedAt(now);
ledger(task, item, position, balance, operatorKey, key, request.reason(), before, delta,
value(item.getActualPackages()), now);
item.setStatus(delta == 0 ? "MATCHED" : "ADJUSTED"); item.setUpdatedAt(now);
item.setVersion(value(item.getVersion()) + 1); items.updateById(item);
}
changedBalances.values().forEach(balances::updateById);
if (tasks.submit(task.getId(), request.expectedVersion(), operatorKey, request.reason()) != 1) {
throw conflict("Stocktake task version changed; refresh and retry");
}
history.record(warehouseId, null, operatorKey, "STOCKTAKE_SUBMIT", taskNo, "COMPLETED",
"STOCKTAKE_SUBMIT:" + key, requestHash,
"盘点提交,差异位置 " + task.getDifferenceCount() + " 个;原因:" + request.reason().trim());
PdaStocktakeResponses.Detail response = detail(requireTask(warehouseId, taskNo, false));
complete(action, response);
return response;
}
private PdaStocktakeResponses.Detail detail(PdaStocktakeTask task) {
return new PdaStocktakeResponses.Detail(summary(task), items.views(task.getId()));
}
private PdaStocktakeResponses.Summary summary(PdaStocktakeTask task) {
return new PdaStocktakeResponses.Summary(task.getTaskNo(), task.getScopeType(), task.getScopeCode(),
task.getStatus(), intValue(task.getPositionCount()), intValue(task.getCountedCount()),
intValue(task.getDifferenceCount()), value(task.getVersion()), task.getCreatedAt(), task.getSubmittedAt());
}
private PdaStocktakeTask requireTask(long warehouseId, String taskNo, boolean lock) {
String normalized = required(taskNo, "Stocktake task number is required");
PdaStocktakeTask task = lock ? tasks.lock(warehouseId, normalized) : tasks.find(warehouseId, normalized);
if (task == null) throw notFound("Stocktake task was not found in the bound warehouse");
return task;
}
private PdaStocktakeAction beginAction(long warehouseId, String operatorKey, String key, String type,
String requestHash, String taskNo) {
PdaStocktakeAction existing = actions.find(warehouseId, operatorKey, key);
if (existing != null) return replay(existing, type, requestHash);
PdaStocktakeAction action = new PdaStocktakeAction();
action.setId(IdWorker.getId()); action.setOverseasWarehouseId(warehouseId); action.setOperatorKey(operatorKey);
action.setIdempotencyKey(key); action.setActionType(type); action.setRequestHash(requestHash);
PdaStocktakeTask task = tasks.find(warehouseId, taskNo); action.setTaskId(task == null ? null : task.getId());
action.setStatus("PROCESSING"); action.setCreatedAt(LocalDateTime.now()); action.setUpdatedAt(action.getCreatedAt());
try { actions.insert(action); return action; }
catch (DuplicateKeyException duplicate) {
PdaStocktakeAction winner = actions.find(warehouseId, operatorKey, key);
if (winner == null) throw duplicate;
return replay(winner, type, requestHash);
}
}
private PdaStocktakeAction replay(PdaStocktakeAction action, String type, String requestHash) {
if (!type.equals(action.getActionType()) || !requestHash.equals(action.getRequestHash())) {
throw conflict("Idempotency key was reused with a different stocktake action");
}
if (!"COMPLETED".equals(action.getStatus())) throw conflict("Stocktake action is still processing");
return action;
}
private void complete(PdaStocktakeAction action, Object response) {
action.setStatus("COMPLETED"); action.setResultJson(write(response)); action.setUpdatedAt(LocalDateTime.now());
actions.updateById(action);
}
private <T> T read(PdaStocktakeAction action, Class<T> type) {
try { return objectMapper.readValue(action.getResultJson(), type); }
catch (JsonProcessingException error) { throw new IllegalStateException("Cannot read stocktake replay", error); }
}
private void ledger(PdaStocktakeTask task, PdaStocktakeItem item, InventoryPosition position,
InventoryBalance balance, String operator, String key, String reason,
long before, long delta, long after, LocalDateTime now) {
InventoryLedger row = new InventoryLedger();
row.setId(IdWorker.getId()); row.setCustomerId(item.getCustomerId()); row.setBalanceId(balance.getId());
row.setPositionId(position.getId()); row.setOverseasWarehouseId(String.valueOf(task.getOverseasWarehouseId()));
row.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); row.setInboundOrderId(balance.getInboundOrderId());
row.setPalletId(position.getPalletId()); row.setPalletNo(position.getPalletNo()); row.setBatchNo(position.getBatchNo());
row.setSkuCode(item.getSkuCode()); row.setWarehouseLocationCode(position.getWarehouseLocationCode());
row.setBusinessType("STOCKTAKE_ADJUST"); row.setBusinessOrderId(task.getId()); row.setBusinessOrderNo(task.getTaskNo());
row.setInboundCode(balance.getInboundCode()); row.setInboundOrderNo(balance.getInboundOrderNo());
row.setIdempotencyKey("STOCKTAKE:" + task.getId() + ":" + item.getId());
row.setStatusFrom("PHYSICAL"); row.setStatusTo("PHYSICAL"); row.setBeforePackages(before);
row.setDeltaPackages(delta); row.setAfterPackages(after); 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 String write(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException error) { throw new IllegalStateException("Cannot serialize stocktake operation", error); }
}
private static String status(String value) {
String normalized = trim(value); if (normalized == null) return null;
normalized = normalized.toUpperCase();
if (!List.of("IN_PROGRESS", "SUBMITTED").contains(normalized)) throw badRequest("Unsupported stocktake status");
return normalized;
}
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 physicalAfter(InventoryPosition value, long available) { return available + 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 int intValue(Integer value) { return value == null ? 0 : value; }
private static String required(String value, String message) { String normalized = trim(value); if (normalized == null) throw badRequest(message); return normalized; }
private static String trim(String value) { return value == null || value.isBlank() ? null : 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); }
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
}