This commit is contained in:
2026-09-04 12:18:36 +08:00
parent 566b07be62
commit b697e4c9f0
51 changed files with 1898 additions and 623 deletions
@@ -30,35 +30,21 @@ public interface WarehousePalletLocationClient {
@org.springframework.web.bind.annotation.PathVariable("palletId") long palletId,
@RequestParam("warehouseId") long warehouseId);
@PostMapping("/pallet-locations/reservations")
ApiResponse<BatchResult> reserve(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ReserveRequest request);
@PostMapping("/pallet-locations/blocks/reservations")
ApiResponse<BlockResult> reserveBlock(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody BlockReserveRequest request);
@PostMapping("/pallet-locations/confirm")
ApiResponse<BatchResult> confirm(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ConfirmRequest request);
@PostMapping("/pallet-locations/blocks/confirm")
ApiResponse<BlockResult> confirmBlock(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody BlockConfirmRequest request);
@PostMapping("/pallet-locations/release")
ApiResponse<List<ActiveResource>> release(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ReleaseRequest request);
@PostMapping("/pallet-locations/blocks/transfer")
ApiResponse<BlockResult> transferBlock(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody BlockTransferRequest request);
@PostMapping("/pallet-locations/release-selected")
ApiResponse<List<SelectiveReleaseResult>> releaseSelected(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody SelectiveReleaseRequest request);
@PostMapping("/pallet-locations/transfer")
ApiResponse<List<TransferResult>> transfer(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody TransferRequest request);
@PostMapping("/pallet-locations/settlements")
ApiResponse<SettlementResult> settle(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody SettlementRequest request);
@GetMapping("/pallet-locations/settlements/target")
ApiResponse<Long> settlementTarget(@RequestHeader("X-Internal-Service-Token") String token,
@RequestParam("sourceBusinessId") long sourceBusinessId,
@RequestParam("idempotencyKey") String idempotencyKey);
@PostMapping("/pallet-locations/blocks/release")
ApiResponse<BlockResult> releaseBlock(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody BlockReleaseRequest request);
@GetMapping("/pallet-locations/occupied-resources")
ApiResponse<List<OccupiedResource>> occupiedResources(
@@ -67,44 +53,18 @@ public interface WarehousePalletLocationClient {
@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, List<PalletReference> pallets,
LocalDateTime expiresAt, boolean measurementCompleted) {}
record PalletReference(long weightResultId, long palletId) {}
record ConfirmRequest(long tenantId, long warehouseId, String sourceService, long businessId,
long operatorId, BigDecimal totalVolumeCbm, int packageCount) {}
record ReleaseRequest(long tenantId, String sourceService, long businessId, long operatorId,
String reason) {}
record SelectiveReleaseRequest(long tenantId, String sourceService, long businessId,
String idempotencyKey, long operatorId, String reason,
List<Long> locationIds) {}
record AllocationResult(long reservationId, String reservationNo, int allocationNo,
long palletId, String palletCode, BigDecimal palletCapacityCbm,
long locationId, String locationCode,
BigDecimal allocatedVolumeCbm, String state) {}
record BatchResult(int palletCount, List<AllocationResult> allocations) {}
record ActiveResource(long reservationId, long warehouseId, long palletId, String palletCode,
long locationId, String locationCode, String state) {}
record SelectiveReleaseResult(long reservationId, long occupancyId, int allocationNo,
long palletId, long locationId, String locationCode,
int allocatedPackageCount) {}
record TransferItem(long locationId, int remainingPackageCount) {}
record TransferRequest(long tenantId, String sourceService, long sourceBusinessId,
long targetBusinessId, String targetInboundCode, String idempotencyKey,
long operatorId, List<TransferItem> items) {}
record TransferResult(long reservationId, String reservationNo, long occupancyId, int allocationNo,
long warehouseResourceTenantId, long warehouseId, long palletId,
String palletCode, BigDecimal palletCapacityCbm, long locationId,
String locationCode, BigDecimal allocatedVolumeCbm,
int remainingPackageCount, String state) {}
record SettlementTransferItem(long reservationId, int remainingPackageCount) {}
record SettlementRequest(long warehouseId, String sourceService, long sourceBusinessId,
long targetBusinessId, int sourcePackageCount, int targetPackageCount,
String targetInboundCode, String idempotencyKey, long operatorId,
String operatorName, String requestId, List<Long> releasedReservationIds,
List<SettlementTransferItem> remainingItems) {}
record SettlementResult(List<SelectiveReleaseResult> released, List<TransferResult> transferred) {}
record BlockReserveRequest(long warehouseId, String sourceService, long businessId,
String inboundCode, int requiredSlotCount, String idempotencyKey,
long operatorId, LocalDateTime expiresAt) {}
record BlockConfirmRequest(long blockId, long businessId, String idempotencyKey,
long operatorId) {}
record BlockTransferRequest(long blockId, long sourceBusinessId, long targetBusinessId,
String targetInboundCode, String idempotencyKey, long operatorId) {}
record BlockReleaseRequest(long blockId, long businessId, String idempotencyKey,
long operatorId, String reason) {}
record BlockResource(long blockId, long reservationId, long locationId, String locationCode,
int slotNo, boolean sharedOverflow, String resourceStatus) {}
record BlockResult(long blockId, String blockStatus, List<BlockResource> resources) {}
record OccupiedResource(long reservationId, long occupancyId, int allocationNo, long warehouseId,
long palletId, long locationId, String locationCode, boolean sharedOverflow,
int allocatedPackageCount) {}
@@ -216,7 +216,8 @@ public class PlatformInboundMeasurementController {
@BusinessAudit(event = "INBOUND_WEIGHT_IMAGE_DELETE", resourceType = "INBOUND_WEIGHT_RESULT",
resourceId = "#resultId", description = "删除称重图片及重量结果")
@Operation(summary = "删除称重图片", description = "删除重量结果和文件;已确认图片删除后重新汇总实际总毛重。")
@Operation(summary = "删除称重图片",
description = "先将图片绑定槽恢复为 RESERVED_EMPTY,再删除重量结果和文件;整票库位块继续归当前入仓码。")
@DeleteMapping("/weight-results/{resultId}")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:edit')")
public ApiResponse<Void> deleteWeightResult(
@@ -47,7 +47,8 @@ public final class InboundMeasurementResponses {
@Schema(description = "Order 服务中的最新客户申报,只用于和既有实测事实对比;不得回写测量、箱码或审核结果")
DeclarationSnapshot latestDeclaration,
List<Item> items,
List<WeightResult> weightResults
List<WeightResult> weightResults,
PalletLocationGuide palletLocationGuide
) {
}
@@ -129,10 +130,42 @@ public final class InboundMeasurementResponses {
LocalDateTime adjustedAt,
Long confirmedBy,
LocalDateTime confirmedAt,
Integer palletSequence,
Long blockId,
Integer slotNo,
Long reservationId,
Long locationId,
String locationCode,
Boolean sharedOverflow,
String slotStatus,
Integer version
) {
}
/** 服务端权威的整票上架引导;包含尚未绑定称重图片的预留空槽。 */
public record PalletLocationGuide(
Long blockId,
Integer reservedSlotCount,
Integer actualPalletCount,
Integer reservedEmptySlotCount,
List<PalletLocationSlot> slots
) {
}
public record PalletLocationSlot(
Integer slotNo,
Long reservationId,
Long locationId,
String locationCode,
Boolean sharedOverflow,
String slotStatus,
Long boundWeightResultId,
Integer palletSequence,
Long palletId,
String palletCode
) {
}
public record PalletSnapshot(
Long palletId,
String palletCode,
@@ -14,9 +14,11 @@ public record InboundOrderDetail(
List<StatusHistorySummary> statusHistory
) {
public record PalletLocationSummary(
Integer allocationNo, Long warehouseId, Long palletId, String palletCode,
Integer allocationNo, Long blockId, Integer slotNo, Long weightResultId,
Integer palletSequence, Long warehouseId, Long palletId, String palletCode,
BigDecimal palletCapacityCbm, BigDecimal allocatedVolumeCbm, Integer allocatedPackageCount,
Long locationId, String locationCode, String reservationNo, String allocationStatus) {
Long reservationId, Long locationId, String locationCode, Boolean sharedOverflow,
String slotStatus, String allocationStatus) {
}
public record FileSummary(
@@ -1,6 +1,5 @@
package com.baiyue.firstleg.dto;
import com.fasterxml.jackson.annotation.JsonAlias;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
@@ -20,31 +19,17 @@ public final class LoadingPickDtos {
public record PickRequest(
@NotNull @Min(1) Long warehouseId,
@NotNull @Min(1) Integer pickedPackageCount,
@Pattern(regexp = "AUTO|MANUAL") String releaseMode,
@JsonAlias("reservationIds") List<@Min(1) Long> locationIds,
@NotNull @Min(0) Integer version,
@NotBlank @Size(max = 128) String operationId
) {
public PickRequest(Long warehouseId, Integer pickedPackageCount, List<Long> locationIds,
Integer version, String operationId) {
this(warehouseId, pickedPackageCount, null, locationIds, version, operationId);
}
}
) {}
/** 部分拣货为该 PDA 明细的最终实际拣出箱数,不是增量。 */
public record PartialPickRequest(
@NotNull @Min(1) Long warehouseId,
@NotNull @Min(1) Integer finalPickedPackageCount,
@Pattern(regexp = "AUTO|MANUAL") String releaseMode,
@JsonAlias("reservationIds") List<@Min(1) Long> locationIds,
@NotNull @Min(0) Integer version,
@NotBlank @Size(max = 128) String operationId
) {
public PartialPickRequest(Long warehouseId, Integer finalPickedPackageCount, List<Long> locationIds,
Integer version, String operationId) {
this(warehouseId, finalPickedPackageCount, null, locationIds, version, operationId);
}
}
) {}
/** 跳拣只能发生在尚未拣出任何箱数的 PDA 明细上。 */
public record SkipRequest(
@@ -70,8 +55,6 @@ public final class LoadingPickDtos {
@NotBlank @Pattern(regexp = "PICK|PARTIAL|SKIP") String action,
@Min(1) Integer pickedPackageCount,
@Min(1) Integer finalPickedPackageCount,
@Pattern(regexp = "AUTO|MANUAL") String releaseMode,
@JsonAlias("reservationIds") List<@Min(1) Long> locationIds,
@NotNull @Min(0) Integer version,
@Size(max = 500) String reason
) {
@@ -19,6 +19,14 @@ public class InboundPalletAllocationEntity {
private Long tenantId;
private Long inboundId;
private Integer allocationNo;
/** 整票连续库位块标识;同一入库单的活动槽位共享该标识。 */
private Long blockId;
/** 连续块内从 1 开始的稳定槽位序号。 */
private Integer slotNo;
/** 称重图片对应的稳定重量结果;空槽允许为空。 */
private Long weightResultId;
/** 已绑定称重托盘在整票内的稳定展示顺序;空槽允许为空。 */
private Integer palletSequence;
private Long warehouseId;
private Long warehouseResourceTenantId;
private Long palletId;
@@ -29,9 +37,12 @@ public class InboundPalletAllocationEntity {
private Integer pickedPackageCount;
private Long locationId;
private String locationCode;
/** 普通连续位不足时由 Warehouse 分配到共享 A0。 */
private Boolean sharedOverflow;
/** 仓库资源唯一预占标识;A0 多托盘场景不能使用 locationId 作为唯一标识。 */
private Long reservationId;
private String reservationNo;
/** 槽位状态:RESERVED_EMPTY、BOUND、TRANSFERRED、RELEASED。 */
private String slotStatus;
private String allocationStatus;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@@ -23,10 +23,6 @@ public class LoadingPickItemEntity {
private Integer plannedPackageCount;
private Integer pickedPackageCount;
private String itemStatus;
/** AUTO 按比例释放,MANUAL 按指定库位释放。 */
private String releaseMode;
/** 人工选择的库位 ID,JSON 数组;空数组表示明确不释放。 */
private String releaseLocationIds;
@Version
private Integer version;
private LocalDateTime createdAt;
@@ -9,6 +9,41 @@ import org.apache.ibatis.annotations.Update;
@Mapper
public interface InboundPalletAllocationMapper extends BaseMapper<InboundPalletAllocationEntity> {
@Select("""
SELECT * FROM fl_inbound_pallet_allocation
WHERE tenant_id = #{tenantId} AND inbound_id = #{inboundId}
AND slot_status = 'RESERVED_EMPTY'
AND allocation_status IN ('RESERVED', 'CONFIRMED')
ORDER BY slot_no ASC
LIMIT 1 FOR UPDATE
""")
InboundPalletAllocationEntity selectNextEmptyForUpdate(
@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
@Update("""
UPDATE fl_inbound_pallet_allocation
SET weight_result_id=#{weightResultId}, pallet_sequence=#{palletSequence},
pallet_id=#{palletId}, pallet_code=#{palletCode},
pallet_capacity_cbm=#{palletCapacityCbm}, slot_status='BOUND',
updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{id} AND tenant_id=#{tenantId} AND inbound_id=#{inboundId}
AND slot_status='RESERVED_EMPTY' AND weight_result_id IS NULL
""")
int bindWeight(@Param("id") long id, @Param("tenantId") long tenantId,
@Param("inboundId") long inboundId, @Param("weightResultId") long weightResultId,
@Param("palletSequence") int palletSequence, @Param("palletId") long palletId,
@Param("palletCode") String palletCode,
@Param("palletCapacityCbm") java.math.BigDecimal palletCapacityCbm);
@Update("""
UPDATE fl_inbound_pallet_allocation
SET weight_result_id=NULL, pallet_sequence=NULL, pallet_id=NULL, pallet_code=NULL,
pallet_capacity_cbm=NULL, slot_status='RESERVED_EMPTY', updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{id} AND tenant_id=#{tenantId} AND inbound_id=#{inboundId}
AND weight_result_id=#{weightResultId} AND slot_status='BOUND'
""")
int unbindWeight(@Param("id") long id, @Param("tenantId") long tenantId,
@Param("inboundId") long inboundId, @Param("weightResultId") long weightResultId);
@Select("""
SELECT *
FROM fl_inbound_pallet_allocation
@@ -39,21 +74,29 @@ public interface InboundPalletAllocationMapper extends BaseMapper<InboundPalletA
int consume(@Param("id") long id, @Param("tenantId") long tenantId,
@Param("inboundId") long inboundId, @Param("count") int count);
@Update("""
UPDATE fl_inbound_pallet_allocation
SET allocation_status = 'TRANSFERRED', updated_at = CURRENT_TIMESTAMP(3)
WHERE id = #{id} AND tenant_id = #{tenantId} AND inbound_id = #{inboundId}
AND allocation_status = 'CONFIRMED' AND picked_package_count < allocated_package_count
@Select("""
SELECT COUNT(*) FROM fl_inbound_pallet_allocation
WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND block_id=#{blockId}
AND allocation_status='CONFIRMED'
""")
int markTransferred(@Param("id") long id, @Param("tenantId") long tenantId,
@Param("inboundId") long inboundId);
int countBlock(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("blockId") long blockId);
@Update("""
UPDATE fl_inbound_pallet_allocation
SET allocation_status = 'RELEASED', updated_at = CURRENT_TIMESTAMP(3)
WHERE tenant_id = #{tenantId} AND inbound_id = #{inboundId}
AND reservation_id = #{reservationId} AND allocation_status = 'CONFIRMED'
SET allocation_status='TRANSFERRED', slot_status='TRANSFERRED', updated_at=CURRENT_TIMESTAMP(3)
WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND block_id=#{blockId}
AND allocation_status='CONFIRMED'
""")
int markReleased(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("reservationId") long reservationId);
int markBlockTransferred(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("blockId") long blockId);
@Update("""
UPDATE fl_inbound_pallet_allocation
SET allocation_status='RELEASED', slot_status='RELEASED', updated_at=CURRENT_TIMESTAMP(3)
WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND block_id=#{blockId}
AND allocation_status='CONFIRMED'
""")
int markBlockReleased(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("blockId") long blockId);
}
@@ -171,7 +171,6 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
SET item_status = CASE WHEN picked_package_count + #{count} = planned_package_count
THEN 'PICKED' ELSE 'PICKING' END,
picked_package_count = picked_package_count + #{count},
release_mode = #{releaseMode}, release_location_ids = #{releaseLocationIds},
version = version + 1
WHERE id = #{id} AND version = #{version}
AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item
@@ -182,15 +181,12 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
AND picked_package_count + #{count} <= planned_package_count
""")
int pick(@Param("id") long id, @Param("count") int count,
@Param("releaseMode") String releaseMode,
@Param("releaseLocationIds") String releaseLocationIds,
@Param("version") int version,
@Param("warehouseId") long warehouseId);
@Update("""
UPDATE fl_loading_pick_item
SET picked_package_count = #{finalCount}, item_status = 'PARTIAL',
release_mode = #{releaseMode}, release_location_ids = #{releaseLocationIds},
version = version + 1
WHERE id = #{id} AND version = #{version}
AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item
@@ -203,8 +199,6 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
AND #{finalCount} < planned_package_count
""")
int partial(@Param("id") long id, @Param("finalCount") int finalCount,
@Param("releaseMode") String releaseMode,
@Param("releaseLocationIds") String releaseLocationIds,
@Param("version") int version,
@Param("warehouseId") long warehouseId);
@@ -212,9 +212,11 @@ public class InboundOrderRepository {
.orderByAsc(InboundPalletAllocationEntity::getAllocationNo));
List<InboundOrderDetail.PalletLocationSummary> palletSummaries = palletAllocations.stream()
.map(item -> new InboundOrderDetail.PalletLocationSummary(
item.getAllocationNo(), item.getWarehouseId(), item.getPalletId(), item.getPalletCode(),
item.getAllocationNo(), item.getBlockId(), item.getSlotNo(), item.getWeightResultId(),
item.getPalletSequence(), item.getWarehouseId(), item.getPalletId(), item.getPalletCode(),
item.getPalletCapacityCbm(), item.getAllocatedVolumeCbm(), item.getAllocatedPackageCount(),
item.getLocationId(), item.getLocationCode(), item.getReservationNo(), item.getAllocationStatus()))
item.getReservationId(), item.getLocationId(), item.getLocationCode(), item.getSharedOverflow(),
item.getSlotStatus(), item.getAllocationStatus()))
.toList();
List<InboundOrderDetail.FileSummary> fileSummaries = files.selectList(
owned(InboundFileEntity::getTenantId, InboundFileEntity::getInboundId, tenantId, inboundId)
@@ -3,66 +3,114 @@ package com.baiyue.firstleg.repository;
import jakarta.annotation.Resource;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.firstleg.client.WarehousePalletLocationClient.BatchResult;
import com.baiyue.firstleg.client.WarehousePalletLocationClient.BlockResult;
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
import com.baiyue.firstleg.entity.InboundWeightResultEntity;
import com.baiyue.firstleg.mapper.InboundPalletAllocationMapper;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
@Repository
public class InboundPalletAllocationRepository {
@Resource private InboundPalletAllocationMapper mapper;
public List<InboundPalletAllocationEntity> list(long tenantId, long inboundId) {
return mapper.selectList(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
.eq(InboundPalletAllocationEntity::getTenantId, tenantId)
.eq(InboundPalletAllocationEntity::getInboundId, inboundId)
.orderByAsc(InboundPalletAllocationEntity::getSlotNo)
.orderByAsc(InboundPalletAllocationEntity::getAllocationNo));
}
public InboundPalletAllocationEntity findByWeightResult(long tenantId, long inboundId,
long weightResultId) {
return mapper.selectOne(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
.eq(InboundPalletAllocationEntity::getTenantId, tenantId)
.eq(InboundPalletAllocationEntity::getInboundId, inboundId)
.eq(InboundPalletAllocationEntity::getWeightResultId, weightResultId));
}
@Transactional
public void replace(long tenantId, long inboundId, long warehouseId, long warehouseResourceTenantId,
int totalPackageCount, BatchResult result) {
Map<Long, String> reservationNos = mapper.selectList(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
.eq(InboundPalletAllocationEntity::getTenantId, tenantId)
.eq(InboundPalletAllocationEntity::getInboundId, inboundId))
.stream().collect(Collectors.toMap(InboundPalletAllocationEntity::getReservationId,
InboundPalletAllocationEntity::getReservationNo, (left, right) -> left));
public void replaceBlock(long tenantId, long inboundId, long warehouseId,
int totalPackageCount, BigDecimal totalVolumeCbm, BlockResult result) {
mapper.delete(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
.eq(InboundPalletAllocationEntity::getTenantId, tenantId)
.eq(InboundPalletAllocationEntity::getInboundId, inboundId));
LocalDateTime now = LocalDateTime.now();
int allocationCount = result.allocations().size();
int allocationCount = result.resources().size();
int basePackageCount = allocationCount == 0 ? 0 : totalPackageCount / allocationCount;
int remainder = allocationCount == 0 ? 0 : totalPackageCount % allocationCount;
for (int index = 0; index < result.allocations().size(); index++) {
var source = result.allocations().get(index);
for (int index = 0; index < result.resources().size(); index++) {
var source = result.resources().get(index);
InboundPalletAllocationEntity value = new InboundPalletAllocationEntity();
value.setTenantId(tenantId);
value.setInboundId(inboundId);
value.setAllocationNo(source.allocationNo());
value.setAllocationNo(source.slotNo());
value.setBlockId(source.blockId());
value.setSlotNo(source.slotNo());
value.setWarehouseId(warehouseId);
value.setWarehouseResourceTenantId(warehouseResourceTenantId);
value.setPalletId(source.palletId());
value.setPalletCode(source.palletCode());
value.setPalletCapacityCbm(source.palletCapacityCbm());
value.setAllocatedVolumeCbm(source.allocatedVolumeCbm());
value.setWarehouseResourceTenantId(0L);
value.setAllocatedVolumeCbm(totalVolumeCbm.divide(BigDecimal.valueOf(allocationCount), 4,
java.math.RoundingMode.HALF_UP));
int allocatedPackages = basePackageCount + (index < remainder ? 1 : 0);
value.setAllocatedPackageCount(allocatedPackages);
value.setPickedPackageCount(0);
value.setLocationId(source.locationId());
value.setLocationCode(source.locationCode());
value.setSharedOverflow(source.sharedOverflow());
value.setReservationId(source.reservationId());
// 仓储确认响应只返回 reservationId;沿用预占阶段的预占单号快照。
value.setReservationNo(source.reservationNo() != null ? source.reservationNo()
: reservationNos.get(source.reservationId()));
value.setAllocationStatus(normalize(source.state()));
value.setSlotStatus("RELEASED".equals(source.resourceStatus()) ? "RELEASED" : "RESERVED_EMPTY");
value.setAllocationStatus(normalize(source.resourceStatus()));
value.setCreatedAt(now);
value.setUpdatedAt(now);
mapper.insert(value);
}
}
/** 已确认图片按 confirmedAt、weightResultId 顺序绑定块内下一个空槽,不依赖列表下标。 */
@Transactional
public void bindConfirmedWeights(long tenantId, long inboundId, List<InboundWeightResultEntity> weights) {
weights.stream().filter(weight -> "CONFIRMED".equals(weight.getRecognitionStatus()))
.sorted(java.util.Comparator.comparing(InboundWeightResultEntity::getConfirmedAt,
java.util.Comparator.nullsLast(LocalDateTime::compareTo))
.thenComparing(InboundWeightResultEntity::getId))
.forEach(weight -> bindNextEmpty(tenantId, inboundId, weight));
}
@Transactional
public InboundPalletAllocationEntity bindNextEmpty(long tenantId, long inboundId,
InboundWeightResultEntity weight) {
InboundPalletAllocationEntity existing = findByWeightResult(tenantId, inboundId, weight.getId());
if (existing != null) return existing;
InboundPalletAllocationEntity empty = mapper.selectNextEmptyForUpdate(tenantId, inboundId);
if (empty == null) {
throw new IllegalStateException("整票预留库位块没有可绑定的空槽");
}
if (mapper.bindWeight(empty.getId(), tenantId, inboundId, weight.getId(), empty.getSlotNo(),
weight.getPalletId(), weight.getPalletCode(), weight.getPalletCapacityCbm()) != 1) {
throw new IllegalStateException("整票库位槽已被并发绑定");
}
empty.setWeightResultId(weight.getId());
empty.setPalletSequence(empty.getSlotNo());
empty.setPalletId(weight.getPalletId());
empty.setPalletCode(weight.getPalletCode());
empty.setPalletCapacityCbm(weight.getPalletCapacityCbm());
empty.setSlotStatus("BOUND");
return empty;
}
@Transactional
public void unbindWeight(long tenantId, long inboundId, long weightResultId) {
InboundPalletAllocationEntity existing = findByWeightResult(tenantId, inboundId, weightResultId);
if (existing != null && mapper.unbindWeight(existing.getId(), tenantId, inboundId, weightResultId) != 1) {
throw new IllegalStateException("整票库位槽解绑失败");
}
}
public List<InboundPalletAllocationEntity> lockConfirmed(long tenantId, long inboundId) {
return mapper.selectConfirmedForUpdate(tenantId, inboundId);
}
@@ -73,54 +121,17 @@ public class InboundPalletAllocationRepository {
}
}
/** 父单快照在仓库资源转移前标记为已转移,后续锁查询不会再将其当作可用资源。 */
public void markTransferred(long tenantId, long inboundId, List<InboundPalletAllocationEntity> allocations,
List<Long> releasedReservationIds) {
for (InboundPalletAllocationEntity allocation : allocations) {
if (releasedReservationIds.contains(allocation.getReservationId())) {
continue;
}
int allocated = allocation.getAllocatedPackageCount() == null ? 0 : allocation.getAllocatedPackageCount();
int picked = allocation.getPickedPackageCount() == null ? 0 : allocation.getPickedPackageCount();
if (allocated <= picked) {
continue;
}
if (allocation.getId() == null) {
throw new IllegalStateException("库位资源转移状态已被并发修改");
}
int changed = mapper.markTransferred(allocation.getId(), tenantId, inboundId);
if (changed != 1 && !isAllocationIdState(allocation.getId(), tenantId, inboundId, "TRANSFERRED")) {
throw new IllegalStateException("库位资源转移状态已被并发修改");
}
}
/** 余量子单存在时整票块转移,包含已经拣空和预留空槽。 */
public void markBlockTransferred(long tenantId, long inboundId, long blockId) {
int expected = mapper.countBlock(tenantId, inboundId, blockId);
int changed = mapper.markBlockTransferred(tenantId, inboundId, blockId);
if (expected < 1 || changed != expected) throw new IllegalStateException("整票库位块转移状态已被并发修改");
}
public void markReleased(long tenantId, long inboundId, List<Long> reservationIds) {
for (Long reservationId : reservationIds) {
if (reservationId == null) {
throw new IllegalStateException("库位释放状态已被并发修改");
}
int changed = mapper.markReleased(tenantId, inboundId, reservationId);
if (changed != 1 && !isReservationState(reservationId, tenantId, inboundId, "RELEASED")) {
throw new IllegalStateException("库位释放状态已被并发修改");
}
}
}
private boolean isAllocationIdState(long id, long tenantId, long inboundId, String status) {
InboundPalletAllocationEntity current = mapper.selectOne(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
.eq(InboundPalletAllocationEntity::getTenantId, tenantId)
.eq(InboundPalletAllocationEntity::getInboundId, inboundId)
.eq(InboundPalletAllocationEntity::getId, id));
return current != null && status.equals(current.getAllocationStatus());
}
private boolean isReservationState(long reservationId, long tenantId, long inboundId, String status) {
InboundPalletAllocationEntity current = mapper.selectOne(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
.eq(InboundPalletAllocationEntity::getTenantId, tenantId)
.eq(InboundPalletAllocationEntity::getInboundId, inboundId)
.eq(InboundPalletAllocationEntity::getReservationId, reservationId));
return current != null && status.equals(current.getAllocationStatus());
public void markBlockReleased(long tenantId, long inboundId, long blockId) {
int expected = mapper.countBlock(tenantId, inboundId, blockId);
int changed = mapper.markBlockReleased(tenantId, inboundId, blockId);
if (expected < 1 || changed != expected) throw new IllegalStateException("整票库位块释放状态已被并发修改");
}
/**
@@ -128,7 +139,7 @@ public class InboundPalletAllocationRepository {
* 仓库 transfer 是本地事务最后一个外部动作,成功后不再执行本地写入,避免跨服务重试时目标子单变化。
*/
@Transactional
public void copyRemainingToChild(long tenantId, long inboundId, List<InboundPalletAllocationEntity> parent) {
public void copyBlockToChild(long tenantId, long inboundId, List<InboundPalletAllocationEntity> parent) {
mapper.delete(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
.eq(InboundPalletAllocationEntity::getTenantId, tenantId)
.eq(InboundPalletAllocationEntity::getInboundId, inboundId));
@@ -137,9 +148,6 @@ public class InboundPalletAllocationRepository {
int allocated = source.getAllocatedPackageCount() == null ? 0 : source.getAllocatedPackageCount();
int picked = source.getPickedPackageCount() == null ? 0 : source.getPickedPackageCount();
int remaining = allocated - picked;
if (remaining <= 0) {
continue;
}
InboundPalletAllocationEntity value = new InboundPalletAllocationEntity();
value.setTenantId(tenantId);
value.setInboundId(inboundId);
@@ -155,7 +163,12 @@ public class InboundPalletAllocationRepository {
value.setLocationId(source.getLocationId());
value.setLocationCode(source.getLocationCode());
value.setReservationId(source.getReservationId());
value.setReservationNo(source.getReservationNo());
value.setBlockId(source.getBlockId());
value.setSlotNo(source.getSlotNo());
value.setWeightResultId(source.getWeightResultId());
value.setPalletSequence(source.getPalletSequence());
value.setSharedOverflow(source.getSharedOverflow());
value.setSlotStatus(source.getSlotStatus());
value.setAllocationStatus("CONFIRMED");
value.setCreatedAt(now);
value.setUpdatedAt(now);
@@ -135,14 +135,12 @@ public class LoadingPickRepository {
.eq(LoadingPickItemEntity::getLoadingPlanItemId, planItemId));
}
public int pick(long itemId, int count, String releaseMode, String releaseLocationIds,
int version, long warehouseId) {
return items.pick(itemId, count, releaseMode, releaseLocationIds, version, warehouseId);
public int pick(long itemId, int count, int version, long warehouseId) {
return items.pick(itemId, count, version, warehouseId);
}
public int partial(long itemId, int finalCount, String releaseMode, String releaseLocationIds,
int version, long warehouseId) {
return items.partial(itemId, finalCount, releaseMode, releaseLocationIds, version, warehouseId);
public int partial(long itemId, int finalCount, int version, long warehouseId) {
return items.partial(itemId, finalCount, version, warehouseId);
}
public int skip(long itemId, int version, long warehouseId) {
@@ -174,6 +172,12 @@ public class LoadingPickRepository {
.eq(LoadingPickHistoryEntity::getOperationId, operationId));
}
public LoadingPickHistoryEntity historyForUpdate(String operationId) {
return histories.selectOne(new LambdaQueryWrapper<LoadingPickHistoryEntity>()
.eq(LoadingPickHistoryEntity::getOperationId, operationId)
.last("FOR UPDATE"));
}
public void insert(LoadingPickHistoryEntity history) {
histories.insert(history);
}
@@ -215,6 +219,17 @@ public class LoadingPickRepository {
.eq(LoadingPickOperationEntity::getOperationId, operationId));
}
/**
* Current read used after the pick-task row lock has serialized concurrent commands.
* A normal repeatable-read lookup would keep the transaction's earlier snapshot and
* miss an operation committed while this request was waiting for that row lock.
*/
public LoadingPickOperationEntity operationForUpdate(String operationId) {
return operations.selectOne(new LambdaQueryWrapper<LoadingPickOperationEntity>()
.eq(LoadingPickOperationEntity::getOperationId, operationId)
.last("FOR UPDATE"));
}
public void insert(LoadingPickOperationEntity operation) {
operations.insert(operation);
}
@@ -19,6 +19,7 @@ import com.baiyue.firstleg.entity.InboundWeightResultEntity;
import com.baiyue.firstleg.entity.InboundWeightRevisionEntity;
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
import com.baiyue.firstleg.repository.InboundPalletAllocationRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@@ -30,6 +31,7 @@ import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/** 平台端入库测量、OCR 修正、重量确认和提交审核应用服务。 */
@@ -63,6 +65,8 @@ public class InboundMeasurementService {
private MeasurementImageClient imageClient;
@Resource
private WarehousePalletLocationClient warehousePallets;
@Resource
private InboundPalletAllocationRepository palletAllocations;
public InboundMeasurementResponses.Detail detail(long tenantId, long inboundId) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
@@ -82,9 +86,18 @@ public class InboundMeasurementService {
List<InboundMeasurementItemEntity> items = measurement == null
? List.of() : repository.listItems(tenantId, measurement.getId());
List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inbound.getId());
List<com.baiyue.firstleg.entity.InboundPalletAllocationEntity> allocationSlots =
palletAllocations.list(tenantId, inbound.getId());
Map<Long, com.baiyue.firstleg.entity.InboundPalletAllocationEntity> allocationsByWeight =
allocationSlots.stream()
.filter(item -> item.getWeightResultId() != null)
.collect(java.util.stream.Collectors.toMap(
com.baiyue.firstleg.entity.InboundPalletAllocationEntity::getWeightResultId,
java.util.function.Function.identity(), (left, right) -> left));
MeasureReviewRecordEntity review = repository.findLatestReview(tenantId, inbound.getId());
InboundMeasurementResponses.DeclarationSnapshot latestDeclaration = loadLatestDeclaration(inbound);
return toDetail(inbound, measurement, items, weights, review, latestDeclaration);
return toDetail(inbound, measurement, items, weights, allocationSlots, allocationsByWeight,
review, latestDeclaration);
}
public PageResult<InboundMeasurementResponses.PalletSnapshot> weightPalletOptions(
@@ -311,6 +324,9 @@ public class InboundMeasurementService {
weight.setVersion(confirmedVersion);
insertRevision(weight, confirmedVersion, "CONFIRM", value, unit, value, unit,
grossWeightKg, netWeightKg, null, operatorId, operatorName);
if (!palletAllocations.list(tenantId, inboundId).isEmpty()) {
palletAllocations.bindNextEmpty(tenantId, inboundId, weight);
}
recalculateConfirmedWeight(inbound, operatorId);
return toWeight(weight);
}
@@ -322,6 +338,7 @@ public class InboundMeasurementService {
requireWeightEditableInbound(inbound);
InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId);
requireVersion(version, weight.getVersion());
palletAllocations.unbindWeight(tenantId, inboundId, resultId);
if (!repository.deleteWeight(tenantId, inboundId, resultId, version)) {
throw concurrentModification();
}
@@ -433,6 +450,8 @@ public class InboundMeasurementService {
private InboundMeasurementResponses.Detail toDetail(
InboundOrderEntity inbound, InboundMeasurementEntity measurement,
List<InboundMeasurementItemEntity> items, List<InboundWeightResultEntity> weights,
List<com.baiyue.firstleg.entity.InboundPalletAllocationEntity> allocationSlots,
Map<Long, com.baiyue.firstleg.entity.InboundPalletAllocationEntity> allocationsByWeight,
MeasureReviewRecordEntity review,
InboundMeasurementResponses.DeclarationSnapshot latestDeclaration) {
return new InboundMeasurementResponses.Detail(
@@ -460,7 +479,32 @@ public class InboundMeasurementService {
measurement != null && StringUtils.hasText(measurement.getActualGoodsName())
? measurement.getActualGoodsName() : latestDeclaration.goodsName(),
latestDeclaration,
items.stream().map(this::toItem).toList(), weights.stream().map(this::toWeight).toList());
items.stream().map(this::toItem).toList(), weights.stream()
.map(weight -> toWeight(weight, allocationsByWeight.get(weight.getId()))).toList(),
toPalletLocationGuide(allocationSlots));
}
private static InboundMeasurementResponses.PalletLocationGuide toPalletLocationGuide(
List<com.baiyue.firstleg.entity.InboundPalletAllocationEntity> slots) {
if (slots.isEmpty()) return null;
Long blockId = slots.get(0).getBlockId();
if (blockId == null || slots.stream().anyMatch(slot -> !java.util.Objects.equals(blockId, slot.getBlockId()))) {
throw new BizException(ResultCode.CONFLICT, "入库单整票库位块快照不一致");
}
List<InboundMeasurementResponses.PalletLocationSlot> ordered = slots.stream()
.sorted(java.util.Comparator.comparing(
com.baiyue.firstleg.entity.InboundPalletAllocationEntity::getSlotNo,
java.util.Comparator.nullsLast(Integer::compareTo)))
.map(slot -> new InboundMeasurementResponses.PalletLocationSlot(
slot.getSlotNo(), slot.getReservationId(), slot.getLocationId(), slot.getLocationCode(),
slot.getSharedOverflow(), slot.getSlotStatus(), slot.getWeightResultId(),
slot.getPalletSequence(), slot.getPalletId(), slot.getPalletCode()))
.toList();
int actualPalletCount = (int) slots.stream().filter(slot -> slot.getWeightResultId() != null).count();
int reservedEmptySlotCount = (int) slots.stream()
.filter(slot -> "RESERVED_EMPTY".equals(slot.getSlotStatus())).count();
return new InboundMeasurementResponses.PalletLocationGuide(
blockId, ordered.size(), actualPalletCount, reservedEmptySlotCount, ordered);
}
/**
@@ -576,6 +620,13 @@ public class InboundMeasurementService {
}
private InboundMeasurementResponses.WeightResult toWeight(InboundWeightResultEntity weight) {
return toWeight(weight, weight.getId() == null ? null
: palletAllocations.findByWeightResult(weight.getTenantId(), weight.getInboundId(), weight.getId()));
}
private InboundMeasurementResponses.WeightResult toWeight(
InboundWeightResultEntity weight,
com.baiyue.firstleg.entity.InboundPalletAllocationEntity allocation) {
return new InboundMeasurementResponses.WeightResult(
weight.getId(), weight.getFileId(), weight.getPalletId(), weight.getPalletCode(),
weight.getPalletType(), weight.getPalletCapacityCbm(), weight.getPalletTareWeightKg(),
@@ -585,7 +636,15 @@ public class InboundMeasurementService {
weight.getAdjustedWeightValue(), weight.getAdjustedWeightUnit(),
weight.getNetWeightKg(),
weight.getRecognitionStatus(), weight.getAdjustmentReason(), weight.getAdjustedBy(),
weight.getAdjustedAt(), weight.getConfirmedBy(), weight.getConfirmedAt(), weight.getVersion());
weight.getAdjustedAt(), weight.getConfirmedBy(), weight.getConfirmedAt(),
allocation == null ? null : allocation.getPalletSequence(),
allocation == null ? null : allocation.getBlockId(),
allocation == null ? null : allocation.getSlotNo(),
allocation == null ? null : allocation.getReservationId(),
allocation == null ? null : allocation.getLocationId(),
allocation == null ? null : allocation.getLocationCode(),
allocation == null ? null : allocation.getSharedOverflow(),
allocation == null ? null : allocation.getSlotStatus(), weight.getVersion());
}
private PageResult<WarehousePalletLocationClient.PalletSnapshot> loadPalletSnapshotPage(
@@ -253,7 +253,7 @@ public class LoadingPickService {
Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId);
if (replay != null) return replay;
LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId);
replay = replayTaskOperation(taskId, operationId, requestHash, operatorId);
replay = replayTaskOperationForUpdate(taskId, operationId, requestHash, operatorId);
if (replay != null) return replay;
LoadingPickAssignmentEntity existing = repository.assignment(taskId);
if (existing == null) {
@@ -284,7 +284,7 @@ public class LoadingPickService {
Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId);
if (replay != null) return replay;
LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId);
replay = replayTaskOperation(taskId, operationId, requestHash, operatorId);
replay = replayTaskOperationForUpdate(taskId, operationId, requestHash, operatorId);
if (replay != null) return replay;
LoadingPickAssignmentEntity existing = repository.assignment(taskId);
int changed = existing == null
@@ -300,10 +300,9 @@ public class LoadingPickService {
@Transactional
public Task pick(long taskId, long itemId, PickRequest request, long operatorId, String operatorName) {
ReleaseSelection release = releaseSelection(request.releaseMode(), request.locationIds());
String operationId = request.operationId().trim();
String requestHash = hash(taskId + "|" + itemId + "|" + request.warehouseId()
+ "|" + request.pickedPackageCount() + "|" + release.mode() + "|" + release.json());
+ "|" + request.pickedPackageCount());
requireNoTaskOperation(operationId);
LoadingPickHistoryEntity replay = repository.history(operationId);
if (replay != null) {
@@ -314,8 +313,8 @@ public class LoadingPickService {
return view(requiredTask(taskId), request.warehouseId());
}
LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId);
requireNoTaskOperation(operationId);
replay = repository.history(operationId);
requireNoTaskOperationForUpdate(operationId);
replay = repository.historyForUpdate(operationId);
if (replay != null) {
assertReplay(replay, taskId, itemId, requestHash);
return view(task, request.warehouseId());
@@ -324,8 +323,7 @@ public class LoadingPickService {
if (item == null) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在");
}
if (repository.pick(itemId, request.pickedPackageCount(), release.mode(), release.json(),
request.version(), request.warehouseId()) != 1) {
if (repository.pick(itemId, request.pickedPackageCount(), request.version(), request.warehouseId()) != 1) {
throw conflict("拣货数量超过计划箱数或明细版本已变化");
}
LoadingPickHistoryEntity history = new LoadingPickHistoryEntity();
@@ -346,10 +344,9 @@ public class LoadingPickService {
@Transactional
public Task partial(long taskId, long itemId, PartialPickRequest request,
long operatorId, String operatorName) {
ReleaseSelection release = releaseSelection(request.releaseMode(), request.locationIds());
String operationId = request.operationId().trim();
String requestHash = hash("PARTIAL|" + taskId + "|" + itemId + "|" + request.warehouseId()
+ "|" + request.finalPickedPackageCount() + "|" + release.mode() + "|" + release.json());
+ "|" + request.finalPickedPackageCount());
requireNoTaskOperation(operationId);
LoadingPickHistoryEntity replay = repository.history(operationId);
if (replay != null) {
@@ -357,15 +354,15 @@ public class LoadingPickService {
return view(requiredTask(taskId), request.warehouseId());
}
LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId);
requireNoTaskOperation(operationId);
replay = repository.history(operationId);
requireNoTaskOperationForUpdate(operationId);
replay = repository.historyForUpdate(operationId);
if (replay != null) {
assertReplay(replay, taskId, itemId, requestHash);
return view(task, request.warehouseId());
}
requireWarehouseItem(taskId, itemId, request.warehouseId());
if (repository.partial(itemId, request.finalPickedPackageCount(), release.mode(), release.json(),
request.version(), request.warehouseId()) != 1) {
if (repository.partial(itemId, request.finalPickedPackageCount(), request.version(),
request.warehouseId()) != 1) {
throw conflict("部分拣货箱数必须大于已拣箱数且小于计划箱数,或明细版本已变化");
}
insertHistory(taskId, itemId, operationId, requestHash, "PARTIAL",
@@ -388,8 +385,8 @@ public class LoadingPickService {
return view(requiredTask(taskId), request.warehouseId());
}
LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId);
requireNoTaskOperation(operationId);
replay = repository.history(operationId);
requireNoTaskOperationForUpdate(operationId);
replay = repository.historyForUpdate(operationId);
if (replay != null) {
assertReplay(replay, taskId, itemId, requestHash);
return view(task, request.warehouseId());
@@ -420,7 +417,7 @@ public class LoadingPickService {
if (replay != null) return replay;
LoadingPickTaskEntity task = requiredTaskForUpdate(taskId);
replay = replayTaskOperation(taskId, operationId, requestHash, operatorId);
replay = replayTaskOperationForUpdate(taskId, operationId, requestHash, operatorId);
if (replay != null) return replay;
if (!"LOADING".equals(task.getTaskStatus())) {
throw conflict("装柜任务已完成,不能重复提交整批拣货");
@@ -990,10 +987,8 @@ public class LoadingPickService {
throw conflict("PDA明细版本已变化,请刷新后重试");
}
String action = requestItem.action().trim().toUpperCase();
ReleaseSelection release = null;
if ("PICK".equals(action)) {
requireNoUnexpectedBatchFields(requestItem, false, true);
release = releaseSelection(requestItem.releaseMode(), requestItem.locationIds());
int current = count(item.getPickedPackageCount());
int planned = count(item.getPlannedPackageCount());
if (requestItem.pickedPackageCount() == null
@@ -1002,7 +997,6 @@ public class LoadingPickService {
}
} else if ("PARTIAL".equals(action)) {
requireNoUnexpectedBatchFields(requestItem, true, false);
release = releaseSelection(requestItem.releaseMode(), requestItem.locationIds());
int current = count(item.getPickedPackageCount());
int planned = count(item.getPlannedPackageCount());
if (requestItem.finalPickedPackageCount() == null
@@ -1011,10 +1005,8 @@ public class LoadingPickService {
throw conflict("PARTIAL最终箱数必须大于已拣且小于计划箱数");
}
} else if ("SKIP".equals(action)) {
if (requestItem.pickedPackageCount() != null || requestItem.finalPickedPackageCount() != null
|| StringUtils.hasText(requestItem.releaseMode())
|| requestItem.locationIds() != null && !requestItem.locationIds().isEmpty()) {
throw new BizException(ResultCode.BAD_REQUEST, "SKIP不能携带拣货数量或释放库位");
if (requestItem.pickedPackageCount() != null || requestItem.finalPickedPackageCount() != null) {
throw new BizException(ResultCode.BAD_REQUEST, "SKIP不能携带拣货数量");
}
if (!StringUtils.hasText(requestItem.reason())) {
throw new BizException(ResultCode.BAD_REQUEST, "SKIP必须填写异常原因");
@@ -1027,7 +1019,7 @@ public class LoadingPickService {
} else {
throw new BizException(ResultCode.BAD_REQUEST, "action只能是PICK、PARTIAL或SKIP");
}
result.add(new BatchLine(item, requestItem, action, release, request.warehouseId()));
result.add(new BatchLine(item, requestItem, action, request.warehouseId()));
}
result.sort(Comparator.comparing(line -> line.item().getId()));
return result;
@@ -1130,12 +1122,10 @@ public class LoadingPickService {
int pickedCount;
if ("PICK".equals(line.action())) {
pickedCount = request.pickedPackageCount();
changed = repository.pick(item.getId(), pickedCount, line.release().mode(),
line.release().json(), request.version(), line.warehouseId());
changed = repository.pick(item.getId(), pickedCount, request.version(), line.warehouseId());
} else if ("PARTIAL".equals(line.action())) {
pickedCount = request.finalPickedPackageCount();
changed = repository.partial(item.getId(), pickedCount, line.release().mode(),
line.release().json(), request.version(), line.warehouseId());
changed = repository.partial(item.getId(), pickedCount, request.version(), line.warehouseId());
} else {
pickedCount = 0;
changed = repository.skip(item.getId(), request.version(), line.warehouseId());
@@ -1145,8 +1135,7 @@ public class LoadingPickService {
requireNoTaskOperation(itemOperationId);
if (repository.history(itemOperationId) != null) throw conflict("整批明细已被重复提交");
String itemHash = hash(line.action() + '|' + item.getId() + '|' + request.version()
+ '|' + pickedCount + '|' + (line.release() == null ? "" : line.release().mode())
+ '|' + (line.release() == null ? "" : line.release().json()) + '|' + trim(request.reason()));
+ '|' + pickedCount + '|' + trim(request.reason()));
insertHistory(taskId, item.getId(), itemOperationId, itemHash, line.action(), pickedCount,
trim(request.reason()), operatorId, operatorName);
}
@@ -1190,6 +1179,18 @@ public class LoadingPickService {
private Task replayTaskOperation(long taskId, String operationId, String requestHash, long operatorId) {
if (repository.history(operationId) != null) throw conflict("operationId已用于明细拣货操作");
LoadingPickOperationEntity replay = repository.operation(operationId);
return replayTaskOperation(taskId, operationId, requestHash, operatorId, replay);
}
private Task replayTaskOperationForUpdate(long taskId, String operationId, String requestHash,
long operatorId) {
if (repository.historyForUpdate(operationId) != null) throw conflict("operationId已用于明细拣货操作");
LoadingPickOperationEntity replay = repository.operationForUpdate(operationId);
return replayTaskOperation(taskId, operationId, requestHash, operatorId, replay);
}
private Task replayTaskOperation(long taskId, String operationId, String requestHash, long operatorId,
LoadingPickOperationEntity replay) {
if (replay == null) return null;
if (!Long.valueOf(taskId).equals(replay.getPickTaskId())
|| !Long.valueOf(operatorId).equals(replay.getOperatorId())
@@ -1215,6 +1216,10 @@ public class LoadingPickService {
if (repository.operation(operationId) != null) throw conflict("operationId已用于PDA任务操作");
}
private void requireNoTaskOperationForUpdate(String operationId) {
if (repository.operationForUpdate(operationId) != null) throw conflict("operationId已用于PDA任务操作");
}
private static String operation(String value) {
String result = trim(value);
if (!StringUtils.hasText(result)) throw new BizException(ResultCode.BAD_REQUEST, "operationId不能为空");
@@ -1270,35 +1275,14 @@ public class LoadingPickService {
.forEach(item -> {
canonical.append('|').append(item.itemId()).append('|').append(trim(item.action()))
.append('|').append(item.pickedPackageCount()).append('|')
.append(item.finalPickedPackageCount()).append('|').append(trim(item.releaseMode()))
.append('|').append(item.version()).append('|').append(trim(item.reason())).append('|');
List<Long> locations = item.locationIds() == null ? List.of()
: item.locationIds().stream().sorted().toList();
canonical.append(locations);
.append(item.finalPickedPackageCount()).append('|')
.append(item.version()).append('|').append(trim(item.reason()));
});
return hash(canonical.toString());
}
private static ReleaseSelection releaseSelection(String requestedMode, List<Long> requestedLocationIds) {
String mode = requestedMode == null || requestedMode.isBlank()
? (requestedLocationIds == null ? "AUTO" : "MANUAL") : requestedMode.trim().toUpperCase();
if (!"AUTO".equals(mode) && !"MANUAL".equals(mode)) {
throw new BizException(ResultCode.BAD_REQUEST, "releaseMode只能是AUTO或MANUAL");
}
List<Long> ids = requestedLocationIds == null ? List.of() : requestedLocationIds.stream().sorted().toList();
if (new HashSet<>(ids).size() != ids.size()) {
throw new BizException(ResultCode.BAD_REQUEST, "释放库位不能重复");
}
if ("AUTO".equals(mode) && !ids.isEmpty()) {
throw new BizException(ResultCode.BAD_REQUEST, "AUTO模式不能指定释放库位");
}
String json = ids.stream().map(String::valueOf).collect(java.util.stream.Collectors.joining(",", "[", "]"));
return new ReleaseSelection(mode, json);
}
private record BatchLine(LoadingPickItemEntity item, BatchItemRequest request, String action,
ReleaseSelection release, long warehouseId) {}
private record ReleaseSelection(String mode, String json) {}
long warehouseId) {}
private static String trim(String value) {
return value == null ? null : value.trim();
@@ -41,30 +41,33 @@ public class MeasurementLocationOccupationService {
int measurementVersion) {
InboundOrderEntity order = requireOrder(tenantId, inboundId);
long warehouseId = requireWarehouse(order);
long resourceTenantId = PLATFORM_WAREHOUSE_RESOURCE_TENANT_ID;
BigDecimal volume = requireVolume(order);
var palletReferences = confirmedPalletReferences(tenantId, inboundId);
// 补偿释放后的预占记录需要保留审计,失败重试必须使用新的物理预占键;
// 若仍存在活动预占,仓储侧会按业务单据复用,不会创建新的资源占用。
String key = "measure-" + inboundId + "-" + measurementId + "-" + measurementVersion
var confirmedWeights = confirmedWeights(tenantId, inboundId);
int requiredSlotCount = requiredSlotCount(volume, confirmedWeights.size());
// Warehouse retains released blocks for audit. A compensated attempt therefore needs a new
// reservation key, otherwise a retry would replay the released block instead of reserving again.
String key = "measure-block-" + inboundId + "-" + measurementId + "-" + measurementVersion
+ "-" + UUID.randomUUID();
WarehousePalletLocationClient.BatchResult reserved = requireSuccess(call(() -> warehouse.reserve(
internalToken, new WarehousePalletLocationClient.ReserveRequest(resourceTenantId, warehouseId,
SOURCE_SERVICE, inboundId, order.getInboundCode(), key, operatorId, volume,
palletReferences,
LocalDateTime.now().plus(properties.getReservationTtl()), true))),
WarehousePalletLocationClient.BlockResult reserved = requireSuccess(call(() -> warehouse.reserveBlock(
internalToken, new WarehousePalletLocationClient.BlockReserveRequest(warehouseId,
SOURCE_SERVICE, inboundId, order.getInboundCode(), requiredSlotCount, key, operatorId,
LocalDateTime.now().plus(properties.getReservationTtl())))),
"测量完成后库位预占失败");
int packageCount = requirePackageCount(order);
allocations.replace(tenantId, inboundId, warehouseId, resourceTenantId, packageCount, reserved);
requireBlockResult(reserved, requiredSlotCount, "RESERVED");
allocations.replaceBlock(tenantId, inboundId, warehouseId, packageCount, volume, reserved);
allocations.bindConfirmedWeights(tenantId, inboundId, confirmedWeights);
try {
WarehousePalletLocationClient.BatchResult occupied = requireSuccess(call(() -> warehouse.confirm(
internalToken, new WarehousePalletLocationClient.ConfirmRequest(resourceTenantId, warehouseId,
SOURCE_SERVICE, inboundId, operatorId, volume, packageCount))), "测量完成后库位占用失败");
allocations.replace(tenantId, inboundId, warehouseId, resourceTenantId, packageCount, occupied);
WarehousePalletLocationClient.BlockResult occupied = requireSuccess(call(() -> warehouse.confirmBlock(
internalToken, new WarehousePalletLocationClient.BlockConfirmRequest(reserved.blockId(), inboundId,
key + "-confirm", operatorId))), "测量完成后库位占用失败");
requireBlockResult(occupied, requiredSlotCount, "OCCUPIED");
allocations.replaceBlock(tenantId, inboundId, warehouseId, packageCount, volume, occupied);
allocations.bindConfirmedWeights(tenantId, inboundId, confirmedWeights);
} catch (RuntimeException failure) {
try {
warehouse.release(internalToken, new WarehousePalletLocationClient.ReleaseRequest(
resourceTenantId, SOURCE_SERVICE, inboundId, operatorId, "测量完成库位占用失败"));
warehouse.releaseBlock(internalToken, new WarehousePalletLocationClient.BlockReleaseRequest(
reserved.blockId(), inboundId, key + "-compensate", operatorId, "MANUAL_ADJUSTMENT"));
} catch (RuntimeException releaseFailure) {
failure.addSuppressed(releaseFailure);
}
@@ -99,9 +102,9 @@ public class MeasurementLocationOccupationService {
return order.getTotalPackageCount();
}
private java.util.List<WarehousePalletLocationClient.PalletReference> confirmedPalletReferences(
private java.util.List<InboundWeightResultEntity> confirmedWeights(
long tenantId, long inboundId) {
java.util.List<WarehousePalletLocationClient.PalletReference> references = new java.util.ArrayList<>();
java.util.List<InboundWeightResultEntity> references = new java.util.ArrayList<>();
for (InboundWeightResultEntity weight : measurements.listWeights(tenantId, inboundId)) {
if (!"CONFIRMED".equals(weight.getRecognitionStatus())) {
continue;
@@ -118,7 +121,7 @@ public class MeasurementLocationOccupationService {
if (weight.getId() == null || weight.getId() <= 0) {
throw new BizException(ResultCode.CONFLICT, "已确认称重图片缺少结果标识,无法分配库位");
}
references.add(new WarehousePalletLocationClient.PalletReference(weight.getId(), weight.getPalletId()));
references.add(weight);
}
if (references.isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "至少需要一张已确认的称重图片才能提交测量");
@@ -129,6 +132,38 @@ public class MeasurementLocationOccupationService {
return references;
}
private int requiredSlotCount(BigDecimal volume, int actualPalletCount) {
BigDecimal capacity = properties.getDefaultPalletCapacityCbm();
if (capacity == null || capacity.signum() <= 0) {
throw new BizException(ResultCode.CONFLICT, "默认托盘容量配置无效,无法计算整票预留库位数");
}
int byVolume;
try {
byVolume = volume.divide(capacity, 0, java.math.RoundingMode.CEILING).intValueExact();
} catch (ArithmeticException exception) {
throw new BizException(ResultCode.VALIDATION_FAILED, "整票预留库位数超过系统上限");
}
int required = Math.max(actualPalletCount, byVolume);
if (required < 1 || required > 200) {
throw new BizException(ResultCode.VALIDATION_FAILED, "整票预留库位数必须在1到200之间");
}
return required;
}
private static void requireBlockResult(WarehousePalletLocationClient.BlockResult result,
int requiredSlotCount, String expectedStatus) {
if (result.blockId() <= 0 || !expectedStatus.equals(result.blockStatus())
|| result.resources() == null || result.resources().size() != requiredSlotCount
|| result.resources().stream().anyMatch(resource -> resource.blockId() != result.blockId())) {
throw new BizException(ResultCode.CONFLICT, "Warehouse返回的整票库位块不完整");
}
java.util.List<Integer> slotNos = result.resources().stream()
.map(WarehousePalletLocationClient.BlockResource::slotNo).sorted().toList();
if (!slotNos.equals(java.util.stream.IntStream.rangeClosed(1, requiredSlotCount).boxed().toList())) {
throw new BizException(ResultCode.CONFLICT, "Warehouse返回的整票库位槽序不连续");
}
}
private static <T> ApiResponse<T> call(RemoteCall<T> call) {
try { return call.invoke(); }
catch (FeignException exception) {
@@ -153,18 +153,12 @@ public class PdaPickSettlementService {
confirmedAllocations.sort(Comparator.comparing(InboundPalletAllocationEntity::getAllocationNo,
Comparator.nullsLast(Integer::compareTo)));
consumePickedAllocations(parent.tenantId(), parentInboundId, confirmedAllocations, pickedPackageCount);
List<Long> releasedReservationIds = releasedReservationIds(confirmedAllocations);
List<WarehousePalletLocationClient.SettlementTransferItem> transferItems =
remainingTransferItems(confirmedAllocations);
int allocationRemaining = transferItems.stream()
.mapToInt(WarehousePalletLocationClient.SettlementTransferItem::remainingPackageCount).sum();
long blockId = blockId(confirmedAllocations);
int allocationRemaining = confirmedAllocations.stream().mapToInt(PdaPickSettlementService::remaining).sum();
if (allocationRemaining != childPackageCount) {
throw conflict("父入库单库位剩余箱数与未装柜箱数不一致");
}
long warehouseId = warehouseId(confirmedAllocations);
if (childPackageCount == 0 && (!transferItems.isEmpty() || releasedReservationIds.isEmpty())) {
throw conflict("全量拣货后库位资源结算不完整");
}
warehouseId(confirmedAllocations);
List<MeasurementItemRow> measurements = splits.lockLatestApprovedItemsForInbound(
parent.tenantId(), parentInboundId);
@@ -186,19 +180,11 @@ public class PdaPickSettlementService {
throw conflict("主入库单测量明细剩余箱数与未装柜箱数不一致");
}
if (childPackageCount > 0 && transferItems.isEmpty()) {
throw conflict("父入库单可转移库位剩余箱数与未装柜箱数不一致");
}
if (root == null || !"MASTER".equals(root.inboundRole())) throw conflict("根主入库单不存在");
OrderFulfillmentSplitClient.Response orderSplit = createOrderSplit(parent, root, measurementLines,
pickedPackageCount, childPackageCount, loadingPlanId, containerBookingId, operationId,
"pda-task-" + taskId, operatorId, operatorName);
String settlementKey = "PDA-SETTLE-" + taskId + '-' + parentInboundId;
long remainderChildId = childPackageCount > 0 ? existingSettlementTarget(parentInboundId, settlementKey) : 0L;
if (childPackageCount > 0 && remainderChildId == 0L) {
remainderChildId = IdWorker.getId();
}
long remainderChildId = childPackageCount > 0 ? IdWorker.getId() : 0L;
long batchId = IdWorker.getId();
String idempotencyKey = "pda-task-" + taskId;
int rootVersion = root.version() == null ? 0 : root.version();
@@ -269,15 +255,14 @@ public class PdaPickSettlementService {
"PDA拣货后来源单承接实装并生成余量子入库单"), "拆分审计");
if (remainderChildId != 0L) {
allocations.copyRemainingToChild(parent.tenantId(), remainderChildId, confirmedAllocations);
allocations.copyBlockToChild(parent.tenantId(), remainderChildId, confirmedAllocations);
allocations.markBlockTransferred(parent.tenantId(), parentInboundId, blockId);
} else {
allocations.markBlockReleased(parent.tenantId(), parentInboundId, blockId);
}
allocations.markTransferred(parent.tenantId(), parentInboundId, confirmedAllocations,
releasedReservationIds);
allocations.markReleased(parent.tenantId(), parentInboundId, releasedReservationIds);
settleWarehouseResources(parentInboundId, remainderChildId,
settleWarehouseBlock(parentInboundId, remainderChildId,
remainderChildCode == null ? root.inboundCode() : remainderChildCode,
taskId, operatorId, operatorName,
warehouseId, parentAvailable, childPackageCount, releasedReservationIds, transferItems);
taskId, operatorId, blockId);
return true;
}
@@ -409,28 +394,17 @@ public class PdaPickSettlementService {
}
}
private static List<Long> releasedReservationIds(List<InboundPalletAllocationEntity> allocations) {
return allocations.stream().filter(item -> remaining(item) == 0).map(PdaPickSettlementService::reservationId)
.distinct().sorted().toList();
}
private static List<WarehousePalletLocationClient.SettlementTransferItem> remainingTransferItems(
List<InboundPalletAllocationEntity> allocations) {
return allocations.stream().filter(item -> remaining(item) > 0)
.map(item -> new WarehousePalletLocationClient.SettlementTransferItem(reservationId(item), remaining(item)))
.toList();
}
private static int remaining(InboundPalletAllocationEntity allocation) {
return count(allocation.getAllocatedPackageCount(), "库位已分配箱数")
- (allocation.getPickedPackageCount() == null ? 0 : allocation.getPickedPackageCount());
}
private static long reservationId(InboundPalletAllocationEntity allocation) {
if (allocation.getReservationId() == null || allocation.getReservationId() <= 0) {
throw conflict("库位资源缺少唯一reservationId,请先重新确认托盘库位分配");
private static long blockId(List<InboundPalletAllocationEntity> allocations) {
if (allocations.isEmpty() || allocations.get(0).getBlockId() == null || allocations.get(0).getBlockId() <= 0
|| allocations.stream().anyMatch(item -> !Objects.equals(allocations.get(0).getBlockId(), item.getBlockId()))) {
throw conflict("PDA结算要求父入库单资源属于同一整票库位块");
}
return allocation.getReservationId();
return allocations.get(0).getBlockId();
}
private static long warehouseId(List<InboundPalletAllocationEntity> allocations) {
@@ -441,36 +415,33 @@ public class PdaPickSettlementService {
return allocations.get(0).getWarehouseId();
}
private void settleWarehouseResources(long parentInboundId, long childInboundId, String childCode,
long taskId, long operatorId, String operatorName, long warehouseId,
int sourcePackageCount, int targetPackageCount,
List<Long> releasedReservationIds,
List<WarehousePalletLocationClient.SettlementTransferItem> remainingItems) {
ApiResponse<WarehousePalletLocationClient.SettlementResult> response;
private void settleWarehouseBlock(long parentInboundId, long childInboundId, String childCode,
long taskId, long operatorId, long blockId) {
ApiResponse<WarehousePalletLocationClient.BlockResult> response;
String key = "PDA-BLOCK-" + taskId + '-' + parentInboundId;
try {
response = warehouse.settle(internalToken, new WarehousePalletLocationClient.SettlementRequest(
warehouseId, SOURCE_SERVICE, parentInboundId, childInboundId,
sourcePackageCount, targetPackageCount, childCode, "PDA-SETTLE-" + taskId + '-' + parentInboundId,
operatorId, operatorName, "PDA-SETTLE-" + taskId + '-' + parentInboundId,
releasedReservationIds, remainingItems));
response = childInboundId > 0
? warehouse.transferBlock(internalToken, new WarehousePalletLocationClient.BlockTransferRequest(
blockId, parentInboundId, childInboundId, childCode, key, operatorId))
: warehouse.releaseBlock(internalToken, new WarehousePalletLocationClient.BlockReleaseRequest(
blockId, parentInboundId, key, operatorId, "ALL_PACKAGES_PICKED"));
} catch (FeignException exception) {
log.warn("PDA warehouse settlement transport failure taskId={} sourceInboundId={} operationId={} "
+ "httpStatus={} releasedReservationIds={} remainingReservations={}",
taskId, parentInboundId, "PDA-SETTLE-" + taskId + '-' + parentInboundId,
exception.status(), releasedReservationIds, settlementTransferFacts(remainingItems));
log.warn("PDA warehouse block settlement transport failure taskId={} sourceInboundId={} "
+ "blockId={} operationId={} httpStatus={}",
taskId, parentInboundId, blockId, key, exception.status());
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓储服务不可用,无法结算PDA库位资源");
}
if (response == null || !response.successful() || response.data() == null
|| response.data().released() == null || response.data().transferred() == null
|| !sameReleasedReservations(releasedReservationIds, response.data().released())
|| !sameTransferredResources(remainingItems, response.data().transferred())) {
log.warn("PDA warehouse settlement rejected taskId={} sourceInboundId={} operationId={} "
+ "responseCode={} requestId={} traceId={} expectedAction=RELEASE_OR_TRANSFER "
+ "releasedReservationIds={} remainingReservations={}",
taskId, parentInboundId, "PDA-SETTLE-" + taskId + '-' + parentInboundId,
response == null ? null : response.code(), response == null ? null : response.requestId(),
response == null ? null : response.traceId(), releasedReservationIds,
settlementTransferFacts(remainingItems));
|| response.data().blockId() != blockId || response.data().resources() == null
|| response.data().resources().isEmpty()
|| response.data().resources().stream().anyMatch(resource -> resource.blockId() != blockId)
|| !(childInboundId > 0 ? "OCCUPIED" : "RELEASED").equals(response.data().blockStatus())
|| response.data().resources().stream().anyMatch(resource ->
!(childInboundId > 0 ? "OCCUPIED" : "RELEASED").equals(resource.resourceStatus()))) {
log.warn("PDA warehouse block settlement rejected taskId={} sourceInboundId={} blockId={} "
+ "operationId={} responseCode={} requestId={} traceId={}",
taskId, parentInboundId, blockId, key, response == null ? null : response.code(),
response == null ? null : response.requestId(), response == null ? null : response.traceId());
ResultCode code = response == null ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.of(response.code());
throw new SettlementFailure(code, response == null || response.message() == null
? "仓储服务原子结算PDA库位资源失败" : response.message(),
@@ -493,40 +464,6 @@ public class PdaPickSettlementService {
public String traceId() { return traceId; }
}
private static List<String> settlementTransferFacts(
List<WarehousePalletLocationClient.SettlementTransferItem> remainingItems) {
return remainingItems.stream().map(item -> item.reservationId() + ":" + item.remainingPackageCount())
.sorted().toList();
}
private long existingSettlementTarget(long sourceInboundId, String idempotencyKey) {
try {
ApiResponse<Long> response = warehouse.settlementTarget(internalToken, sourceInboundId, idempotencyKey);
return response != null && response.successful() && response.data() != null
? response.data() : 0L;
} catch (FeignException exception) {
return 0L;
}
}
private static boolean sameReleasedReservations(List<Long> expected,
List<WarehousePalletLocationClient.SelectiveReleaseResult> actual) {
return expected.equals(actual.stream().map(WarehousePalletLocationClient.SelectiveReleaseResult::reservationId)
.sorted().toList());
}
private static boolean sameTransferredResources(List<WarehousePalletLocationClient.SettlementTransferItem> expected,
List<WarehousePalletLocationClient.TransferResult> actual) {
Map<Long, Integer> expectedByLocation = expected.stream().collect(Collectors.toMap(
WarehousePalletLocationClient.SettlementTransferItem::reservationId,
WarehousePalletLocationClient.SettlementTransferItem::remainingPackageCount));
Map<Long, Integer> actualByLocation = actual.stream().collect(Collectors.toMap(
WarehousePalletLocationClient.TransferResult::reservationId,
WarehousePalletLocationClient.TransferResult::remainingPackageCount,
(left, right) -> -1));
return expectedByLocation.equals(actualByLocation);
}
static String childInboundCode(String rootInboundCode, int splitSequence) {
if (splitSequence <= 0) {
throw conflict("子入库单拆分序号不合法");
@@ -847,7 +847,14 @@ public class SmartLoadingService {
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
Candidate candidate = repository.candidate(planId, booking.getWarehouseId(), item.getInboundId(),
item.getInboundDetailId());
if (candidate == null || request.packageCount() > candidate.availablePackageCount() + item.getPackageCount()) throw validation("调整箱数超过可用箱数");
if (candidate == null) throw validation("调整箱数超过可用箱数");
int currentPackageCount = item.getPackageCount() == null ? 0 : item.getPackageCount();
int remainingPackageCount = Math.max(0, candidate.availablePackageCount() == null
? 0 : candidate.availablePackageCount());
if (request.packageCount() > currentPackageCount
&& request.packageCount() - currentPackageCount > remainingPackageCount) {
throw validation("调整箱数超过可用箱数");
}
requireSameCustomerScope(booking, candidate);
OrderCargoClient.CargoBinding cargo = loadCargo(candidate);
List<LoadingPlanItemEntity> others = new ArrayList<>(repository.items(planId));