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,58 @@
package com.baiyue.lastleg.palletops.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.palletops.dto.PalletOperationRequests;
import com.baiyue.lastleg.palletops.dto.PalletOperationResponses;
import com.baiyue.lastleg.palletops.service.PdaPalletOperationService;
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/pallet-operations")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')")
public class PdaPalletOperationController {
@Resource private PdaPalletOperationService service;
@PostMapping("/pallets/resolve")
public ApiResponse<PalletOperationResponses.PalletContent> resolve(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PalletOperationRequests.Scan request) {
return ApiResponse.success(service.resolve(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/assemble")
public ApiResponse<PalletOperationResponses.Operation> assemble(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PalletOperationRequests.Assemble request) {
return ApiResponse.success(service.assemble(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/disassemble")
public ApiResponse<PalletOperationResponses.Operation> disassemble(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PalletOperationRequests.Disassemble request) {
return ApiResponse.success(service.disassemble(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/swap")
public ApiResponse<PalletOperationResponses.Operation> swap(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PalletOperationRequests.Swap request) {
return ApiResponse.success(service.swap(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/unbind")
public ApiResponse<PalletOperationResponses.Operation> unbind(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PalletOperationRequests.Unbind request) {
return ApiResponse.success(service.unbind(PdaWarehouseIdentity.require(user), request));
}
}
@@ -0,0 +1,42 @@
package com.baiyue.lastleg.palletops.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.util.List;
public final class PalletOperationRequests {
private PalletOperationRequests() { }
public record Scan(@NotBlank @Size(max = 128) String code) { }
public record PalletExpectation(@NotBlank @Size(max = 128) String palletCode,
@PositiveOrZero int expectedVersion) { }
public record MoveLine(@Positive long positionId, @Positive long packages,
@PositiveOrZero long expectedPositionVersion) { }
public record TargetMove(@Valid PalletExpectation target,
@NotEmpty @Size(max = 200) List<@Valid MoveLine> lines) { }
public record Assemble(@NotBlank @Size(max = 128) String idempotencyKey,
@Valid PalletExpectation target,
@NotEmpty @Size(max = 20) List<@Valid PalletExpectation> sources,
@NotEmpty @Size(max = 200) List<@Valid MoveLine> lines,
@Size(max = 500) String reason) { }
public record Disassemble(@NotBlank @Size(max = 128) String idempotencyKey,
@Valid PalletExpectation source,
@NotEmpty @Size(max = 20) List<@Valid TargetMove> targets,
@Size(max = 500) String reason) { }
public record Swap(@NotBlank @Size(max = 128) String idempotencyKey,
@Valid PalletExpectation source,
@Valid PalletExpectation target,
@Size(max = 500) String reason) { }
public record Unbind(@NotBlank @Size(max = 128) String idempotencyKey,
@Valid PalletExpectation source,
@Size(max = 500) String reason) { }
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.palletops.dto;
import java.time.LocalDateTime;
import java.util.List;
public final class PalletOperationResponses {
private PalletOperationResponses() { }
public record InventoryLine(String customerId, String customerName, long balanceId, long positionId,
String inboundCode, String skuCode, String productName,
long availablePackages, long holdPackages, long allocatedPackages,
long pickedPackages, long receivingPackages, long positionVersion) { }
public record PalletContent(long palletId, String palletCode, String status, int palletVersion,
long locationId, String locationCode, String destinationWarehouseCode,
long totalPackages, List<InventoryLine> inventory) { }
public record Operation(long operationId, String operationType, String idempotencyKey,
String status, boolean replayed, LocalDateTime completedAt,
List<PalletContent> pallets) {
public Operation asReplay() {
return new Operation(operationId, operationType, idempotencyKey, status, true, completedAt, pallets);
}
}
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.palletops.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_pallet_operation")
public class PdaPalletOperation {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long overseasWarehouseId;
private String operatorKey;
private String idempotencyKey;
private String operationType;
private String requestHash;
private String status;
private String resultJson;
private String remoteTransitionsJson;
private String lastError;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,75 @@
package com.baiyue.lastleg.palletops.gateway;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatusCode;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.util.LinkedHashMap;
import java.util.Map;
@Component
public class HttpPalletPhysicalGateway implements PalletPhysicalGateway {
@Resource private RestClient.Builder restClientBuilder;
@Value("${baiyue.warehouse.base-url:http://127.0.0.1:9305}") private String baseUrl;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
private RestClient client;
@PostConstruct void initialize() { client = restClientBuilder.baseUrl(baseUrl).build(); }
@Override
public PalletReference resolve(String palletCode) {
ResolveEnvelope response = client.get().uri("/internal/v1/warehouse-pallets/resolve/{code}", palletCode)
.header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalArgumentException("Warehouse rejected pallet scan: HTTP " + result.getStatusCode());
}).body(ResolveEnvelope.class);
if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned no pallet");
return response.data();
}
@Override
public LocationReference resolveLocation(long warehouseId, long locationId) {
LocationEnvelope response = client.get().uri(uri -> uri
.path("/internal/v1/warehouse-resource-locks/locations/{locationId}")
.queryParam("warehouseId", warehouseId).build(locationId))
.header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalArgumentException("Warehouse rejected location lookup: HTTP " + result.getStatusCode());
}).body(LocationEnvelope.class);
if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned no location");
return response.data();
}
@Override
public Transition apply(long warehouseId, PalletReference pallet, String targetStatus, long targetLocationId,
String operationKey, String reason) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("warehouseId", warehouseId); body.put("expectedVersion", pallet.version());
body.put("targetStatus", targetStatus); body.put("targetLocationId", targetLocationId);
body.put("operationKey", operationKey); if (reason != null) body.put("reason", reason);
TransitionEnvelope response = client.post().uri("/internal/v1/pda-pallet-transitions/{id}", pallet.palletId())
.header("X-Internal-Service-Token", internalToken).body(body).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalStateException("Warehouse rejected pallet transition: HTTP " + result.getStatusCode());
}).body(TransitionEnvelope.class);
if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned no transition");
return response.data();
}
@Override
public void compensate(long warehouseId, Transition transition) {
client.post().uri("/internal/v1/pda-pallet-transitions/{id}/{key}/compensate",
transition.palletId(), transition.operationKey())
.header("X-Internal-Service-Token", internalToken).body(Map.of("warehouseId", warehouseId))
.retrieve().onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalStateException("Warehouse rejected pallet compensation: HTTP " + result.getStatusCode());
}).toBodilessEntity();
}
private record ResolveEnvelope(PalletReference data) { }
private record LocationEnvelope(LocationReference data) { }
private record TransitionEnvelope(Transition data) { }
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.palletops.gateway;
public interface PalletPhysicalGateway {
PalletReference resolve(String palletCode);
LocationReference resolveLocation(long warehouseId, long locationId);
Transition apply(long warehouseId, PalletReference pallet, String targetStatus, long targetLocationId,
String operationKey, String reason);
void compensate(long warehouseId, Transition transition);
record PalletReference(long palletId, String palletCode, long warehouseId, long locationId,
String status, String fbaWarehouseCode, int version) { }
record LocationReference(long locationId, String locationCode, String locationType, String status) { }
record Transition(String operationKey, long palletId, String palletCode, long warehouseId,
String beforeStatus, String afterStatus, long beforeLocationId, long afterLocationId,
int beforeVersion, int afterVersion, String operationStatus, boolean replayed) { }
}
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.palletops.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.palletops.entity.PdaPalletOperation;
import com.baiyue.lastleg.palletops.model.PalletInventoryRow;
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 PdaPalletOperationMapper extends BaseMapper<PdaPalletOperation> {
String CONTENT_SELECT = "SELECT b.customer_id,b.customer_name,b.id balance_id,p.id position_id," +
"b.inbound_code,b.sku_code,b.product_name_zh,b.product_name_en,b.destination_warehouse_code," +
"p.pallet_id,p.pallet_no,p.batch_no,p.warehouse_location_id,p.warehouse_location_code," +
"p.available_packages,p.hold_packages,p.allocated_packages,p.picked_packages,p.receiving_packages,p.version " +
"FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id AND b.customer_id=p.customer_id ";
@Select("SELECT * FROM ll_pda_pallet_operation WHERE operator_key=#{operatorKey} " +
"AND idempotency_key=#{idempotencyKey} LIMIT 1")
PdaPalletOperation findOperation(@Param("operatorKey") String operatorKey,
@Param("idempotencyKey") String idempotencyKey);
@Select(CONTENT_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} AND p.pallet_id=#{palletId} " +
"AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)&gt;0 " +
"ORDER BY b.customer_id,b.inbound_code,p.id")
List<PalletInventoryRow> content(@Param("warehouseId") String warehouseId,
@Param("palletId") long palletId);
@Select(CONTENT_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} AND p.id=#{positionId} " +
"LIMIT 1 FOR UPDATE")
PalletInventoryRow lockPosition(@Param("warehouseId") String warehouseId,
@Param("positionId") long positionId);
@Select("SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND balance_id=#{balanceId} " +
"AND pallet_id=#{palletId} AND batch_no=#{batchNo} LIMIT 1 FOR UPDATE")
InventoryPosition lockTarget(@Param("customerId") String customerId, @Param("balanceId") long balanceId,
@Param("palletId") long palletId, @Param("batchNo") String batchNo);
@Select("SELECT COALESCE(SUM(available_packages+hold_packages+allocated_packages+picked_packages+receiving_packages),0) " +
"FROM ll_inventory_position WHERE pallet_id=#{palletId} AND overseas_warehouse_id=#{warehouseId}")
long palletPackages(@Param("warehouseId") String warehouseId, @Param("palletId") long palletId);
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.palletops.model;
import lombok.Data;
@Data
public class PalletInventoryRow {
private String customerId;
private String customerName;
private Long balanceId;
private Long positionId;
private String inboundCode;
private String skuCode;
private String productNameZh;
private String productNameEn;
private String destinationWarehouseCode;
private Long palletId;
private String palletNo;
private String batchNo;
private Long warehouseLocationId;
private String warehouseLocationCode;
private Long availablePackages;
private Long holdPackages;
private Long allocatedPackages;
private Long pickedPackages;
private Long receivingPackages;
private Long version;
}
@@ -0,0 +1,506 @@
package com.baiyue.lastleg.palletops.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
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.dto.PalletOperationRequests;
import com.baiyue.lastleg.palletops.dto.PalletOperationResponses;
import com.baiyue.lastleg.palletops.entity.PdaPalletOperation;
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper;
import com.baiyue.lastleg.palletops.model.PalletInventoryRow;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
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.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
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.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@Service
public class PdaPalletOperationService {
@Resource private PdaPalletOperationMapper mapper;
@Resource private InventoryPositionMapper positions;
@Resource private InventoryBalanceMapper balances;
@Resource private InventoryLedgerMapper ledgers;
@Resource private PalletPhysicalGateway physical;
@Resource private PdaWarehouseService history;
@Resource private ObjectMapper objectMapper;
private TransactionTemplate transaction;
private TransactionTemplate newTransaction;
@Resource
void configureTransactions(PlatformTransactionManager manager) {
transaction = new TransactionTemplate(manager);
DefaultTransactionDefinition definition = new DefaultTransactionDefinition();
definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
newTransaction = new TransactionTemplate(manager, definition);
}
public PalletOperationResponses.PalletContent resolve(PdaWarehouseIdentity identity,
PalletOperationRequests.Scan request) {
PalletPhysicalGateway.PalletReference pallet = requirePallet(identity.overseasWarehouseId(), request.code());
return content(identity.overseasWarehouseId(), pallet);
}
public PalletOperationResponses.Operation assemble(PdaWarehouseIdentity identity,
PalletOperationRequests.Assemble request) {
Map<String, PalletPhysicalGateway.PalletReference> pallets = resolvePallets(identity.overseasWarehouseId(),
request.target(), request.sources());
PalletPhysicalGateway.PalletReference target = pallets.get(code(request.target().palletCode()));
Map<String, PalletPhysicalGateway.PalletReference> sources = new LinkedHashMap<>();
request.sources().forEach(value -> sources.put(code(value.palletCode()), pallets.get(code(value.palletCode()))));
List<PlannedMove> moves = plannedLines(identity.overseasWarehouseId(), request.lines(), sources,
target, null);
requireCommonDestination(identity.overseasWarehouseId(), moves, List.of(target));
return execute(identity, "PALLET_ASSEMBLE", request.idempotencyKey(), request, request.reason(),
plan(identity.overseasWarehouseId(), pallets, moves, Set.of(target.palletId()), false));
}
public PalletOperationResponses.Operation disassemble(PdaWarehouseIdentity identity,
PalletOperationRequests.Disassemble request) {
LinkedHashMap<String, PalletOperationRequests.PalletExpectation> expectations = new LinkedHashMap<>();
expectations.put(code(request.source().palletCode()), request.source());
request.targets().forEach(value -> expectations.put(code(value.target().palletCode()), value.target()));
Map<String, PalletPhysicalGateway.PalletReference> pallets = resolvePallets(identity.overseasWarehouseId(),
expectations.values());
PalletPhysicalGateway.PalletReference source = pallets.get(code(request.source().palletCode()));
List<PlannedMove> moves = new ArrayList<>();
for (PalletOperationRequests.TargetMove targetMove : request.targets()) {
PalletPhysicalGateway.PalletReference target = pallets.get(code(targetMove.target().palletCode()));
moves.addAll(plannedLines(identity.overseasWarehouseId(), targetMove.lines(), Map.of(
code(request.source().palletCode()), source), target, source));
}
requireUniqueLines(moves);
List<PalletPhysicalGateway.PalletReference> targets = request.targets().stream()
.map(value -> pallets.get(code(value.target().palletCode()))).toList();
requireCommonDestination(identity.overseasWarehouseId(), moves, targets);
return execute(identity, "PALLET_DISASSEMBLE", request.idempotencyKey(), request, request.reason(),
plan(identity.overseasWarehouseId(), pallets, moves,
targets.stream().map(PalletPhysicalGateway.PalletReference::palletId).collect(java.util.stream.Collectors.toSet()),
false));
}
public PalletOperationResponses.Operation swap(PdaWarehouseIdentity identity,
PalletOperationRequests.Swap request) {
Map<String, PalletPhysicalGateway.PalletReference> pallets = resolvePallets(identity.overseasWarehouseId(),
request.source(), List.of(request.target()));
PalletPhysicalGateway.PalletReference source = pallets.get(code(request.source().palletCode()));
PalletPhysicalGateway.PalletReference target = pallets.get(code(request.target().palletCode()));
if (!mapper.content(String.valueOf(identity.overseasWarehouseId()), target.palletId()).isEmpty()) {
throw conflict("换托目标托盘必须为空");
}
List<PalletInventoryRow> rows = nonEmptyContent(identity.overseasWarehouseId(), source.palletId());
List<PlannedMove> moves = rows.stream().map(row -> {
requireMovable(row, value(row.getAvailablePackages()));
return new PlannedMove(row.getPositionId(), value(row.getAvailablePackages()), value(row.getVersion()),
source.palletId(), source.palletCode(), target.palletId(), target.palletCode(), target.locationId(),
locationCode(identity.overseasWarehouseId(), target.locationId()));
}).toList();
return execute(identity, "PALLET_SWAP", request.idempotencyKey(), request, request.reason(),
plan(identity.overseasWarehouseId(), pallets, moves, Set.of(target.palletId()), false));
}
public PalletOperationResponses.Operation unbind(PdaWarehouseIdentity identity,
PalletOperationRequests.Unbind request) {
Map<String, PalletPhysicalGateway.PalletReference> pallets = resolvePallets(identity.overseasWarehouseId(),
List.of(request.source()));
PalletPhysicalGateway.PalletReference source = pallets.get(code(request.source().palletCode()));
List<PalletInventoryRow> rows = nonEmptyContent(identity.overseasWarehouseId(), source.palletId());
List<PlannedMove> moves = rows.stream().map(row -> {
requireMovable(row, value(row.getAvailablePackages()));
return new PlannedMove(row.getPositionId(), value(row.getAvailablePackages()), value(row.getVersion()),
source.palletId(), source.palletCode(), 0L, "UNBOUND", source.locationId(),
locationCode(identity.overseasWarehouseId(), source.locationId()));
}).toList();
return execute(identity, "PALLET_UNBIND", request.idempotencyKey(), request, request.reason(),
plan(identity.overseasWarehouseId(), pallets, moves, Set.of(), true));
}
private PalletOperationResponses.Operation execute(PdaWarehouseIdentity identity, String type, String key,
Object request, String reason, Plan plan) {
String normalizedKey = required(key, "幂等键不能为空");
String requestHash = hash(request);
PdaPalletOperation operation = begin(identity, type, normalizedKey, requestHash);
if ("COMPLETED".equals(operation.getStatus())) return read(operation).asReplay();
List<PalletPhysicalGateway.Transition> transitions = new ArrayList<>();
try {
for (PalletChange change : plan.changes()) {
transitions.add(physical.apply(identity.overseasWarehouseId(), change.pallet(), change.targetStatus(),
change.targetLocationId(), transitionKey(identity.operatorKey(), normalizedKey,
change.pallet().palletId()), reason));
}
saveTransitions(operation.getId(), transitions);
PalletOperationResponses.Operation result = transaction.execute(status ->
mutate(identity, operation, plan, normalizedKey, requestHash, reason));
if (result == null) throw new IllegalStateException("托盘作业事务没有返回结果");
return result;
} catch (RuntimeException failure) {
boolean compensated = compensate(identity.overseasWarehouseId(), transitions);
fail(operation.getId(), failure, compensated);
throw failure;
}
}
private PalletOperationResponses.Operation mutate(PdaWarehouseIdentity identity, PdaPalletOperation operation,
Plan plan, String key, String requestHash, String reason) {
Set<Long> seen = new HashSet<>();
LinkedHashSet<String> customers = new LinkedHashSet<>();
for (PlannedMove move : plan.moves()) {
if (!seen.add(move.positionId())) throw conflict("同一库存行不能在一次托盘作业中重复移动");
PalletInventoryRow locked = mapper.lockPosition(String.valueOf(identity.overseasWarehouseId()),
move.positionId());
if (locked == null || !Objects.equals(locked.getPalletId(), move.sourcePalletId())) {
throw conflict("库存所在托盘已变化,请重新扫码");
}
if (value(locked.getVersion()) != move.expectedVersion()) throw conflict("库存版本已变化,请重新扫码");
requireMovable(locked, move.packages());
customers.add(locked.getCustomerId());
moveInventory(identity, operation, locked, move, key, reason);
}
for (PalletChange change : plan.changes()) {
long actual = mapper.palletPackages(String.valueOf(identity.overseasWarehouseId()), change.pallet().palletId());
boolean shouldBeEmpty = "IDLE".equals(change.targetStatus());
if (shouldBeEmpty != (actual == 0)) {
throw conflict("库存与实物托盘状态不一致,作业已回滚");
}
}
List<PalletOperationResponses.PalletContent> contents = plan.pallets().values().stream()
.sorted(Comparator.comparingLong(PalletPhysicalGateway.PalletReference::palletId))
.map(pallet -> content(identity.overseasWarehouseId(), physical.resolve(pallet.palletCode())))
.toList();
PalletOperationResponses.Operation result = new PalletOperationResponses.Operation(operation.getId(),
operation.getOperationType(), key, "COMPLETED", false, LocalDateTime.now(), contents);
operation.setStatus("COMPLETED"); operation.setResultJson(write(result)); operation.setLastError(null);
operation.setUpdatedAt(result.completedAt()); mapper.updateById(operation);
history.record(identity.overseasWarehouseId(), customers.size() == 1 ? customers.iterator().next() : null,
identity.operatorKey(), operation.getOperationType(), businessKey(plan), "COMPLETED", key, requestHash,
"托盘作业完成,移动库存行 " + plan.moves().size() + " 条");
return result;
}
private void moveInventory(PdaWarehouseIdentity identity, PdaPalletOperation operation,
PalletInventoryRow sourceRow, PlannedMove move, String key, String reason) {
InventoryPosition source = positions.selectById(sourceRow.getPositionId());
if (source == null) throw conflict("库存行不存在");
long sourceBefore = value(source.getAvailablePackages());
source.setAvailablePackages(sourceBefore - move.packages());
source.setStatus(positionPackages(source) == 0 ? "EMPTY" : "OCCUPIED");
source.setVersion(value(source.getVersion()) + 1); source.setUpdatedAt(LocalDateTime.now());
positions.updateById(source);
InventoryPosition target = mapper.lockTarget(source.getCustomerId(), source.getBalanceId(),
move.targetPalletId(), source.getBatchNo());
if (target == null) {
target = new InventoryPosition(); target.setId(IdWorker.getId());
target.setCustomerId(source.getCustomerId()); target.setBalanceId(source.getBalanceId());
target.setOverseasWarehouseId(source.getOverseasWarehouseId());
target.setDestinationWarehouseCode(source.getDestinationWarehouseCode());
target.setPalletId(move.targetPalletId()); target.setPalletNo(move.targetPalletCode());
target.setBatchNo(source.getBatchNo()); target.setWarehouseLocationId(move.targetLocationId());
target.setWarehouseLocationCode(move.targetLocationCode());
target.setAvailablePackages(0L); target.setHoldPackages(0L); target.setAllocatedPackages(0L);
target.setPickedPackages(0L); target.setReceivingPackages(0L); target.setStatus("OCCUPIED");
target.setVersion(0L); target.setCreatedAt(LocalDateTime.now()); target.setUpdatedAt(target.getCreatedAt());
try { positions.insert(target); }
catch (DuplicateKeyException duplicate) {
target = mapper.lockTarget(source.getCustomerId(), source.getBalanceId(), move.targetPalletId(), source.getBatchNo());
if (target == null) throw duplicate;
}
}
long targetBefore = value(target.getAvailablePackages());
target.setAvailablePackages(targetBefore + move.packages()); target.setStatus("OCCUPIED");
target.setWarehouseLocationId(move.targetLocationId());
target.setWarehouseLocationCode(move.targetLocationCode());
target.setVersion(value(target.getVersion()) + 1); target.setUpdatedAt(LocalDateTime.now());
positions.updateById(target);
InventoryBalance balance = balances.selectById(source.getBalanceId());
if (balance == null) throw new IllegalStateException("库存余额不存在");
ledger(identity, operation, balance, source, key + ":" + source.getId() + ":OUT",
sourceBefore, -move.packages(), sourceBefore - move.packages(), move.sourcePalletCode(), move.targetPalletCode(), reason);
ledger(identity, operation, balance, target, key + ":" + source.getId() + ":IN",
targetBefore, move.packages(), targetBefore + move.packages(), move.sourcePalletCode(), move.targetPalletCode(), reason);
}
private void ledger(PdaWarehouseIdentity identity, PdaPalletOperation operation, InventoryBalance balance,
InventoryPosition position, String idempotencyKey, long before, long delta, long after,
String sourcePallet, String targetPallet, String reason) {
InventoryLedger ledger = new InventoryLedger(); ledger.setId(IdWorker.getId());
ledger.setCustomerId(balance.getCustomerId()); ledger.setBalanceId(balance.getId());
ledger.setPositionId(position.getId()); ledger.setOverseasWarehouseId(balance.getOverseasWarehouseId());
ledger.setDestinationWarehouseCode(balance.getDestinationWarehouseCode());
ledger.setInboundOrderId(balance.getInboundOrderId()); ledger.setPalletId(position.getPalletId());
ledger.setPalletNo(position.getPalletNo()); ledger.setBatchNo(position.getBatchNo());
ledger.setSkuCode(balance.getSkuCode()); ledger.setWarehouseLocationCode(position.getWarehouseLocationCode());
ledger.setBusinessType(operation.getOperationType()); ledger.setBusinessOrderId(operation.getId());
ledger.setBusinessOrderNo(String.valueOf(operation.getId())); ledger.setInboundCode(balance.getInboundCode());
ledger.setInboundOrderNo(balance.getInboundOrderNo()); ledger.setIdempotencyKey(idempotencyKey);
ledger.setStatusFrom("PALLET:" + sourcePallet); ledger.setStatusTo("PALLET:" + targetPallet);
ledger.setBeforePackages(before); ledger.setDeltaPackages(delta); ledger.setAfterPackages(after);
ledger.setOperatorId(identity.operatorKey()); ledger.setRemark(trim(reason));
ledger.setOccurredAt(LocalDateTime.now()); ledger.setCreatedAt(ledger.getOccurredAt()); ledgers.insert(ledger);
}
private Plan plan(long warehouseId, Map<String, PalletPhysicalGateway.PalletReference> pallets,
List<PlannedMove> moves, Set<Long> targets, boolean unbind) {
Map<Long, Long> movedBySource = new HashMap<>();
moves.forEach(move -> movedBySource.merge(move.sourcePalletId(), move.packages(), Long::sum));
List<PalletChange> changes = new ArrayList<>();
for (PalletPhysicalGateway.PalletReference pallet : pallets.values()) {
long total = mapper.palletPackages(String.valueOf(warehouseId), pallet.palletId());
long moved = movedBySource.getOrDefault(pallet.palletId(), 0L);
String targetStatus = targets.contains(pallet.palletId()) ? "OCCUPIED"
: moved > 0 && moved == total ? "IDLE" : pallet.status();
if ("SCRAPPED".equals(targetStatus)) throw conflict("已报废托盘不能执行作业");
changes.add(new PalletChange(pallet, targetStatus, pallet.locationId()));
}
if (unbind && changes.stream().noneMatch(change -> "IDLE".equals(change.targetStatus()))) {
throw conflict("解绑必须清空来源托盘");
}
changes.sort(Comparator.comparingLong(change -> change.pallet().palletId()));
return new Plan(pallets, moves, changes);
}
private List<PlannedMove> plannedLines(long warehouseId, List<PalletOperationRequests.MoveLine> lines,
Map<String, PalletPhysicalGateway.PalletReference> sources,
PalletPhysicalGateway.PalletReference target,
PalletPhysicalGateway.PalletReference requiredSource) {
Map<Long, PalletInventoryRow> available = new HashMap<>();
for (PalletPhysicalGateway.PalletReference source : sources.values()) {
for (PalletInventoryRow row : mapper.content(String.valueOf(warehouseId), source.palletId())) {
available.put(row.getPositionId(), row);
}
}
List<PlannedMove> result = new ArrayList<>();
for (PalletOperationRequests.MoveLine line : lines) {
PalletInventoryRow row = available.get(line.positionId());
if (row == null || requiredSource != null && !Objects.equals(row.getPalletId(), requiredSource.palletId())) {
throw conflict("库存行不属于已扫描的来源托盘");
}
if (value(row.getVersion()) != line.expectedPositionVersion()) throw conflict("库存版本已变化,请重新扫码");
requireMovable(row, line.packages());
result.add(new PlannedMove(row.getPositionId(), line.packages(), line.expectedPositionVersion(),
row.getPalletId(), row.getPalletNo(), target.palletId(), target.palletCode(), target.locationId(),
locationCode(warehouseId, target.locationId())));
}
requireUniqueLines(result);
return result;
}
private void requireCommonDestination(long warehouseId, List<PlannedMove> moves,
List<PalletPhysicalGateway.PalletReference> targets) {
LinkedHashSet<String> destinations = new LinkedHashSet<>();
for (PlannedMove move : moves) {
PalletInventoryRow row = mapper.content(String.valueOf(warehouseId), move.sourcePalletId()).stream()
.filter(value -> Objects.equals(value.getPositionId(), move.positionId())).findFirst()
.orElseThrow(() -> conflict("库存行不存在"));
destinations.add(text(row.getDestinationWarehouseCode()));
}
for (PalletPhysicalGateway.PalletReference target : targets) {
mapper.content(String.valueOf(warehouseId), target.palletId()).stream()
.map(PalletInventoryRow::getDestinationWarehouseCode).map(PdaPalletOperationService::text)
.forEach(destinations::add);
}
if (destinations.size() > 1) throw conflict("不同目的仓货物不能组到同一托盘");
}
private Map<String, PalletPhysicalGateway.PalletReference> resolvePallets(long warehouseId,
PalletOperationRequests.PalletExpectation first, List<PalletOperationRequests.PalletExpectation> rest) {
List<PalletOperationRequests.PalletExpectation> values = new ArrayList<>(); values.add(first); values.addAll(rest);
return resolvePallets(warehouseId, values);
}
private Map<String, PalletPhysicalGateway.PalletReference> resolvePallets(long warehouseId,
java.util.Collection<PalletOperationRequests.PalletExpectation> values) {
LinkedHashMap<String, PalletPhysicalGateway.PalletReference> result = new LinkedHashMap<>();
for (PalletOperationRequests.PalletExpectation expected : values) {
String code = code(expected.palletCode());
if (result.containsKey(code)) throw badRequest("一次作业不能重复提交同一托盘");
PalletPhysicalGateway.PalletReference pallet = requirePallet(warehouseId, code);
if (pallet.version() != expected.expectedVersion()) throw conflict("托盘版本已变化,请重新扫码");
result.put(code, pallet);
}
return result;
}
private PalletPhysicalGateway.PalletReference requirePallet(long warehouseId, String code) {
PalletPhysicalGateway.PalletReference pallet = physical.resolve(code(code));
if (pallet.warehouseId() != warehouseId) throw conflict("托盘不属于 Token 冻结的海外仓");
if ("SCRAPPED".equals(pallet.status())) throw conflict("已报废托盘不能执行作业");
return pallet;
}
private List<PalletInventoryRow> nonEmptyContent(long warehouseId, long palletId) {
List<PalletInventoryRow> values = mapper.content(String.valueOf(warehouseId), palletId);
if (values.isEmpty()) throw conflict("来源托盘没有可操作库存");
return values;
}
private PalletOperationResponses.PalletContent content(long warehouseId,
PalletPhysicalGateway.PalletReference pallet) {
List<PalletInventoryRow> rows = mapper.content(String.valueOf(warehouseId), pallet.palletId());
LinkedHashSet<String> destinations = new LinkedHashSet<>();
List<PalletOperationResponses.InventoryLine> inventory = rows.stream().map(row -> {
destinations.add(text(row.getDestinationWarehouseCode()));
return new PalletOperationResponses.InventoryLine(row.getCustomerId(), row.getCustomerName(),
value(row.getBalanceId()), value(row.getPositionId()), row.getInboundCode(), row.getSkuCode(),
first(row.getProductNameZh(), row.getProductNameEn()), value(row.getAvailablePackages()),
value(row.getHoldPackages()), value(row.getAllocatedPackages()), value(row.getPickedPackages()),
value(row.getReceivingPackages()), value(row.getVersion()));
}).toList();
long total = rows.stream().mapToLong(PdaPalletOperationService::rowPackages).sum();
String locationCode = locationCode(warehouseId, pallet.locationId());
return new PalletOperationResponses.PalletContent(pallet.palletId(), pallet.palletCode(), pallet.status(),
pallet.version(), pallet.locationId(), locationCode, destinations.size() == 1 ? destinations.iterator().next() : null,
total, inventory);
}
private PdaPalletOperation begin(PdaWarehouseIdentity identity, String type, String key, String requestHash) {
PdaPalletOperation existing = mapper.findOperation(identity.operatorKey(), key);
if (existing != null) return validateExisting(existing, type, requestHash);
try {
return newTransaction.execute(status -> {
PdaPalletOperation operation = new PdaPalletOperation(); operation.setId(IdWorker.getId());
operation.setOverseasWarehouseId(identity.overseasWarehouseId());
operation.setOperatorKey(identity.operatorKey()); operation.setIdempotencyKey(key);
operation.setOperationType(type); operation.setRequestHash(requestHash); operation.setStatus("PROCESSING");
operation.setCreatedAt(LocalDateTime.now()); operation.setUpdatedAt(operation.getCreatedAt());
mapper.insert(operation); return operation;
});
} catch (DuplicateKeyException duplicate) {
PdaPalletOperation winner = mapper.findOperation(identity.operatorKey(), key);
if (winner != null) return validateExisting(winner, type, requestHash);
throw duplicate;
}
}
private PdaPalletOperation validateExisting(PdaPalletOperation value, String type, String requestHash) {
if (!type.equals(value.getOperationType()) || !requestHash.equals(value.getRequestHash())) {
throw conflict("幂等键已被不同托盘作业使用");
}
if (!"COMPLETED".equals(value.getStatus())) {
throw conflict("同一托盘作业正在处理或需要人工恢复");
}
return value;
}
private void saveTransitions(long operationId, List<PalletPhysicalGateway.Transition> transitions) {
newTransaction.executeWithoutResult(status -> {
PdaPalletOperation operation = mapper.selectById(operationId);
operation.setRemoteTransitionsJson(write(transitions)); operation.setUpdatedAt(LocalDateTime.now());
mapper.updateById(operation);
});
}
private void fail(long operationId, RuntimeException failure, boolean compensated) {
newTransaction.executeWithoutResult(status -> {
PdaPalletOperation operation = mapper.selectById(operationId);
if (operation == null || "COMPLETED".equals(operation.getStatus())) return;
operation.setStatus(compensated ? "FAILED_COMPENSATED" : "COMPENSATION_REQUIRED");
operation.setLastError(limit(failure.getMessage(), 2000)); operation.setUpdatedAt(LocalDateTime.now());
mapper.updateById(operation);
});
}
private boolean compensate(long warehouseId, List<PalletPhysicalGateway.Transition> transitions) {
boolean success = true;
for (int i = transitions.size() - 1; i >= 0; i--) {
try { physical.compensate(warehouseId, transitions.get(i)); }
catch (RuntimeException ignored) { success = false; }
}
return success;
}
private PalletOperationResponses.Operation read(PdaPalletOperation operation) {
try { return objectMapper.readValue(operation.getResultJson(), PalletOperationResponses.Operation.class); }
catch (JsonProcessingException exception) { throw new IllegalStateException("无法读取托盘作业幂等结果", exception); }
}
private String write(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException exception) { throw new IllegalStateException("无法序列化托盘作业", exception); }
}
private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(write(value).getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); }
}
private static String transitionKey(String operator, String key, long palletId) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest((operator + "|" + key + "|" + palletId).getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); }
}
private String locationCode(long warehouseId, long locationId) {
PalletPhysicalGateway.LocationReference location = physical.resolveLocation(warehouseId, locationId);
if (location == null || location.locationId() != locationId || "INACTIVE".equals(location.status())) {
throw conflict("托盘库位不存在或已停用");
}
return location.locationCode();
}
private static void requireMovable(PalletInventoryRow row, long packages) {
if (packages <= 0 || value(row.getAvailablePackages()) < packages) throw conflict("可用库存箱数不足");
if (value(row.getHoldPackages()) > 0 || value(row.getAllocatedPackages()) > 0
|| value(row.getPickedPackages()) > 0 || value(row.getReceivingPackages()) > 0) {
throw conflict("冻结、已分配、已拣或收货中的库存不能执行托盘作业");
}
}
private static void requireUniqueLines(List<PlannedMove> moves) {
Set<Long> ids = new HashSet<>();
if (moves.stream().anyMatch(move -> !ids.add(move.positionId()))) throw badRequest("库存行不能重复提交");
}
private static long rowPackages(PalletInventoryRow row) {
return value(row.getAvailablePackages()) + value(row.getHoldPackages()) + value(row.getAllocatedPackages())
+ value(row.getPickedPackages()) + value(row.getReceivingPackages());
}
private static long positionPackages(InventoryPosition value) {
return value(value.getAvailablePackages()) + value(value.getHoldPackages()) + value(value.getAllocatedPackages())
+ value(value.getPickedPackages()) + value(value.getReceivingPackages());
}
private static String businessKey(Plan plan) {
return plan.pallets().values().stream().map(PalletPhysicalGateway.PalletReference::palletCode)
.sorted().collect(java.util.stream.Collectors.joining("->"));
}
private static String required(String value, String message) { String result = trim(value); if (result == null) throw badRequest(message); return result; }
private static String code(String value) { return required(value, "托盘码不能为空").toUpperCase(); }
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static String text(String value) { return value == null ? "" : value.trim(); }
private static String first(String first, String second) { String value = trim(first); return value == null ? trim(second) : value; }
private static String limit(String value, int size) { if (value == null) return null; return value.length() <= size ? value : value.substring(0, size); }
private static long value(Long value) { return value == null ? 0L : value; }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private record PlannedMove(long positionId, long packages, long expectedVersion,
long sourcePalletId, String sourcePalletCode,
long targetPalletId, String targetPalletCode, long targetLocationId,
String targetLocationCode) { }
private record PalletChange(PalletPhysicalGateway.PalletReference pallet, String targetStatus,
long targetLocationId) { }
private record Plan(Map<String, PalletPhysicalGateway.PalletReference> pallets,
List<PlannedMove> moves, List<PalletChange> changes) { }
}