This commit is contained in:
2026-08-11 18:43:50 +08:00
parent e1487162a5
commit bc5d9a6215
25 changed files with 1424 additions and 28 deletions
@@ -3,6 +3,8 @@ package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
@@ -38,6 +40,13 @@ public interface WarehousePalletLocationClient {
ApiResponse<SettlementResult> settle(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody SettlementRequest request);
@GetMapping("/occupied-resources")
ApiResponse<List<OccupiedResource>> occupiedResources(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestParam("tenantId") long tenantId,
@RequestParam("sourceService") String sourceService,
@RequestParam("businessId") long businessId);
record ReserveRequest(long tenantId, long warehouseId, String sourceService, long businessId,
String inboundCode, String idempotencyKey, long operatorId,
BigDecimal totalVolumeCbm, BigDecimal palletCapacityCbm, String palletType,
@@ -75,4 +84,7 @@ public interface WarehousePalletLocationClient {
String operatorName, String requestId, List<Long> releasedReservationIds,
List<SettlementTransferItem> remainingItems) {}
record SettlementResult(List<SelectiveReleaseResult> released, List<TransferResult> transferred) {}
record OccupiedResource(long reservationId, long occupancyId, int allocationNo, long warehouseId,
long palletId, long locationId, String locationCode, boolean sharedOverflow,
int allocatedPackageCount) {}
}
@@ -10,6 +10,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.AssignRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.Operation;
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.InventorySnapshot;
import java.util.List;
import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
@@ -48,6 +49,15 @@ public class PlatformLoadingPdaController {
return ApiResponse.success(service.task(containerNo, warehouseId));
}
@GetMapping("/tasks/{taskId}/inventory-snapshot")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<InventorySnapshot> inventorySnapshot(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@RequestParam @Positive long warehouseId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.inventorySnapshot(taskId, warehouseId));
}
@PostMapping("/tasks/{taskId}/claim")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Task> claim(@AuthenticationPrincipal AuthenticatedUser user,
@@ -60,6 +60,7 @@ public final class LoadingPickDtos {
@NotNull @Min(1) Long warehouseId,
@NotBlank @Size(max = 64) String containerNo,
@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 64) String inventorySnapshotToken,
@NotEmpty @Size(max = 500) List<@Valid BatchItemRequest> items
) {
}
@@ -118,4 +119,18 @@ public final class LoadingPickDtos {
String workflowStatus, Integer version, Assignment assignment,
Review review, List<PickItem> items) {
}
public record InventoryResource(Long reservationId, Long occupancyId, Long palletId,
Long locationId, String locationCode, Boolean sharedOverflow,
Integer availablePackageCount) {
}
public record InboundInventory(Long inboundId, Integer availablePackageCount,
List<InventoryResource> resources) {
}
public record InventorySnapshot(Long taskId, Integer taskVersion, String containerNo,
Long warehouseId, LocalDateTime capturedAt, String snapshotToken,
List<InboundInventory> inbounds) {
}
}
@@ -1,7 +1,9 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehousePalletLocationClient;
import com.baiyue.firstleg.dto.LoadingPickDtos.PickItem;
import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest;
@@ -15,6 +17,9 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.Operation;
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.InventoryResource;
import com.baiyue.firstleg.dto.LoadingPickDtos.InboundInventory;
import com.baiyue.firstleg.dto.LoadingPickDtos.InventorySnapshot;
import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
@@ -26,6 +31,8 @@ import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import jakarta.annotation.Resource;
import feign.FeignException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -43,10 +50,13 @@ import java.util.HashMap;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import java.time.LocalDateTime;
/** 装柜中 PDA 拣货任务及明细同步。 */
@Service
public class LoadingPickService {
private static final long PLATFORM_WAREHOUSE_RESOURCE_TENANT_ID = 0L;
private static final String SOURCE_SERVICE = "firstleg-service";
@Resource
private LoadingPickRepository repository;
@Resource
@@ -55,6 +65,10 @@ public class LoadingPickService {
private LoadingListService loadingListService;
@Resource
private PdaPickSettlementService settlementService;
@Resource
private WarehousePalletLocationClient warehouse;
@Value("${baiyue.security.internal-service-token:test-internal-service-token}")
private String internalToken;
@Transactional
public void begin(long planId, String containerNo, long operatorId, String operatorName,
@@ -105,6 +119,12 @@ public class LoadingPickService {
return view(task, warehouseId);
}
public InventorySnapshot inventorySnapshot(long taskId, long warehouseId) {
LoadingPickTaskEntity task = requiredTask(taskId);
List<LoadingPickItemEntity> items = warehouseScopedItems(task, warehouseId);
return buildInventorySnapshot(task, warehouseId, items);
}
@Transactional
public Task claim(long taskId, ClaimRequest request, long operatorId, String operatorName) {
String operationId = operation(request.operationId());
@@ -275,6 +295,7 @@ public class LoadingPickService {
}
List<BatchLine> lines = validateBatchLines(task, request);
validateInventorySnapshot(task, request, lines);
for (BatchLine line : lines) {
applyBatchLine(taskId, line, operationId, operatorId, operatorName);
}
@@ -554,6 +575,93 @@ public class LoadingPickService {
return result;
}
private List<LoadingPickItemEntity> warehouseScopedItems(LoadingPickTaskEntity task, long warehouseId) {
List<LoadingPickItemEntity> allItems = repository.items(task.getId());
List<LoadingPickItemEntity> scopedItems = repository.items(task.getId(), warehouseId);
if (allItems.isEmpty() || scopedItems.size() != allItems.size()) {
throw conflict("PDA任务柜次、仓库或客户归属不一致");
}
return scopedItems;
}
private InventorySnapshot buildInventorySnapshot(LoadingPickTaskEntity task, long warehouseId,
List<LoadingPickItemEntity> items) {
List<InboundInventory> inbounds = items.stream().map(LoadingPickItemEntity::getInboundId)
.filter(Objects::nonNull).distinct().sorted().map(inboundId -> {
List<WarehousePalletLocationClient.OccupiedResource> current = occupiedResources(inboundId);
List<InventoryResource> resources = current.stream()
.filter(resource -> resource.warehouseId() == warehouseId)
.sorted(Comparator.comparingLong(WarehousePalletLocationClient.OccupiedResource::reservationId)
.thenComparingLong(WarehousePalletLocationClient.OccupiedResource::occupancyId))
.map(resource -> new InventoryResource(resource.reservationId(), resource.occupancyId(),
resource.palletId(), resource.locationId(), resource.locationCode(),
resource.sharedOverflow(), resource.allocatedPackageCount()))
.toList();
int available = resources.stream().mapToInt(InventoryResource::availablePackageCount).sum();
return new InboundInventory(inboundId, available, resources);
}).toList();
String token = inventoryToken(task, warehouseId, inbounds);
return new InventorySnapshot(task.getId(), task.getVersion(), task.getContainerNo(), warehouseId,
LocalDateTime.now(), token, inbounds);
}
private List<WarehousePalletLocationClient.OccupiedResource> occupiedResources(long inboundId) {
ApiResponse<List<WarehousePalletLocationClient.OccupiedResource>> response;
try {
response = warehouse.occupiedResources(internalToken, PLATFORM_WAREHOUSE_RESOURCE_TENANT_ID,
SOURCE_SERVICE, inboundId);
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓储实时库存查询失败,请刷新后重试");
}
if (response == null || !response.successful() || response.data() == null) {
ResultCode code = response == null ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.of(response.code());
throw new BizException(code, response == null || response.message() == null
? "仓储实时库存查询失败,请刷新后重试" : response.message());
}
return response.data();
}
private static String inventoryToken(LoadingPickTaskEntity task, long warehouseId,
List<InboundInventory> inbounds) {
StringBuilder canonical = new StringBuilder("INVENTORY|").append(task.getId()).append('|')
.append(task.getVersion()).append('|').append(warehouseId);
for (InboundInventory inbound : inbounds) {
canonical.append('|').append(inbound.inboundId()).append('|').append(inbound.availablePackageCount());
for (InventoryResource resource : inbound.resources()) {
canonical.append('|').append(resource.reservationId()).append('|').append(resource.occupancyId())
.append('|').append(resource.locationId()).append('|').append(resource.availablePackageCount());
}
}
return hash(canonical.toString());
}
private void validateInventorySnapshot(LoadingPickTaskEntity task, BatchRequest request,
List<BatchLine> lines) {
List<LoadingPickItemEntity> allItems = warehouseScopedItems(task, request.warehouseId());
InventorySnapshot current = buildInventorySnapshot(task, request.warehouseId(), allItems);
if (!current.snapshotToken().equals(request.inventorySnapshotToken().trim())) {
throw conflict("库存已变化,请刷新后重试");
}
Map<Long, Integer> availableByInbound = current.inbounds().stream().collect(Collectors.toMap(
InboundInventory::inboundId, InboundInventory::availablePackageCount));
Map<Long, Integer> requestedByInbound = new HashMap<>();
Map<Long, BatchLine> lineByItem = lines.stream().collect(Collectors.toMap(
line -> line.item().getId(), line -> line));
for (LoadingPickItemEntity item : allItems) {
BatchLine line = lineByItem.get(item.getId());
int picked = line == null ? count(item.getPickedPackageCount())
: "PICK".equals(line.action())
? count(item.getPickedPackageCount()) + line.request().pickedPackageCount()
: "PARTIAL".equals(line.action()) ? line.request().finalPickedPackageCount() : 0;
requestedByInbound.merge(item.getInboundId(), picked, Integer::sum);
}
requestedByInbound.forEach((inboundId, requested) -> {
if (requested > availableByInbound.getOrDefault(inboundId, 0)) {
throw conflict("库存已变化,请刷新后重试");
}
});
}
private void applyBatchLine(long taskId, BatchLine line, String batchOperationId,
long operatorId, String operatorName) {
LoadingPickItemEntity item = line.item();
@@ -690,12 +798,13 @@ public class LoadingPickService {
private static String batchRequestHash(long taskId, BatchRequest request) {
if (request == null || request.warehouseId() == null || !StringUtils.hasText(request.containerNo())
|| request.expectedVersion() == null || request.items() == null || request.items().isEmpty()) {
|| request.expectedVersion() == null || !StringUtils.hasText(request.inventorySnapshotToken())
|| request.items() == null || request.items().isEmpty()) {
throw new BizException(ResultCode.BAD_REQUEST, "整批提交参数不完整");
}
StringBuilder canonical = new StringBuilder("BATCH|").append(taskId).append('|')
.append(request.warehouseId()).append('|').append(request.containerNo().trim()).append('|')
.append(request.expectedVersion());
.append(request.expectedVersion()).append('|').append(request.inventorySnapshotToken().trim());
request.items().stream().sorted(Comparator.comparing(BatchItemRequest::itemId,
Comparator.nullsFirst(Long::compareTo)))
.forEach(item -> {