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) { }
}
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.pda.move.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest;
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveResponse;
import com.baiyue.lastleg.pda.move.service.PdaInventoryMoveService;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/pda/v1/last-leg/inventory-moves")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')")
public class PdaInventoryMoveController {
@Resource private PdaInventoryMoveService service;
@PostMapping
public ApiResponse<PdaInventoryMoveResponse> move(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PdaInventoryMoveRequest request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.move(identity.overseasWarehouseId(), identity.operatorKey(), request));
}
}
@@ -0,0 +1,13 @@
package com.baiyue.lastleg.pda.move.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
public record PdaInventoryMoveRequest(
@NotBlank @Size(max = 128) String sourcePalletCode,
@NotBlank @Size(max = 128) String targetLocationCode,
@PositiveOrZero int expectedPalletVersion,
@NotBlank @Size(max = 128) String idempotencyKey,
@NotBlank @Size(max = 500) String reason
) { }
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.pda.move.dto;
import java.time.LocalDateTime;
public record PdaInventoryMoveResponse(
String moveNo,
long palletId,
String palletCode,
long sourceLocationId,
String sourceLocationCode,
long targetLocationId,
String targetLocationCode,
int customerCount,
int positionCount,
long packageCount,
int palletVersion,
String status,
LocalDateTime completedAt,
boolean replayed
) {
public PdaInventoryMoveResponse asReplay() {
return replayed ? this : new PdaInventoryMoveResponse(moveNo, palletId, palletCode, sourceLocationId,
sourceLocationCode, targetLocationId, targetLocationCode, customerCount, positionCount,
packageCount, palletVersion, status, completedAt, true);
}
}
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.pda.move.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_pda_inventory_move")
public class PdaInventoryMove {
@TableId(type = IdType.INPUT) private Long id;
private String moveNo;
private Long overseasWarehouseId;
private Long palletId;
private String palletCode;
private Long sourceLocationId;
private String sourceLocationCode;
private Long targetLocationId;
private String targetLocationCode;
private Integer customerCount;
private Integer positionCount;
private Long packageCount;
private Integer palletVersionBefore;
private Integer palletVersionAfter;
private String operatorKey;
private String reason;
private String idempotencyKey;
private String requestHash;
private String status;
private String resultJson;
private LocalDateTime createdAt;
private LocalDateTime completedAt;
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.pda.move.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.pda.move.entity.PdaInventoryMove;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface PdaInventoryMoveMapper extends BaseMapper<PdaInventoryMove> {
@Select("SELECT * FROM ll_pda_inventory_move WHERE overseas_warehouse_id=#{warehouseId} " +
"AND operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1")
PdaInventoryMove find(@Param("warehouseId") long warehouseId,
@Param("operatorKey") String operatorKey, @Param("key") String key);
@Select("SELECT * FROM ll_inventory_position WHERE overseas_warehouse_id=#{warehouseId} " +
"AND pallet_id=#{palletId} AND " +
"(available_packages+hold_packages+allocated_packages+picked_packages+receiving_packages)>0 " +
"ORDER BY customer_id,id FOR UPDATE")
List<InventoryPosition> lockPositions(@Param("warehouseId") String warehouseId,
@Param("palletId") long palletId);
}
@@ -0,0 +1,192 @@
package com.baiyue.lastleg.pda.move.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest;
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveResponse;
import com.baiyue.lastleg.pda.move.entity.PdaInventoryMove;
import com.baiyue.lastleg.pda.move.mapper.PdaInventoryMoveMapper;
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.List;
import java.util.Set;
@Service
public class PdaInventoryMoveService {
@Resource private PdaInventoryMoveMapper moves;
@Resource private InventoryPositionMapper positions;
@Resource private InventoryBalanceMapper balances;
@Resource private InventoryLedgerMapper ledgers;
@Resource private WarehouseResourceGateway warehouse;
@Resource private PalletPhysicalGateway physical;
@Resource private PdaWarehouseService history;
@Resource private ObjectMapper objectMapper;
@Transactional
public PdaInventoryMoveResponse move(long warehouseId, String operatorKey, PdaInventoryMoveRequest request) {
String key = required(request.idempotencyKey(), "Idempotency key is required");
String requestHash = hash(request);
PdaInventoryMove existing = moves.find(warehouseId, operatorKey, key);
if (existing != null) return replay(existing, requestHash);
var pallet = warehouse.resolvePallet(required(request.sourcePalletCode(), "Source pallet code is required"));
if (pallet.warehouseId() != warehouseId) throw conflict("Source pallet belongs to another overseas warehouse");
var target = warehouse.resolveLocation(warehouseId,
required(request.targetLocationCode(), "Target location code is required"));
if (!Set.of("ACTIVE", "IDLE", "OCCUPIED").contains(target.status())) {
throw conflict("Target location is not available");
}
boolean recoveringRemoteSuccess = pallet.locationId() == target.locationId()
&& pallet.version() == request.expectedPalletVersion() + 1;
if (!recoveringRemoteSuccess && pallet.version() != request.expectedPalletVersion()) {
throw conflict("Source pallet version changed; rescan it");
}
List<InventoryPosition> inventory = moves.lockPositions(String.valueOf(warehouseId), pallet.palletId());
if (inventory.isEmpty()) throw conflict("Source pallet has no movable inventory");
Set<Long> inventoryLocations = new HashSet<>();
inventory.forEach(position -> inventoryLocations.add(position.getWarehouseLocationId()));
if (inventoryLocations.contains(null) || inventoryLocations.size() != 1) {
throw conflict("Inventory and warehouse pallet locations are inconsistent; reconcile before moving");
}
long sourceLocationId = inventoryLocations.iterator().next();
if (!recoveringRemoteSuccess && sourceLocationId != pallet.locationId()) {
throw conflict("Inventory and warehouse pallet locations are inconsistent; reconcile before moving");
}
if (sourceLocationId == target.locationId()) throw badRequest("Source and target locations must differ");
var source = warehouse.resolveLocation(warehouseId, sourceLocationId);
long packages = inventory.stream().mapToLong(PdaInventoryMoveService::physical).sum();
Set<String> customers = new HashSet<>();
inventory.forEach(position -> customers.add(position.getCustomerId()));
long moveId = IdWorker.getId();
PdaInventoryMove operation = new PdaInventoryMove();
operation.setId(moveId); operation.setMoveNo("MV-" + Long.toUnsignedString(moveId));
operation.setOverseasWarehouseId(warehouseId); operation.setPalletId(pallet.palletId());
operation.setPalletCode(pallet.palletNo()); operation.setSourceLocationId(source.locationId());
operation.setSourceLocationCode(source.locationCode()); operation.setTargetLocationId(target.locationId());
operation.setTargetLocationCode(target.locationCode()); operation.setCustomerCount(customers.size());
operation.setPositionCount(inventory.size()); operation.setPackageCount(packages);
operation.setPalletVersionBefore(pallet.version()); operation.setOperatorKey(operatorKey);
operation.setReason(request.reason().trim()); operation.setIdempotencyKey(key); operation.setRequestHash(requestHash);
operation.setStatus("PROCESSING"); operation.setCreatedAt(LocalDateTime.now());
try { moves.insert(operation); }
catch (DuplicateKeyException duplicate) {
PdaInventoryMove winner = moves.find(warehouseId, operatorKey, key);
if (winner == null) throw duplicate;
return replay(winner, requestHash);
}
String transitionKey = transitionKey(operatorKey, key, pallet.palletId());
var transitionSource = new PalletPhysicalGateway.PalletReference(pallet.palletId(), pallet.palletNo(),
warehouseId, source.locationId(), "OCCUPIED", pallet.fbaWarehouseCode(), request.expectedPalletVersion());
var relocated = physical.apply(warehouseId, transitionSource, "OCCUPIED", target.locationId(),
transitionKey, request.reason());
if (relocated.afterLocationId() != target.locationId()
|| !"APPLIED".equals(relocated.operationStatus())) {
throw new IllegalStateException("Warehouse relocation returned another target location");
}
LocalDateTime completedAt = LocalDateTime.now();
for (InventoryPosition position : inventory) {
InventoryBalance balance = balances.selectById(position.getBalanceId());
if (balance == null || !position.getCustomerId().equals(balance.getCustomerId())
|| !String.valueOf(warehouseId).equals(balance.getOverseasWarehouseId())) {
throw new IllegalStateException("Inventory balance scope is inconsistent during relocation");
}
position.setWarehouseLocationId(target.locationId()); position.setWarehouseLocationCode(target.locationCode());
position.setVersion(value(position.getVersion()) + 1); position.setUpdatedAt(completedAt);
positions.updateById(position);
ledger(operation, position, balance, operatorKey, key, request.reason(), source.locationCode(),
target.locationCode(), completedAt);
}
operation.setPalletVersionAfter(relocated.afterVersion()); operation.setStatus("COMPLETED");
operation.setCompletedAt(completedAt);
PdaInventoryMoveResponse response = response(operation, false);
operation.setResultJson(write(response)); moves.updateById(operation);
history.record(warehouseId, null, operatorKey, "INVENTORY_MOVE", operation.getMoveNo(), "COMPLETED",
"INVENTORY_MOVE:" + key, requestHash,
pallet.palletNo() + ":" + source.locationCode() + " → " + target.locationCode()
+ "," + packages + " 箱");
return response;
}
private PdaInventoryMoveResponse replay(PdaInventoryMove operation, String requestHash) {
if (!requestHash.equals(operation.getRequestHash())) {
throw conflict("Idempotency key was reused with a different inventory move");
}
if (!"COMPLETED".equals(operation.getStatus()) || operation.getResultJson() == null) {
throw conflict("Inventory move is still processing");
}
try { return objectMapper.readValue(operation.getResultJson(), PdaInventoryMoveResponse.class).asReplay(); }
catch (JsonProcessingException error) { throw new IllegalStateException("Cannot read inventory move replay", error); }
}
private PdaInventoryMoveResponse response(PdaInventoryMove value, boolean replayed) {
return new PdaInventoryMoveResponse(value.getMoveNo(), value.getPalletId(), value.getPalletCode(),
value.getSourceLocationId(), value.getSourceLocationCode(), value.getTargetLocationId(),
value.getTargetLocationCode(), value.getCustomerCount(), value.getPositionCount(),
value.getPackageCount(), value.getPalletVersionAfter(), value.getStatus(), value.getCompletedAt(), replayed);
}
private void ledger(PdaInventoryMove operation, InventoryPosition position, InventoryBalance balance,
String operator, String key, String reason, String source, String target,
LocalDateTime now) {
InventoryLedger row = new InventoryLedger(); row.setId(IdWorker.getId()); row.setCustomerId(position.getCustomerId());
row.setBalanceId(balance.getId()); row.setPositionId(position.getId());
row.setOverseasWarehouseId(String.valueOf(operation.getOverseasWarehouseId()));
row.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); row.setInboundOrderId(balance.getInboundOrderId());
row.setPalletId(position.getPalletId()); row.setPalletNo(position.getPalletNo()); row.setBatchNo(position.getBatchNo());
row.setSkuCode(balance.getSkuCode()); row.setWarehouseLocationCode(target); row.setBusinessType("INTERNAL_MOVE");
row.setBusinessOrderId(operation.getId()); row.setBusinessOrderNo(operation.getMoveNo());
row.setInboundCode(balance.getInboundCode()); row.setInboundOrderNo(balance.getInboundOrderNo());
row.setIdempotencyKey("MOVE:" + operation.getId() + ":" + position.getId());
row.setStatusFrom(source); row.setStatusTo(target); long packages = physical(position);
row.setBeforePackages(packages); row.setDeltaPackages(0L); row.setAfterPackages(packages);
row.setOperatorId(operator); row.setRemark(reason.trim()); row.setOccurredAt(now); row.setCreatedAt(now);
ledgers.insert(row);
}
private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(write(value).getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); }
}
private static String transitionKey(String operator, String key, long palletId) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(("INVENTORY_MOVE|" + operator + "|" + key + "|" + palletId)
.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); }
}
private String write(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException error) { throw new IllegalStateException("Cannot serialize inventory move", error); }
}
private static long physical(InventoryPosition value) { return value(value.getAvailablePackages()) + value(value.getHoldPackages())
+ value(value.getAllocatedPackages()) + value(value.getPickedPackages()) + value(value.getReceivingPackages()); }
private static long value(Long value) { return value == null ? 0 : value; }
private static String required(String value, String message) { if (value == null || value.isBlank()) throw badRequest(message); return value.trim(); }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -0,0 +1,61 @@
package com.baiyue.lastleg.pda.stocktake.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses;
import com.baiyue.lastleg.pda.stocktake.service.PdaStocktakeService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/pda/v1/last-leg/stocktakes")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')")
public class PdaStocktakeController {
@Resource private PdaStocktakeService service;
@GetMapping
public ApiResponse<PageResult<PdaStocktakeResponses.Summary>> page(
@AuthenticationPrincipal AuthenticatedUser user, @Valid PdaStocktakeRequests.TaskQuery query) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.page(identity.overseasWarehouseId(), query));
}
@PostMapping
public ApiResponse<PdaStocktakeResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PdaStocktakeRequests.Create request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.create(identity.overseasWarehouseId(), identity.operatorKey(), request));
}
@GetMapping("/{taskNo}")
public ApiResponse<PdaStocktakeResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String taskNo) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.detail(identity.overseasWarehouseId(), taskNo));
}
@PostMapping("/{taskNo}/counts")
public ApiResponse<PdaStocktakeResponses.Item> count(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String taskNo, @Valid @RequestBody PdaStocktakeRequests.Count request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.count(identity.overseasWarehouseId(), identity.operatorKey(), taskNo, request));
}
@PostMapping("/{taskNo}/submit")
public ApiResponse<PdaStocktakeResponses.Detail> submit(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String taskNo, @Valid @RequestBody PdaStocktakeRequests.Submit request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.submit(identity.overseasWarehouseId(), identity.operatorKey(), taskNo, request));
}
}
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.pda.stocktake.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
public final class PdaStocktakeRequests {
private PdaStocktakeRequests() { }
public record TaskQuery(@Min(1) long page, @Min(1) @Max(100) long size, String status) {
public TaskQuery { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record Create(@NotBlank @Size(max = 16) String scopeType,
@NotBlank @Size(max = 128) String scopeCode,
@NotBlank @Size(max = 128) String idempotencyKey) { }
public record Count(@NotNull Long positionId, @PositiveOrZero long actualPackages,
@PositiveOrZero long expectedVersion,
@NotBlank @Size(max = 128) String idempotencyKey) { }
public record Submit(@PositiveOrZero long expectedVersion,
@NotBlank @Size(max = 500) String reason,
@NotBlank @Size(max = 128) String idempotencyKey) { }
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.pda.stocktake.dto;
import java.time.LocalDateTime;
import java.util.List;
public final class PdaStocktakeResponses {
private PdaStocktakeResponses() { }
public record Summary(String taskNo, String scopeType, String scopeCode, String status,
int positionCount, int countedCount, int differenceCount, long version,
LocalDateTime createdAt, LocalDateTime submittedAt) { }
public record Item(long itemId, String customerId, long balanceId, long positionId,
String skuCode, String productName, String inboundCode,
long palletId, String palletCode, long locationId, String locationCode,
long bookPackages, Long actualPackages, Long differencePackages,
String status, long positionVersion, long version) { }
public record Detail(Summary task, List<Item> items) { }
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.pda.stocktake.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_pda_stocktake_action")
public class PdaStocktakeAction {
@TableId(type = IdType.INPUT) private Long id;
private Long overseasWarehouseId;
private String operatorKey;
private String idempotencyKey;
private String actionType;
private Long taskId;
private String requestHash;
private String status;
private String resultJson;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.pda.stocktake.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_pda_stocktake_item")
public class PdaStocktakeItem {
@TableId(type = IdType.INPUT) private Long id;
private Long taskId;
private String customerId;
private Long balanceId;
private Long positionId;
private String skuCode;
private String productName;
private String inboundCode;
private Long palletId;
private String palletCode;
private Long locationId;
private String locationCode;
private Long bookPackages;
private Long actualPackages;
private Long differencePackages;
private String status;
private Long positionVersion;
private String countedBy;
private Long version;
private LocalDateTime countedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.pda.stocktake.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_pda_stocktake_task")
public class PdaStocktakeTask {
@TableId(type = IdType.INPUT) private Long id;
private String taskNo;
private Long overseasWarehouseId;
private String scopeType;
private Long scopeResourceId;
private String scopeCode;
private String status;
private Integer positionCount;
private Integer countedCount;
private Integer differenceCount;
private String createdBy;
private String submittedBy;
private String submitReason;
private String idempotencyKey;
private String requestHash;
private Long version;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private LocalDateTime submittedAt;
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.pda.stocktake.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeAction;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface PdaStocktakeActionMapper extends BaseMapper<PdaStocktakeAction> {
@Select("SELECT * FROM ll_pda_stocktake_action WHERE overseas_warehouse_id=#{warehouseId} " +
"AND operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1")
PdaStocktakeAction find(@Param("warehouseId") long warehouseId,
@Param("operatorKey") String operatorKey, @Param("key") String key);
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.pda.stocktake.mapper;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface PdaStocktakeInventoryMapper {
@Select("SELECT * FROM ll_inventory_position WHERE id=#{positionId} AND customer_id=#{customerId} " +
"AND overseas_warehouse_id=#{warehouseId} LIMIT 1 FOR UPDATE")
InventoryPosition lockPosition(@Param("positionId") long positionId, @Param("customerId") String customerId,
@Param("warehouseId") String warehouseId);
@Select("SELECT * FROM ll_inventory_balance WHERE id=#{balanceId} AND customer_id=#{customerId} " +
"AND overseas_warehouse_id=#{warehouseId} LIMIT 1 FOR UPDATE")
InventoryBalance lockBalance(@Param("balanceId") long balanceId, @Param("customerId") String customerId,
@Param("warehouseId") String warehouseId);
}
@@ -0,0 +1,66 @@
package com.baiyue.lastleg.pda.stocktake.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses;
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeItem;
import com.baiyue.lastleg.pda.stocktake.model.StocktakeCandidate;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
public interface PdaStocktakeItemMapper extends BaseMapper<PdaStocktakeItem> {
String CANDIDATE = "SELECT p.customer_id,b.id balance_id,p.id position_id,b.sku_code," +
"COALESCE(NULLIF(b.product_name_zh,''),b.product_name_en) product_name,b.inbound_code," +
"p.pallet_id,p.pallet_no pallet_code,p.warehouse_location_id location_id," +
"p.warehouse_location_code location_code," +
"(p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages) book_packages," +
"p.version position_version FROM ll_inventory_position p JOIN ll_inventory_balance b " +
"ON b.id=p.balance_id AND b.customer_id=p.customer_id WHERE p.overseas_warehouse_id=#{warehouseId} ";
@Select(CANDIDATE + "AND p.pallet_id=#{palletId} AND " +
"(p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " +
"ORDER BY p.customer_id,p.id")
List<StocktakeCandidate> candidatesByPallet(@Param("warehouseId") String warehouseId,
@Param("palletId") long palletId);
@Select(CANDIDATE + "AND (p.warehouse_location_id=#{locationId} OR p.warehouse_location_code=#{locationCode}) " +
"AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " +
"ORDER BY p.customer_id,p.pallet_no,p.id")
List<StocktakeCandidate> candidatesByLocation(@Param("warehouseId") String warehouseId,
@Param("locationId") long locationId,
@Param("locationCode") String locationCode);
@Select("SELECT * FROM ll_pda_stocktake_item WHERE task_id=#{taskId} ORDER BY location_code,pallet_code,position_id")
List<PdaStocktakeItem> list(@Param("taskId") long taskId);
@Select("SELECT * FROM ll_pda_stocktake_item WHERE task_id=#{taskId} ORDER BY id FOR UPDATE")
List<PdaStocktakeItem> lockAll(@Param("taskId") long taskId);
@Select("SELECT * FROM ll_pda_stocktake_item WHERE task_id=#{taskId} AND position_id=#{positionId} LIMIT 1 FOR UPDATE")
PdaStocktakeItem lockItem(@Param("taskId") long taskId, @Param("positionId") long positionId);
@Update("UPDATE ll_pda_stocktake_item SET actual_packages=#{actualPackages}," +
"difference_packages=#{differencePackages},status=#{status},counted_by=#{operatorKey}," +
"counted_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),version=version+1 " +
"WHERE id=#{itemId} AND version=#{expectedVersion} AND status IN ('PENDING','COUNTED')")
int countItem(@Param("itemId") long itemId, @Param("expectedVersion") long expectedVersion,
@Param("actualPackages") long actualPackages, @Param("differencePackages") long differencePackages,
@Param("status") String status, @Param("operatorKey") String operatorKey);
@Select("SELECT COUNT(*) FROM ll_pda_stocktake_item WHERE task_id=#{taskId} AND actual_packages IS NOT NULL")
int counted(@Param("taskId") long taskId);
@Select("SELECT COUNT(*) FROM ll_pda_stocktake_item WHERE task_id=#{taskId} " +
"AND actual_packages IS NOT NULL AND difference_packages&lt;&gt;0")
int differences(@Param("taskId") long taskId);
@Select("SELECT id item_id,customer_id,balance_id,position_id,sku_code,product_name,inbound_code," +
"pallet_id,pallet_code,location_id,location_code,book_packages,actual_packages,difference_packages," +
"status,position_version,version FROM ll_pda_stocktake_item WHERE task_id=#{taskId} " +
"ORDER BY location_code,pallet_code,position_id")
List<PdaStocktakeResponses.Item> views(@Param("taskId") long taskId);
}
@@ -0,0 +1,51 @@
package com.baiyue.lastleg.pda.stocktake.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses;
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeTask;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
public interface PdaStocktakeTaskMapper extends BaseMapper<PdaStocktakeTask> {
@Select("SELECT * FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " +
"AND created_by=#{operatorKey} AND idempotency_key=#{key} LIMIT 1")
PdaStocktakeTask findCreate(@Param("warehouseId") long warehouseId,
@Param("operatorKey") String operatorKey, @Param("key") String key);
@Select("SELECT * FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " +
"AND task_no=#{taskNo} LIMIT 1")
PdaStocktakeTask find(@Param("warehouseId") long warehouseId, @Param("taskNo") String taskNo);
@Select("SELECT * FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " +
"AND task_no=#{taskNo} LIMIT 1 FOR UPDATE")
PdaStocktakeTask lock(@Param("warehouseId") long warehouseId, @Param("taskNo") String taskNo);
@Select("<script>SELECT task_no,scope_type,scope_code,status,position_count,counted_count,difference_count," +
"version,created_at,submitted_at FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " +
"<if test='status != null and status != \"\"'>AND status=#{status} </if>" +
"ORDER BY created_at DESC,id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<PdaStocktakeResponses.Summary> page(@Param("warehouseId") long warehouseId,
@Param("status") String status,
@Param("offset") long offset, @Param("limit") long limit);
@Select("<script>SELECT COUNT(*) FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " +
"<if test='status != null and status != \"\"'>AND status=#{status}</if></script>")
long count(@Param("warehouseId") long warehouseId, @Param("status") String status);
@Update("UPDATE ll_pda_stocktake_task SET counted_count=#{countedCount},difference_count=#{differenceCount}," +
"version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{taskId} AND version=#{expectedVersion} " +
"AND status='IN_PROGRESS'")
int refreshCounts(@Param("taskId") long taskId, @Param("expectedVersion") long expectedVersion,
@Param("countedCount") int countedCount, @Param("differenceCount") int differenceCount);
@Update("UPDATE ll_pda_stocktake_task SET status='SUBMITTED',submitted_by=#{operatorKey}," +
"submit_reason=#{reason},submitted_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3)," +
"version=version+1 WHERE id=#{taskId} AND version=#{expectedVersion} AND status='IN_PROGRESS'")
int submit(@Param("taskId") long taskId, @Param("expectedVersion") long expectedVersion,
@Param("operatorKey") String operatorKey, @Param("reason") String reason);
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.pda.stocktake.model;
import lombok.Data;
@Data
public class StocktakeCandidate {
private String customerId;
private Long balanceId;
private Long positionId;
private String skuCode;
private String productName;
private String inboundCode;
private Long palletId;
private String palletCode;
private Long locationId;
private String locationCode;
private Long bookPackages;
private Long positionVersion;
}
@@ -0,0 +1,323 @@
package com.baiyue.lastleg.pda.stocktake.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests;
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses;
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeAction;
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeItem;
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeTask;
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeActionMapper;
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeInventoryMapper;
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeItemMapper;
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeTaskMapper;
import com.baiyue.lastleg.pda.stocktake.model.StocktakeCandidate;
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
@Service
public class PdaStocktakeService {
@Resource private PdaStocktakeTaskMapper tasks;
@Resource private PdaStocktakeItemMapper items;
@Resource private PdaStocktakeActionMapper actions;
@Resource private PdaStocktakeInventoryMapper inventory;
@Resource private InventoryPositionMapper positions;
@Resource private InventoryBalanceMapper balances;
@Resource private InventoryLedgerMapper ledgers;
@Resource private WarehouseResourceGateway warehouse;
@Resource private PdaWarehouseService history;
@Resource private ObjectMapper objectMapper;
public PageResult<PdaStocktakeResponses.Summary> page(long warehouseId, PdaStocktakeRequests.TaskQuery query) {
String status = status(query.status());
long offset = (query.page() - 1) * query.size();
List<PdaStocktakeResponses.Summary> rows = tasks.page(warehouseId, status, offset, query.size());
long total = tasks.count(warehouseId, status);
return new PageResult<>(rows, query.page(), query.size(), total,
total == 0 ? 0 : (total + query.size() - 1) / query.size());
}
public PdaStocktakeResponses.Detail detail(long warehouseId, String taskNo) {
PdaStocktakeTask task = requireTask(warehouseId, taskNo, false);
return detail(task);
}
@Transactional
public PdaStocktakeResponses.Detail create(long warehouseId, String operatorKey,
PdaStocktakeRequests.Create request) {
String key = required(request.idempotencyKey(), "Idempotency key is required");
String requestHash = hash(request);
PdaStocktakeTask replay = tasks.findCreate(warehouseId, operatorKey, key);
if (replay != null) {
if (!requestHash.equals(replay.getRequestHash())) throw conflict("Idempotency key was reused with another stocktake");
return detail(replay);
}
String scopeType = required(request.scopeType(), "Scope type is required").toUpperCase();
String scopeCode = required(request.scopeCode(), "Scope code is required");
long resourceId;
List<StocktakeCandidate> candidates;
if ("PALLET".equals(scopeType)) {
var pallet = warehouse.resolvePallet(scopeCode);
if (pallet.warehouseId() != warehouseId) throw conflict("Pallet belongs to another overseas warehouse");
resourceId = pallet.palletId();
scopeCode = pallet.palletNo();
candidates = items.candidatesByPallet(String.valueOf(warehouseId), resourceId);
} else if ("LOCATION".equals(scopeType)) {
var location = warehouse.resolveLocation(warehouseId, scopeCode);
if (!List.of("ACTIVE", "IDLE", "OCCUPIED").contains(location.status())) {
throw conflict("Stocktake location is not active");
}
resourceId = location.locationId();
scopeCode = location.locationCode();
candidates = items.candidatesByLocation(String.valueOf(warehouseId), resourceId, scopeCode);
} else {
throw badRequest("scopeType must be LOCATION or PALLET");
}
if (candidates.isEmpty()) throw conflict("No inventory is available in the scanned scope");
long taskId = IdWorker.getId();
LocalDateTime now = LocalDateTime.now();
PdaStocktakeTask task = new PdaStocktakeTask();
task.setId(taskId); task.setTaskNo("CC-" + Long.toUnsignedString(taskId));
task.setOverseasWarehouseId(warehouseId); task.setScopeType(scopeType); task.setScopeResourceId(resourceId);
task.setScopeCode(scopeCode); task.setStatus("IN_PROGRESS"); task.setPositionCount(candidates.size());
task.setCountedCount(0); task.setDifferenceCount(0); task.setCreatedBy(operatorKey);
task.setIdempotencyKey(key); task.setRequestHash(requestHash); task.setVersion(0L);
task.setCreatedAt(now); task.setUpdatedAt(now);
try {
tasks.insert(task);
} catch (DuplicateKeyException duplicate) {
PdaStocktakeTask winner = tasks.findCreate(warehouseId, operatorKey, key);
if (winner == null || !requestHash.equals(winner.getRequestHash())) throw duplicate;
return detail(winner);
}
for (StocktakeCandidate candidate : candidates) {
PdaStocktakeItem item = new PdaStocktakeItem();
item.setId(IdWorker.getId()); item.setTaskId(taskId); item.setCustomerId(candidate.getCustomerId());
item.setBalanceId(candidate.getBalanceId()); item.setPositionId(candidate.getPositionId());
item.setSkuCode(candidate.getSkuCode()); item.setProductName(candidate.getProductName());
item.setInboundCode(candidate.getInboundCode()); item.setPalletId(candidate.getPalletId());
item.setPalletCode(candidate.getPalletCode()); item.setLocationId(candidate.getLocationId());
item.setLocationCode(candidate.getLocationCode()); item.setBookPackages(value(candidate.getBookPackages()));
item.setStatus("PENDING"); item.setPositionVersion(value(candidate.getPositionVersion()));
item.setVersion(0L); item.setCreatedAt(now); item.setUpdatedAt(now); items.insert(item);
}
history.record(warehouseId, null, operatorKey, "STOCKTAKE_CREATE", task.getTaskNo(), "IN_PROGRESS",
"STOCKTAKE_CREATE:" + key, requestHash,
scopeType + " " + scopeCode + ",共 " + candidates.size() + " 个库存位置");
return detail(task);
}
@Transactional
public PdaStocktakeResponses.Item count(long warehouseId, String operatorKey, String taskNo,
PdaStocktakeRequests.Count request) {
String key = required(request.idempotencyKey(), "Idempotency key is required");
String requestHash = hash(request);
PdaStocktakeAction action = beginAction(warehouseId, operatorKey, key, "COUNT", requestHash, taskNo);
if ("COMPLETED".equals(action.getStatus())) return read(action, PdaStocktakeResponses.Item.class);
PdaStocktakeTask task = requireTask(warehouseId, taskNo, true);
if (!"IN_PROGRESS".equals(task.getStatus())) throw conflict("Submitted stocktake cannot be changed");
PdaStocktakeItem item = items.lockItem(task.getId(), request.positionId());
if (item == null) throw notFound("Stocktake position was not found in this task");
long difference = request.actualPackages() - value(item.getBookPackages());
if (items.countItem(item.getId(), request.expectedVersion(), request.actualPackages(), difference,
difference == 0 ? "MATCHED" : "DIFFERENCE", operatorKey) != 1) {
throw conflict("Stocktake item version changed; refresh and retry");
}
int counted = items.counted(task.getId());
int differences = items.differences(task.getId());
if (tasks.refreshCounts(task.getId(), value(task.getVersion()), counted, differences) != 1) {
throw conflict("Stocktake task version changed; refresh and retry");
}
PdaStocktakeResponses.Item response = items.views(task.getId()).stream()
.filter(row -> row.positionId() == request.positionId()).findFirst().orElseThrow();
complete(action, response);
return response;
}
@Transactional
public PdaStocktakeResponses.Detail submit(long warehouseId, String operatorKey, String taskNo,
PdaStocktakeRequests.Submit request) {
String key = required(request.idempotencyKey(), "Idempotency key is required");
String requestHash = hash(request);
PdaStocktakeAction action = beginAction(warehouseId, operatorKey, key, "SUBMIT", requestHash, taskNo);
if ("COMPLETED".equals(action.getStatus())) return read(action, PdaStocktakeResponses.Detail.class);
PdaStocktakeTask task = requireTask(warehouseId, taskNo, true);
if (!"IN_PROGRESS".equals(task.getStatus())) throw conflict("Stocktake was already submitted");
if (value(task.getVersion()) != request.expectedVersion()) throw conflict("Stocktake task version changed; refresh and retry");
List<PdaStocktakeItem> taskItems = items.lockAll(task.getId());
if (taskItems.isEmpty() || taskItems.stream().anyMatch(item -> item.getActualPackages() == null)) {
throw conflict("Every stocktake position must be counted before submission");
}
Map<Long, InventoryBalance> changedBalances = new HashMap<>();
LocalDateTime now = LocalDateTime.now();
for (PdaStocktakeItem item : taskItems) {
InventoryPosition position = inventory.lockPosition(item.getPositionId(), item.getCustomerId(),
String.valueOf(warehouseId));
if (position == null || value(position.getVersion()) != value(item.getPositionVersion())) {
throw conflict("Inventory changed after counting; recreate the stocktake task");
}
long before = physical(position);
if (before != value(item.getBookPackages())) {
throw conflict("Inventory package total changed after counting; recreate the stocktake task");
}
long fixed = value(position.getHoldPackages()) + value(position.getAllocatedPackages())
+ value(position.getPickedPackages()) + value(position.getReceivingPackages());
if (value(item.getActualPackages()) < fixed) {
throw conflict("Actual packages cannot be lower than reserved or processing packages");
}
long newAvailable = value(item.getActualPackages()) - fixed;
long delta = newAvailable - value(position.getAvailablePackages());
InventoryBalance balance = changedBalances.get(item.getBalanceId());
if (balance == null) {
balance = inventory.lockBalance(item.getBalanceId(), item.getCustomerId(), String.valueOf(warehouseId));
if (balance == null) throw new IllegalStateException("Stocktake inventory balance is missing");
changedBalances.put(item.getBalanceId(), balance);
}
long balanceAfter = value(balance.getAvailablePackages()) + delta;
if (balanceAfter < 0) throw conflict("Stocktake adjustment would make inventory negative");
position.setAvailablePackages(newAvailable); position.setStatus(physicalAfter(position, newAvailable) == 0 ? "EMPTY" : "OCCUPIED");
position.setVersion(value(position.getVersion()) + 1); position.setUpdatedAt(now); positions.updateById(position);
balance.setAvailablePackages(balanceAfter); balance.setVersion(value(balance.getVersion()) + 1); balance.setUpdatedAt(now);
ledger(task, item, position, balance, operatorKey, key, request.reason(), before, delta,
value(item.getActualPackages()), now);
item.setStatus(delta == 0 ? "MATCHED" : "ADJUSTED"); item.setUpdatedAt(now);
item.setVersion(value(item.getVersion()) + 1); items.updateById(item);
}
changedBalances.values().forEach(balances::updateById);
if (tasks.submit(task.getId(), request.expectedVersion(), operatorKey, request.reason()) != 1) {
throw conflict("Stocktake task version changed; refresh and retry");
}
history.record(warehouseId, null, operatorKey, "STOCKTAKE_SUBMIT", taskNo, "COMPLETED",
"STOCKTAKE_SUBMIT:" + key, requestHash,
"盘点提交,差异位置 " + task.getDifferenceCount() + " 个;原因:" + request.reason().trim());
PdaStocktakeResponses.Detail response = detail(requireTask(warehouseId, taskNo, false));
complete(action, response);
return response;
}
private PdaStocktakeResponses.Detail detail(PdaStocktakeTask task) {
return new PdaStocktakeResponses.Detail(summary(task), items.views(task.getId()));
}
private PdaStocktakeResponses.Summary summary(PdaStocktakeTask task) {
return new PdaStocktakeResponses.Summary(task.getTaskNo(), task.getScopeType(), task.getScopeCode(),
task.getStatus(), intValue(task.getPositionCount()), intValue(task.getCountedCount()),
intValue(task.getDifferenceCount()), value(task.getVersion()), task.getCreatedAt(), task.getSubmittedAt());
}
private PdaStocktakeTask requireTask(long warehouseId, String taskNo, boolean lock) {
String normalized = required(taskNo, "Stocktake task number is required");
PdaStocktakeTask task = lock ? tasks.lock(warehouseId, normalized) : tasks.find(warehouseId, normalized);
if (task == null) throw notFound("Stocktake task was not found in the bound warehouse");
return task;
}
private PdaStocktakeAction beginAction(long warehouseId, String operatorKey, String key, String type,
String requestHash, String taskNo) {
PdaStocktakeAction existing = actions.find(warehouseId, operatorKey, key);
if (existing != null) return replay(existing, type, requestHash);
PdaStocktakeAction action = new PdaStocktakeAction();
action.setId(IdWorker.getId()); action.setOverseasWarehouseId(warehouseId); action.setOperatorKey(operatorKey);
action.setIdempotencyKey(key); action.setActionType(type); action.setRequestHash(requestHash);
PdaStocktakeTask task = tasks.find(warehouseId, taskNo); action.setTaskId(task == null ? null : task.getId());
action.setStatus("PROCESSING"); action.setCreatedAt(LocalDateTime.now()); action.setUpdatedAt(action.getCreatedAt());
try { actions.insert(action); return action; }
catch (DuplicateKeyException duplicate) {
PdaStocktakeAction winner = actions.find(warehouseId, operatorKey, key);
if (winner == null) throw duplicate;
return replay(winner, type, requestHash);
}
}
private PdaStocktakeAction replay(PdaStocktakeAction action, String type, String requestHash) {
if (!type.equals(action.getActionType()) || !requestHash.equals(action.getRequestHash())) {
throw conflict("Idempotency key was reused with a different stocktake action");
}
if (!"COMPLETED".equals(action.getStatus())) throw conflict("Stocktake action is still processing");
return action;
}
private void complete(PdaStocktakeAction action, Object response) {
action.setStatus("COMPLETED"); action.setResultJson(write(response)); action.setUpdatedAt(LocalDateTime.now());
actions.updateById(action);
}
private <T> T read(PdaStocktakeAction action, Class<T> type) {
try { return objectMapper.readValue(action.getResultJson(), type); }
catch (JsonProcessingException error) { throw new IllegalStateException("Cannot read stocktake replay", error); }
}
private void ledger(PdaStocktakeTask task, PdaStocktakeItem item, InventoryPosition position,
InventoryBalance balance, String operator, String key, String reason,
long before, long delta, long after, LocalDateTime now) {
InventoryLedger row = new InventoryLedger();
row.setId(IdWorker.getId()); row.setCustomerId(item.getCustomerId()); row.setBalanceId(balance.getId());
row.setPositionId(position.getId()); row.setOverseasWarehouseId(String.valueOf(task.getOverseasWarehouseId()));
row.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); row.setInboundOrderId(balance.getInboundOrderId());
row.setPalletId(position.getPalletId()); row.setPalletNo(position.getPalletNo()); row.setBatchNo(position.getBatchNo());
row.setSkuCode(item.getSkuCode()); row.setWarehouseLocationCode(position.getWarehouseLocationCode());
row.setBusinessType("STOCKTAKE_ADJUST"); row.setBusinessOrderId(task.getId()); row.setBusinessOrderNo(task.getTaskNo());
row.setInboundCode(balance.getInboundCode()); row.setInboundOrderNo(balance.getInboundOrderNo());
row.setIdempotencyKey("STOCKTAKE:" + task.getId() + ":" + item.getId());
row.setStatusFrom("PHYSICAL"); row.setStatusTo("PHYSICAL"); row.setBeforePackages(before);
row.setDeltaPackages(delta); row.setAfterPackages(after); row.setOperatorId(operator); row.setRemark(reason.trim());
row.setOccurredAt(now); row.setCreatedAt(now); ledgers.insert(row);
}
private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(write(value).getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); }
}
private String write(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException error) { throw new IllegalStateException("Cannot serialize stocktake operation", error); }
}
private static String status(String value) {
String normalized = trim(value); if (normalized == null) return null;
normalized = normalized.toUpperCase();
if (!List.of("IN_PROGRESS", "SUBMITTED").contains(normalized)) throw badRequest("Unsupported stocktake status");
return normalized;
}
private static long physical(InventoryPosition value) { return value(value.getAvailablePackages()) + value(value.getHoldPackages())
+ value(value.getAllocatedPackages()) + value(value.getPickedPackages()) + value(value.getReceivingPackages()); }
private static long physicalAfter(InventoryPosition value, long available) { return available + value(value.getHoldPackages())
+ value(value.getAllocatedPackages()) + value(value.getPickedPackages()) + value(value.getReceivingPackages()); }
private static long value(Long value) { return value == null ? 0 : value; }
private static int intValue(Integer value) { return value == null ? 0 : value; }
private static String required(String value, String message) { String normalized = trim(value); if (normalized == null) throw badRequest(message); return normalized; }
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.relabel.pda;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.baiyue.lastleg.relabel.pda.mapper")
public class PdaRelabelMapperConfiguration {
}
@@ -0,0 +1,127 @@
package com.baiyue.lastleg.relabel.pda.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests;
import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelResponses;
import com.baiyue.lastleg.relabel.pda.service.PdaRelabelService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
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.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController
@RequestMapping("/api/pda/v1/last-leg/relabel-tasks")
@PreAuthorize("hasAuthority('lastleg:pda:operate')")
public class PdaRelabelController {
public static final String OPERATE_PERMISSION = "lastleg:pda:operate";
private static final String PICKER_ROLE = "ROLE_WAREHOUSE_PICKER";
@Resource private PdaRelabelService service;
@GetMapping("/candidates")
public ApiResponse<List<PdaRelabelResponses.Candidate>> candidates(
@AuthenticationPrincipal AuthenticatedUser user) {
Identity identity = identity(user);
return ApiResponse.success(service.candidates(identity.operatorKey(), identity.overseasWarehouseId()));
}
@GetMapping("/{relabelNo}")
public ApiResponse<PdaRelabelResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo) {
Identity identity = identity(user);
return ApiResponse.success(service.detail(identity.operatorKey(), identity.overseasWarehouseId(), relabelNo));
}
@PostMapping("/{relabelNo}/claim")
public ApiResponse<PdaRelabelResponses.Detail> claim(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Claim request) {
Identity identity = identity(user);
return ApiResponse.success(service.claim(identity.operatorKey(), identity.operatorName(),
identity.overseasWarehouseId(), relabelNo, request));
}
@PostMapping("/{relabelNo}/units")
public ApiResponse<PdaRelabelResponses.Detail> scan(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Scan request) {
Identity identity = identity(user);
return ApiResponse.success(service.scan(identity.operatorKey(), identity.operatorName(),
identity.overseasWarehouseId(), relabelNo, request));
}
@PostMapping("/{relabelNo}/units/skip")
public ApiResponse<PdaRelabelResponses.Detail> skip(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Skip request) {
Identity identity = identity(user);
return ApiResponse.success(service.skip(identity.operatorKey(), identity.operatorName(),
identity.overseasWarehouseId(), relabelNo, request));
}
@PostMapping("/{relabelNo}/exceptions")
public ApiResponse<PdaRelabelResponses.Detail> exception(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.ExceptionFact request) {
Identity identity = identity(user);
return ApiResponse.success(service.exception(identity.operatorKey(), identity.operatorName(),
identity.overseasWarehouseId(), relabelNo, request));
}
@PostMapping(value = "/{relabelNo}/photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<PdaRelabelResponses.Photo> uploadPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @RequestPart("file") MultipartFile file) {
Identity identity = identity(user);
return ApiResponse.success(service.uploadPhoto(identity.operatorKey(), identity.overseasWarehouseId(),
identity.userId(), relabelNo, file));
}
@PostMapping("/{relabelNo}/complete")
public ApiResponse<PdaRelabelResponses.Detail> complete(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Complete request) {
Identity identity = identity(user);
return ApiResponse.success(service.complete(identity.operatorKey(), identity.operatorName(),
identity.overseasWarehouseId(), relabelNo, request));
}
@PostMapping("/{relabelNo}/review")
public ApiResponse<PdaRelabelResponses.Detail> review(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Review request) {
Identity identity = identity(user);
return ApiResponse.success(service.review(identity.operatorKey(), identity.operatorName(),
identity.overseasWarehouseId(), relabelNo, request));
}
static Identity identity(AuthenticatedUser user) {
if (user == null || user.userId() == null || user.tenantId() == null || user.tenantId() != 0L
|| !"PLATFORM".equals(user.appCode())) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,
"Authenticated platform warehouse PDA identity is required");
}
if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)
|| !user.permissions().contains(PICKER_ROLE)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
"WAREHOUSE_PICKER and " + OPERATE_PERMISSION + " are required");
}
if (user.overseasWarehouseId() == null || user.overseasWarehouseId() <= 0L) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
"PDA operator must be bound to one overseas warehouse");
}
String operator = String.valueOf(user.userId());
return new Identity(user.userId(), operator,
user.username() == null || user.username().isBlank() ? operator : user.username(),
user.overseasWarehouseId());
}
record Identity(long userId, String operatorKey, String operatorName, long overseasWarehouseId) { }
}
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.relabel.pda.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.util.List;
public final class PdaRelabelRequests {
private PdaRelabelRequests() { }
public record Claim(@NotBlank @Size(max = 255) String idempotencyKey) { }
public record Scan(@NotNull @Positive Long itemId,
@NotBlank @Size(max = 255) String oldLabel,
@NotBlank @Size(max = 255) String newLabel,
@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record Skip(@NotNull @Positive Long itemId,
@NotBlank @Size(max = 255) String oldLabel,
@NotBlank @Size(max = 1000) String reason,
@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record ExceptionFact(@NotNull @Positive Long itemId,
@Size(max = 255) String oldLabel,
@NotBlank @Size(max = 32) String exceptionCode,
@NotBlank @Size(max = 1000) String reason,
@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record Complete(@NotEmpty @Size(max = 20) List<@Positive Long> photoFileIds,
@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) { }
public record Review(@NotBlank @Size(max = 16) String decision,
@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) { }
}
@@ -0,0 +1,37 @@
package com.baiyue.lastleg.relabel.pda.dto;
import java.time.LocalDateTime;
import java.util.List;
public final class PdaRelabelResponses {
private PdaRelabelResponses() { }
public record Candidate(String relabelNo, String customerOrderNo, LocalDateTime labelDeadline,
String applicationStatus, String taskStatus, int itemCount,
int expectedPackages, int relabelledPackages, int skippedPackages,
int exceptionPackages, int version, boolean claimedByCurrentOperator) { }
public record Detail(String relabelNo, String customerOrderNo, LocalDateTime labelDeadline,
String contactName, String contactPhone, String applicationStatus,
String taskStatus, int expectedPackages, int relabelledPackages,
int skippedPackages, int exceptionPackages, int version,
String operatorName, String reviewerName, List<Long> completionPhotoFileIds,
String completionRemark, String reviewDecision, String reviewRemark,
LocalDateTime claimedAt, LocalDateTime submittedAt, LocalDateTime reviewedAt,
List<Item> items, List<UnitFact> unitFacts, List<History> history) { }
public record Item(Long itemId, String skuCode, String productNameZh, String shipmentNo,
String trackingCode, String newSkuCode, String labelRequirement,
String relabelFileId, String relabelFileName, String relabelFileUrl,
int expectedPackages, int relabelledPackages, int skippedPackages,
int exceptionPackages) { }
public record UnitFact(Long factId, Long itemId, String oldLabel, String newLabel,
String result, String exceptionCode, String reason,
String operatorName, LocalDateTime occurredAt) { }
public record History(String action, String fromStatus, String toStatus,
String operatorName, String detail, LocalDateTime createdAt) { }
public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { }
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.relabel.pda.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_relabel_pda_history")
public class PdaRelabelHistory {
@TableId(type = IdType.INPUT) private Long id;
private Long taskId;
private Long applicationId;
private String customerId;
private String action;
private String fromStatus;
private String toStatus;
private String operatorKey;
private String operatorName;
private String detailJson;
private LocalDateTime createdAt;
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.relabel.pda.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_relabel_pda_operation")
public class PdaRelabelOperation {
@TableId(type = IdType.INPUT) private Long id;
private Long taskId;
private String customerId;
private String operatorKey;
private String action;
private String idempotencyKey;
private String requestHash;
private String resultJson;
private LocalDateTime createdAt;
}
@@ -0,0 +1,37 @@
package com.baiyue.lastleg.relabel.pda.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_relabel_pda_task")
public class PdaRelabelTask {
@TableId(type = IdType.INPUT) private Long id;
private Long applicationId;
private String customerId;
private String relabelNo;
private Long overseasWarehouseId;
private String status;
private String operatorKey;
private String operatorName;
private String reviewerKey;
private String reviewerName;
private Integer expectedPackages;
private Integer relabelledPackages;
private Integer skippedPackages;
private Integer exceptionPackages;
private String completionPhotoFileIds;
private String completionRemark;
private String reviewDecision;
private String reviewRemark;
private LocalDateTime claimedAt;
private LocalDateTime submittedAt;
private LocalDateTime reviewedAt;
private Integer version;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.relabel.pda.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_relabel_pda_unit_fact")
public class PdaRelabelUnitFact {
@TableId(type = IdType.INPUT) private Long id;
private Long taskId;
private Long applicationId;
private Long itemId;
private String customerId;
private String oldLabel;
private String newLabel;
private String result;
private String exceptionCode;
private String reason;
private String operatorKey;
private String operatorName;
private LocalDateTime occurredAt;
}
@@ -0,0 +1,109 @@
package com.baiyue.lastleg.relabel.pda.gateway;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
@Component
public class HttpRelabelPhotoFileGateway implements RelabelPhotoFileGateway {
private static final String BUSINESS_TYPE = "OVERSEAS_RELABEL_PHOTO";
@Resource private RestClient.Builder restClientBuilder;
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
private RestClient client;
@PostConstruct
void initializeClient() { client = restClientBuilder.baseUrl(baseUrl).build(); }
@Override
public UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file) {
long tenantId = tenantId(customerId);
MultipartBodyBuilder body = new MultipartBodyBuilder();
try {
body.part("file", new ByteArrayResource(file.getBytes()) {
@Override public String getFilename() { return file.getOriginalFilename(); }
}).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM
: MediaType.parseMediaType(file.getContentType()));
} catch (IOException exception) {
throw new IllegalArgumentException("Cannot read relabel photo", exception);
}
MetadataEnvelope response = client.post()
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}",
tenantId, BUSINESS_TYPE, taskId)
.header("X-Internal-Service-Token", internalToken)
.header("X-Operator-Id", String.valueOf(operatorId))
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new ResponseStatusException(result.getStatusCode().is4xxClientError()
? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE,
"Relabel photo upload failed");
}).body(MetadataEnvelope.class);
requireSuccess(response, "Relabel photo upload failed");
Metadata data = response == null ? null : response.data();
if (data == null || data.fileId() == null) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"File service returned no relabel photo metadata");
}
return new UploadedFile(data.fileId(), data.fileName(), data.contentType(),
data.sizeBytes() == null ? 0L : data.sizeBytes());
}
@Override
public void validate(String customerId, long taskId, long fileId) {
long tenantId = tenantId(customerId);
MetadataEnvelope response = client.get()
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}",
tenantId, BUSINESS_TYPE, taskId, fileId)
.header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new ResponseStatusException(result.getStatusCode().is4xxClientError()
? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE,
"Relabel photo does not exist or its ownership is invalid");
}).body(MetadataEnvelope.class);
requireSuccess(response, "Relabel photo does not exist or its ownership is invalid");
Metadata data = response == null ? null : response.data();
if (data == null || data.fileId() == null || data.fileId() != fileId
|| data.tenantId() == null || data.tenantId() != tenantId
|| !BUSINESS_TYPE.equals(data.businessType())
|| !String.valueOf(taskId).equals(data.businessId())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Relabel photo metadata does not match the PDA task");
}
}
private static long tenantId(String customerId) {
try { return Long.parseLong(customerId); }
catch (NumberFormatException exception) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Customer ID must be numeric", exception);
}
}
private static void requireSuccess(MetadataEnvelope response, String fallback) {
if (response == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, fallback);
if ("200".equals(response.code())) return;
HttpStatus status = switch (String.valueOf(response.code())) {
case "A0400", "A0422" -> HttpStatus.BAD_REQUEST;
case "A0403" -> HttpStatus.FORBIDDEN;
case "A0404" -> HttpStatus.NOT_FOUND;
default -> HttpStatus.SERVICE_UNAVAILABLE;
};
throw new ResponseStatusException(status,
response.message() == null || response.message().isBlank() ? fallback : response.message());
}
private record MetadataEnvelope(String code, String message, Metadata data) { }
private record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
String fileName, String contentType, Long sizeBytes) { }
}
@@ -0,0 +1,10 @@
package com.baiyue.lastleg.relabel.pda.gateway;
import org.springframework.web.multipart.MultipartFile;
public interface RelabelPhotoFileGateway {
UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file);
void validate(String customerId, long taskId, long fileId);
record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) { }
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.relabel.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PdaRelabelHistoryMapper extends BaseMapper<PdaRelabelHistory> {
}
@@ -0,0 +1,13 @@
package com.baiyue.lastleg.relabel.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelOperation;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface PdaRelabelOperationMapper extends BaseMapper<PdaRelabelOperation> {
@Select("SELECT * FROM ll_relabel_pda_operation WHERE operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1")
PdaRelabelOperation find(@Param("operatorKey") String operatorKey, @Param("key") String key);
}
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.relabel.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface PdaRelabelTaskMapper extends BaseMapper<PdaRelabelTask> {
@Select("SELECT * FROM ll_relabel_pda_task WHERE customer_id=#{customerId} AND application_id=#{applicationId} LIMIT 1")
PdaRelabelTask find(@Param("customerId") String customerId, @Param("applicationId") Long applicationId);
@Select("SELECT * FROM ll_relabel_pda_task WHERE customer_id=#{customerId} AND application_id=#{applicationId} LIMIT 1 FOR UPDATE")
PdaRelabelTask lock(@Param("customerId") String customerId, @Param("applicationId") Long applicationId);
@Update("""
UPDATE ll_relabel_pda_task
SET status=#{task.status}, operator_key=#{task.operatorKey}, operator_name=#{task.operatorName},
reviewer_key=#{task.reviewerKey}, reviewer_name=#{task.reviewerName},
relabelled_packages=#{task.relabelledPackages}, skipped_packages=#{task.skippedPackages},
exception_packages=#{task.exceptionPackages}, completion_photo_file_ids=#{task.completionPhotoFileIds},
completion_remark=#{task.completionRemark}, review_decision=#{task.reviewDecision},
review_remark=#{task.reviewRemark}, submitted_at=#{task.submittedAt}, reviewed_at=#{task.reviewedAt},
version=version+1, updated_at=#{task.updatedAt}
WHERE id=#{task.id} AND customer_id=#{task.customerId} AND version=#{expectedVersion}
""")
int updateWithVersion(@Param("task") PdaRelabelTask task, @Param("expectedVersion") Integer expectedVersion);
}
@@ -0,0 +1,13 @@
package com.baiyue.lastleg.relabel.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface PdaRelabelUnitFactMapper extends BaseMapper<PdaRelabelUnitFact> {
@Select("SELECT * FROM ll_relabel_pda_unit_fact WHERE old_label=#{oldLabel} LIMIT 1")
PdaRelabelUnitFact findOldLabel(@Param("oldLabel") String oldLabel);
}
@@ -0,0 +1,480 @@
package com.baiyue.lastleg.relabel.pda.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests;
import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelResponses;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelOperation;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact;
import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelOperationMapper;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelUnitFactMapper;
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import jakarta.annotation.PostConstruct;
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.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors;
@Service
public class PdaRelabelService {
public static final String PROCESSING = "PROCESSING";
public static final String WAIT_REVIEW = "WAIT_REVIEW";
public static final String COMPLETED = "COMPLETED";
private static final long MAX_PHOTO_SIZE = 10L * 1024 * 1024;
private static final Set<String> EXCEPTION_CODES = Set.of(
"DAMAGED", "MISSING_LABEL", "LABEL_MISMATCH", "UNREADABLE", "OTHER");
@Resource private RelabelApplicationMapper applications;
@Resource private RelabelItemMapper items;
@Resource private PdaRelabelTaskMapper tasks;
@Resource private PdaRelabelUnitFactMapper unitFacts;
@Resource private PdaRelabelOperationMapper operations;
@Resource private PdaRelabelHistoryMapper histories;
@Resource private RelabelApplicationService applicationService;
@Resource private RelabelPhotoFileGateway photoFiles;
@Resource private ObjectMapper sourceObjectMapper;
private ObjectMapper objectMapper;
@PostConstruct
void initializeObjectMapper() {
objectMapper = sourceObjectMapper.copy().findAndRegisterModules()
.configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true)
.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
}
@Transactional(readOnly = true)
public List<PdaRelabelResponses.Candidate> candidates(String operatorKey, long overseasWarehouseId) {
return applications.selectList(new LambdaQueryWrapper<RelabelApplication>()
.eq(RelabelApplication::getOverseasWarehouseId, String.valueOf(overseasWarehouseId))
.in(RelabelApplication::getStatus, RelabelApplicationService.PENDING_PROCESSING, PROCESSING)
.orderByAsc(RelabelApplication::getLabelDeadline)
.orderByAsc(RelabelApplication::getCreatedAt))
.stream().map(application -> candidate(application,
tasks.find(application.getCustomerId(), application.getId()), operatorKey)).toList();
}
@Transactional(readOnly = true)
public PdaRelabelResponses.Detail detail(String operatorKey, long overseasWarehouseId, String relabelNo) {
RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, false);
return detail(application, tasks.find(application.getCustomerId(), application.getId()));
}
@Transactional
public PdaRelabelResponses.Detail claim(String operatorKey, String operatorName, long overseasWarehouseId,
String relabelNo, PdaRelabelRequests.Claim request) {
ClaimCommand command = new ClaimCommand(relabelNo, request.idempotencyKey());
PdaRelabelResponses.Detail replay = replay(operatorKey, overseasWarehouseId, relabelNo,
"CLAIM", request.idempotencyKey(), command);
if (replay != null) return replay;
RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true);
if (!Set.of(RelabelApplicationService.PENDING_PROCESSING, PROCESSING).contains(application.getStatus())) {
throw conflict("Relabel application is not executable");
}
PdaRelabelTask task = tasks.lock(application.getCustomerId(), application.getId());
if (task != null) {
if (!operatorKey.equals(task.getOperatorKey()) || !PROCESSING.equals(task.getStatus())) {
throw conflict("Relabel task is already claimed or awaiting review");
}
} else {
LocalDateTime now = LocalDateTime.now();
task = new PdaRelabelTask();
task.setId(IdWorker.getId());
task.setApplicationId(application.getId()); task.setCustomerId(application.getCustomerId());
task.setRelabelNo(application.getRelabelNo()); task.setOverseasWarehouseId(overseasWarehouseId);
task.setStatus(PROCESSING); task.setOperatorKey(operatorKey); task.setOperatorName(operatorName);
task.setExpectedPackages(expectedPackages(application.getId()));
task.setRelabelledPackages(0); task.setSkippedPackages(0); task.setExceptionPackages(0);
task.setClaimedAt(now); task.setVersion(0); task.setCreatedAt(now); task.setUpdatedAt(now);
try { tasks.insert(task); }
catch (DuplicateKeyException duplicate) { throw conflict("Relabel task was claimed concurrently"); }
if (RelabelApplicationService.PENDING_PROCESSING.equals(application.getStatus())) {
applicationService.changeStatus(application.getCustomerId(), application.getRelabelNo(), operatorKey,
new RelabelRequests.ChangeStatus(PROCESSING, "PDA relabel task claimed"));
application.setStatus(PROCESSING);
}
insertHistory(task, "CLAIM", null, PROCESSING, operatorKey, operatorName,
Map.of("relabelNo", relabelNo));
}
PdaRelabelResponses.Detail result = detail(application, task);
saveOperation(task, operatorKey, "CLAIM", request.idempotencyKey(), command, result);
return result;
}
@Transactional
public PdaRelabelResponses.Detail scan(String operatorKey, String operatorName, long overseasWarehouseId,
String relabelNo, PdaRelabelRequests.Scan request) {
return unitAction(operatorKey, operatorName, overseasWarehouseId, relabelNo, "RELABEL", request.idempotencyKey(),
request, request.itemId(), request.oldLabel(), request.newLabel(), "RELABELLED", null, null,
request.expectedVersion(), item -> {
if (!item.getNewSkuCode().equalsIgnoreCase(request.newLabel().trim())) {
throw conflict("Scanned new label does not match the approved new SKU");
}
if (request.oldLabel().trim().equalsIgnoreCase(request.newLabel().trim())) {
throw conflict("Old and new labels must be different");
}
});
}
@Transactional
public PdaRelabelResponses.Detail skip(String operatorKey, String operatorName, long overseasWarehouseId,
String relabelNo, PdaRelabelRequests.Skip request) {
return unitAction(operatorKey, operatorName, overseasWarehouseId, relabelNo, "SKIP", request.idempotencyKey(),
request, request.itemId(), request.oldLabel(), null, "SKIPPED", null, request.reason(),
request.expectedVersion(), ignored -> { });
}
@Transactional
public PdaRelabelResponses.Detail exception(String operatorKey, String operatorName, long overseasWarehouseId,
String relabelNo, PdaRelabelRequests.ExceptionFact request) {
String code = request.exceptionCode().trim().toUpperCase(Locale.ROOT);
if (!EXCEPTION_CODES.contains(code)) throw badRequest("Unsupported relabel exception code");
return unitAction(operatorKey, operatorName, overseasWarehouseId, relabelNo, "EXCEPTION",
request.idempotencyKey(), request, request.itemId(), trim(request.oldLabel()), null,
"EXCEPTION", code, request.reason(), request.expectedVersion(), ignored -> { });
}
public PdaRelabelResponses.Photo uploadPhoto(String operatorKey, long overseasWarehouseId, long operatorId,
String relabelNo, MultipartFile file) {
RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, false);
PdaRelabelTask task = requireOwnedTask(application, operatorKey, false);
if (!PROCESSING.equals(task.getStatus())) throw conflict("Relabel photos can only be uploaded while processing");
if (file == null || file.isEmpty()) throw badRequest("Relabel photo is required");
if (file.getSize() > MAX_PHOTO_SIZE) throw badRequest("Relabel photo exceeds 10 MB");
if (file.getContentType() == null || !file.getContentType().toLowerCase(Locale.ROOT).startsWith("image/")) {
throw badRequest("Relabel photo must be an image");
}
RelabelPhotoFileGateway.UploadedFile uploaded = photoFiles.upload(application.getCustomerId(),
task.getId(), operatorId, file);
return new PdaRelabelResponses.Photo(uploaded.fileId(), uploaded.fileName(),
uploaded.contentType(), uploaded.sizeBytes());
}
@Transactional
public PdaRelabelResponses.Detail complete(String operatorKey, String operatorName, long overseasWarehouseId,
String relabelNo, PdaRelabelRequests.Complete request) {
CompleteCommand command = new CompleteCommand(relabelNo, request);
PdaRelabelResponses.Detail replay = replay(operatorKey, overseasWarehouseId, relabelNo,
"COMPLETE", request.idempotencyKey(), command);
if (replay != null) return replay;
RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true);
PdaRelabelTask task = requireOwnedTask(application, operatorKey, true);
requireVersion(task, request.expectedVersion());
List<RelabelItem> detailItems = itemList(application);
List<PdaRelabelUnitFact> facts = factList(task);
for (RelabelItem item : detailItems) {
long actual = facts.stream().filter(fact -> item.getId().equals(fact.getItemId())).count();
if (actual != value(item.getNotifiedPackageCount())) {
throw conflict("Every relabel item must have exactly one fact per notified package");
}
}
List<Long> photoIds = request.photoFileIds().stream().distinct().toList();
if (photoIds.size() != request.photoFileIds().size()) throw conflict("Relabel photo IDs must be unique");
photoIds.forEach(fileId -> photoFiles.validate(application.getCustomerId(), task.getId(), fileId));
String before = task.getStatus();
task.setStatus(WAIT_REVIEW); task.setCompletionPhotoFileIds(write(photoIds));
task.setCompletionRemark(trim(request.remark())); task.setSubmittedAt(LocalDateTime.now());
updateTask(task, request.expectedVersion());
insertHistory(task, "COMPLETE", before, WAIT_REVIEW, operatorKey, operatorName,
Map.of("photoFileIds", photoIds, "remark", Objects.toString(trim(request.remark()), "")));
application.setStatus(PROCESSING);
PdaRelabelResponses.Detail result = detail(application, task);
saveOperation(task, operatorKey, "COMPLETE", request.idempotencyKey(), command, result);
return result;
}
@Transactional
public PdaRelabelResponses.Detail review(String operatorKey, String operatorName, long overseasWarehouseId,
String relabelNo, PdaRelabelRequests.Review request) {
ReviewCommand command = new ReviewCommand(relabelNo, request);
PdaRelabelResponses.Detail replay = replay(operatorKey, overseasWarehouseId, relabelNo,
"REVIEW", request.idempotencyKey(), command);
if (replay != null) return replay;
String decision = request.decision().trim().toUpperCase(Locale.ROOT);
if (!Set.of("APPROVE", "REWORK").contains(decision)) throw badRequest("Review decision must be APPROVE or REWORK");
RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true);
if (!PROCESSING.equals(application.getStatus())) {
throw conflict("Relabel application is no longer executable");
}
PdaRelabelTask task = tasks.lock(application.getCustomerId(), application.getId());
if (task == null) throw notFound("PDA relabel task was not found");
if (!Objects.equals(task.getOverseasWarehouseId(), overseasWarehouseId)) {
throw notFound("PDA relabel task was not found in the bound overseas warehouse");
}
if (!WAIT_REVIEW.equals(task.getStatus())) throw conflict("Relabel task is not awaiting review");
requireVersion(task, request.expectedVersion());
String before = task.getStatus();
String after = "APPROVE".equals(decision) ? COMPLETED : PROCESSING;
task.setStatus(after); task.setReviewerKey(operatorKey); task.setReviewerName(operatorName);
task.setReviewDecision(decision); task.setReviewRemark(trim(request.remark()));
task.setReviewedAt(LocalDateTime.now());
updateTask(task, request.expectedVersion());
if ("APPROVE".equals(decision)) {
applicationService.changeStatus(application.getCustomerId(), relabelNo, operatorKey,
new RelabelRequests.ChangeStatus(COMPLETED, request.remark()));
application.setStatus(COMPLETED);
}
insertHistory(task, "REVIEW_" + decision, before, after, operatorKey, operatorName,
Map.of("decision", decision, "remark", Objects.toString(trim(request.remark()), "")));
PdaRelabelResponses.Detail result = detail(application, task);
saveOperation(task, operatorKey, "REVIEW", request.idempotencyKey(), command, result);
return result;
}
private PdaRelabelResponses.Detail unitAction(String operatorKey, String operatorName,
long overseasWarehouseId, String relabelNo, String action, String idempotencyKey, Object command,
Long itemId, String oldLabel, String newLabel, String resultCode, String exceptionCode, String reason,
Integer expectedVersion, Consumer<RelabelItem> validation) {
UnitCommand replayCommand = new UnitCommand(relabelNo, action, command);
PdaRelabelResponses.Detail replay = replay(operatorKey, overseasWarehouseId, relabelNo,
action, idempotencyKey, replayCommand);
if (replay != null) return replay;
RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true);
PdaRelabelTask task = requireOwnedTask(application, operatorKey, true);
requireVersion(task, expectedVersion);
RelabelItem item = requireItem(application, itemId);
validation.accept(item);
List<PdaRelabelUnitFact> existingFacts = factList(task);
long itemFacts = existingFacts.stream().filter(fact -> itemId.equals(fact.getItemId())).count();
if (itemFacts >= value(item.getNotifiedPackageCount())) {
throw conflict("Relabel item already has all required package facts");
}
String normalizedOldLabel = trim(oldLabel);
if (normalizedOldLabel != null && unitFacts.findOldLabel(normalizedOldLabel) != null) {
throw conflict("Old label was already processed by another relabel unit");
}
PdaRelabelUnitFact fact = new PdaRelabelUnitFact();
fact.setId(IdWorker.getId()); fact.setTaskId(task.getId()); fact.setApplicationId(application.getId());
fact.setItemId(itemId); fact.setCustomerId(application.getCustomerId());
fact.setOldLabel(normalizedOldLabel); fact.setNewLabel(trim(newLabel)); fact.setResult(resultCode);
fact.setExceptionCode(exceptionCode); fact.setReason(trim(reason));
fact.setOperatorKey(operatorKey); fact.setOperatorName(operatorName); fact.setOccurredAt(LocalDateTime.now());
try { unitFacts.insert(fact); }
catch (DuplicateKeyException duplicate) { throw conflict("Old label was processed concurrently"); }
if ("RELABELLED".equals(resultCode)) task.setRelabelledPackages(value(task.getRelabelledPackages()) + 1);
if ("SKIPPED".equals(resultCode)) task.setSkippedPackages(value(task.getSkippedPackages()) + 1);
if ("EXCEPTION".equals(resultCode)) task.setExceptionPackages(value(task.getExceptionPackages()) + 1);
updateTask(task, expectedVersion);
insertHistory(task, action, PROCESSING, PROCESSING, operatorKey, operatorName,
Map.of("factId", fact.getId(), "itemId", itemId, "result", resultCode));
PdaRelabelResponses.Detail response = detail(application, task);
saveOperation(task, operatorKey, action, idempotencyKey, replayCommand, response);
return response;
}
private RelabelApplication requireApplication(String relabelNo, long overseasWarehouseId, boolean lock) {
RelabelApplication application = lock ? applications.lockAnyByRelabelNo(relabelNo)
: applications.findAnyByRelabelNo(relabelNo);
if (application == null) throw notFound("Relabel application was not found");
long applicationWarehouse;
try { applicationWarehouse = Long.parseLong(application.getOverseasWarehouseId()); }
catch (Exception invalid) { throw conflict("Relabel application has no valid overseas warehouse"); }
if (applicationWarehouse != overseasWarehouseId) {
throw notFound("Relabel application was not found in the bound overseas warehouse");
}
return application;
}
private PdaRelabelTask requireOwnedTask(RelabelApplication application, String operatorKey, boolean lock) {
if (!PROCESSING.equals(application.getStatus())) {
throw conflict("Relabel application is no longer executable");
}
PdaRelabelTask task = lock ? tasks.lock(application.getCustomerId(), application.getId())
: tasks.find(application.getCustomerId(), application.getId());
if (task == null) throw conflict("Claim the relabel task before operating");
if (!operatorKey.equals(task.getOperatorKey())) throw conflict("Relabel task is claimed by another operator");
if (!Objects.equals(task.getOverseasWarehouseId(), Long.parseLong(application.getOverseasWarehouseId()))) {
throw conflict("Relabel task warehouse does not match the approved application");
}
if (!PROCESSING.equals(task.getStatus())) throw conflict("Relabel task is not in processing status");
return task;
}
private RelabelItem requireItem(RelabelApplication application, Long itemId) {
RelabelItem item = items.selectOne(new LambdaQueryWrapper<RelabelItem>()
.eq(RelabelItem::getId, itemId).eq(RelabelItem::getApplicationId, application.getId())
.eq(RelabelItem::getCustomerId, application.getCustomerId()));
if (item == null) throw notFound("Relabel item does not belong to the current application");
return item;
}
private void requireVersion(PdaRelabelTask task, Integer expectedVersion) {
if (!Objects.equals(task.getVersion(), expectedVersion)) {
throw conflict("Relabel task version changed; reload before retrying");
}
}
private void updateTask(PdaRelabelTask task, Integer expectedVersion) {
task.setUpdatedAt(LocalDateTime.now());
if (tasks.updateWithVersion(task, expectedVersion) != 1) {
throw conflict("Relabel task version changed concurrently");
}
task.setVersion(expectedVersion + 1);
}
private int expectedPackages(Long applicationId) {
return itemList(applicationId).stream().mapToInt(item -> value(item.getNotifiedPackageCount())).sum();
}
private List<RelabelItem> itemList(RelabelApplication application) { return itemList(application.getId()); }
private List<RelabelItem> itemList(Long applicationId) {
return items.selectList(new LambdaQueryWrapper<RelabelItem>()
.eq(RelabelItem::getApplicationId, applicationId).orderByAsc(RelabelItem::getId));
}
private List<PdaRelabelUnitFact> factList(PdaRelabelTask task) {
return unitFacts.selectList(new LambdaQueryWrapper<PdaRelabelUnitFact>()
.eq(PdaRelabelUnitFact::getCustomerId, task.getCustomerId())
.eq(PdaRelabelUnitFact::getTaskId, task.getId())
.orderByAsc(PdaRelabelUnitFact::getOccurredAt).orderByAsc(PdaRelabelUnitFact::getId));
}
private PdaRelabelResponses.Candidate candidate(RelabelApplication application, PdaRelabelTask task,
String operatorKey) {
return new PdaRelabelResponses.Candidate(application.getRelabelNo(), application.getCustomerOrderNo(),
application.getLabelDeadline(), application.getStatus(), task == null ? "AVAILABLE" : task.getStatus(),
value(application.getItemCount()), task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()),
task == null ? 0 : value(task.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()),
task == null ? 0 : value(task.getExceptionPackages()), task == null ? 0 : value(task.getVersion()),
task != null && operatorKey.equals(task.getOperatorKey()));
}
private PdaRelabelResponses.Detail detail(RelabelApplication application, PdaRelabelTask task) {
List<RelabelItem> detailItems = itemList(application);
List<PdaRelabelUnitFact> facts = task == null ? List.of() : factList(task);
Map<Long, List<PdaRelabelUnitFact>> factsByItem = facts.stream()
.collect(Collectors.groupingBy(PdaRelabelUnitFact::getItemId, LinkedHashMap::new, Collectors.toList()));
List<PdaRelabelResponses.Item> itemResponses = detailItems.stream().map(item -> {
List<PdaRelabelUnitFact> itemFacts = factsByItem.getOrDefault(item.getId(), List.of());
return new PdaRelabelResponses.Item(item.getId(), item.getSkuCode(), item.getProductNameZh(),
item.getShipmentNo(), item.getTrackingCode(), item.getNewSkuCode(), item.getLabelRequirement(),
item.getRelabelFileId(), item.getRelabelFileName(), item.getRelabelFileUrl(),
value(item.getNotifiedPackageCount()), count(itemFacts, "RELABELLED"), count(itemFacts, "SKIPPED"),
count(itemFacts, "EXCEPTION"));
}).toList();
List<PdaRelabelResponses.UnitFact> factResponses = facts.stream().map(fact ->
new PdaRelabelResponses.UnitFact(fact.getId(), fact.getItemId(), fact.getOldLabel(), fact.getNewLabel(),
fact.getResult(), fact.getExceptionCode(), fact.getReason(), fact.getOperatorName(), fact.getOccurredAt())).toList();
List<PdaRelabelResponses.History> historyResponses = task == null ? List.of() : histories.selectList(
new LambdaQueryWrapper<PdaRelabelHistory>().eq(PdaRelabelHistory::getCustomerId, application.getCustomerId())
.eq(PdaRelabelHistory::getTaskId, task.getId())
.orderByAsc(PdaRelabelHistory::getCreatedAt).orderByAsc(PdaRelabelHistory::getId)).stream()
.map(history -> new PdaRelabelResponses.History(history.getAction(), history.getFromStatus(),
history.getToStatus(), history.getOperatorName(), history.getDetailJson(), history.getCreatedAt())).toList();
return new PdaRelabelResponses.Detail(application.getRelabelNo(), application.getCustomerOrderNo(),
application.getLabelDeadline(), application.getContactName(), application.getContactPhone(),
application.getStatus(), task == null ? "AVAILABLE" : task.getStatus(),
task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()),
task == null ? 0 : value(task.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()),
task == null ? 0 : value(task.getExceptionPackages()), task == null ? 0 : value(task.getVersion()),
task == null ? null : task.getOperatorName(), task == null ? null : task.getReviewerName(),
task == null ? List.of() : readIds(task.getCompletionPhotoFileIds()),
task == null ? null : task.getCompletionRemark(), task == null ? null : task.getReviewDecision(),
task == null ? null : task.getReviewRemark(), task == null ? null : task.getClaimedAt(),
task == null ? null : task.getSubmittedAt(), task == null ? null : task.getReviewedAt(),
itemResponses, factResponses, historyResponses);
}
private void insertHistory(PdaRelabelTask task, String action, String from, String to,
String operatorKey, String operatorName, Object detail) {
PdaRelabelHistory history = new PdaRelabelHistory();
history.setId(IdWorker.getId()); history.setTaskId(task.getId());
history.setApplicationId(task.getApplicationId()); history.setCustomerId(task.getCustomerId());
history.setAction(action); history.setFromStatus(from); history.setToStatus(to);
history.setOperatorKey(operatorKey); history.setOperatorName(operatorName);
history.setDetailJson(write(detail)); history.setCreatedAt(LocalDateTime.now());
histories.insert(history);
}
private PdaRelabelResponses.Detail replay(String operatorKey, long overseasWarehouseId, String relabelNo,
String action, String key, Object request) {
PdaRelabelOperation operation = operations.find(operatorKey, idempotencyKey(key));
if (operation == null) return null;
PdaRelabelTask task = tasks.selectById(operation.getTaskId());
if (task == null || !Objects.equals(task.getOverseasWarehouseId(), overseasWarehouseId)
|| !Objects.equals(task.getRelabelNo(), relabelNo)
|| !Objects.equals(task.getCustomerId(), operation.getCustomerId())) {
throw notFound("PDA relabel operation was not found in the bound overseas warehouse");
}
if (!action.equals(operation.getAction()) || !hash(request).equals(operation.getRequestHash())) {
throw conflict("Idempotency key was already used with a different relabel request");
}
try { return objectMapper.readValue(operation.getResultJson(), PdaRelabelResponses.Detail.class); }
catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read PDA relabel replay", exception); }
}
private void saveOperation(PdaRelabelTask task, String operatorKey, String action, String key,
Object request, PdaRelabelResponses.Detail result) {
PdaRelabelOperation operation = new PdaRelabelOperation();
operation.setId(IdWorker.getId()); operation.setTaskId(task.getId()); operation.setCustomerId(task.getCustomerId());
operation.setOperatorKey(operatorKey); operation.setAction(action); operation.setIdempotencyKey(idempotencyKey(key));
operation.setRequestHash(hash(request)); operation.setResultJson(write(result)); operation.setCreatedAt(LocalDateTime.now());
try { operations.insert(operation); }
catch (DuplicateKeyException duplicate) { throw conflict("Relabel operation was submitted concurrently"); }
}
private String hash(Object value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(write(value).getBytes(StandardCharsets.UTF_8)));
} catch (Exception exception) { throw new IllegalStateException(exception); }
}
private String write(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException exception) { throw new IllegalStateException(exception); }
}
private List<Long> readIds(String value) {
if (!StringUtils.hasText(value)) return List.of();
try {
return objectMapper.readValue(value, objectMapper.getTypeFactory().constructCollectionType(List.class, Long.class));
} catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read relabel photo IDs", exception); }
}
private static int count(List<PdaRelabelUnitFact> facts, String result) {
return (int) facts.stream().filter(fact -> result.equals(fact.getResult())).count();
}
private static int value(Integer value) { return value == null ? 0 : value; }
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static String idempotencyKey(String value) { return value == null ? 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); }
private record ClaimCommand(String relabelNo, String idempotencyKey) { }
private record UnitCommand(String relabelNo, String action, Object request) { }
private record CompleteCommand(String relabelNo, PdaRelabelRequests.Complete request) { }
private record ReviewCommand(String relabelNo, PdaRelabelRequests.Review request) { }
}