feat: complete overseas pda warehouse operations
This commit is contained in:
+58
@@ -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));
|
||||
}
|
||||
}
|
||||
+42
@@ -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) { }
|
||||
}
|
||||
+23
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -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;
|
||||
}
|
||||
+75
@@ -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) { }
|
||||
}
|
||||
+16
@@ -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) { }
|
||||
}
|
||||
+45
@@ -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)>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);
|
||||
}
|
||||
+27
@@ -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;
|
||||
}
|
||||
+506
@@ -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) { }
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.baiyue.lastleg.pda.move.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest;
|
||||
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveResponse;
|
||||
import com.baiyue.lastleg.pda.move.service.PdaInventoryMoveService;
|
||||
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/pda/v1/last-leg/inventory-moves")
|
||||
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')")
|
||||
public class PdaInventoryMoveController {
|
||||
@Resource private PdaInventoryMoveService service;
|
||||
|
||||
@PostMapping
|
||||
public ApiResponse<PdaInventoryMoveResponse> move(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody PdaInventoryMoveRequest request) {
|
||||
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||
return ApiResponse.success(service.move(identity.overseasWarehouseId(), identity.operatorKey(), request));
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.baiyue.lastleg.pda.move.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record PdaInventoryMoveRequest(
|
||||
@NotBlank @Size(max = 128) String sourcePalletCode,
|
||||
@NotBlank @Size(max = 128) String targetLocationCode,
|
||||
@PositiveOrZero int expectedPalletVersion,
|
||||
@NotBlank @Size(max = 128) String idempotencyKey,
|
||||
@NotBlank @Size(max = 500) String reason
|
||||
) { }
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.baiyue.lastleg.pda.move.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record PdaInventoryMoveResponse(
|
||||
String moveNo,
|
||||
long palletId,
|
||||
String palletCode,
|
||||
long sourceLocationId,
|
||||
String sourceLocationCode,
|
||||
long targetLocationId,
|
||||
String targetLocationCode,
|
||||
int customerCount,
|
||||
int positionCount,
|
||||
long packageCount,
|
||||
int palletVersion,
|
||||
String status,
|
||||
LocalDateTime completedAt,
|
||||
boolean replayed
|
||||
) {
|
||||
public PdaInventoryMoveResponse asReplay() {
|
||||
return replayed ? this : new PdaInventoryMoveResponse(moveNo, palletId, palletCode, sourceLocationId,
|
||||
sourceLocationCode, targetLocationId, targetLocationCode, customerCount, positionCount,
|
||||
packageCount, palletVersion, status, completedAt, true);
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.baiyue.lastleg.pda.move.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_pda_inventory_move")
|
||||
public class PdaInventoryMove {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private String moveNo;
|
||||
private Long overseasWarehouseId;
|
||||
private Long palletId;
|
||||
private String palletCode;
|
||||
private Long sourceLocationId;
|
||||
private String sourceLocationCode;
|
||||
private Long targetLocationId;
|
||||
private String targetLocationCode;
|
||||
private Integer customerCount;
|
||||
private Integer positionCount;
|
||||
private Long packageCount;
|
||||
private Integer palletVersionBefore;
|
||||
private Integer palletVersionAfter;
|
||||
private String operatorKey;
|
||||
private String reason;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
private String status;
|
||||
private String resultJson;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime completedAt;
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.lastleg.pda.move.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
|
||||
import com.baiyue.lastleg.pda.move.entity.PdaInventoryMove;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface PdaInventoryMoveMapper extends BaseMapper<PdaInventoryMove> {
|
||||
@Select("SELECT * FROM ll_pda_inventory_move WHERE overseas_warehouse_id=#{warehouseId} " +
|
||||
"AND operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1")
|
||||
PdaInventoryMove find(@Param("warehouseId") long warehouseId,
|
||||
@Param("operatorKey") String operatorKey, @Param("key") String key);
|
||||
|
||||
@Select("SELECT * FROM ll_inventory_position WHERE overseas_warehouse_id=#{warehouseId} " +
|
||||
"AND pallet_id=#{palletId} AND " +
|
||||
"(available_packages+hold_packages+allocated_packages+picked_packages+receiving_packages)>0 " +
|
||||
"ORDER BY customer_id,id FOR UPDATE")
|
||||
List<InventoryPosition> lockPositions(@Param("warehouseId") String warehouseId,
|
||||
@Param("palletId") long palletId);
|
||||
}
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
package com.baiyue.lastleg.pda.move.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
|
||||
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
|
||||
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest;
|
||||
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveResponse;
|
||||
import com.baiyue.lastleg.pda.move.entity.PdaInventoryMove;
|
||||
import com.baiyue.lastleg.pda.move.mapper.PdaInventoryMoveMapper;
|
||||
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class PdaInventoryMoveService {
|
||||
@Resource private PdaInventoryMoveMapper moves;
|
||||
@Resource private InventoryPositionMapper positions;
|
||||
@Resource private InventoryBalanceMapper balances;
|
||||
@Resource private InventoryLedgerMapper ledgers;
|
||||
@Resource private WarehouseResourceGateway warehouse;
|
||||
@Resource private PalletPhysicalGateway physical;
|
||||
@Resource private PdaWarehouseService history;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
|
||||
@Transactional
|
||||
public PdaInventoryMoveResponse move(long warehouseId, String operatorKey, PdaInventoryMoveRequest request) {
|
||||
String key = required(request.idempotencyKey(), "Idempotency key is required");
|
||||
String requestHash = hash(request);
|
||||
PdaInventoryMove existing = moves.find(warehouseId, operatorKey, key);
|
||||
if (existing != null) return replay(existing, requestHash);
|
||||
|
||||
var pallet = warehouse.resolvePallet(required(request.sourcePalletCode(), "Source pallet code is required"));
|
||||
if (pallet.warehouseId() != warehouseId) throw conflict("Source pallet belongs to another overseas warehouse");
|
||||
var target = warehouse.resolveLocation(warehouseId,
|
||||
required(request.targetLocationCode(), "Target location code is required"));
|
||||
if (!Set.of("ACTIVE", "IDLE", "OCCUPIED").contains(target.status())) {
|
||||
throw conflict("Target location is not available");
|
||||
}
|
||||
boolean recoveringRemoteSuccess = pallet.locationId() == target.locationId()
|
||||
&& pallet.version() == request.expectedPalletVersion() + 1;
|
||||
if (!recoveringRemoteSuccess && pallet.version() != request.expectedPalletVersion()) {
|
||||
throw conflict("Source pallet version changed; rescan it");
|
||||
}
|
||||
|
||||
List<InventoryPosition> inventory = moves.lockPositions(String.valueOf(warehouseId), pallet.palletId());
|
||||
if (inventory.isEmpty()) throw conflict("Source pallet has no movable inventory");
|
||||
Set<Long> inventoryLocations = new HashSet<>();
|
||||
inventory.forEach(position -> inventoryLocations.add(position.getWarehouseLocationId()));
|
||||
if (inventoryLocations.contains(null) || inventoryLocations.size() != 1) {
|
||||
throw conflict("Inventory and warehouse pallet locations are inconsistent; reconcile before moving");
|
||||
}
|
||||
long sourceLocationId = inventoryLocations.iterator().next();
|
||||
if (!recoveringRemoteSuccess && sourceLocationId != pallet.locationId()) {
|
||||
throw conflict("Inventory and warehouse pallet locations are inconsistent; reconcile before moving");
|
||||
}
|
||||
if (sourceLocationId == target.locationId()) throw badRequest("Source and target locations must differ");
|
||||
var source = warehouse.resolveLocation(warehouseId, sourceLocationId);
|
||||
long packages = inventory.stream().mapToLong(PdaInventoryMoveService::physical).sum();
|
||||
Set<String> customers = new HashSet<>();
|
||||
inventory.forEach(position -> customers.add(position.getCustomerId()));
|
||||
|
||||
long moveId = IdWorker.getId();
|
||||
PdaInventoryMove operation = new PdaInventoryMove();
|
||||
operation.setId(moveId); operation.setMoveNo("MV-" + Long.toUnsignedString(moveId));
|
||||
operation.setOverseasWarehouseId(warehouseId); operation.setPalletId(pallet.palletId());
|
||||
operation.setPalletCode(pallet.palletNo()); operation.setSourceLocationId(source.locationId());
|
||||
operation.setSourceLocationCode(source.locationCode()); operation.setTargetLocationId(target.locationId());
|
||||
operation.setTargetLocationCode(target.locationCode()); operation.setCustomerCount(customers.size());
|
||||
operation.setPositionCount(inventory.size()); operation.setPackageCount(packages);
|
||||
operation.setPalletVersionBefore(pallet.version()); operation.setOperatorKey(operatorKey);
|
||||
operation.setReason(request.reason().trim()); operation.setIdempotencyKey(key); operation.setRequestHash(requestHash);
|
||||
operation.setStatus("PROCESSING"); operation.setCreatedAt(LocalDateTime.now());
|
||||
try { moves.insert(operation); }
|
||||
catch (DuplicateKeyException duplicate) {
|
||||
PdaInventoryMove winner = moves.find(warehouseId, operatorKey, key);
|
||||
if (winner == null) throw duplicate;
|
||||
return replay(winner, requestHash);
|
||||
}
|
||||
|
||||
String transitionKey = transitionKey(operatorKey, key, pallet.palletId());
|
||||
var transitionSource = new PalletPhysicalGateway.PalletReference(pallet.palletId(), pallet.palletNo(),
|
||||
warehouseId, source.locationId(), "OCCUPIED", pallet.fbaWarehouseCode(), request.expectedPalletVersion());
|
||||
var relocated = physical.apply(warehouseId, transitionSource, "OCCUPIED", target.locationId(),
|
||||
transitionKey, request.reason());
|
||||
if (relocated.afterLocationId() != target.locationId()
|
||||
|| !"APPLIED".equals(relocated.operationStatus())) {
|
||||
throw new IllegalStateException("Warehouse relocation returned another target location");
|
||||
}
|
||||
LocalDateTime completedAt = LocalDateTime.now();
|
||||
for (InventoryPosition position : inventory) {
|
||||
InventoryBalance balance = balances.selectById(position.getBalanceId());
|
||||
if (balance == null || !position.getCustomerId().equals(balance.getCustomerId())
|
||||
|| !String.valueOf(warehouseId).equals(balance.getOverseasWarehouseId())) {
|
||||
throw new IllegalStateException("Inventory balance scope is inconsistent during relocation");
|
||||
}
|
||||
position.setWarehouseLocationId(target.locationId()); position.setWarehouseLocationCode(target.locationCode());
|
||||
position.setVersion(value(position.getVersion()) + 1); position.setUpdatedAt(completedAt);
|
||||
positions.updateById(position);
|
||||
ledger(operation, position, balance, operatorKey, key, request.reason(), source.locationCode(),
|
||||
target.locationCode(), completedAt);
|
||||
}
|
||||
operation.setPalletVersionAfter(relocated.afterVersion()); operation.setStatus("COMPLETED");
|
||||
operation.setCompletedAt(completedAt);
|
||||
PdaInventoryMoveResponse response = response(operation, false);
|
||||
operation.setResultJson(write(response)); moves.updateById(operation);
|
||||
history.record(warehouseId, null, operatorKey, "INVENTORY_MOVE", operation.getMoveNo(), "COMPLETED",
|
||||
"INVENTORY_MOVE:" + key, requestHash,
|
||||
pallet.palletNo() + ":" + source.locationCode() + " → " + target.locationCode()
|
||||
+ "," + packages + " 箱");
|
||||
return response;
|
||||
}
|
||||
|
||||
private PdaInventoryMoveResponse replay(PdaInventoryMove operation, String requestHash) {
|
||||
if (!requestHash.equals(operation.getRequestHash())) {
|
||||
throw conflict("Idempotency key was reused with a different inventory move");
|
||||
}
|
||||
if (!"COMPLETED".equals(operation.getStatus()) || operation.getResultJson() == null) {
|
||||
throw conflict("Inventory move is still processing");
|
||||
}
|
||||
try { return objectMapper.readValue(operation.getResultJson(), PdaInventoryMoveResponse.class).asReplay(); }
|
||||
catch (JsonProcessingException error) { throw new IllegalStateException("Cannot read inventory move replay", error); }
|
||||
}
|
||||
|
||||
private PdaInventoryMoveResponse response(PdaInventoryMove value, boolean replayed) {
|
||||
return new PdaInventoryMoveResponse(value.getMoveNo(), value.getPalletId(), value.getPalletCode(),
|
||||
value.getSourceLocationId(), value.getSourceLocationCode(), value.getTargetLocationId(),
|
||||
value.getTargetLocationCode(), value.getCustomerCount(), value.getPositionCount(),
|
||||
value.getPackageCount(), value.getPalletVersionAfter(), value.getStatus(), value.getCompletedAt(), replayed);
|
||||
}
|
||||
|
||||
private void ledger(PdaInventoryMove operation, InventoryPosition position, InventoryBalance balance,
|
||||
String operator, String key, String reason, String source, String target,
|
||||
LocalDateTime now) {
|
||||
InventoryLedger row = new InventoryLedger(); row.setId(IdWorker.getId()); row.setCustomerId(position.getCustomerId());
|
||||
row.setBalanceId(balance.getId()); row.setPositionId(position.getId());
|
||||
row.setOverseasWarehouseId(String.valueOf(operation.getOverseasWarehouseId()));
|
||||
row.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); row.setInboundOrderId(balance.getInboundOrderId());
|
||||
row.setPalletId(position.getPalletId()); row.setPalletNo(position.getPalletNo()); row.setBatchNo(position.getBatchNo());
|
||||
row.setSkuCode(balance.getSkuCode()); row.setWarehouseLocationCode(target); row.setBusinessType("INTERNAL_MOVE");
|
||||
row.setBusinessOrderId(operation.getId()); row.setBusinessOrderNo(operation.getMoveNo());
|
||||
row.setInboundCode(balance.getInboundCode()); row.setInboundOrderNo(balance.getInboundOrderNo());
|
||||
row.setIdempotencyKey("MOVE:" + operation.getId() + ":" + position.getId());
|
||||
row.setStatusFrom(source); row.setStatusTo(target); long packages = physical(position);
|
||||
row.setBeforePackages(packages); row.setDeltaPackages(0L); row.setAfterPackages(packages);
|
||||
row.setOperatorId(operator); row.setRemark(reason.trim()); row.setOccurredAt(now); row.setCreatedAt(now);
|
||||
ledgers.insert(row);
|
||||
}
|
||||
|
||||
private String hash(Object value) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(write(value).getBytes(StandardCharsets.UTF_8))); }
|
||||
catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); }
|
||||
}
|
||||
private static String transitionKey(String operator, String key, long palletId) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(("INVENTORY_MOVE|" + operator + "|" + key + "|" + palletId)
|
||||
.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); }
|
||||
}
|
||||
private String write(Object value) {
|
||||
try { return objectMapper.writeValueAsString(value); }
|
||||
catch (JsonProcessingException error) { throw new IllegalStateException("Cannot serialize inventory move", error); }
|
||||
}
|
||||
private static long physical(InventoryPosition value) { return value(value.getAvailablePackages()) + value(value.getHoldPackages())
|
||||
+ value(value.getAllocatedPackages()) + value(value.getPickedPackages()) + value(value.getReceivingPackages()); }
|
||||
private static long value(Long value) { return value == null ? 0 : value; }
|
||||
private static String required(String value, String message) { if (value == null || value.isBlank()) throw badRequest(message); return value.trim(); }
|
||||
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package com.baiyue.lastleg.pda.stocktake.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
|
||||
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests;
|
||||
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses;
|
||||
import com.baiyue.lastleg.pda.stocktake.service.PdaStocktakeService;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/pda/v1/last-leg/stocktakes")
|
||||
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')")
|
||||
public class PdaStocktakeController {
|
||||
@Resource private PdaStocktakeService service;
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<PageResult<PdaStocktakeResponses.Summary>> page(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @Valid PdaStocktakeRequests.TaskQuery query) {
|
||||
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||
return ApiResponse.success(service.page(identity.overseasWarehouseId(), query));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ApiResponse<PdaStocktakeResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody PdaStocktakeRequests.Create request) {
|
||||
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||
return ApiResponse.success(service.create(identity.overseasWarehouseId(), identity.operatorKey(), request));
|
||||
}
|
||||
|
||||
@GetMapping("/{taskNo}")
|
||||
public ApiResponse<PdaStocktakeResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String taskNo) {
|
||||
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||
return ApiResponse.success(service.detail(identity.overseasWarehouseId(), taskNo));
|
||||
}
|
||||
|
||||
@PostMapping("/{taskNo}/counts")
|
||||
public ApiResponse<PdaStocktakeResponses.Item> count(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String taskNo, @Valid @RequestBody PdaStocktakeRequests.Count request) {
|
||||
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||
return ApiResponse.success(service.count(identity.overseasWarehouseId(), identity.operatorKey(), taskNo, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{taskNo}/submit")
|
||||
public ApiResponse<PdaStocktakeResponses.Detail> submit(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String taskNo, @Valid @RequestBody PdaStocktakeRequests.Submit request) {
|
||||
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||
return ApiResponse.success(service.submit(identity.overseasWarehouseId(), identity.operatorKey(), taskNo, request));
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.baiyue.lastleg.pda.stocktake.dto;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public final class PdaStocktakeRequests {
|
||||
private PdaStocktakeRequests() { }
|
||||
|
||||
public record TaskQuery(@Min(1) long page, @Min(1) @Max(100) long size, String status) {
|
||||
public TaskQuery { if (page == 0) page = 1; if (size == 0) size = 20; }
|
||||
}
|
||||
|
||||
public record Create(@NotBlank @Size(max = 16) String scopeType,
|
||||
@NotBlank @Size(max = 128) String scopeCode,
|
||||
@NotBlank @Size(max = 128) String idempotencyKey) { }
|
||||
|
||||
public record Count(@NotNull Long positionId, @PositiveOrZero long actualPackages,
|
||||
@PositiveOrZero long expectedVersion,
|
||||
@NotBlank @Size(max = 128) String idempotencyKey) { }
|
||||
|
||||
public record Submit(@PositiveOrZero long expectedVersion,
|
||||
@NotBlank @Size(max = 500) String reason,
|
||||
@NotBlank @Size(max = 128) String idempotencyKey) { }
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.baiyue.lastleg.pda.stocktake.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public final class PdaStocktakeResponses {
|
||||
private PdaStocktakeResponses() { }
|
||||
|
||||
public record Summary(String taskNo, String scopeType, String scopeCode, String status,
|
||||
int positionCount, int countedCount, int differenceCount, long version,
|
||||
LocalDateTime createdAt, LocalDateTime submittedAt) { }
|
||||
|
||||
public record Item(long itemId, String customerId, long balanceId, long positionId,
|
||||
String skuCode, String productName, String inboundCode,
|
||||
long palletId, String palletCode, long locationId, String locationCode,
|
||||
long bookPackages, Long actualPackages, Long differencePackages,
|
||||
String status, long positionVersion, long version) { }
|
||||
|
||||
public record Detail(Summary task, List<Item> items) { }
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.lastleg.pda.stocktake.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_pda_stocktake_action")
|
||||
public class PdaStocktakeAction {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private Long overseasWarehouseId;
|
||||
private String operatorKey;
|
||||
private String idempotencyKey;
|
||||
private String actionType;
|
||||
private Long taskId;
|
||||
private String requestHash;
|
||||
private String status;
|
||||
private String resultJson;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.baiyue.lastleg.pda.stocktake.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_pda_stocktake_item")
|
||||
public class PdaStocktakeItem {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private Long taskId;
|
||||
private String customerId;
|
||||
private Long balanceId;
|
||||
private Long positionId;
|
||||
private String skuCode;
|
||||
private String productName;
|
||||
private String inboundCode;
|
||||
private Long palletId;
|
||||
private String palletCode;
|
||||
private Long locationId;
|
||||
private String locationCode;
|
||||
private Long bookPackages;
|
||||
private Long actualPackages;
|
||||
private Long differencePackages;
|
||||
private String status;
|
||||
private Long positionVersion;
|
||||
private String countedBy;
|
||||
private Long version;
|
||||
private LocalDateTime countedAt;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.baiyue.lastleg.pda.stocktake.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_pda_stocktake_task")
|
||||
public class PdaStocktakeTask {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private String taskNo;
|
||||
private Long overseasWarehouseId;
|
||||
private String scopeType;
|
||||
private Long scopeResourceId;
|
||||
private String scopeCode;
|
||||
private String status;
|
||||
private Integer positionCount;
|
||||
private Integer countedCount;
|
||||
private Integer differenceCount;
|
||||
private String createdBy;
|
||||
private String submittedBy;
|
||||
private String submitReason;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
private Long version;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
private LocalDateTime submittedAt;
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.baiyue.lastleg.pda.stocktake.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeAction;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface PdaStocktakeActionMapper extends BaseMapper<PdaStocktakeAction> {
|
||||
@Select("SELECT * FROM ll_pda_stocktake_action WHERE overseas_warehouse_id=#{warehouseId} " +
|
||||
"AND operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1")
|
||||
PdaStocktakeAction find(@Param("warehouseId") long warehouseId,
|
||||
@Param("operatorKey") String operatorKey, @Param("key") String key);
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.baiyue.lastleg.pda.stocktake.mapper;
|
||||
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface PdaStocktakeInventoryMapper {
|
||||
@Select("SELECT * FROM ll_inventory_position WHERE id=#{positionId} AND customer_id=#{customerId} " +
|
||||
"AND overseas_warehouse_id=#{warehouseId} LIMIT 1 FOR UPDATE")
|
||||
InventoryPosition lockPosition(@Param("positionId") long positionId, @Param("customerId") String customerId,
|
||||
@Param("warehouseId") String warehouseId);
|
||||
|
||||
@Select("SELECT * FROM ll_inventory_balance WHERE id=#{balanceId} AND customer_id=#{customerId} " +
|
||||
"AND overseas_warehouse_id=#{warehouseId} LIMIT 1 FOR UPDATE")
|
||||
InventoryBalance lockBalance(@Param("balanceId") long balanceId, @Param("customerId") String customerId,
|
||||
@Param("warehouseId") String warehouseId);
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package com.baiyue.lastleg.pda.stocktake.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses;
|
||||
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeItem;
|
||||
import com.baiyue.lastleg.pda.stocktake.model.StocktakeCandidate;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface PdaStocktakeItemMapper extends BaseMapper<PdaStocktakeItem> {
|
||||
String CANDIDATE = "SELECT p.customer_id,b.id balance_id,p.id position_id,b.sku_code," +
|
||||
"COALESCE(NULLIF(b.product_name_zh,''),b.product_name_en) product_name,b.inbound_code," +
|
||||
"p.pallet_id,p.pallet_no pallet_code,p.warehouse_location_id location_id," +
|
||||
"p.warehouse_location_code location_code," +
|
||||
"(p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages) book_packages," +
|
||||
"p.version position_version FROM ll_inventory_position p JOIN ll_inventory_balance b " +
|
||||
"ON b.id=p.balance_id AND b.customer_id=p.customer_id WHERE p.overseas_warehouse_id=#{warehouseId} ";
|
||||
|
||||
@Select(CANDIDATE + "AND p.pallet_id=#{palletId} AND " +
|
||||
"(p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " +
|
||||
"ORDER BY p.customer_id,p.id")
|
||||
List<StocktakeCandidate> candidatesByPallet(@Param("warehouseId") String warehouseId,
|
||||
@Param("palletId") long palletId);
|
||||
|
||||
@Select(CANDIDATE + "AND (p.warehouse_location_id=#{locationId} OR p.warehouse_location_code=#{locationCode}) " +
|
||||
"AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " +
|
||||
"ORDER BY p.customer_id,p.pallet_no,p.id")
|
||||
List<StocktakeCandidate> candidatesByLocation(@Param("warehouseId") String warehouseId,
|
||||
@Param("locationId") long locationId,
|
||||
@Param("locationCode") String locationCode);
|
||||
|
||||
@Select("SELECT * FROM ll_pda_stocktake_item WHERE task_id=#{taskId} ORDER BY location_code,pallet_code,position_id")
|
||||
List<PdaStocktakeItem> list(@Param("taskId") long taskId);
|
||||
|
||||
@Select("SELECT * FROM ll_pda_stocktake_item WHERE task_id=#{taskId} ORDER BY id FOR UPDATE")
|
||||
List<PdaStocktakeItem> lockAll(@Param("taskId") long taskId);
|
||||
|
||||
@Select("SELECT * FROM ll_pda_stocktake_item WHERE task_id=#{taskId} AND position_id=#{positionId} LIMIT 1 FOR UPDATE")
|
||||
PdaStocktakeItem lockItem(@Param("taskId") long taskId, @Param("positionId") long positionId);
|
||||
|
||||
@Update("UPDATE ll_pda_stocktake_item SET actual_packages=#{actualPackages}," +
|
||||
"difference_packages=#{differencePackages},status=#{status},counted_by=#{operatorKey}," +
|
||||
"counted_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),version=version+1 " +
|
||||
"WHERE id=#{itemId} AND version=#{expectedVersion} AND status IN ('PENDING','COUNTED')")
|
||||
int countItem(@Param("itemId") long itemId, @Param("expectedVersion") long expectedVersion,
|
||||
@Param("actualPackages") long actualPackages, @Param("differencePackages") long differencePackages,
|
||||
@Param("status") String status, @Param("operatorKey") String operatorKey);
|
||||
|
||||
@Select("SELECT COUNT(*) FROM ll_pda_stocktake_item WHERE task_id=#{taskId} AND actual_packages IS NOT NULL")
|
||||
int counted(@Param("taskId") long taskId);
|
||||
|
||||
@Select("SELECT COUNT(*) FROM ll_pda_stocktake_item WHERE task_id=#{taskId} " +
|
||||
"AND actual_packages IS NOT NULL AND difference_packages<>0")
|
||||
int differences(@Param("taskId") long taskId);
|
||||
|
||||
@Select("SELECT id item_id,customer_id,balance_id,position_id,sku_code,product_name,inbound_code," +
|
||||
"pallet_id,pallet_code,location_id,location_code,book_packages,actual_packages,difference_packages," +
|
||||
"status,position_version,version FROM ll_pda_stocktake_item WHERE task_id=#{taskId} " +
|
||||
"ORDER BY location_code,pallet_code,position_id")
|
||||
List<PdaStocktakeResponses.Item> views(@Param("taskId") long taskId);
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.baiyue.lastleg.pda.stocktake.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses;
|
||||
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeTask;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface PdaStocktakeTaskMapper extends BaseMapper<PdaStocktakeTask> {
|
||||
@Select("SELECT * FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " +
|
||||
"AND created_by=#{operatorKey} AND idempotency_key=#{key} LIMIT 1")
|
||||
PdaStocktakeTask findCreate(@Param("warehouseId") long warehouseId,
|
||||
@Param("operatorKey") String operatorKey, @Param("key") String key);
|
||||
|
||||
@Select("SELECT * FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " +
|
||||
"AND task_no=#{taskNo} LIMIT 1")
|
||||
PdaStocktakeTask find(@Param("warehouseId") long warehouseId, @Param("taskNo") String taskNo);
|
||||
|
||||
@Select("SELECT * FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " +
|
||||
"AND task_no=#{taskNo} LIMIT 1 FOR UPDATE")
|
||||
PdaStocktakeTask lock(@Param("warehouseId") long warehouseId, @Param("taskNo") String taskNo);
|
||||
|
||||
@Select("<script>SELECT task_no,scope_type,scope_code,status,position_count,counted_count,difference_count," +
|
||||
"version,created_at,submitted_at FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " +
|
||||
"<if test='status != null and status != \"\"'>AND status=#{status} </if>" +
|
||||
"ORDER BY created_at DESC,id DESC LIMIT #{limit} OFFSET #{offset}</script>")
|
||||
List<PdaStocktakeResponses.Summary> page(@Param("warehouseId") long warehouseId,
|
||||
@Param("status") String status,
|
||||
@Param("offset") long offset, @Param("limit") long limit);
|
||||
|
||||
@Select("<script>SELECT COUNT(*) FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " +
|
||||
"<if test='status != null and status != \"\"'>AND status=#{status}</if></script>")
|
||||
long count(@Param("warehouseId") long warehouseId, @Param("status") String status);
|
||||
|
||||
@Update("UPDATE ll_pda_stocktake_task SET counted_count=#{countedCount},difference_count=#{differenceCount}," +
|
||||
"version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{taskId} AND version=#{expectedVersion} " +
|
||||
"AND status='IN_PROGRESS'")
|
||||
int refreshCounts(@Param("taskId") long taskId, @Param("expectedVersion") long expectedVersion,
|
||||
@Param("countedCount") int countedCount, @Param("differenceCount") int differenceCount);
|
||||
|
||||
@Update("UPDATE ll_pda_stocktake_task SET status='SUBMITTED',submitted_by=#{operatorKey}," +
|
||||
"submit_reason=#{reason},submitted_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3)," +
|
||||
"version=version+1 WHERE id=#{taskId} AND version=#{expectedVersion} AND status='IN_PROGRESS'")
|
||||
int submit(@Param("taskId") long taskId, @Param("expectedVersion") long expectedVersion,
|
||||
@Param("operatorKey") String operatorKey, @Param("reason") String reason);
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.lastleg.pda.stocktake.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class StocktakeCandidate {
|
||||
private String customerId;
|
||||
private Long balanceId;
|
||||
private Long positionId;
|
||||
private String skuCode;
|
||||
private String productName;
|
||||
private String inboundCode;
|
||||
private Long palletId;
|
||||
private String palletCode;
|
||||
private Long locationId;
|
||||
private String locationCode;
|
||||
private Long bookPackages;
|
||||
private Long positionVersion;
|
||||
}
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
package com.baiyue.lastleg.pda.stocktake.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
|
||||
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests;
|
||||
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses;
|
||||
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeAction;
|
||||
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeItem;
|
||||
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeTask;
|
||||
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeActionMapper;
|
||||
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeInventoryMapper;
|
||||
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeItemMapper;
|
||||
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeTaskMapper;
|
||||
import com.baiyue.lastleg.pda.stocktake.model.StocktakeCandidate;
|
||||
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class PdaStocktakeService {
|
||||
@Resource private PdaStocktakeTaskMapper tasks;
|
||||
@Resource private PdaStocktakeItemMapper items;
|
||||
@Resource private PdaStocktakeActionMapper actions;
|
||||
@Resource private PdaStocktakeInventoryMapper inventory;
|
||||
@Resource private InventoryPositionMapper positions;
|
||||
@Resource private InventoryBalanceMapper balances;
|
||||
@Resource private InventoryLedgerMapper ledgers;
|
||||
@Resource private WarehouseResourceGateway warehouse;
|
||||
@Resource private PdaWarehouseService history;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
|
||||
public PageResult<PdaStocktakeResponses.Summary> page(long warehouseId, PdaStocktakeRequests.TaskQuery query) {
|
||||
String status = status(query.status());
|
||||
long offset = (query.page() - 1) * query.size();
|
||||
List<PdaStocktakeResponses.Summary> rows = tasks.page(warehouseId, status, offset, query.size());
|
||||
long total = tasks.count(warehouseId, status);
|
||||
return new PageResult<>(rows, query.page(), query.size(), total,
|
||||
total == 0 ? 0 : (total + query.size() - 1) / query.size());
|
||||
}
|
||||
|
||||
public PdaStocktakeResponses.Detail detail(long warehouseId, String taskNo) {
|
||||
PdaStocktakeTask task = requireTask(warehouseId, taskNo, false);
|
||||
return detail(task);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PdaStocktakeResponses.Detail create(long warehouseId, String operatorKey,
|
||||
PdaStocktakeRequests.Create request) {
|
||||
String key = required(request.idempotencyKey(), "Idempotency key is required");
|
||||
String requestHash = hash(request);
|
||||
PdaStocktakeTask replay = tasks.findCreate(warehouseId, operatorKey, key);
|
||||
if (replay != null) {
|
||||
if (!requestHash.equals(replay.getRequestHash())) throw conflict("Idempotency key was reused with another stocktake");
|
||||
return detail(replay);
|
||||
}
|
||||
|
||||
String scopeType = required(request.scopeType(), "Scope type is required").toUpperCase();
|
||||
String scopeCode = required(request.scopeCode(), "Scope code is required");
|
||||
long resourceId;
|
||||
List<StocktakeCandidate> candidates;
|
||||
if ("PALLET".equals(scopeType)) {
|
||||
var pallet = warehouse.resolvePallet(scopeCode);
|
||||
if (pallet.warehouseId() != warehouseId) throw conflict("Pallet belongs to another overseas warehouse");
|
||||
resourceId = pallet.palletId();
|
||||
scopeCode = pallet.palletNo();
|
||||
candidates = items.candidatesByPallet(String.valueOf(warehouseId), resourceId);
|
||||
} else if ("LOCATION".equals(scopeType)) {
|
||||
var location = warehouse.resolveLocation(warehouseId, scopeCode);
|
||||
if (!List.of("ACTIVE", "IDLE", "OCCUPIED").contains(location.status())) {
|
||||
throw conflict("Stocktake location is not active");
|
||||
}
|
||||
resourceId = location.locationId();
|
||||
scopeCode = location.locationCode();
|
||||
candidates = items.candidatesByLocation(String.valueOf(warehouseId), resourceId, scopeCode);
|
||||
} else {
|
||||
throw badRequest("scopeType must be LOCATION or PALLET");
|
||||
}
|
||||
if (candidates.isEmpty()) throw conflict("No inventory is available in the scanned scope");
|
||||
|
||||
long taskId = IdWorker.getId();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
PdaStocktakeTask task = new PdaStocktakeTask();
|
||||
task.setId(taskId); task.setTaskNo("CC-" + Long.toUnsignedString(taskId));
|
||||
task.setOverseasWarehouseId(warehouseId); task.setScopeType(scopeType); task.setScopeResourceId(resourceId);
|
||||
task.setScopeCode(scopeCode); task.setStatus("IN_PROGRESS"); task.setPositionCount(candidates.size());
|
||||
task.setCountedCount(0); task.setDifferenceCount(0); task.setCreatedBy(operatorKey);
|
||||
task.setIdempotencyKey(key); task.setRequestHash(requestHash); task.setVersion(0L);
|
||||
task.setCreatedAt(now); task.setUpdatedAt(now);
|
||||
try {
|
||||
tasks.insert(task);
|
||||
} catch (DuplicateKeyException duplicate) {
|
||||
PdaStocktakeTask winner = tasks.findCreate(warehouseId, operatorKey, key);
|
||||
if (winner == null || !requestHash.equals(winner.getRequestHash())) throw duplicate;
|
||||
return detail(winner);
|
||||
}
|
||||
for (StocktakeCandidate candidate : candidates) {
|
||||
PdaStocktakeItem item = new PdaStocktakeItem();
|
||||
item.setId(IdWorker.getId()); item.setTaskId(taskId); item.setCustomerId(candidate.getCustomerId());
|
||||
item.setBalanceId(candidate.getBalanceId()); item.setPositionId(candidate.getPositionId());
|
||||
item.setSkuCode(candidate.getSkuCode()); item.setProductName(candidate.getProductName());
|
||||
item.setInboundCode(candidate.getInboundCode()); item.setPalletId(candidate.getPalletId());
|
||||
item.setPalletCode(candidate.getPalletCode()); item.setLocationId(candidate.getLocationId());
|
||||
item.setLocationCode(candidate.getLocationCode()); item.setBookPackages(value(candidate.getBookPackages()));
|
||||
item.setStatus("PENDING"); item.setPositionVersion(value(candidate.getPositionVersion()));
|
||||
item.setVersion(0L); item.setCreatedAt(now); item.setUpdatedAt(now); items.insert(item);
|
||||
}
|
||||
history.record(warehouseId, null, operatorKey, "STOCKTAKE_CREATE", task.getTaskNo(), "IN_PROGRESS",
|
||||
"STOCKTAKE_CREATE:" + key, requestHash,
|
||||
scopeType + " " + scopeCode + ",共 " + candidates.size() + " 个库存位置");
|
||||
return detail(task);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PdaStocktakeResponses.Item count(long warehouseId, String operatorKey, String taskNo,
|
||||
PdaStocktakeRequests.Count request) {
|
||||
String key = required(request.idempotencyKey(), "Idempotency key is required");
|
||||
String requestHash = hash(request);
|
||||
PdaStocktakeAction action = beginAction(warehouseId, operatorKey, key, "COUNT", requestHash, taskNo);
|
||||
if ("COMPLETED".equals(action.getStatus())) return read(action, PdaStocktakeResponses.Item.class);
|
||||
|
||||
PdaStocktakeTask task = requireTask(warehouseId, taskNo, true);
|
||||
if (!"IN_PROGRESS".equals(task.getStatus())) throw conflict("Submitted stocktake cannot be changed");
|
||||
PdaStocktakeItem item = items.lockItem(task.getId(), request.positionId());
|
||||
if (item == null) throw notFound("Stocktake position was not found in this task");
|
||||
long difference = request.actualPackages() - value(item.getBookPackages());
|
||||
if (items.countItem(item.getId(), request.expectedVersion(), request.actualPackages(), difference,
|
||||
difference == 0 ? "MATCHED" : "DIFFERENCE", operatorKey) != 1) {
|
||||
throw conflict("Stocktake item version changed; refresh and retry");
|
||||
}
|
||||
int counted = items.counted(task.getId());
|
||||
int differences = items.differences(task.getId());
|
||||
if (tasks.refreshCounts(task.getId(), value(task.getVersion()), counted, differences) != 1) {
|
||||
throw conflict("Stocktake task version changed; refresh and retry");
|
||||
}
|
||||
PdaStocktakeResponses.Item response = items.views(task.getId()).stream()
|
||||
.filter(row -> row.positionId() == request.positionId()).findFirst().orElseThrow();
|
||||
complete(action, response);
|
||||
return response;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PdaStocktakeResponses.Detail submit(long warehouseId, String operatorKey, String taskNo,
|
||||
PdaStocktakeRequests.Submit request) {
|
||||
String key = required(request.idempotencyKey(), "Idempotency key is required");
|
||||
String requestHash = hash(request);
|
||||
PdaStocktakeAction action = beginAction(warehouseId, operatorKey, key, "SUBMIT", requestHash, taskNo);
|
||||
if ("COMPLETED".equals(action.getStatus())) return read(action, PdaStocktakeResponses.Detail.class);
|
||||
|
||||
PdaStocktakeTask task = requireTask(warehouseId, taskNo, true);
|
||||
if (!"IN_PROGRESS".equals(task.getStatus())) throw conflict("Stocktake was already submitted");
|
||||
if (value(task.getVersion()) != request.expectedVersion()) throw conflict("Stocktake task version changed; refresh and retry");
|
||||
List<PdaStocktakeItem> taskItems = items.lockAll(task.getId());
|
||||
if (taskItems.isEmpty() || taskItems.stream().anyMatch(item -> item.getActualPackages() == null)) {
|
||||
throw conflict("Every stocktake position must be counted before submission");
|
||||
}
|
||||
|
||||
Map<Long, InventoryBalance> changedBalances = new HashMap<>();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
for (PdaStocktakeItem item : taskItems) {
|
||||
InventoryPosition position = inventory.lockPosition(item.getPositionId(), item.getCustomerId(),
|
||||
String.valueOf(warehouseId));
|
||||
if (position == null || value(position.getVersion()) != value(item.getPositionVersion())) {
|
||||
throw conflict("Inventory changed after counting; recreate the stocktake task");
|
||||
}
|
||||
long before = physical(position);
|
||||
if (before != value(item.getBookPackages())) {
|
||||
throw conflict("Inventory package total changed after counting; recreate the stocktake task");
|
||||
}
|
||||
long fixed = value(position.getHoldPackages()) + value(position.getAllocatedPackages())
|
||||
+ value(position.getPickedPackages()) + value(position.getReceivingPackages());
|
||||
if (value(item.getActualPackages()) < fixed) {
|
||||
throw conflict("Actual packages cannot be lower than reserved or processing packages");
|
||||
}
|
||||
long newAvailable = value(item.getActualPackages()) - fixed;
|
||||
long delta = newAvailable - value(position.getAvailablePackages());
|
||||
InventoryBalance balance = changedBalances.get(item.getBalanceId());
|
||||
if (balance == null) {
|
||||
balance = inventory.lockBalance(item.getBalanceId(), item.getCustomerId(), String.valueOf(warehouseId));
|
||||
if (balance == null) throw new IllegalStateException("Stocktake inventory balance is missing");
|
||||
changedBalances.put(item.getBalanceId(), balance);
|
||||
}
|
||||
long balanceAfter = value(balance.getAvailablePackages()) + delta;
|
||||
if (balanceAfter < 0) throw conflict("Stocktake adjustment would make inventory negative");
|
||||
|
||||
position.setAvailablePackages(newAvailable); position.setStatus(physicalAfter(position, newAvailable) == 0 ? "EMPTY" : "OCCUPIED");
|
||||
position.setVersion(value(position.getVersion()) + 1); position.setUpdatedAt(now); positions.updateById(position);
|
||||
balance.setAvailablePackages(balanceAfter); balance.setVersion(value(balance.getVersion()) + 1); balance.setUpdatedAt(now);
|
||||
|
||||
ledger(task, item, position, balance, operatorKey, key, request.reason(), before, delta,
|
||||
value(item.getActualPackages()), now);
|
||||
item.setStatus(delta == 0 ? "MATCHED" : "ADJUSTED"); item.setUpdatedAt(now);
|
||||
item.setVersion(value(item.getVersion()) + 1); items.updateById(item);
|
||||
}
|
||||
changedBalances.values().forEach(balances::updateById);
|
||||
if (tasks.submit(task.getId(), request.expectedVersion(), operatorKey, request.reason()) != 1) {
|
||||
throw conflict("Stocktake task version changed; refresh and retry");
|
||||
}
|
||||
history.record(warehouseId, null, operatorKey, "STOCKTAKE_SUBMIT", taskNo, "COMPLETED",
|
||||
"STOCKTAKE_SUBMIT:" + key, requestHash,
|
||||
"盘点提交,差异位置 " + task.getDifferenceCount() + " 个;原因:" + request.reason().trim());
|
||||
PdaStocktakeResponses.Detail response = detail(requireTask(warehouseId, taskNo, false));
|
||||
complete(action, response);
|
||||
return response;
|
||||
}
|
||||
|
||||
private PdaStocktakeResponses.Detail detail(PdaStocktakeTask task) {
|
||||
return new PdaStocktakeResponses.Detail(summary(task), items.views(task.getId()));
|
||||
}
|
||||
|
||||
private PdaStocktakeResponses.Summary summary(PdaStocktakeTask task) {
|
||||
return new PdaStocktakeResponses.Summary(task.getTaskNo(), task.getScopeType(), task.getScopeCode(),
|
||||
task.getStatus(), intValue(task.getPositionCount()), intValue(task.getCountedCount()),
|
||||
intValue(task.getDifferenceCount()), value(task.getVersion()), task.getCreatedAt(), task.getSubmittedAt());
|
||||
}
|
||||
|
||||
private PdaStocktakeTask requireTask(long warehouseId, String taskNo, boolean lock) {
|
||||
String normalized = required(taskNo, "Stocktake task number is required");
|
||||
PdaStocktakeTask task = lock ? tasks.lock(warehouseId, normalized) : tasks.find(warehouseId, normalized);
|
||||
if (task == null) throw notFound("Stocktake task was not found in the bound warehouse");
|
||||
return task;
|
||||
}
|
||||
|
||||
private PdaStocktakeAction beginAction(long warehouseId, String operatorKey, String key, String type,
|
||||
String requestHash, String taskNo) {
|
||||
PdaStocktakeAction existing = actions.find(warehouseId, operatorKey, key);
|
||||
if (existing != null) return replay(existing, type, requestHash);
|
||||
PdaStocktakeAction action = new PdaStocktakeAction();
|
||||
action.setId(IdWorker.getId()); action.setOverseasWarehouseId(warehouseId); action.setOperatorKey(operatorKey);
|
||||
action.setIdempotencyKey(key); action.setActionType(type); action.setRequestHash(requestHash);
|
||||
PdaStocktakeTask task = tasks.find(warehouseId, taskNo); action.setTaskId(task == null ? null : task.getId());
|
||||
action.setStatus("PROCESSING"); action.setCreatedAt(LocalDateTime.now()); action.setUpdatedAt(action.getCreatedAt());
|
||||
try { actions.insert(action); return action; }
|
||||
catch (DuplicateKeyException duplicate) {
|
||||
PdaStocktakeAction winner = actions.find(warehouseId, operatorKey, key);
|
||||
if (winner == null) throw duplicate;
|
||||
return replay(winner, type, requestHash);
|
||||
}
|
||||
}
|
||||
|
||||
private PdaStocktakeAction replay(PdaStocktakeAction action, String type, String requestHash) {
|
||||
if (!type.equals(action.getActionType()) || !requestHash.equals(action.getRequestHash())) {
|
||||
throw conflict("Idempotency key was reused with a different stocktake action");
|
||||
}
|
||||
if (!"COMPLETED".equals(action.getStatus())) throw conflict("Stocktake action is still processing");
|
||||
return action;
|
||||
}
|
||||
|
||||
private void complete(PdaStocktakeAction action, Object response) {
|
||||
action.setStatus("COMPLETED"); action.setResultJson(write(response)); action.setUpdatedAt(LocalDateTime.now());
|
||||
actions.updateById(action);
|
||||
}
|
||||
|
||||
private <T> T read(PdaStocktakeAction action, Class<T> type) {
|
||||
try { return objectMapper.readValue(action.getResultJson(), type); }
|
||||
catch (JsonProcessingException error) { throw new IllegalStateException("Cannot read stocktake replay", error); }
|
||||
}
|
||||
|
||||
private void ledger(PdaStocktakeTask task, PdaStocktakeItem item, InventoryPosition position,
|
||||
InventoryBalance balance, String operator, String key, String reason,
|
||||
long before, long delta, long after, LocalDateTime now) {
|
||||
InventoryLedger row = new InventoryLedger();
|
||||
row.setId(IdWorker.getId()); row.setCustomerId(item.getCustomerId()); row.setBalanceId(balance.getId());
|
||||
row.setPositionId(position.getId()); row.setOverseasWarehouseId(String.valueOf(task.getOverseasWarehouseId()));
|
||||
row.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); row.setInboundOrderId(balance.getInboundOrderId());
|
||||
row.setPalletId(position.getPalletId()); row.setPalletNo(position.getPalletNo()); row.setBatchNo(position.getBatchNo());
|
||||
row.setSkuCode(item.getSkuCode()); row.setWarehouseLocationCode(position.getWarehouseLocationCode());
|
||||
row.setBusinessType("STOCKTAKE_ADJUST"); row.setBusinessOrderId(task.getId()); row.setBusinessOrderNo(task.getTaskNo());
|
||||
row.setInboundCode(balance.getInboundCode()); row.setInboundOrderNo(balance.getInboundOrderNo());
|
||||
row.setIdempotencyKey("STOCKTAKE:" + task.getId() + ":" + item.getId());
|
||||
row.setStatusFrom("PHYSICAL"); row.setStatusTo("PHYSICAL"); row.setBeforePackages(before);
|
||||
row.setDeltaPackages(delta); row.setAfterPackages(after); row.setOperatorId(operator); row.setRemark(reason.trim());
|
||||
row.setOccurredAt(now); row.setCreatedAt(now); ledgers.insert(row);
|
||||
}
|
||||
|
||||
private String hash(Object value) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(write(value).getBytes(StandardCharsets.UTF_8))); }
|
||||
catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); }
|
||||
}
|
||||
private String write(Object value) {
|
||||
try { return objectMapper.writeValueAsString(value); }
|
||||
catch (JsonProcessingException error) { throw new IllegalStateException("Cannot serialize stocktake operation", error); }
|
||||
}
|
||||
private static String status(String value) {
|
||||
String normalized = trim(value); if (normalized == null) return null;
|
||||
normalized = normalized.toUpperCase();
|
||||
if (!List.of("IN_PROGRESS", "SUBMITTED").contains(normalized)) throw badRequest("Unsupported stocktake status");
|
||||
return normalized;
|
||||
}
|
||||
private static long physical(InventoryPosition value) { return value(value.getAvailablePackages()) + value(value.getHoldPackages())
|
||||
+ value(value.getAllocatedPackages()) + value(value.getPickedPackages()) + value(value.getReceivingPackages()); }
|
||||
private static long physicalAfter(InventoryPosition value, long available) { return available + value(value.getHoldPackages())
|
||||
+ value(value.getAllocatedPackages()) + value(value.getPickedPackages()) + value(value.getReceivingPackages()); }
|
||||
private static long value(Long value) { return value == null ? 0 : value; }
|
||||
private static int intValue(Integer value) { return value == null ? 0 : value; }
|
||||
private static String required(String value, String message) { String normalized = trim(value); if (normalized == null) throw badRequest(message); return normalized; }
|
||||
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
|
||||
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
|
||||
}
|
||||
+9
@@ -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 {
|
||||
}
|
||||
+127
@@ -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) { }
|
||||
}
|
||||
+45
@@ -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) { }
|
||||
}
|
||||
+37
@@ -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) { }
|
||||
}
|
||||
+24
@@ -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;
|
||||
}
|
||||
+22
@@ -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;
|
||||
}
|
||||
+37
@@ -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;
|
||||
}
|
||||
+26
@@ -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;
|
||||
}
|
||||
+109
@@ -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) { }
|
||||
}
|
||||
+10
@@ -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) { }
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
+30
@@ -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);
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
+480
@@ -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) { }
|
||||
}
|
||||
Reference in New Issue
Block a user