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); }
|
||||
}
|
||||
+61
@@ -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));
|
||||
}
|
||||
}
|
||||
+28
@@ -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) { }
|
||||
}
|
||||
+20
@@ -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) { }
|
||||
}
|
||||
+24
@@ -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;
|
||||
}
|
||||
+35
@@ -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;
|
||||
}
|
||||
+32
@@ -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;
|
||||
}
|
||||
+15
@@ -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);
|
||||
}
|
||||
+20
@@ -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);
|
||||
}
|
||||
+66
@@ -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<>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);
|
||||
}
|
||||
+51
@@ -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);
|
||||
}
|
||||
+19
@@ -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;
|
||||
}
|
||||
+323
@@ -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); }
|
||||
}
|
||||
Reference in New Issue
Block a user