Merge remote-tracking branch 'origin/test' into dev

# Conflicts:
#	order/AGENTS.md
This commit is contained in:
2026-09-04 12:29:57 +08:00
87 changed files with 3134 additions and 720 deletions
@@ -23,6 +23,7 @@ public interface PendingReviewOrderClient {
@RequestParam(required = false) String inboundCode,
@RequestParam(required = false) String createdStart,
@RequestParam(required = false) String createdEnd,
@RequestParam(required = false) List<Long> excludedOrderIds,
@RequestParam long page,
@RequestParam long size);
@@ -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(
@@ -38,4 +38,6 @@ public class PlatformSmartLoadingController {
public ApiResponse<Plan> warehouseRemark(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody WarehouseRemarkRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.updateWarehouseRemark(planId,itemId,r,u.userId(),u.username()));}
@DeleteMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:delete')")
public ApiResponse<Plan> delete(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody DeleteRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.delete(planId,itemId,r,u.userId(),u.username()));}
@DeleteMapping("/plans/{planId}/unpicked-items") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:delete')")
public ApiResponse<Plan> removeUnpicked(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody RemoveUnpickedRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.removeUnpicked(planId,r,u.userId(),u.username()));}
}
@@ -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,
@@ -21,9 +21,11 @@ public record InboundOrderDetail(
this(summary, publicRemark, List.of(), palletLocations, files, internalNotes, 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
) {
@@ -73,6 +73,8 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
@NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {}
public record DeleteRequest(@NotNull @Min(0) Integer expectedPlanVersion,
@NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {}
public record RemoveUnpickedRequest(@NotNull @Min(0) Integer expectedPlanVersion,
@NotBlank @Size(max=128) String operationId) {}
public record WarehouseRemarkRequest(@Size(max=1000) String warehouseRemark,
@NotNull @Min(0) Integer expectedPlanVersion, @NotNull @Min(0) Integer expectedItemVersion,
@NotBlank @Size(max=128) String operationId) {}
@@ -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;
@@ -15,6 +15,7 @@ import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.CompletionTarget;
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.loading.repository.LoadingRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.service.FbaPricingService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
@@ -40,6 +41,7 @@ public class LoadingDeliveryService {
@Resource private LoadingCompletionGateService completionGate;
@Resource private InboundStatusHistoryMapper histories;
@Resource private CabinetLoadingEvidenceService cabinetEvidenceService;
@Resource private FbaPricingService pricingService;
@Resource private ObjectMapper objectMapper;
@Transactional
@@ -64,6 +66,7 @@ public class LoadingDeliveryService {
}
if ("LOADED".equals(inbound.processStatus()) || "DELIVERED".equals(inbound.processStatus())) {
requireLoadingPlanGate(tenantId, inboundId, acceptedPdaCompletion);
pricingService.autoConfirmAfterLoading(tenantId, inboundId);
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse(inbound.processStatus()));
}
@@ -74,6 +77,7 @@ public class LoadingDeliveryService {
requireCabinetEvidence(tenantId, inboundId);
if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent();
addHistory(tenantId, inboundId, "LOADING", "LOADED", COMPLETE_LOADING, request.remark(), operatorId, operatorName);
pricingService.autoConfirmAfterLoading(tenantId, inboundId);
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse("LOADED"));
}
@@ -116,6 +120,7 @@ public class LoadingDeliveryService {
CompletionTarget target,
boolean acceptedPdaCompletion) {
if ("LOADED".equals(target.loadedChildStatus())) {
pricingService.autoConfirmAfterLoading(tenantId, target.completionInboundId());
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse("LOADED"));
}
@@ -131,6 +136,7 @@ public class LoadingDeliveryService {
repository.aggregateRootLoaded(tenantId, target.rootInboundId(), operatorId);
addHistory(tenantId, target.completionInboundId(), "LOADING", "LOADED", COMPLETE_LOADING,
request.remark(), operatorId, operatorName);
pricingService.autoConfirmAfterLoading(tenantId, target.completionInboundId());
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse("LOADED"));
}
@@ -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);
@@ -28,6 +28,27 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
""")
List<PlanColoadMemberRow> planColoadMembers(@Param("planId") long planId);
@Select("""
<script>
SELECT DISTINCT item.order_id
FROM fl_loading_plan_item item
JOIN fl_loading_plan plan ON plan.id=item.loading_plan_id
JOIN fl_inbound_order inbound
ON inbound.tenant_id=item.account_id AND inbound.id=item.inbound_id
WHERE item.deleted=0 AND plan.plan_status IN ('EDITING','LOADING')
AND item.order_id IS NOT NULL
AND inbound.channel_code IN
<foreach collection="actualChannels" item="channel" open="(" separator="," close=")">#{channel}</foreach>
<if test="query.warehouseId != null">AND inbound.warehouse_id=#{query.warehouseId}</if>
<if test="query.customerId != null">AND item.account_id=#{query.customerId}</if>
<if test="query.inboundCode != null and query.inboundCode.trim() != ''">
AND item.inbound_code=#{query.inboundCode}
</if>
</script>
""")
List<Long> occupiedOrderIds(@Param("query") InboundCandidateQuery query,
@Param("actualChannels") Set<String> actualChannels);
@Select("""
SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId,
o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode,
@@ -134,6 +155,11 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
o.process_status IN ('WAIT_MEASURE','MEASURING','MEASURE_PENDING','MEASURE_REJECTED',
'WAIT_PRICING','FEE_CONFIRMING','INBOUNDING','WAIT_SPLIT','LOADING')
)
AND NOT EXISTS (
SELECT 1 FROM fl_loading_plan_item active_item
WHERE active_item.account_id=o.tenant_id AND active_item.inbound_id=o.id
AND active_item.deleted=0
)
) eligible
LEFT JOIN fl_coload_group_member current_member
ON current_member.tenant_id=eligible.accountId AND current_member.inbound_id=eligible.inboundId
@@ -169,7 +169,7 @@ public class FbaPricingRepository {
public List<CustomerChargeRuleEntity> matchRules(InboundPricingEntity inbound, LocalDateTime at) {
return rules.selectList(new LambdaQueryWrapper<CustomerChargeRuleEntity>()
.eq(CustomerChargeRuleEntity::getTenantId, inbound.getTenantId())
.eq(CustomerChargeRuleEntity::getCustomerId, inbound.getCustomerId())
.eq(CustomerChargeRuleEntity::getCustomerId, inbound.getTenantId())
.eq(CustomerChargeRuleEntity::getBusinessType, inbound.getBusinessType())
.eq(CustomerChargeRuleEntity::getTaxMode, inbound.getTaxMode())
.eq(CustomerChargeRuleEntity::getStatus, "ACTIVE")
@@ -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);
}
@@ -59,6 +59,9 @@ public class SmartLoadingRepository {
public List<PlanColoadMemberRow> planColoadMembers(long planId) {
return candidates.planColoadMembers(planId);
}
public List<Long> occupiedOrderIds(InboundCandidateQuery query, Set<String> actualChannels) {
return candidates.occupiedOrderIds(query, actualChannels);
}
public Candidate candidate(long planId, long warehouseId, long inboundId, long detailId) {
return candidates.candidate(planId, warehouseId, inboundId, detailId);
}
@@ -35,6 +35,8 @@ import java.util.List;
@Service
public class FbaPricingService {
private static final int MONEY_SCALE = 2;
private static final long AUTOMATIC_CONFIRM_OPERATOR_ID = 0L;
private static final String AUTOMATIC_CONFIRM_OPERATOR_NAME = "装柜完成自动确认";
@Resource
private FbaPricingRepository repository;
@Resource
@@ -91,16 +93,25 @@ public class FbaPricingService {
}
}
var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId);
if ("OUTER".equalsIgnoreCase(volumeSource) && measurement == null) {
throw conflict("外箱规测量批次不存在,不能生成对客费用");
boolean actualMeasurementSource = "ACTUAL".equalsIgnoreCase(weightSource)
|| "ACTUAL".equalsIgnoreCase(volumeSource);
boolean outerMeasurementSource = "OUTER".equalsIgnoreCase(weightSource)
|| "OUTER".equalsIgnoreCase(volumeSource);
if ((actualMeasurementSource || outerMeasurementSource) && measurement == null) {
throw conflict(actualMeasurementSource
? "实际测量数据不存在,不能生成对客费用"
: "外箱规测量批次不存在,不能生成对客费用");
}
if ("OUTER".equalsIgnoreCase(volumeSource)) {
var outerItems = measurementRepository.listItems(tenantId, measurement.getId());
boolean incomplete = outerItems.isEmpty() || outerItems.stream().anyMatch(item ->
if (actualMeasurementSource || outerMeasurementSource) {
var measurementItems = measurementRepository.listItems(tenantId, measurement.getId());
if (actualMeasurementSource && measurementItems.isEmpty()) {
throw conflict("实际测量明细不存在,不能生成对客费用");
}
boolean incomplete = outerMeasurementSource && (measurementItems.isEmpty() || measurementItems.stream().anyMatch(item ->
item.getOuterLengthCm() == null || item.getOuterLengthCm().signum() <= 0
|| item.getOuterWidthCm() == null || item.getOuterWidthCm().signum() <= 0
|| item.getOuterHeightCm() == null || item.getOuterHeightCm().signum() <= 0
|| !StringUtils.hasText(item.getCalculationRuleVersion()));
|| !StringUtils.hasText(item.getCalculationRuleVersion())));
if (incomplete) throw conflict("外箱规数据或绑定的计算规则不完整,不能生成对客费用");
}
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(new EstimateRequest(
@@ -204,6 +215,21 @@ public class FbaPricingService {
return response(tenantId, fee.getId(), null);
}
/** 客户未操作时,装柜完成以系统身份确认最新一版已发送费用。 */
@Transactional
public void autoConfirmAfterLoading(long tenantId, long inboundId) {
InboundFeeEntity current = repository.findLatestFeeForUpdate(tenantId, inboundId);
if (current == null || !"SENT".equals(current.getFeeStatus())
|| current.getCustomerConfirmedAt() != null) {
return;
}
LocalDateTime now = LocalDateTime.now(clock);
if (repository.confirmFeeByCustomer(tenantId, current.getId(), current.getFeeVersion(),
AUTOMATIC_CONFIRM_OPERATOR_ID, AUTOMATIC_CONFIRM_OPERATOR_NAME, now) != 1) {
throw conflict("装柜完成自动确认费用失败,请重试");
}
}
@Transactional
public FeeSnapshotResponse confirmByCustomer(long tenantId, long operatorId, String operatorName,
long inboundId, int expectedFeeVersion,
@@ -339,9 +365,6 @@ public class FbaPricingService {
if (!"FBA".equals(inbound.getDestinationType()) && !"OVERSEAS".equals(inbound.getDestinationType())) {
throw conflict("当前目的地类型不支持自动计费");
}
if (inbound.getCustomerId() == null || inbound.getCustomerId() <= 0) {
throw conflict("入库单缺少订单客户归属,不能生成对客费用");
}
if (inbound.getWarehouseId() == null || !StringUtils.hasText(inbound.getChannelCode())) {
throw conflict("入库单缺少起运仓或渠道匹配维度");
}
@@ -419,7 +442,7 @@ public class FbaPricingService {
fee.setChargeRuleId(null); fee.setChargeRuleVersion(null);
fee.setCarrierRateId(rate.getId()); fee.setCarrierRateVersion(rate.getRateVersion());
fee.setPriceBookVersionId(estimate.priceBookVersionId());
fee.setRuleCode(null); fee.setCustomerId(inbound.getCustomerId());
fee.setRuleCode(null); fee.setCustomerId(tenantId);
fee.setCustomerName(inbound.getCustomerName()); fee.setBusinessType(inbound.getBusinessType());
fee.setTaxMode(inbound.getTaxMode()); fee.setPricingTaxMode(inbound.getPricingTaxMode());
fee.setRoundingScale(estimate.roundingScale()); fee.setRoundingMode(estimate.roundingMode());
@@ -100,7 +100,7 @@ public class InboundFeeDetailService {
detail.setInboundCode(inbound.getInboundCode());
detail.setDirection("RECEIVABLE");
detail.setBusinessDate(now.toLocalDate());
detail.setCustomerId(inbound.getCustomerId());
detail.setCustomerId(tenantId);
detail.setCustomerName(inbound.getCustomerName());
detail.setInvoiceTitle(inbound.getCompanyName());
detail.setFeeCategorySnapshot(feeItem.getCategory());
@@ -186,7 +186,7 @@ public class InboundFeeDetailService {
detail.setSourceType("FBA_PRICING"); detail.setSourceFeeId(fee.getId());
detail.setSourceFeeVersion(fee.getFeeVersion()); detail.setSourceFeeNo(fee.getFeeNo());
detail.setInboundCode(inbound.getInboundCode()); detail.setDirection("RECEIVABLE");
detail.setBusinessDate(now.toLocalDate()); detail.setCustomerId(inbound.getCustomerId());
detail.setBusinessDate(now.toLocalDate()); detail.setCustomerId(tenantId);
detail.setCustomerName(inbound.getCustomerName()); detail.setInvoiceTitle(inbound.getCompanyName());
detail.setFeeCategorySnapshot(feeItem.getCategory()); detail.setTradeScopeSnapshot(feeItem.getTradeScope());
detail.setFeeTypeNameSnapshot(feeItem.getFeeTypeName()); detail.setChannelCode(inbound.getChannelCode());
@@ -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,6 +253,8 @@ public class LoadingPickService {
Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId);
if (replay != null) return replay;
LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId);
replay = replayTaskOperationForUpdate(taskId, operationId, requestHash, operatorId);
if (replay != null) return replay;
LoadingPickAssignmentEntity existing = repository.assignment(taskId);
if (existing == null) {
if (request.expectedAssignmentVersion() != 0
@@ -282,6 +284,8 @@ public class LoadingPickService {
Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId);
if (replay != null) return replay;
LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId);
replay = replayTaskOperationForUpdate(taskId, operationId, requestHash, operatorId);
if (replay != null) return replay;
LoadingPickAssignmentEntity existing = repository.assignment(taskId);
int changed = existing == null
? request.expectedAssignmentVersion() == 0
@@ -296,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) {
@@ -310,12 +313,17 @@ public class LoadingPickService {
return view(requiredTask(taskId), request.warehouseId());
}
LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId);
requireNoTaskOperationForUpdate(operationId);
replay = repository.historyForUpdate(operationId);
if (replay != null) {
assertReplay(replay, taskId, itemId, requestHash);
return view(task, request.warehouseId());
}
LoadingPickItemEntity item = repository.item(taskId, itemId, request.warehouseId());
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();
@@ -336,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) {
@@ -347,9 +354,15 @@ public class LoadingPickService {
return view(requiredTask(taskId), request.warehouseId());
}
LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId);
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",
@@ -372,6 +385,12 @@ public class LoadingPickService {
return view(requiredTask(taskId), request.warehouseId());
}
LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId);
requireNoTaskOperationForUpdate(operationId);
replay = repository.historyForUpdate(operationId);
if (replay != null) {
assertReplay(replay, taskId, itemId, requestHash);
return view(task, request.warehouseId());
}
LoadingPickItemEntity item = requireWarehouseItem(taskId, itemId, request.warehouseId());
if (repository.hasPickedOrPartialItem(taskId, item.getInboundId())) {
throw conflict("同一入库单已存在已拣或部分拣货明细,不能跳拣并恢复智能装柜候选");
@@ -398,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("装柜任务已完成,不能重复提交整批拣货");
@@ -970,10 +989,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
@@ -982,7 +999,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
@@ -991,10 +1007,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必须填写异常原因");
@@ -1007,7 +1021,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;
@@ -1110,12 +1124,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());
@@ -1125,8 +1137,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);
}
@@ -1170,6 +1181,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())
@@ -1195,6 +1218,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不能为空");
@@ -1250,35 +1277,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();
@@ -62,7 +62,7 @@ public class MeasurementBatchService {
}
validateSource(command);
List<InboundMeasurementItemEntity> calculatedItems = calculateItems(
command, dimensionWeighting.load(command.tenantId()), inbound.getCustomerId());
command, dimensionWeighting.load(command.tenantId()));
InboundMeasurementEntity measurement;
if (command.measurementId() == null) {
@@ -176,8 +176,7 @@ public class MeasurementBatchService {
}
private List<InboundMeasurementItemEntity> calculateItems(
MeasurementBatchCommand command, CustomerDimensionWeightingService.RuleSet dimensionRules,
Long customerId) {
MeasurementBatchCommand command, CustomerDimensionWeightingService.RuleSet dimensionRules) {
Set<String> itemNumbers = new HashSet<>();
return command.items().stream().map(source -> {
if (!itemNumbers.add(source.itemNo())) {
@@ -199,8 +198,8 @@ public class MeasurementBatchService {
baseLength, baseWidth, baseHeight);
String requestedMode = normalizeMode(source.coefficientMode());
CustomerChargeRuleEntity coefficientRule = "MULTIPLY".equals(requestedMode)
? requireCoefficientRule(command.tenantId(), customerId, source.chargeRuleId())
: requestedMode == null ? findDefaultCoefficientRule(command.tenantId(), customerId) : null;
? requireCoefficientRule(command.tenantId(), source.chargeRuleId())
: requestedMode == null ? findDefaultCoefficientRule(command.tenantId()) : null;
String coefficientMode = coefficientRule != null ? "MULTIPLY" : requestedMode;
boolean useDimensionRule = "ADD".equals(coefficientMode);
BigDecimal addition = weighted.edgeAdditionCm() == null
@@ -293,15 +292,13 @@ public class MeasurementBatchService {
return normalized;
}
private CustomerChargeRuleEntity findDefaultCoefficientRule(long tenantId, Long customerId) {
if (customerId == null || customerId <= 0) return null;
return pricingRepository.findDefaultRule(tenantId, customerId);
private CustomerChargeRuleEntity findDefaultCoefficientRule(long tenantId) {
return pricingRepository.findDefaultRule(tenantId, tenantId);
}
private CustomerChargeRuleEntity requireCoefficientRule(long tenantId, Long customerId, Long ruleId) {
Long effectiveCustomerId = tenantId;
private CustomerChargeRuleEntity requireCoefficientRule(long tenantId, Long ruleId) {
CustomerChargeRuleEntity rule = ruleId == null
? pricingRepository.findDefaultRule(tenantId, effectiveCustomerId)
? pricingRepository.findDefaultRule(tenantId, tenantId)
: pricingRepository.findRule(tenantId, ruleId);
if (rule == null || !"ACTIVE".equals(rule.getStatus())
|| !java.util.Objects.equals(rule.getTenantId(), tenantId)) {
@@ -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);
@@ -193,21 +187,13 @@ public class PdaPickSettlementService {
throw conflict("主入库单测量明细剩余箱数与未装柜箱数不一致");
}
if (childPackageCount > 0 && transferItems.isEmpty()) {
throw conflict("父入库单可转移库位剩余箱数与未装柜箱数不一致");
}
if (root == null || !"MASTER".equals(root.inboundRole())) throw conflict("根主入库单不存在");
Map<Long, Integer> orderQuantities = dropshipWholePallet
? new LinkedHashMap<>(pickedByDetail) : measurementQuantities(measurementLines);
OrderFulfillmentSplitClient.Response orderSplit = createOrderSplit(parent, root, orderQuantities,
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();
@@ -278,15 +264,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;
}
@@ -433,28 +418,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) {
@@ -465,36 +439,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(),
@@ -517,40 +488,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("子入库单拆分序号不合法");
@@ -125,24 +125,28 @@ public class SmartLoadingService {
? EnumSet.allOf(CandidateOrderStatus.class)
: EnumSet.copyOf(query.orderStatuses());
boolean includePending = selected.remove(CandidateOrderStatus.PENDING_REVIEW);
InboundCandidateQuery firstlegQuery = withStatuses(query, List.copyOf(selected), 1, 1);
if (!includePending) return firstlegCandidates(withStatuses(query, List.copyOf(selected),
query.page(), query.size()), actualChannels);
if (selected.isEmpty()) return pendingReviewCandidates(query, actualChannels, query.page(), query.size());
PageResult<InboundCandidate> pendingCount = pendingReviewCandidates(query, actualChannels, 1, 1);
PageResult<InboundCandidate> firstlegCount = firstlegCandidates(firstlegQuery, actualChannels);
long total = pendingCount.total() + firstlegCount.total();
long offset = Math.multiplyExact(query.page() - 1, query.size());
PageResult<InboundCandidate> pendingPage = pendingReviewCandidates(query, actualChannels,
query.page(), query.size());
List<InboundCandidate> records = new ArrayList<>();
if (offset < pendingCount.total()) {
records.addAll(pendingReviewRange(query, actualChannels, offset, query.size()));
}
long firstlegOffset = Math.max(0, offset - pendingCount.total());
int remaining = Math.toIntExact(query.size() - records.size());
if (remaining > 0 && firstlegOffset < firstlegCount.total()) {
records.addAll(firstlegRange(query, actualChannels, List.copyOf(selected), firstlegOffset, remaining));
PageResult<InboundCandidate> firstlegPage;
if (offset < pendingPage.total()) {
records.addAll(pendingPage.records());
int remaining = Math.toIntExact(query.size() - records.size());
firstlegPage = firstlegCandidates(withStatuses(query, List.copyOf(selected), 1,
Math.max(1, remaining)), actualChannels);
if (remaining > 0) records.addAll(firstlegPage.records());
} else {
long firstlegOffset = offset - pendingPage.total();
firstlegPage = firstlegRange(query, actualChannels, List.copyOf(selected),
firstlegOffset, query.size());
records.addAll(firstlegPage.records());
}
long total = pendingPage.total() + firstlegPage.total();
long pages = total == 0 ? 0 : (total + query.size() - 1) / query.size();
return new PageResult<>(records, query.page(), query.size(), total, pages);
}
@@ -184,7 +188,7 @@ public class SmartLoadingService {
try {
response = pendingReviewOrders.page(internalToken, List.copyOf(actualChannels), query.warehouseId(),
query.customerId(), query.inboundCode(), isoDate(query.createdStart()), isoDate(query.createdEnd()),
page, size);
repository.occupiedOrderIds(query, actualChannels), page, size);
} catch (RuntimeException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "待审核订单服务暂时不可用");
}
@@ -205,27 +209,20 @@ public class SmartLoadingService {
return value == null ? null : value.toString();
}
private List<InboundCandidate> pendingReviewRange(InboundCandidateQuery query, Set<String> channels,
long offset, long limit) {
return pageRange(offset, limit, (page, size) -> pendingReviewCandidates(query, channels, page, size));
}
private List<InboundCandidate> firstlegRange(InboundCandidateQuery query, Set<String> channels,
List<CandidateOrderStatus> statuses,
long offset, long limit) {
return pageRange(offset, limit, (page, size) -> firstlegCandidates(
withStatuses(query, statuses, page, size), channels));
}
private static List<InboundCandidate> pageRange(long offset, long limit,
java.util.function.BiFunction<Long, Long, PageResult<InboundCandidate>> source) {
if (limit <= 0) return List.of();
private PageResult<InboundCandidate> firstlegRange(InboundCandidateQuery query, Set<String> channels,
List<CandidateOrderStatus> statuses,
long offset, long limit) {
if (limit <= 0) return new PageResult<>(List.of(), 1, 1, 0, 0);
final long chunk = 200;
long page = offset / chunk + 1;
int skip = Math.toIntExact(offset % chunk);
List<InboundCandidate> result = new ArrayList<>();
long total = 0;
while (result.size() < limit) {
List<InboundCandidate> rows = source.apply(page++, chunk).records();
PageResult<InboundCandidate> sourcePage = firstlegCandidates(
withStatuses(query, statuses, page++, chunk), channels);
total = sourcePage.total();
List<InboundCandidate> rows = sourcePage.records();
if (rows.isEmpty()) break;
int from = Math.min(skip, rows.size());
int take = Math.min(Math.toIntExact(limit - result.size()), rows.size() - from);
@@ -233,7 +230,8 @@ public class SmartLoadingService {
skip = 0;
if (rows.size() < chunk) break;
}
return List.copyOf(result);
return new PageResult<>(List.copyOf(result), query.page(), query.size(), total,
total == 0 ? 0 : (total + query.size() - 1) / query.size());
}
private static InboundCandidateQuery withStatuses(InboundCandidateQuery source,
@@ -420,7 +418,6 @@ public class SmartLoadingService {
request.inboundDetailId());
if (candidate == null) throw conflict("入库货物不存在或尚未完成入库");
if (!wholeGroupAssignment) requireStandalone(candidate.accountId(), candidate.inboundId());
requireSameChannel(booking, candidate);
requireSameCustomerScope(booking, candidate);
var task = loadingPickService.mutationSnapshot(planId);
if (task.taskId() != null && "LOADING".equals(task.taskStatus())) {
@@ -525,7 +522,6 @@ public class SmartLoadingService {
for (InboundAllocation allocation : allocations) {
if (allocation.packageCount() == 0) continue;
Candidate candidate = allocation.candidate();
requireSameChannel(booking, candidate);
requireSameCustomerScope(booking, candidate);
checkRouteAndCapacity(booking, current, candidate, allocation.packageCount(), true);
OrderCargoClient.CargoBinding cargo = declared.stream()
@@ -581,7 +577,7 @@ public class SmartLoadingService {
LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName);
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
List<Candidate> details = frozenDetails == null
? candidates(plan.getContainerBookingId(), booking.getWarehouseId()).stream()
? manualCandidates(booking).stream()
.filter(candidate -> Objects.equals(candidate.inboundId(), request.inboundId())).toList()
: List.copyOf(frozenDetails);
if (details.isEmpty()) throw conflict("入仓码不存在、已分配完或尚未完成入库");
@@ -641,7 +637,6 @@ public class SmartLoadingService {
for (InboundAllocation allocation : allocations) {
if (allocation.packageCount() == 0) continue;
Candidate candidate = allocation.candidate();
requireSameChannel(booking, candidate);
requireSameCustomerScope(booking, candidate);
checkRouteAndCapacity(booking, current, candidate, allocation.packageCount(), true);
OrderCargoClient.CargoBinding cargo = declared.stream().map(DeclaredCargo::cargo)
@@ -852,8 +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("调整箱数超过可用箱数");
requireSameChannel(booking, candidate);
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));
@@ -945,6 +946,84 @@ public class SmartLoadingService {
return view(repository.plan(planId), requireBooking(plan.getContainerBookingId()));
}
@Transactional
@BusinessAudit(event = "SMART_LOADING_UNPICKED_REMOVE", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "批量移除智能装柜未拣明细")
public Plan removeUnpicked(long planId, RemoveUnpickedRequest request,
long operatorId, String operatorName) {
String operation = operation(request.operationId());
String requestHash = sha256("REMOVE_UNPICKED|" + planId + '|' + request.expectedPlanVersion());
Plan replay = replay(operation, planId, "REMOVE_UNPICKED", null, requestHash);
if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion(), operatorId, operatorName);
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
List<LoadingPlanItemEntity> items = repository.items(planId);
LoadingPickService.MutationSnapshot pickSnapshot = loadingPickService.mutationSnapshot(planId);
Map<Long, Integer> picked = pickSnapshot == null ? Map.of() : pickSnapshot.pickedByPlanItem();
Map<String, GroupSnapshot> groupByMember = new HashMap<>();
Map<Long, GroupSnapshot> groups = new LinkedHashMap<>();
for (LoadingPlanItemEntity item : items) {
String memberKey = item.getAccountId() + "|" + item.getInboundId();
if (!groupByMember.containsKey(memberKey)) {
groupByMember.put(memberKey, group(item.getAccountId(), item.getInboundId()));
}
GroupSnapshot group = groupByMember.get(memberKey);
if (group != null) groups.putIfAbsent(group.groupId(), group);
}
Set<Long> groupsWithPickedItems = new HashSet<>();
for (LoadingPlanItemEntity item : items) {
if (picked.getOrDefault(item.getId(), 0) <= 0) continue;
GroupSnapshot group = groupByMember.get(item.getAccountId() + "|" + item.getInboundId());
if (group != null) groupsWithPickedItems.add(group.groupId());
}
List<LoadingPlanItemEntity> removable = items.stream().filter(item -> {
if (picked.getOrDefault(item.getId(), 0) > 0) return false;
GroupSnapshot group = groupByMember.get(item.getAccountId() + "|" + item.getInboundId());
return group == null || !groupsWithPickedItems.contains(group.groupId());
}).toList();
Set<Long> removableIds = removable.stream().map(LoadingPlanItemEntity::getId)
.collect(java.util.stream.Collectors.toSet());
Set<Long> removableGroupIds = groups.values().stream()
.filter(group -> !groupsWithPickedItems.contains(group.groupId()))
.filter(group -> items.stream().anyMatch(item -> removableIds.contains(item.getId())
&& group.members().stream().anyMatch(member -> member.tenantId() == item.getAccountId()
&& member.inboundId() == item.getInboundId())))
.map(GroupSnapshot::groupId).collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
for (Long groupId : removableGroupIds) {
requireCompleteGroupAllocation(planId, groups.get(groupId), items);
}
if (!removable.isEmpty()) {
touch(plan, operatorId, operatorName);
for (LoadingPlanItemEntity item : removable) {
loadingPickService.remove(item.getId());
if (repository.remove(planId, item.getId(), item.getVersion(), operatorId, operatorName) != 1) {
throw conflict("装柜未拣明细已被并发修改");
}
}
Set<String> releasedInbounds = new HashSet<>();
for (LoadingPlanItemEntity item : removable) {
String key = item.getAccountId() + "|" + item.getInboundId();
if (releasedInbounds.add(key)) {
loadingModelBridge.removeIfUnallocated(planId, item.getAccountId(), item.getInboundId());
}
}
for (Long groupId : removableGroupIds) {
GroupSnapshot group = groups.get(groupId);
coloadGroups.unlockWholeGroupAfterRemoval(groupId, planId,
"UNLOCK-" + sha256(operation + '|' + groupId).substring(0, 48),
operatorId, operatorName);
}
}
history(planId, null, "REMOVE_UNPICKED", plan.getPlanStatus(), plan.getPlanStatus(), operation,
json(values("version", request.expectedPlanVersion(), "itemCount", items.size())),
json(values("requestHash", requestHash, "removedItemCount", removable.size(),
"removedGroupCount", removableGroupIds.size())), operatorId, operatorName);
return view(repository.plan(planId), booking);
}
private List<Candidate> enrichColoadCandidates(ContainerBookingEntity booking, LoadingPlanEntity plan,
List<Candidate> values) {
Map<String, GroupSnapshot> snapshots = new HashMap<>();
@@ -1013,7 +1092,7 @@ public class SmartLoadingService {
if (!"ACTIVE".equals(group.status()) || group.lockedPlanId() != null) {
throw conflict("同柜不可拆组已锁定或已消费");
}
List<Candidate> available = availableCandidates(booking);
List<Candidate> available = manualCandidates(booking);
Map<Long, List<Candidate>> result = new LinkedHashMap<>();
for (Member member : group.members()) {
if (!repository.activeItemsByInbound(member.tenantId(), member.inboundId()).isEmpty()) {
@@ -1040,6 +1119,11 @@ public class SmartLoadingService {
.filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)).toList();
}
private List<Candidate> manualCandidates(ContainerBookingEntity booking) {
return repository.candidates(booking.getWarehouseId()).stream()
.filter(candidate -> sameCustomerScope(booking, candidate)).toList();
}
private boolean groupFits(ContainerBookingEntity booking, List<LoadingPlanItemEntity> planItems,
GroupSnapshot group, List<Candidate> available) {
if (booking.getCapacityCbm() == null || booking.getMaxWeightKg() == null) return false;
@@ -1189,7 +1273,8 @@ public class SmartLoadingService {
private PendingReviewOrderClient.Row requirePendingReviewOrder(AssignPendingReviewRequest request) {
try {
ApiResponse<PageResult<PendingReviewOrderClient.Row>> response = pendingReviewOrders.page(
internalToken, null, null, request.customerId(), request.inboundCode(), null, null, 1, 2);
internalToken, null, null, request.customerId(), request.inboundCode(), null, null,
null, 1, 2);
if (response == null || !response.successful() || response.data() == null) throw unavailable();
return response.data().records().stream()
.filter(row -> Objects.equals(row.orderId(), request.orderId())
@@ -1272,7 +1357,6 @@ public class SmartLoadingService {
private record DeclaredCargo(OrderCargoClient.CargoBinding cargo) {}
private void checkRouteAndCapacity(ContainerBookingEntity b,List<LoadingPlanItemEntity> lines,Candidate c,
int count, boolean requireSameDestination){
requireSameChannel(b, c);
if (!Objects.equals(b.getWarehouseId(), c.warehouseId())) throw validation("装柜仓库不一致");
if (requireSameDestination && StringUtils.hasText(b.getDestinationWarehouseCode())
&& !Objects.equals(b.getDestinationWarehouseCode(), c.destinationWarehouseCode())) {
@@ -1376,12 +1460,6 @@ public class SmartLoadingService {
private static boolean sameCustomerScope(ContainerBookingEntity booking, Candidate candidate) {
return booking.getCustomerId() == null || Objects.equals(booking.getCustomerId(), candidate.accountId());
}
private static void requireSameChannel(ContainerBookingEntity booking, Candidate candidate) {
if (!StringUtils.hasText(booking.getChannelCode()) || !StringUtils.hasText(candidate.channelCode())) {
throw validation("柜次或入库货物缺少渠道快照,请先完成存量数据回填");
}
if (!sameChannel(booking, candidate)) throw validation("禁止跨渠道混装");
}
private static void requireSameCustomerScope(ContainerBookingEntity booking, Candidate candidate) {
if (!sameCustomerScope(booking, candidate)) throw forbidden();
}
@@ -716,22 +716,29 @@ CREATE TABLE IF NOT EXISTS fl_inbound_pallet_allocation (
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
allocation_no INT UNSIGNED NOT NULL COMMENT '入库单内分配序号',
block_id BIGINT UNSIGNED NOT NULL COMMENT 'Warehouse整票连续库位块ID',
slot_no INT UNSIGNED NOT NULL COMMENT '连续块内稳定槽位序号',
weight_result_id BIGINT UNSIGNED NULL COMMENT '已绑定称重结果ID;预留空槽为空',
pallet_sequence INT UNSIGNED NULL COMMENT '已绑定称重托盘在整票内的稳定顺序;空槽为空',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务仓库ID',
warehouse_resource_tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共仓储资源固定使用 0;tenant_id 仍是客户隔离键',
pallet_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务托盘ID',
pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照',
pallet_capacity_cbm DECIMAL(18,4) NOT NULL COMMENT '托盘可用容积快照',
pallet_id BIGINT UNSIGNED NULL COMMENT '仓库服务托盘ID;预留空槽为空',
pallet_code VARCHAR(50) NULL COMMENT '托盘编码快照;预留空槽为空',
pallet_capacity_cbm DECIMAL(18,4) NULL COMMENT '托盘可用容积快照;预留空槽为空',
allocated_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '本托盘分配体积',
allocated_package_count INT UNSIGNED NULL COMMENT '本托盘分配箱数',
location_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务库位ID',
location_code VARCHAR(50) NOT NULL COMMENT '库位编码快照',
reservation_id BIGINT UNSIGNED NULL COMMENT '仓库预占资源唯一ID,A0共享场景用于区分托盘',
reservation_no VARCHAR(64) NOT NULL COMMENT '仓库库位预占单号',
shared_overflow TINYINT(1) NOT NULL DEFAULT 0 COMMENT '普通连续位不足时是否使用共享A0',
reservation_id BIGINT UNSIGNED NOT NULL COMMENT '仓库预占资源唯一ID,A0共享场景用于区分槽位',
slot_status VARCHAR(24) NOT NULL DEFAULT 'RESERVED_EMPTY' COMMENT '槽位状态:RESERVED_EMPTY、BOUND、TRANSFERRED、RELEASED',
allocation_status VARCHAR(24) NOT NULL DEFAULT 'RESERVED' COMMENT '托盘库位分配状态:RESERVED已预占、CONFIRMED已确认、TRANSFERRED已转移、RELEASED已释放',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_pallet_allocation_no (tenant_id, inbound_id, allocation_no),
UNIQUE KEY uk_pallet_allocation_slot (tenant_id, block_id, slot_no),
UNIQUE KEY uk_pallet_allocation_weight (tenant_id, weight_result_id),
KEY idx_allocation_location (tenant_id, warehouse_id, location_id, allocation_status),
KEY idx_allocation_reservation (reservation_id),
CONSTRAINT fk_allocation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
@@ -466,7 +466,8 @@ paths:
description: >-
需要 platform:firstleg:smart-loading:list 权限。渠道目录只来自平台当前唯一 ACTIVE 价格版本中
对该国内仓存在 ACTIVE 价格行的渠道;候选入仓码数、可用箱数及可编排柜次数由 Firstleg 当前事实聚合。
零候选或零可用柜次的有效渠道仍返回,Admin 可展示空态;实际分配仍由服务端重新校验仓库、渠道和资格。
零候选或零可用柜次的有效渠道仍返回,Admin 可展示空态。渠道只约束智能推荐;人工加入允许跨渠道,
仍由服务端重新校验国内仓、客户范围、占用、同柜组、箱数和容量资格。
parameters:
- {name: warehouseId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses:
@@ -493,7 +494,7 @@ paths:
LOADING 全部未完成状态,已完成装柜永不返回。状态映射为:Order SUBMITTED=PENDING_REVIEW;
Firstleg WAIT_MEASURE/MEASURING/MEASURE_PENDING/MEASURE_REJECTED/WAIT_PRICING/FEE_CONFIRMING/INBOUNDING
=WAIT_MEASURE;WAIT_SPLIT=WAIT_SPLIT;LOADING=LOADING。状态只用于搜索和展示,canAssign 由有效柜次占用、
剩余箱数、同柜组、仓库、渠道和容量等事实决定;容积和重量均允许达到柜型额定值的150%,超过即拒绝。
剩余箱数、同柜组、仓库和容量等事实决定;容积和重量均允许达到柜型额定值的150%,超过即拒绝。
待审核及待测量可以先加入清单;确认预装/PDA 前
仍必须完成审核与测量并进入 WAIT_SPLIT。同柜不可拆组仅在全部成员命中当前状态集合时作为一个分页单位返回。
parameters:
@@ -597,7 +598,8 @@ paths:
summary: 向预装计划新增入库单明细
description: >-
EDITING 或 LOADING 且 PDA task 尚未完成时可用。服务端按 planId 锁定同一 PDA task,
校验仓库、渠道、客户范围、可用箱数、容积和限重后原子新增/累加明细;LOADING 时同步到原 taskId,
校验仓库、客户范围、可用箱数、容积和限重后原子新增/累加明细;人工加入允许订单渠道与柜次渠道不同,
智能推荐仍只选择柜次渠道。LOADING 时同步到原 taskId,
递增 planVersion、taskVersion,原 inventorySnapshotToken 随即失效。与 PDA batch/final complete 并发时,
锁顺序为 PDA task 行锁 -> plan 乐观锁;仅先取得任务锁且持有匹配版本的一方成功,另一方返回 A0409。
PDA 应重新 GET task detail 与 inventory snapshot,Admin 应重新 GET SmartLoadingPlan。新增项沿用原 taskId、
@@ -616,6 +618,7 @@ paths:
description: >-
需要 platform:firstleg:smart-loading:assign 权限。操作员只选择某个入仓码的本次计划箱数;
服务端在单一事务内按剩余规格箱数比例生成内部规格快照,用于体积估算及兼容 PDA/拆单;
人工分配不限制订单渠道与柜次渠道一致,渠道只约束智能推荐;国内仓、占用、同柜组和容量门禁不变。
柜内总容积和总重量分别允许达到柜型额定值的150%,超过即拒绝。
只记录数量计划,具体实装箱号归属为 UNVERIFIED,不得将内部比例分摊宣称为现场箱号事实。
parameters:
@@ -631,7 +634,8 @@ paths:
description: >-
需要 platform:firstleg:smart-loading:assign 权限。订单审核状态不作为清单占位门禁;服务端以
customerId + orderId + inboundCode 重新核验 Order 权威待审核事实,幂等创建或复用 PREPARING
入仓投影,并按订单申报箱数、重量和体积建立柜次占位。已被其他有效柜次占用、仓库/渠道不一致、
入仓投影,并按订单申报箱数、重量和体积建立柜次占位。人工分配允许订单渠道与柜次渠道不同;
已被其他有效柜次占用、国内仓不一致、
超容量或订单事实变化返回业务冲突且事务零漂移。清单中存在 PREPARING/WAIT_MEASURE 订单时不得
确认预装或启动 PDA,审核测量完成并进入 WAIT_SPLIT 后才可继续物理装柜。
parameters:
@@ -670,6 +674,20 @@ paths:
content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingDeleteRequest'}}}
responses:
'200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan}
/api/platform/v1/first-leg/smart-loading/plans/{planId}/unpicked-items:
delete:
summary: 一次性移除当前计划全部未拣明细
description: >-
需要 platform:firstleg:smart-loading:delete 权限。单次请求、单事务锁定计划与 PDA task,移除全部
pickedPackageCount=0 的普通入仓码;同柜不可拆组只在组内所有明细均未拣时整组移除,任一成员已拣则整组保留。
planVersion 只递增一次;并发版本变化或任一步失败均整体回滚。同 operationId 重放返回既有结果。
parameters:
- {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
requestBody:
required: true
content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingRemoveUnpickedRequest'}}}
responses:
'200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan}
/api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}/warehouse-remark:
patch:
summary: 修改装柜明细仓库备注
@@ -1029,6 +1047,7 @@ paths:
summary: 删除称重图片及重量结果
description: >-
需要 platform:firstleg:inbound:weight:edit 权限;仅允许在可编辑的测量状态删除。
已绑定图片先将对应槽恢复为 RESERVED_EMPTY,整票块仍归当前入仓码;解绑或文件删除失败时事务失败。
删除已确认图片后由生产者分别重算剩余确认称重的毛重、净重;消费端不得用净重填充毛重。
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
@@ -1604,6 +1623,14 @@ components:
adjustedAt: {type: string, format: date-time, nullable: true}
confirmedBy: {type: string, nullable: true}
confirmedAt: {type: string, format: date-time, nullable: true}
palletSequence: {type: integer, minimum: 1, nullable: true, description: 已绑定称重托盘在整票内的稳定顺序;未绑定槽位时为空}
blockId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: Warehouse 整票连续库位块标识}
slotNo: {type: integer, minimum: 1, nullable: true, description: 整票连续块内的稳定槽位序号}
reservationId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: Warehouse 预占资源唯一标识;A0 共享时仍按此字段区分}
locationId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: Warehouse 库位标识}
locationCode: {type: string, nullable: true, description: 库位业务编码,例如 A01 或 A0}
sharedOverflow: {type: boolean, nullable: true, description: 是否因普通连续位不足而使用共享 A0}
slotStatus: {type: string, enum: [RESERVED_EMPTY, BOUND, TRANSFERRED, RELEASED], nullable: true, description: 当前整票槽位状态}
version: {type: integer, minimum: 0}
InboundMeasurementItem:
type: object
@@ -1698,6 +1725,38 @@ components:
type: array
description: WEIGHT 称重结果及其图片、确认状态和服务端重量事实
items: {$ref: '#/components/schemas/InboundWeightResult'}
palletLocationGuide:
allOf: [{$ref: '#/components/schemas/InboundPalletLocationGuide'}]
nullable: true
description: 服务端权威的整票上架引导;尚未预留库位时为空,禁止由 weightResults 数量或数组下标推导
InboundPalletLocationGuide:
type: object
required: [blockId, reservedSlotCount, actualPalletCount, reservedEmptySlotCount, slots]
properties:
blockId: {type: string, pattern: '^[1-9][0-9]*$', description: Warehouse 整票连续库位块标识}
reservedSlotCount: {type: integer, minimum: 1, description: 整票当前预留槽位总数,等于 slots 长度}
actualPalletCount: {type: integer, minimum: 0, description: 已绑定称重结果的实际托数}
reservedEmptySlotCount: {type: integer, minimum: 0, description: slotStatus=RESERVED_EMPTY 的预留空位数}
slots:
type: array
minItems: 1
maxItems: 200
description: 严格按 slotNo 升序返回的整票槽位
items: {$ref: '#/components/schemas/InboundPalletLocationSlot'}
InboundPalletLocationSlot:
type: object
required: [slotNo, reservationId, locationId, locationCode, sharedOverflow, slotStatus]
properties:
slotNo: {type: integer, minimum: 1}
reservationId: {type: string, pattern: '^[1-9][0-9]*$'}
locationId: {type: string, pattern: '^[1-9][0-9]*$'}
locationCode: {type: string}
sharedOverflow: {type: boolean}
slotStatus: {type: string, enum: [RESERVED_EMPTY, BOUND, TRANSFERRED, RELEASED]}
boundWeightResultId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
palletSequence: {type: integer, minimum: 1, nullable: true}
palletId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
palletCode: {type: string, nullable: true}
InboundMeasurementLatestDeclaration:
type: object
description: Order 服务中的最新客户申报只读视图;订单编辑只能改变本对象,不得覆盖箱码、实测尺寸/重量或审核结果
@@ -1956,6 +2015,12 @@ components:
expectedPlanVersion: {type: integer, minimum: 0}
expectedItemVersion: {type: integer, minimum: 0}
operationId: {type: string, minLength: 1, maxLength: 128}
SmartLoadingRemoveUnpickedRequest:
type: object
required: [expectedPlanVersion, operationId]
properties:
expectedPlanVersion: {type: integer, minimum: 0}
operationId: {type: string, minLength: 1, maxLength: 128}
SmartLoadingWarehouseRemarkRequest:
type: object
required: [expectedPlanVersion, expectedItemVersion, operationId]
@@ -368,10 +368,6 @@ components:
action: {type: string, enum: [PICK, PARTIAL, SKIP]}
pickedPackageCount: {type: integer, minimum: 1, description: Incremental count required by PICK}
finalPickedPackageCount: {type: integer, minimum: 1, description: Final actual count required by PARTIAL}
releaseMode: {type: string, enum: [AUTO, MANUAL]}
reservationIds:
type: array
items: {type: integer, format: int64, minimum: 1}
version: {type: integer, minimum: 0}
reason: {type: string, maxLength: 500, description: Required by SKIP}
ReviewRequest:
@@ -24,7 +24,8 @@ class InboundMeasurementEvidenceOpenApiContractTest {
Map<String, Object> schemas = stringMap(stringMap(document.get("components")).get("schemas"));
Map<String, Object> detailProperties = properties(schemas, "InboundMeasurementDetail");
assertThat(detailProperties).containsKeys(
"actualGrossWeightKg", "actualNetWeightKg", "latestDeclaration", "items", "weightResults");
"actualGrossWeightKg", "actualNetWeightKg", "latestDeclaration", "items", "weightResults",
"palletLocationGuide");
assertThat(stringMap(detailProperties.get("latestDeclaration")))
.containsEntry("$ref", "#/components/schemas/InboundMeasurementLatestDeclaration");
assertThat(properties(schemas, "InboundMeasurementLatestDeclaration"))
@@ -52,7 +53,16 @@ class InboundMeasurementEvidenceOpenApiContractTest {
assertThat(itemProperties).containsKeys("id", "intelligentImageFileId");
Map<String, Object> weightProperties = properties(schemas, "InboundWeightResult");
assertThat(weightProperties).containsKeys(
"id", "fileId", "recognitionStatus", "grossWeightKg", "netWeightKg", "confirmedAt");
"id", "fileId", "recognitionStatus", "grossWeightKg", "netWeightKg", "confirmedAt",
"palletSequence", "blockId", "slotNo", "reservationId", "locationId", "locationCode",
"sharedOverflow", "slotStatus");
assertThat(stringMap(weightProperties.get("slotStatus"))).containsEntry(
"enum", java.util.List.of("RESERVED_EMPTY", "BOUND", "TRANSFERRED", "RELEASED"));
assertThat(properties(schemas, "InboundPalletLocationGuide")).containsKeys(
"blockId", "reservedSlotCount", "actualPalletCount", "reservedEmptySlotCount", "slots");
assertThat(properties(schemas, "InboundPalletLocationSlot")).containsKeys(
"slotNo", "reservationId", "locationId", "locationCode", "sharedOverflow", "slotStatus",
"boundWeightResultId", "palletSequence", "palletId", "palletCode");
assertThat(stringMap(detailProperties.get("actualGrossWeightKg")).get("description").toString())
.contains("CONFIRMED", "grossWeightKg", "含托盘毛重");
assertThat(stringMap(detailProperties.get("actualNetWeightKg")).get("description").toString())
@@ -49,6 +49,9 @@ class PdaConvergenceOpenApiContractTest {
String batchRequest = yaml.substring(
yaml.indexOf(" BatchRequest:"),
yaml.indexOf(" BatchItemRequest:"));
String batchItemRequest = yaml.substring(
yaml.indexOf(" BatchItemRequest:"),
yaml.indexOf(" ReviewRequest:"));
assertThat(taskLookup)
.contains("name: cabinetNo", "name: loadingPlanId")
.doesNotContain("name: containerNo");
@@ -59,5 +62,7 @@ class PdaConvergenceOpenApiContractTest {
assertThat(batchRequest)
.contains("loadingPlanId:", "containerBookingId:")
.doesNotContain("containerNo:");
assertThat(batchItemRequest)
.doesNotContain("releaseMode:", "reservationIds:", "locationIds:");
}
}
@@ -56,6 +56,7 @@ class PlatformSmartLoadingControllerContractTest {
authority("adjust","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,long.class,AdjustRequest.class);
authority("warehouseRemark","platform:firstleg:preload:edit",AuthenticatedUser.class,long.class,long.class,WarehouseRemarkRequest.class);
authority("delete","platform:firstleg:smart-loading:delete",AuthenticatedUser.class,long.class,long.class,DeleteRequest.class);
authority("removeUnpicked","platform:firstleg:smart-loading:delete",AuthenticatedUser.class,long.class,RemoveUnpickedRequest.class);
}
@Test void openApiHasNoManualCreateOrPushStep() throws Exception {
String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml"));
@@ -75,6 +76,7 @@ class PlatformSmartLoadingControllerContractTest {
.contains("SmartLoadingAssignRequest:")
.contains("SmartLoadingAdjustRequest:")
.contains("SmartLoadingDeleteRequest:")
.contains("SmartLoadingRemoveUnpickedRequest:")
.contains("SmartLoadingWarehouseRemarkRequest:")
.contains("/items/{itemId}/warehouse-remark:")
.contains("需要 platform:firstleg:preload:edit 权限")
@@ -83,6 +85,8 @@ class PlatformSmartLoadingControllerContractTest {
.contains("items[].version 是递增后的 itemVersion")
.contains("数量调整或动态增量不会覆盖已有 warehouseRemark")
.contains("柜内总容积和总重量分别允许达到柜型额定值的150%")
.contains("人工分配不限制订单渠道与柜次渠道一致")
.contains("渠道只约束智能推荐")
.contains("canAssign: {type: boolean}")
.contains("canEdit: {type: boolean}")
.contains("pickedPackageCount: {type: integer, minimum: 0}")
@@ -134,9 +134,42 @@ class CabinetLoadingEvidenceServiceTest {
verify(files, times(1)).upload(anyString(), anyLong(), anyLong(), anyString(), any());
}
@Test
void multipartReplayRejectsChangedContentBeforeUploadingAnotherFile() {
var first = new MockMultipartFile("file", "photo.png", "image/png", new byte[] {1, 2, 3});
var changed = new MockMultipartFile("file", "photo.png", "image/png", new byte[] {1, 2, 4});
CabinetLoadingEvidenceEntity existing = evidence("PHOTO_STEP_1");
existing.setSourceTaskId(20L);
existing.setOperationKey("same-key");
existing.setRequestHash(uploadHashForTest(8L, 0, "PHOTO_STEP_1", null, "remark", first));
when(mapper.byOperation(20L, "same-key")).thenReturn(existing);
assertThatThrownBy(() -> service.upload(20L, 8L, "same-key", 0, "PHOTO_STEP_1",
null, "remark", changed, 9L, "operator"))
.isInstanceOf(BizException.class).hasMessageContaining("请求内容不一致");
verify(files, never()).upload(anyString(), anyLong(), anyLong(), anyString(), any());
}
private static CabinetLoadingEvidenceEntity evidence(String type) {
CabinetLoadingEvidenceEntity value = new CabinetLoadingEvidenceEntity();
value.setEvidenceType(type); value.setStatus("ACTIVE"); value.setContainerBookingId(99L);
return value;
}
private static String uploadHashForTest(long warehouseId, int expectedVersion, String evidenceType,
String sealNo, String remark, MockMultipartFile multipart) {
try {
var digest = java.security.MessageDigest.getInstance("SHA-256");
String metadata = warehouseId + "|" + expectedVersion + "|" + evidenceType + "|"
+ java.util.Objects.toString(sealNo, "") + "|" + java.util.Objects.toString(remark, "") + "|"
+ java.util.Objects.toString(multipart.getOriginalFilename(), "") + "|"
+ java.util.Objects.toString(multipart.getContentType(), "") + "|" + multipart.getSize() + "|";
digest.update(metadata.getBytes(java.nio.charset.StandardCharsets.UTF_8));
digest.update(multipart.getBytes());
return java.util.HexFormat.of().formatHex(digest.digest());
} catch (java.io.IOException | java.security.NoSuchAlgorithmException exception) {
throw new IllegalStateException(exception);
}
}
}
@@ -6,6 +6,7 @@ import com.baiyue.firstleg.loading.repository.LoadingRepository;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper.InboundProgress;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.service.FbaPricingService;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
@@ -27,6 +28,7 @@ class LoadingDeliveryServiceTest {
@MockBean LoadingCompletionGateService completionGate;
@MockBean InboundStatusHistoryMapper histories;
@MockBean CabinetLoadingEvidenceService cabinetEvidence;
@MockBean FbaPricingService pricingService;
@MockBean ObjectMapper objectMapper;
@Resource LoadingDeliveryService service;
@@ -46,6 +48,7 @@ class LoadingDeliveryServiceTest {
new com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest(3, "done"));
verify(cabinetEvidence).requireComplete(99L);
verify(pricingService).autoConfirmAfterLoading(10L, 20L);
}
@Test
@@ -86,6 +89,7 @@ class LoadingDeliveryServiceTest {
verify(repository).findInboundForUpdate(10L, 21L);
verify(repository).completeLoadedChild(10L, 20L, 9L, 9);
verify(pricingService).autoConfirmAfterLoading(10L, 21L);
}
@Test
@@ -1,84 +1,104 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
import com.baiyue.firstleg.mapper.InboundPalletAllocationMapper;
import com.baiyue.firstleg.client.WarehousePalletLocationClient;
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
import com.baiyue.firstleg.entity.InboundWeightResultEntity;
import com.baiyue.firstleg.mapper.InboundPalletAllocationMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.math.BigDecimal;
import org.mockito.ArgumentCaptor;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InboundPalletAllocationRepositoryTest {
@Mock
private InboundPalletAllocationMapper mapper;
@InjectMocks
private InboundPalletAllocationRepository repository;
@Mock private InboundPalletAllocationMapper mapper;
@InjectMocks private InboundPalletAllocationRepository repository;
@Test
void acceptsReleasedSnapshotWhenRetryUpdateAffectsNoRows() {
InboundPalletAllocationEntity current = allocation(1L, 91L, "RELEASED", 10, 10);
when(mapper.markReleased(10001L, 31L, 91L)).thenReturn(0);
when(mapper.selectOne(any())).thenReturn(current);
void savesEveryBlockSlotIncludingReservedEmptySlots() {
var result = new WarehousePalletLocationClient.BlockResult(700L, "OCCUPIED", List.of(
resource(700L, 91L, 11L, "A01", 1, false),
resource(700L, 92L, 12L, "A02", 2, false),
resource(700L, 93L, 99L, "A0", 3, true)));
assertThatCode(() -> repository.markReleased(10001L, 31L, List.of(91L)))
.doesNotThrowAnyException();
repository.replaceBlock(10001L, 31L, 88L, 20, new BigDecimal("2.5000"), result);
ArgumentCaptor<InboundPalletAllocationEntity> inserted =
ArgumentCaptor.forClass(InboundPalletAllocationEntity.class);
verify(mapper, times(3)).insert(inserted.capture());
assertThat(inserted.getAllValues()).extracting(InboundPalletAllocationEntity::getSlotNo)
.containsExactly(1, 2, 3);
assertThat(inserted.getAllValues()).extracting(InboundPalletAllocationEntity::getWeightResultId)
.containsOnlyNulls();
assertThat(inserted.getAllValues()).extracting(InboundPalletAllocationEntity::getSlotStatus)
.containsOnly("RESERVED_EMPTY");
assertThat(inserted.getAllValues().get(2).getSharedOverflow()).isTrue();
}
@Test
void acceptsTransferredSnapshotWhenRetryUpdateAffectsNoRows() {
InboundPalletAllocationEntity source = allocation(1L, 91L, "CONFIRMED", 10, 2);
InboundPalletAllocationEntity current = allocation(1L, 91L, "TRANSFERRED", 10, 2);
when(mapper.markTransferred(1L, 10001L, 31L)).thenReturn(0);
when(mapper.selectOne(any())).thenReturn(current);
void bindsConfirmedWeightsByConfirmationTimeToTheNextEmptySlot() {
InboundPalletAllocationEntity firstEmpty = allocation(1L, 1);
InboundPalletAllocationEntity secondEmpty = allocation(2L, 2);
when(mapper.selectNextEmptyForUpdate(10001L, 31L)).thenReturn(firstEmpty, secondEmpty);
when(mapper.bindWeight(any(Long.class), any(Long.class), any(Long.class), any(Long.class),
any(Integer.class), any(Long.class), any(String.class), any(BigDecimal.class))).thenReturn(1);
InboundWeightResultEntity later = weight(102L, LocalDateTime.of(2026, 9, 4, 10, 2));
InboundWeightResultEntity earlier = weight(101L, LocalDateTime.of(2026, 9, 4, 10, 1));
assertThatCode(() -> repository.markTransferred(10001L, 31L, List.of(source), List.of()))
.doesNotThrowAnyException();
repository.bindConfirmedWeights(10001L, 31L, List.of(later, earlier));
org.mockito.InOrder order = org.mockito.Mockito.inOrder(mapper);
order.verify(mapper).bindWeight(1L, 10001L, 31L, 101L, 1, 501L, "P-501",
new BigDecimal("1.2000"));
order.verify(mapper).bindWeight(2L, 10001L, 31L, 102L, 2, 501L, "P-501",
new BigDecimal("1.2000"));
}
@Test
void restoresReservationNumbersByReservationIdWhenPalletIsReused() {
InboundPalletAllocationEntity first = allocation(1L, 91L, "RESERVED", 1, 0);
first.setPalletId(501L);
first.setReservationNo("LR-91");
InboundPalletAllocationEntity second = allocation(2L, 92L, "RESERVED", 1, 0);
second.setPalletId(501L);
second.setReservationNo("LR-92");
when(mapper.selectList(any())).thenReturn(List.of(first, second));
void unbindRestoresOnlyTheBoundSlotToReservedEmpty() {
InboundPalletAllocationEntity bound = allocation(1L, 1);
bound.setWeightResultId(101L);
when(mapper.selectOne(any())).thenReturn(bound);
when(mapper.unbindWeight(1L, 10001L, 31L, 101L)).thenReturn(1);
var result = new WarehousePalletLocationClient.BatchResult(2, List.of(
new WarehousePalletLocationClient.AllocationResult(91L, null, 1, 501L, "P1", new BigDecimal("1.2"), 11L, "A1",
new BigDecimal("1.2"), "OCCUPIED"),
new WarehousePalletLocationClient.AllocationResult(92L, null, 2, 501L, "P1", new BigDecimal("1.2"), 12L, "A2",
new BigDecimal("1.2"), "OCCUPIED")));
repository.unbindWeight(10001L, 31L, 101L);
repository.replace(10001L, 31L, 88L, 0L, 2, result);
ArgumentCaptor<InboundPalletAllocationEntity> inserted = ArgumentCaptor.forClass(InboundPalletAllocationEntity.class);
verify(mapper, org.mockito.Mockito.times(2)).insert(inserted.capture());
assertThat(inserted.getAllValues()).extracting(InboundPalletAllocationEntity::getReservationNo)
.containsExactly("LR-91", "LR-92");
verify(mapper).unbindWeight(1L, 10001L, 31L, 101L);
}
private static InboundPalletAllocationEntity allocation(long id, long reservationId, String status,
int allocated, int picked) {
InboundPalletAllocationEntity entity = new InboundPalletAllocationEntity();
entity.setId(id);
entity.setReservationId(reservationId);
entity.setAllocationStatus(status);
entity.setAllocatedPackageCount(allocated);
entity.setPickedPackageCount(picked);
return entity;
private static WarehousePalletLocationClient.BlockResource resource(
long blockId, long reservationId, long locationId, String code, int slotNo, boolean overflow) {
return new WarehousePalletLocationClient.BlockResource(
blockId, reservationId, locationId, code, slotNo, overflow, "OCCUPIED");
}
private static InboundPalletAllocationEntity allocation(long id, int slotNo) {
InboundPalletAllocationEntity value = new InboundPalletAllocationEntity();
value.setId(id);
value.setSlotNo(slotNo);
return value;
}
private static InboundWeightResultEntity weight(long id, LocalDateTime confirmedAt) {
InboundWeightResultEntity value = new InboundWeightResultEntity();
value.setId(id);
value.setRecognitionStatus("CONFIRMED");
value.setConfirmedAt(confirmedAt);
value.setPalletId(501L);
value.setPalletCode("P-501");
value.setPalletCapacityCbm(new BigDecimal("1.2000"));
return value;
}
}
@@ -90,6 +90,7 @@ class SmartLoadingCandidateMapperContractTest {
.contains("status.name() == 'WAIT_MEASURE'")
.contains("status.name() == 'WAIT_SPLIT'")
.contains("status.name() == 'LOADING'")
.contains("NOT EXISTS (", "active_item.inbound_id=o.id", "active_item.deleted=0")
.contains("current_group.id groupId")
.contains("group_member_count.group_id=current_group.id")
.doesNotContain("fl_container_booking")
@@ -36,6 +36,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.never;
@@ -69,6 +70,48 @@ class FbaPricingServiceTest {
verify(repository, never()).findLatestFee(TENANT, INBOUND);
}
@Test
@DisplayName("装柜完成后系统自动确认客户未确认的已发送费用")
void automaticallyConfirmsSentFeeAfterLoading() {
InboundFeeEntity sent = savedFee("SENT");
when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent);
when(repository.confirmFeeByCustomer(TENANT, 501L, 1, 0L,
"装柜完成自动确认", NOW)).thenReturn(1);
service.autoConfirmAfterLoading(TENANT, INBOUND);
verify(repository).confirmFeeByCustomer(TENANT, 501L, 1, 0L,
"装柜完成自动确认", NOW);
}
@Test
@DisplayName("客户已经确认时装柜完成不覆盖原确认信息")
void keepsExistingCustomerConfirmationAfterLoading() {
InboundFeeEntity sent = savedFee("SENT");
sent.setCustomerConfirmedBy(88L);
sent.setCustomerConfirmedByName("客户成员");
sent.setCustomerConfirmedAt(NOW.minusHours(1));
when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent);
service.autoConfirmAfterLoading(TENANT, INBOUND);
verify(repository, never()).confirmFeeByCustomer(anyLong(), anyLong(), anyInt(),
anyLong(), anyString(), any());
}
@Test
@DisplayName("没有已发送费用时装柜完成不受影响")
void ignoresMissingOrUnsentFeeAfterLoading() {
when(repository.findLatestFeeForUpdate(TENANT, INBOUND))
.thenReturn(null, savedFee("CONFIRMED"));
service.autoConfirmAfterLoading(TENANT, INBOUND);
service.autoConfirmAfterLoading(TENANT, INBOUND);
verify(repository, never()).confirmFeeByCustomer(anyLong(), anyLong(), anyInt(),
anyLong(), anyString(), any());
}
@Test
@DisplayName("包税KG按生效价格表计费且不依赖客户规则或承运商")
void calculatesTaxIncludedKgWithoutCustomerRuleOrCarrier() {
@@ -98,7 +141,7 @@ class FbaPricingServiceTest {
assertThat(fee.getValue().getPricingTaxMode()).isEqualTo("TAX_INCLUDED");
assertThat(fee.getValue().getChargeRuleId()).isNull();
assertThat(fee.getValue().getCarrierId()).isNull();
assertThat(fee.getValue().getCustomerId()).isEqualTo(11L);
assertThat(fee.getValue().getCustomerId()).isEqualTo(TENANT);
assertThat(fee.getValue().getConversionFactor()).isEqualByComparingTo("167");
assertThat(fee.getValue().getRoundingScale()).isEqualTo(6);
assertThat(fee.getValue().getRoundingMode()).isEqualTo("HALF_UP");
@@ -134,19 +177,37 @@ class FbaPricingServiceTest {
}
@Test
@DisplayName("缺少订单客户归属时拒绝生成对客费用")
void rejectsMissingOrderCustomerOwnership() {
@DisplayName("指定实际测量来源时必须存在测量批次和明细")
void requiresActualMeasurementDetailsForActualSource() {
readyRepository();
InboundPricingEntity inbound = inbound("100.000", "0.7880");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW,
new BigDecimal("100.000"), new BigDecimal("0.7880"),
"ACTUAL", "ACTUAL", "calc-actual-missing"))
.isInstanceOf(BizException.class)
.hasMessageContaining("实际测量数据不存在");
verify(priceBookEstimator, never()).estimate(any());
verify(repository, never()).insertFee(any());
}
@Test
@DisplayName("订单客户字段为空时使用租户归属生成对客费用")
void usesTenantOwnershipWhenOrderCustomerIsMissing() {
readyRepository();
InboundPricingEntity inbound = inbound("200.000", "0.2000");
inbound.setCustomerId(null);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(priceBookEstimator.estimate(any())).thenReturn(
estimate("KG", "200.000", "2.000000", "400.00", "0.00"));
assertThatThrownBy(() -> service.calculate(
TENANT, OPERATOR, INBOUND, NOW, "calc-no-customer"))
.isInstanceOf(BizException.class)
.hasMessageContaining("缺少订单客户归属");
verify(priceBookEstimator, never()).estimate(any());
verify(repository, never()).insertFee(any());
assertThat(service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-no-customer"))
.isNotNull();
ArgumentCaptor<InboundFeeEntity> fee = ArgumentCaptor.forClass(InboundFeeEntity.class);
verify(repository).insertFee(fee.capture());
assertThat(fee.getValue().getCustomerId()).isEqualTo(TENANT);
}
@Test
@@ -12,9 +12,11 @@ import com.baiyue.firstleg.dto.InboundMeasurementResponses;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
import com.baiyue.firstleg.entity.InboundWeightResultEntity;
import com.baiyue.firstleg.entity.InboundWeightRevisionEntity;
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
import com.baiyue.firstleg.repository.InboundPalletAllocationRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
@@ -58,6 +60,7 @@ class InboundMeasurementServiceTest {
@MockBean private OrderCargoClient orderCargoClient;
@MockBean private MeasurementImageClient imageClient;
@MockBean private WarehousePalletLocationClient warehousePallets;
@MockBean private InboundPalletAllocationRepository palletAllocations;
@Resource private InboundMeasurementService service;
@BeforeEach
@@ -205,6 +208,10 @@ class InboundMeasurementServiceTest {
when(repository.findLatestMeasurement(1L, 10L)).thenReturn(measurement);
when(repository.listItems(1L, 30L)).thenReturn(List.of(item));
when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight));
InboundPalletAllocationEntity bound = locationSlot(1, 801L, "A01", "BOUND", 50L);
InboundPalletAllocationEntity secondBound = locationSlot(2, 802L, "A02", "BOUND", 51L);
InboundPalletAllocationEntity empty = locationSlot(3, 803L, "A03", "RESERVED_EMPTY", null);
when(palletAllocations.list(1L, 10L)).thenReturn(List.of(empty, secondBound, bound));
InboundMeasurementResponses.Detail result = service.detail(1L, 10L);
@@ -224,6 +231,16 @@ class InboundMeasurementServiceTest {
assertThat(confirmedWeight.grossWeightKg()).isEqualByComparingTo("30.000");
assertThat(confirmedWeight.netWeightKg()).isEqualByComparingTo("11.500");
assertThat(confirmedWeight.confirmedAt()).isNotNull();
assertThat(confirmedWeight.locationCode()).isEqualTo("A01");
});
assertThat(result.palletLocationGuide()).satisfies(guide -> {
assertThat(guide.blockId()).isEqualTo(700L);
assertThat(guide.reservedSlotCount()).isEqualTo(3);
assertThat(guide.actualPalletCount()).isEqualTo(2);
assertThat(guide.reservedEmptySlotCount()).isEqualTo(1);
assertThat(guide.slots()).extracting(InboundMeasurementResponses.PalletLocationSlot::locationCode)
.containsExactly("A01", "A02", "A03");
assertThat(guide.slots().get(2).boundWeightResultId()).isNull();
});
}
@@ -537,6 +554,23 @@ class InboundMeasurementServiceTest {
assertThat(revision.getValue().getNetWeightKg()).isEqualByComparingTo("11.500");
}
@Test
void confirmingAWeightBindsItToTheNextEmptySlotWhenTheTicketAlreadyHasABlock() {
InboundOrderEntity inbound = inbound("MEASURE_REJECTED");
InboundWeightResultEntity weight = weight(20L, "30.000", "KG", "PENDING_CONFIRM", 0);
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
when(repository.updateWeight(weight)).thenReturn(true);
when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight));
when(palletAllocations.list(1L, 10L)).thenReturn(List.of(
locationSlot(1, 801L, "A01", "RESERVED_EMPTY", null)));
service.confirmWeightResult(1L, 10L, 20L, 9L, "操作员",
new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null));
verify(palletAllocations).bindNextEmpty(1L, 10L, weight);
}
@Test
void confirmingFifthPalletSeparatelyAggregatesGrossAndCargoNetWeight() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
@@ -834,6 +868,26 @@ class InboundMeasurementServiceTest {
verify(repository).updateInboundWeightSummary(inbound);
}
@Test
void deletingBoundWeightUnbindsTheSlotBeforeDeletingTheResultAndFailsOnFileError() {
InboundOrderEntity inbound = inbound("MEASURING");
InboundWeightResultEntity weight = weight(20L, "20.000", "KG", "CONFIRMED", 1);
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
when(repository.deleteWeight(1L, 10L, 20L, 1)).thenReturn(true);
when(imageClient.delete("internal-token", 9L, 1L, 10L, 120L))
.thenReturn(ApiResponse.failure(com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE,
"file unavailable", null, null));
assertThatThrownBy(() -> service.deleteWeightResult(1L, 10L, 20L, 1, 9L))
.isInstanceOf(BizException.class).hasMessageContaining("file unavailable");
org.mockito.InOrder order = org.mockito.Mockito.inOrder(palletAllocations, repository, imageClient);
order.verify(palletAllocations).unbindWeight(1L, 10L, 20L);
order.verify(repository).deleteWeight(1L, 10L, 20L, 1);
order.verify(imageClient).delete("internal-token", 9L, 1L, 10L, 120L);
}
@Test
void deletingLastConfirmedWeightClearsInboundTotal() {
InboundOrderEntity inbound = inbound("MEASURING");
@@ -1051,6 +1105,21 @@ class InboundMeasurementServiceTest {
verify(batchService, never()).saveBatch(any());
}
private static InboundPalletAllocationEntity locationSlot(
int slotNo, long locationId, String locationCode, String slotStatus, Long weightResultId) {
InboundPalletAllocationEntity value = new InboundPalletAllocationEntity();
value.setBlockId(700L);
value.setSlotNo(slotNo);
value.setReservationId(900L + slotNo);
value.setLocationId(locationId);
value.setLocationCode(locationCode);
value.setSharedOverflow(false);
value.setSlotStatus(slotStatus);
value.setWeightResultId(weightResultId);
value.setPalletSequence(weightResultId == null ? null : slotNo);
return value;
}
private InboundOrderEntity inbound(String status) {
InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setId(10L);
@@ -8,6 +8,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.AssignRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.CompleteLoadingRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
import com.baiyue.firstleg.client.WarehousePalletLocationClient;
@@ -341,7 +342,7 @@ class LoadingPickServiceTest {
preparePick(task, item, "pick-1");
when(repository.allTaskItemsResolved(20L)).thenReturn(false);
service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-1"), 9L, "operator");
service.pick(20L, 30L, new PickRequest(101L, 2, 0, "pick-1"), 9L, "operator");
verify(repository).touchTask(20L, 0);
verify(repository).syncInboundProjections(20L, 9L, "operator");
@@ -357,7 +358,7 @@ class LoadingPickServiceTest {
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-2"), 9L, "operator");
service.pick(20L, 30L, new PickRequest(101L, 2, 0, "pick-2"), 9L, "operator");
verify(repository).touchTask(20L, 0);
verify(settlementService, never()).settle(20L, 9L, "operator");
@@ -373,7 +374,7 @@ class LoadingPickServiceTest {
preparePick(task, item, "pick-start-loading");
when(smartLoadingRepository.startPicking(10L, 9L, "operator")).thenReturn(1);
service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-start-loading"),
service.pick(20L, 30L, new PickRequest(101L, 2, 0, "pick-start-loading"),
9L, "operator");
verify(smartLoadingRepository).startPicking(10L, 9L, "operator");
@@ -392,7 +393,7 @@ class LoadingPickServiceTest {
new LoadingInboundMapper.Snapshot(50L, 101L, "WAIT_SPLIT", 4));
when(repository.beginInboundLoading(101L, 50L, 9L, 4)).thenReturn(1);
service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-start-inbound"),
service.pick(20L, 30L, new PickRequest(101L, 2, 0, "pick-start-inbound"),
9L, "operator");
verify(repository).beginInboundLoading(101L, 50L, 9L, 4);
@@ -418,7 +419,7 @@ class LoadingPickServiceTest {
new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 5));
when(repository.beginInboundLoading(101L, 50L, 9L, 4)).thenReturn(0);
service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-concurrent-inbound"),
service.pick(20L, 30L, new PickRequest(101L, 2, 0, "pick-concurrent-inbound"),
9L, "operator");
verify(repository).beginInboundLoading(101L, 50L, 9L, 4);
@@ -513,6 +514,104 @@ class LoadingPickServiceTest {
verify(repository, times(1)).createAssignment(20L, 9L, "operator", 9L, "operator", "CLAIMED");
}
@Test
void claimRechecksSameOperationAfterWaitingForTaskLock() {
LoadingPickTaskEntity task = task();
LoadingPickOperationEntity completed = taskOperation("claim-race", "CLAIM");
when(repository.operation("claim-race")).thenReturn(null);
when(repository.operationForUpdate("claim-race")).thenReturn(completed);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.task(20L)).thenReturn(task);
when(repository.items(20L)).thenReturn(List.of());
Task replay = service.claim(20L, new ClaimRequest(0, "claim-race"), 9L, "operator");
assertThat(replay.id()).isEqualTo(20L);
verify(repository, never()).createAssignment(anyLong(), anyLong(), anyString(), anyLong(),
anyString(), anyString());
verify(smartLoadingRepository, never()).startPicking(anyLong(), anyLong(), anyString());
}
@Test
void assignRechecksSameOperationAfterWaitingForTaskLock() {
LoadingPickTaskEntity task = task();
LoadingPickOperationEntity completed = taskOperation("assign-race", "ASSIGN");
when(repository.operation("assign-race")).thenReturn(null);
when(repository.operationForUpdate("assign-race")).thenReturn(completed);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.task(20L)).thenReturn(task);
when(repository.items(20L)).thenReturn(List.of());
Task replay = service.assign(20L, new AssignRequest(11L, "picker", 0, "assign-race"),
9L, "operator");
assertThat(replay.id()).isEqualTo(20L);
verify(repository, never()).createAssignment(anyLong(), anyLong(), anyString(), anyLong(),
anyString(), anyString());
verify(repository, never()).reassign(anyLong(), anyLong(), anyString(), anyLong(),
anyString(), anyString(), anyInt());
}
@Test
void pickRechecksSameHistoryAfterWaitingForTaskLock() {
LoadingPickTaskEntity task = task();
LoadingPickHistoryEntity completed = itemHistory("pick-race", "PICK",
"20|30|101|2");
when(repository.history("pick-race")).thenReturn(null);
when(repository.historyForUpdate("pick-race")).thenReturn(completed);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.items(20L, 101L)).thenReturn(List.of(item()));
Task replay = service.pick(20L, 30L,
new PickRequest(101L, 2, 0, "pick-race"), 9L, "operator");
assertThat(replay.id()).isEqualTo(20L);
verify(repository, never()).pick(anyLong(), anyInt(), anyInt(), anyLong());
verify(repository, never()).insert(any(LoadingPickHistoryEntity.class));
verify(repository, never()).touchTask(anyLong(), anyInt());
}
@Test
void partialRechecksSameHistoryAfterWaitingForTaskLock() {
LoadingPickTaskEntity task = task();
LoadingPickHistoryEntity completed = itemHistory("partial-race", "PARTIAL",
"PARTIAL|20|30|101|1");
when(repository.history("partial-race")).thenReturn(null);
when(repository.historyForUpdate("partial-race")).thenReturn(completed);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.items(20L, 101L)).thenReturn(List.of(item()));
Task replay = service.partial(20L, 30L,
new PartialPickRequest(101L, 1, 0, "partial-race"), 9L, "operator");
assertThat(replay.id()).isEqualTo(20L);
verify(repository, never()).partial(anyLong(), anyInt(), anyInt(), anyLong());
verify(repository, never()).insert(any(LoadingPickHistoryEntity.class));
verify(repository, never()).touchTask(anyLong(), anyInt());
}
@Test
void skipRechecksSameHistoryAfterWaitingForTaskLock() {
LoadingPickTaskEntity task = task();
LoadingPickHistoryEntity completed = itemHistory("skip-race", "SKIP",
"SKIP|20|30|101|damaged");
when(repository.history("skip-race")).thenReturn(null);
when(repository.historyForUpdate("skip-race")).thenReturn(completed);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.items(20L, 101L)).thenReturn(List.of(item()));
Task replay = service.skip(20L, 30L,
new SkipRequest(101L, 0, "skip-race", "damaged"), 9L, "operator");
assertThat(replay.id()).isEqualTo(20L);
verify(repository, never()).skip(anyLong(), anyInt(), anyLong());
verify(repository, never()).insert(any(LoadingPickHistoryEntity.class));
verify(repository, never()).touchTask(anyLong(), anyInt());
}
@Test
void keepsPartialPickLockedUntilManualCompletion() {
LoadingPickTaskEntity task = task();
@@ -521,7 +620,7 @@ class LoadingPickServiceTest {
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
service.partial(20L, 30L, new PartialPickRequest(101L, 1, List.of(), 0, "partial-1"),
service.partial(20L, 30L, new PartialPickRequest(101L, 1, 0, "partial-1"),
9L, "operator");
verify(repository).touchTask(20L, 0);
@@ -1046,7 +1145,7 @@ class LoadingPickServiceTest {
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.items(20L)).thenReturn(items);
when(repository.items(20L, 101L)).thenReturn(items);
when(repository.pick(30L, 2, "MANUAL", "[]", 0, 101L)).thenReturn(1);
when(repository.pick(30L, 2, 0, 101L)).thenReturn(1);
when(repository.skip(31L, 0, 101L)).thenReturn(1);
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.touchTask(20L, 0)).thenReturn(1);
@@ -1056,13 +1155,13 @@ class LoadingPickServiceTest {
71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 2))));
String snapshotToken = service.inventorySnapshot(20L, 101L).snapshotToken();
BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, snapshotToken, List.of(
new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null),
new BatchItemRequest(31L, "SKIP", null, null, null, null, 0, "damaged carton")));
new BatchItemRequest(30L, "PICK", 2, null, 0, null),
new BatchItemRequest(31L, "SKIP", null, null, 0, "damaged carton")));
service.batch(20L, "batch-1", request, 9L, "operator");
service.batch(20L, "batch-1", request, 9L, "operator");
verify(repository, times(1)).pick(30L, 2, "MANUAL", "[]", 0, 101L);
verify(repository, times(1)).pick(30L, 2, 0, 101L);
verify(repository, times(1)).skip(31L, 0, 101L);
verify(repository, times(1)).touchTask(20L, 0);
verify(settlementService, never()).settle(20L, 9L, "operator");
@@ -1087,7 +1186,7 @@ class LoadingPickServiceTest {
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.items(20L)).thenReturn(List.of(item));
when(repository.items(20L, 101L)).thenReturn(List.of(item));
when(repository.pick(30L, 100, "AUTO", "[]", 0, 101L)).thenReturn(1);
when(repository.pick(30L, 100, 0, 101L)).thenReturn(1);
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.touchTask(20L, 0)).thenReturn(1);
when(smartLoadingRepository.startPicking(10L, 9L, "operator")).thenReturn(1);
@@ -1097,11 +1196,11 @@ class LoadingPickServiceTest {
71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 100))));
String snapshotToken = service.inventorySnapshot(20L, 101L).snapshotToken();
BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, snapshotToken, List.of(
new BatchItemRequest(30L, "PICK", 100, null, "AUTO", List.of(), 0, null)));
new BatchItemRequest(30L, "PICK", 100, null, 0, null)));
service.batch(20L, "batch-reset-plan", request, 9L, "operator");
verify(repository).pick(30L, 100, "AUTO", "[]", 0, 101L);
verify(repository).pick(30L, 100, 0, 101L);
verify(smartLoadingRepository).startPicking(10L, 9L, "operator");
verify(smartLoadingRepository, never()).insert(any(LoadingPlanHistoryEntity.class));
verify(repository).touchTask(20L, 0);
@@ -1123,14 +1222,14 @@ class LoadingPickServiceTest {
when(repository.items(20L)).thenReturn(items);
when(repository.items(20L, 101L)).thenReturn(items);
BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, "stale-token", List.of(
new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null),
new BatchItemRequest(31L, "PARTIAL", null, 4, "AUTO", null, 0, null)));
new BatchItemRequest(30L, "PICK", 2, null, 0, null),
new BatchItemRequest(31L, "PARTIAL", null, 4, 0, null)));
assertThatThrownBy(() -> service.batch(20L, "batch-invalid", request, 9L, "operator"))
.isInstanceOf(BizException.class);
verify(repository, never()).pick(any(Long.class), any(Integer.class), any(), any(), any(Integer.class), any(Long.class));
verify(repository, never()).partial(any(Long.class), any(Integer.class), any(), any(), any(Integer.class), any(Long.class));
verify(repository, never()).pick(any(Long.class), any(Integer.class), any(Integer.class), any(Long.class));
verify(repository, never()).partial(any(Long.class), any(Integer.class), any(Integer.class), any(Long.class));
verify(repository, never()).touchTask(any(Long.class), any(Integer.class));
verify(settlementService, never()).settle(20L, 9L, "operator");
}
@@ -1147,13 +1246,13 @@ class LoadingPickServiceTest {
ApiResponse.success(List.of(new WarehousePalletLocationClient.OccupiedResource(
71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 2))));
BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, "0".repeat(64), List.of(
new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null)));
new BatchItemRequest(30L, "PICK", 2, null, 0, null)));
assertThatThrownBy(() -> service.batch(20L, "batch-inventory-stale", request, 9L, "operator"))
.isInstanceOf(BizException.class)
.hasMessageContaining("库存已变化");
verify(repository, never()).pick(any(Long.class), any(Integer.class), any(), any(),
verify(repository, never()).pick(any(Long.class), any(Integer.class),
any(Integer.class), any(Long.class));
verify(repository, never()).touchTask(any(Long.class), any(Integer.class));
verify(settlementService, never()).settle(20L, 9L, "operator");
@@ -1165,7 +1264,7 @@ class LoadingPickServiceTest {
task.setVersion(3);
when(repository.taskForUpdate(20L)).thenReturn(task);
BatchRequest request = new BatchRequest(101L, 10L, 99L, 2, "stale-token", List.of(
new BatchItemRequest(30L, "PICK", 2, null, "AUTO", null, 0, null)));
new BatchItemRequest(30L, "PICK", 2, null, 0, null)));
assertThatThrownBy(() -> service.batch(20L, "batch-stale", request, 9L, "operator"))
.isInstanceOf(BizException.class);
@@ -1183,15 +1282,14 @@ class LoadingPickServiceTest {
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.items(20L)).thenReturn(List.of(item));
when(repository.items(20L, 101L)).thenReturn(List.of(item));
when(repository.pick(30L, 2, "MANUAL", "[]", 0, 101L)).thenReturn(1);
when(repository.pick(30L, 2, 0, 101L)).thenReturn(1);
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.touchTask(20L, 0)).thenReturn(1);
when(warehouse.occupiedResources(any(), any(Long.class), any(), any(Long.class))).thenReturn(
ApiResponse.success(List.of(new WarehousePalletLocationClient.OccupiedResource(
71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 2))));
String snapshotToken = service.inventorySnapshot(20L, 101L).snapshotToken();
BatchItemRequest line = new BatchItemRequest(
30L, "PICK", 2, null, "MANUAL", List.of(), 0, null);
BatchItemRequest line = new BatchItemRequest(30L, "PICK", 2, null, 0, null);
assertThatThrownBy(() -> service.batch(20L, "batch-stale", new BatchRequest(
101L, 10L, 99L, 1, snapshotToken, List.of(line)), 9L, "operator"))
@@ -1203,7 +1301,7 @@ class LoadingPickServiceTest {
service.batch(20L, "batch-valid", new BatchRequest(
101L, 10L, 99L, 0, snapshotToken, List.of(line)), 9L, "operator");
verify(repository, times(1)).pick(30L, 2, "MANUAL", "[]", 0, 101L);
verify(repository, times(1)).pick(30L, 2, 0, 101L);
verify(repository, times(1)).touchTask(20L, 0);
}
@@ -1224,7 +1322,7 @@ class LoadingPickServiceTest {
BatchRequest staleRequest = new BatchRequest(101L, 10L, 99L, 1,
"stale-token", List.of(
new BatchItemRequest(30L, "PICK", 2, null, "AUTO", null, 0, null)));
new BatchItemRequest(30L, "PICK", 2, null, 0, null)));
assertThatThrownBy(() -> service.batch(20L, "batch-stale-after-resync", staleRequest,
9L, "operator")).isInstanceOf(BizException.class);
@@ -1232,7 +1330,7 @@ class LoadingPickServiceTest {
assertThat(pending.getPickedPackageCount()).isZero();
assertThat(pending.getItemStatus()).isEqualTo("PENDING");
verify(repository, never()).update(any(LoadingPickItemEntity.class));
verify(repository, never()).pick(any(Long.class), any(Integer.class), any(), any(),
verify(repository, never()).pick(any(Long.class), any(Integer.class),
any(Integer.class), any(Long.class));
verify(repository, never()).touchTask(any(Long.class), any(Integer.class));
}
@@ -1251,7 +1349,7 @@ class LoadingPickServiceTest {
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.item(20L, 30L, 101L)).thenReturn(item);
when(repository.pick(30L, 2, "MANUAL", "[]", 0, 101L)).thenReturn(1);
when(repository.pick(30L, 2, 0, 101L)).thenReturn(1);
when(repository.touchTask(20L, 0)).thenReturn(1);
when(repository.items(20L, 101L)).thenReturn(List.of(item));
}
@@ -1262,7 +1360,7 @@ class LoadingPickServiceTest {
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.item(20L, 30L, 101L)).thenReturn(item);
when(repository.partial(30L, 1, "MANUAL", "[]", 0, 101L)).thenReturn(1);
when(repository.partial(30L, 1, 0, 101L)).thenReturn(1);
when(repository.touchTask(20L, 0)).thenReturn(1);
when(repository.items(20L, 101L)).thenReturn(List.of(item));
}
@@ -1316,6 +1414,41 @@ class LoadingPickServiceTest {
return assignment;
}
private static LoadingPickOperationEntity taskOperation(String operationId, String action) {
LoadingPickOperationEntity operation = new LoadingPickOperationEntity();
operation.setPickTaskId(20L);
operation.setOperationId(operationId);
operation.setRequestHash(hashForTest(action + "|20|0"));
if ("ASSIGN".equals(action)) {
operation.setRequestHash(hashForTest("ASSIGN|20|11|picker|0"));
}
operation.setActionType(action);
operation.setOperatorId(9L);
operation.setOperatorName("operator");
return operation;
}
private static LoadingPickHistoryEntity itemHistory(String operationId, String action, String canonical) {
LoadingPickHistoryEntity history = new LoadingPickHistoryEntity();
history.setPickTaskId(20L);
history.setPickItemId(30L);
history.setOperationId(operationId);
history.setRequestHash(hashForTest(canonical));
history.setActionType(action);
history.setOperatorId(9L);
history.setOperatorName("operator");
return history;
}
private static String hashForTest(String value) {
try {
return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
} catch (java.security.NoSuchAlgorithmException exception) {
throw new IllegalStateException(exception);
}
}
private static GroupSnapshot coloadGroup(String status, Long lockedPlanId, Member... members) {
GroupSnapshot group = mock(GroupSnapshot.class);
when(group.groupId()).thenReturn(700L);
@@ -462,7 +462,7 @@ class MeasurementBatchServiceTest {
InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setId(10L);
inbound.setTenantId(1L);
inbound.setCustomerId(1L);
inbound.setCustomerId(null);
inbound.setProcessStatus("WAIT_MEASURE");
inbound.setVersion(0);
return inbound;
@@ -52,37 +52,38 @@ class MeasurementLocationOccupationServiceTest {
properties.setWarehouseResourceTenantId(90001L);
when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound());
when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(confirmedWeights());
when(warehouse.reserve(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("RESERVED")));
when(warehouse.confirm(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("OCCUPIED")));
when(warehouse.reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("RESERVED", 3)));
when(warehouse.confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("OCCUPIED", 3)));
service.occupy(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3);
ArgumentCaptor<WarehousePalletLocationClient.ReserveRequest> reserve =
ArgumentCaptor.forClass(WarehousePalletLocationClient.ReserveRequest.class);
verify(warehouse).reserve(org.mockito.ArgumentMatchers.eq("internal-token"), reserve.capture());
assertThat(reserve.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT);
ArgumentCaptor<WarehousePalletLocationClient.BlockReserveRequest> reserve =
ArgumentCaptor.forClass(WarehousePalletLocationClient.BlockReserveRequest.class);
verify(warehouse).reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"), reserve.capture());
assertThat(reserve.getValue().businessId()).isEqualTo(INBOUND);
assertThat(reserve.getValue().idempotencyKey()).startsWith("measure-88-30-3-");
assertThat(reserve.getValue().pallets())
.extracting(WarehousePalletLocationClient.PalletReference::palletId)
.containsExactly(601L, 601L, 601L);
assertThat(reserve.getValue().idempotencyKey()).startsWith("measure-block-88-30-3-");
assertThat(reserve.getValue().requiredSlotCount()).isEqualTo(3);
ArgumentCaptor<WarehousePalletLocationClient.ConfirmRequest> confirm =
ArgumentCaptor.forClass(WarehousePalletLocationClient.ConfirmRequest.class);
verify(warehouse).confirm(org.mockito.ArgumentMatchers.eq("internal-token"), confirm.capture());
assertThat(confirm.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT);
ArgumentCaptor<WarehousePalletLocationClient.BlockConfirmRequest> confirm =
ArgumentCaptor.forClass(WarehousePalletLocationClient.BlockConfirmRequest.class);
verify(warehouse).confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"), confirm.capture());
assertThat(confirm.getValue().businessId()).isEqualTo(INBOUND);
assertThat(confirm.getValue().packageCount()).isEqualTo(20);
assertThat(confirm.getValue().blockId()).isEqualTo(500L);
assertThat(confirm.getValue().idempotencyKey())
.isEqualTo(reserve.getValue().idempotencyKey() + "-confirm");
verify(allocations, times(2)).replace(
verify(allocations, times(2)).replaceBlock(
org.mockito.ArgumentMatchers.eq(BUSINESS_TENANT),
org.mockito.ArgumentMatchers.eq(INBOUND),
org.mockito.ArgumentMatchers.eq(WAREHOUSE),
org.mockito.ArgumentMatchers.eq(PLATFORM_RESOURCE_TENANT),
org.mockito.ArgumentMatchers.eq(20),
org.mockito.ArgumentMatchers.eq(new BigDecimal("1.0000")),
org.mockito.ArgumentMatchers.any());
verify(allocations, times(2)).bindConfirmedWeights(
org.mockito.ArgumentMatchers.eq(BUSINESS_TENANT),
org.mockito.ArgumentMatchers.eq(INBOUND), org.mockito.ArgumentMatchers.anyList());
}
@Test
@@ -90,33 +91,51 @@ class MeasurementLocationOccupationServiceTest {
properties.setWarehouseResourceTenantId(0L);
when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound());
when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(confirmedWeights());
when(warehouse.reserve(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("RESERVED")));
when(warehouse.confirm(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("OCCUPIED")));
when(warehouse.reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("RESERVED", 3)));
when(warehouse.confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("OCCUPIED", 3)));
service.occupy(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3);
ArgumentCaptor<WarehousePalletLocationClient.ReserveRequest> reserve =
ArgumentCaptor.forClass(WarehousePalletLocationClient.ReserveRequest.class);
verify(warehouse).reserve(org.mockito.ArgumentMatchers.eq("internal-token"), reserve.capture());
assertThat(reserve.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT);
ArgumentCaptor<WarehousePalletLocationClient.BlockReserveRequest> reserve =
ArgumentCaptor.forClass(WarehousePalletLocationClient.BlockReserveRequest.class);
verify(warehouse).reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"), reserve.capture());
assertThat(reserve.getValue().warehouseId()).isEqualTo(WAREHOUSE);
ArgumentCaptor<WarehousePalletLocationClient.ConfirmRequest> confirm =
ArgumentCaptor.forClass(WarehousePalletLocationClient.ConfirmRequest.class);
verify(warehouse).confirm(org.mockito.ArgumentMatchers.eq("internal-token"), confirm.capture());
assertThat(confirm.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT);
assertThat(confirm.getValue().packageCount()).isEqualTo(20);
ArgumentCaptor<WarehousePalletLocationClient.BlockConfirmRequest> confirm =
ArgumentCaptor.forClass(WarehousePalletLocationClient.BlockConfirmRequest.class);
verify(warehouse).confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"), confirm.capture());
assertThat(confirm.getValue().businessId()).isEqualTo(INBOUND);
verify(allocations, times(2)).replace(
verify(allocations, times(2)).replaceBlock(
org.mockito.ArgumentMatchers.eq(BUSINESS_TENANT),
org.mockito.ArgumentMatchers.eq(INBOUND),
org.mockito.ArgumentMatchers.eq(WAREHOUSE),
org.mockito.ArgumentMatchers.eq(PLATFORM_RESOURCE_TENANT),
org.mockito.ArgumentMatchers.eq(20),
org.mockito.ArgumentMatchers.eq(new BigDecimal("1.0000")),
org.mockito.ArgumentMatchers.any());
}
@Test
void reservesThreeSlotsForVolumeWhileBindingOnlyTwoConfirmedWeightImages() {
InboundOrderEntity inbound = inbound();
inbound.setActualVolumeCbm(new BigDecimal("2.5000"));
List<InboundWeightResultEntity> twoWeights = confirmedWeights().subList(0, 2);
when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound);
when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(twoWeights);
when(warehouse.reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("RESERVED", 3)));
when(warehouse.confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("OCCUPIED", 3)));
service.occupy(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3);
verify(warehouse).reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.argThat(request -> request.requiredSlotCount() == 3));
verify(allocations, times(2)).bindConfirmedWeights(BUSINESS_TENANT, INBOUND, twoWeights);
}
@Test
void rejectsConfirmedWeightWithoutConfirmationTimeInsteadOfDroppingIt() {
InboundWeightResultEntity weight = confirmedWeights().get(0);
@@ -128,7 +147,7 @@ class MeasurementLocationOccupationServiceTest {
.isInstanceOf(BizException.class)
.hasMessageContaining("缺少确认时间");
verify(warehouse, never()).reserve(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any());
verify(warehouse, never()).reserveBlock(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any());
}
@Test
@@ -142,7 +161,7 @@ class MeasurementLocationOccupationServiceTest {
.isInstanceOf(BizException.class)
.hasMessageContaining("缺少有效净重");
verify(warehouse, never()).reserve(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any());
verify(warehouse, never()).reserveBlock(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any());
}
@Test
@@ -156,7 +175,7 @@ class MeasurementLocationOccupationServiceTest {
.isInstanceOf(BizException.class)
.hasMessageContaining("缺少有效托盘");
verify(warehouse, never()).reserve(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any());
verify(warehouse, never()).reserveBlock(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any());
}
@Test
@@ -164,24 +183,23 @@ class MeasurementLocationOccupationServiceTest {
properties.setWarehouseResourceTenantId(90001L);
when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound());
when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(confirmedWeights());
when(warehouse.reserve(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(batch("RESERVED")));
when(warehouse.reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("RESERVED", 3)));
IllegalStateException failure = new IllegalStateException("confirm failed");
when(warehouse.confirm(org.mockito.ArgumentMatchers.eq("internal-token"),
when(warehouse.confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenThrow(failure);
when(warehouse.release(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(List.of()));
when(warehouse.releaseBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("RELEASED", 3)));
assertThatThrownBy(() -> service.occupy(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3))
.isSameAs(failure);
ArgumentCaptor<WarehousePalletLocationClient.ReleaseRequest> release =
ArgumentCaptor.forClass(WarehousePalletLocationClient.ReleaseRequest.class);
verify(warehouse).release(org.mockito.ArgumentMatchers.eq("internal-token"), release.capture());
assertThat(release.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT);
ArgumentCaptor<WarehousePalletLocationClient.BlockReleaseRequest> release =
ArgumentCaptor.forClass(WarehousePalletLocationClient.BlockReleaseRequest.class);
verify(warehouse).releaseBlock(org.mockito.ArgumentMatchers.eq("internal-token"), release.capture());
assertThat(release.getValue().businessId()).isEqualTo(INBOUND);
verify(allocations).replace(BUSINESS_TENANT, INBOUND, WAREHOUSE, PLATFORM_RESOURCE_TENANT,
20, batch("RESERVED"));
verify(allocations).replaceBlock(BUSINESS_TENANT, INBOUND, WAREHOUSE,
20, new BigDecimal("1.0000"), block("RESERVED", 3));
}
private static InboundProperties properties() {
@@ -209,12 +227,12 @@ class MeasurementLocationOccupationServiceTest {
return value;
}
private static WarehousePalletLocationClient.BatchResult batch(String state) {
return new WarehousePalletLocationClient.BatchResult(3, java.util.stream.IntStream.rangeClosed(1, 3)
.mapToObj(index -> new WarehousePalletLocationClient.AllocationResult(
500L + index, "RES-00" + index, index, 601L, "PL-001", new BigDecimal("1.200000"),
700L + index, "A-00" + index, new BigDecimal("0.333333"), state))
.toList());
private static WarehousePalletLocationClient.BlockResult block(String state, int count) {
return new WarehousePalletLocationClient.BlockResult(500L, state,
java.util.stream.IntStream.rangeClosed(1, count)
.mapToObj(index -> new WarehousePalletLocationClient.BlockResource(
500L, 600L + index, 700L + index, "A-00" + index, index, false, state))
.toList());
}
private static List<InboundWeightResultEntity> confirmedWeights() {
@@ -56,9 +56,6 @@ class PdaPickSettlementServiceTest {
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20)));
when(allocations.lockConfirmed(10001L, 31L)).thenAnswer(invocation ->
List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10)));
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new WarehousePalletLocationClient.SettlementResult(List.of(), List.of(
transfer(1, 501L, 2), transfer(2, 502L, 10)))));
writes(12, 8);
service.settle(1L, 9L, "operator");
@@ -71,14 +68,13 @@ class PdaPickSettlementServiceTest {
verify(splits).normalizeLoadedCartonLabels(10001L, 31L, 8);
verify(splits).markSourceLoaded(10001L, 31L, 8, 9L, 3);
verify(allocations).consume(10001L, 31L, 1L, 8);
verify(warehouse).settle(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request ->
request.warehouseId() == 701L && request.sourceBusinessId() == 31L
&& request.idempotencyKey().equals("PDA-SETTLE-1-31")
&& request.releasedReservationIds().isEmpty()
&& request.remainingItems().equals(List.of(new WarehousePalletLocationClient.SettlementTransferItem(91L, 2),
new WarehousePalletLocationClient.SettlementTransferItem(92L, 10)))));
verify(allocations).copyRemainingToChild(eq(10001L), anyLong(), any());
verify(allocations).markTransferred(eq(10001L), eq(31L), any(), eq(List.of()));
verify(warehouse).transferBlock(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request ->
request.blockId() == 800L && request.sourceBusinessId() == 31L
&& request.targetBusinessId() > 0
&& request.idempotencyKey().equals("PDA-BLOCK-1-31")));
verify(warehouse, never()).releaseBlock(anyString(), any());
verify(allocations).copyBlockToChild(eq(10001L), anyLong(), any());
verify(allocations).markBlockTransferred(10001L, 31L, 800L);
verify(splits).convergePartialPlanItem(21L, 9L, "operator");
}
@@ -92,8 +88,6 @@ class PdaPickSettlementServiceTest {
new MeasurementItemRow(52L, 61L, 41L, 1)));
when(allocations.lockConfirmed(10001L, 31L)).thenAnswer(invocation ->
List.of(allocation(1L, 1, 501L, 2)));
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new WarehousePalletLocationClient.SettlementResult(List.of(), List.of(transfer(1, 501L, 1)))));
writes(1, 1);
when(splits.insertSplitItem(anyLong(), eq(10001L), anyLong(), eq(31L), anyLong(), anyInt(),
eq(41L), eq(61L), eq(52L), eq(1))).thenReturn(1);
@@ -114,7 +108,7 @@ class PdaPickSettlementServiceTest {
}
@Test
void reusesWarehouseSettlementTargetWhenRetryingAfterLocalRollback() {
void transfersTheWholeBlockToTheGeneratedRemainderChild() {
LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 3, 1, "PARTIAL");
resolved(List.of(item));
when(splits.lockParent(31L)).thenReturn(parent(3));
@@ -122,19 +116,16 @@ class PdaPickSettlementServiceTest {
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 3)));
when(allocations.lockConfirmed(10001L, 31L)).thenAnswer(invocation ->
List.of(allocation(1L, 1, 501L, 3)));
when(warehouse.settlementTarget("internal-token", 31L, "PDA-SETTLE-1-31"))
.thenReturn(ApiResponse.success(999L));
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new WarehousePalletLocationClient.SettlementResult(List.of(), List.of(transfer(1, 501L, 2)))));
writes(2, 1);
service.settle(1L, 9L, "operator");
verify(splits).cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), eq(999L),
verify(splits).cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), anyLong(),
eq(1002L), eq("ORDER-REMAINDER"), eq("IN-001-1"), eq(2),
eq(1), eq("REMAINDER"), eq(9L), eq("operator"));
verify(warehouse).settle(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request ->
request.targetBusinessId() == 999L && request.targetPackageCount() == 2));
verify(warehouse).transferBlock(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request ->
request.blockId() == 800L && request.sourceBusinessId() == 31L
&& request.targetBusinessId() > 0));
}
@Test
@@ -146,12 +137,13 @@ class PdaPickSettlementServiceTest {
service.settle(1L, 9L, "operator");
verify(warehouse, never()).settle(anyString(), any());
verify(warehouse, never()).transferBlock(anyString(), any());
verify(warehouse, never()).releaseBlock(anyString(), any());
verify(splits, never()).convergePartialPlanItem(anyLong(), anyLong(), anyString());
}
@Test
void releasesFullyPickedAllocationBeforeTransferringChildResources() {
void partialPickTransfersTheWholeBlockWithoutReleasingPickedEmptySlots() {
LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 10, 10, "PICKED");
resolved(List.of(item));
when(splits.lockParent(31L)).thenReturn(parent(20));
@@ -159,17 +151,13 @@ class PdaPickSettlementServiceTest {
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20)));
when(allocations.lockConfirmed(10001L, 31L)).thenAnswer(invocation ->
List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10)));
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new WarehousePalletLocationClient.SettlementResult(List.of(released(91L, 501L)),
List.of(transfer(2, 502L, 10)))));
writes(10, 10);
service.settle(1L, 9L, "operator");
verify(warehouse).settle(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request ->
request.idempotencyKey().equals("PDA-SETTLE-1-31")
&& request.releasedReservationIds().equals(List.of(91L))
&& request.remainingItems().equals(List.of(new WarehousePalletLocationClient.SettlementTransferItem(92L, 10)))));
verify(warehouse).transferBlock(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request ->
request.blockId() == 800L && request.sourceBusinessId() == 31L));
verify(warehouse, never()).releaseBlock(anyString(), any());
}
@Test
@@ -184,9 +172,6 @@ class PdaPickSettlementServiceTest {
first.setPalletId(801L);
second.setPalletId(801L);
when(allocations.lockConfirmed(10001L, 31L)).thenReturn(List.of(first, second));
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new WarehousePalletLocationClient.SettlementResult(List.of(released(91L, 501L), released(92L, 501L)),
List.of())));
writes(0, 20);
service.settle(1L, 9L, "operator");
@@ -196,10 +181,10 @@ class PdaPickSettlementServiceTest {
verify(splits, never()).cloneChildWithPurpose(anyLong(), anyLong(), anyLong(), anyLong(),
anyLong(), anyString(), anyString(), anyInt(), anyInt(), anyString(), anyLong(), anyString());
verify(splits).markSourceLoaded(10001L, 31L, 20, 9L, 3);
verify(warehouse).settle(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request ->
request.targetBusinessId() == 0L && request.targetPackageCount() == 0
&& request.releasedReservationIds().equals(List.of(91L, 92L))
&& request.remainingItems().isEmpty()));
verify(warehouse).releaseBlock(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request ->
request.blockId() == 800L && request.businessId() == 31L
&& request.reason().equals("ALL_PACKAGES_PICKED")));
verify(warehouse, never()).transferBlock(anyString(), any());
}
@Test
@@ -251,9 +236,6 @@ class PdaPickSettlementServiceTest {
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20)));
when(allocations.lockConfirmed(10001L, 31L))
.thenReturn(List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10)));
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new WarehousePalletLocationClient.SettlementResult(
List.of(released(91L, 501L), released(92L, 502L)), List.of())));
writes(0, 20);
service.settle(1L, 9L, "operator");
@@ -272,9 +254,6 @@ class PdaPickSettlementServiceTest {
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20)));
when(allocations.lockConfirmed(10001L, 31L))
.thenReturn(List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10)));
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new WarehousePalletLocationClient.SettlementResult(
List.of(released(91L, 501L), released(92L, 502L)), List.of())));
writes(0, 20);
service.settle(1L, 9L, "operator");
@@ -323,9 +302,6 @@ class PdaPickSettlementServiceTest {
stubOrderSplitSuccess();
writes(0, 20);
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new WarehousePalletLocationClient.SettlementResult(
List.of(released(91L, 501L), released(92L, 502L)), List.of())));
service.settle(1L, 9L, "operator");
@@ -341,8 +317,6 @@ class PdaPickSettlementServiceTest {
resolvedChild(item, 32L, 42L, 62L, 52L, 12, 2);
when(splits.nextSplitSequence(10001L, 31L)).thenReturn(2);
childWrites(32L, 42L, 62L, 52L, 5, 2);
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new WarehousePalletLocationClient.SettlementResult(List.of(), List.of(transfer(1, 501L, 7)))));
service.settle(1L, 9L, "operator");
@@ -365,8 +339,6 @@ class PdaPickSettlementServiceTest {
when(splits.nextSplitSequence(10001L, 31L)).thenReturn(3);
childWrites(33L, 43L, 63L, 53L, 4, 3);
when(splits.markSourceLoaded(10001L, 33L, 4, 9L, 3)).thenReturn(0, 1);
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new WarehousePalletLocationClient.SettlementResult(List.of(), List.of(transfer(1, 501L, 5)))));
assertThatThrownBy(() -> service.settle(1L, 9L, "operator"))
.hasMessageContaining("来源分片状态已被并发修改");
@@ -389,6 +361,10 @@ class PdaPickSettlementServiceTest {
task.setId(1L); task.setLoadingPlanId(701L);
when(picks.task(1L)).thenReturn(task);
when(splits.containerBookingId(701L)).thenReturn(801L);
when(warehouse.transferBlock(eq("internal-token"), any()))
.thenReturn(ApiResponse.success(block("OCCUPIED")));
when(warehouse.releaseBlock(eq("internal-token"), any()))
.thenReturn(ApiResponse.success(block("RELEASED")));
stubOrderSplitSuccess();
}
@@ -485,19 +461,16 @@ class PdaPickSettlementServiceTest {
private static InboundPalletAllocationEntity allocation(long id, int no, long locationId, int allocated) {
InboundPalletAllocationEntity value = new InboundPalletAllocationEntity();
value.setId(id); value.setAllocationNo(no); value.setLocationId(locationId); value.setWarehouseId(701L);
value.setBlockId(800L); value.setSlotNo(no); value.setAllocationStatus("CONFIRMED");
value.setReservationId(90L + no);
value.setAllocatedPackageCount(allocated); value.setPickedPackageCount(0);
return value;
}
private static WarehousePalletLocationClient.TransferResult transfer(int no, long locationId, int remaining) {
return new WarehousePalletLocationClient.TransferResult(90L + no, "RES-" + no, 92L + no, no,
0L, 701L, 801L + no, "PALLET-" + no, null, locationId, "L-" + no, null, remaining, "OCCUPIED");
}
private static WarehousePalletLocationClient.SelectiveReleaseResult released(long reservationId, long locationId) {
return new WarehousePalletLocationClient.SelectiveReleaseResult(reservationId, 92L, 1, 801L,
locationId, "L-1", 10);
private static WarehousePalletLocationClient.BlockResult block(String status) {
return new WarehousePalletLocationClient.BlockResult(800L, status, List.of(
new WarehousePalletLocationClient.BlockResource(
800L, 91L, 501L, "L-1", 1, false, status)));
}
private static LoadingPickItemEntity item(long id, long planItemId, long inboundId, long detailId,
@@ -81,7 +81,6 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.atLeastOnce;
@SpringJUnitConfig(SmartLoadingServiceTest.Config.class)
class SmartLoadingServiceTest {
@@ -108,6 +107,7 @@ class SmartLoadingServiceTest {
when(documentNumbers.next("LP")).thenReturn("LP-260818-0001");
when(loadingPickService.mutationSnapshot(anyLong())).thenReturn(
new LoadingPickService.MutationSnapshot(null, null, null, Map.of()));
when(repository.occupiedOrderIds(any(), anySet())).thenReturn(List.of());
when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(
ApiResponse.success(new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 5,
new BigDecimal("10"), new BigDecimal("2"))));
@@ -224,9 +224,9 @@ class SmartLoadingServiceTest {
100L, "EXX_TRUCK", "EXX海卡", 3, LocalDateTime.parse("2026-09-01T09:00:00"),
"PENDING_REVIEW");
when(pendingReviewOrders.page(anyString(), anyList(), isNull(), isNull(), isNull(),
isNull(), isNull(), anyLong(), anyLong())).thenAnswer(invocation -> {
long page = invocation.getArgument(7);
long size = invocation.getArgument(8);
isNull(), isNull(), anyList(), anyLong(), anyLong())).thenAnswer(invocation -> {
long page = invocation.getArgument(8);
long size = invocation.getArgument(9);
return ApiResponse.success(new PageResult<>(page == 1 ? List.of(pending) : List.of(),
page, size, 1, 1));
});
@@ -248,11 +248,15 @@ class SmartLoadingServiceTest {
.containsExactly(true, true);
ArgumentCaptor<InboundCandidateQuery> firstlegQueries =
ArgumentCaptor.forClass(InboundCandidateQuery.class);
verify(repository, atLeastOnce()).globalCandidateUnits(firstlegQueries.capture(), anySet());
verify(repository).globalCandidateUnits(firstlegQueries.capture(), anySet());
assertThat(firstlegQueries.getValue().page()).isEqualTo(1);
assertThat(firstlegQueries.getValue().size()).isEqualTo(19);
assertThat(firstlegQueries.getAllValues()).allSatisfy(requested ->
assertThat(requested.orderStatuses()).containsExactly(
CandidateOrderStatus.WAIT_MEASURE, CandidateOrderStatus.WAIT_SPLIT,
CandidateOrderStatus.LOADING));
verify(pendingReviewOrders).page(anyString(), anyList(), isNull(), isNull(), isNull(),
isNull(), isNull(), anyList(), eq(1L), eq(20L));
}
@Test
@@ -311,9 +315,10 @@ class SmartLoadingServiceTest {
"EXXHK2609020361", 2092797167699816450L, "客户", 193000000000200002L,
"EXX_TRUCK", "EXX海卡", 100, LocalDateTime.parse("2026-09-02T15:31:36"),
"PENDING_REVIEW");
when(repository.occupiedOrderIds(eq(query), anySet())).thenReturn(List.of(998L));
when(pendingReviewOrders.page(anyString(), anyList(), eq(193000000000200002L),
isNull(), eq("EXXHK2609020361"), eq("2026-09-02"),
eq("2026-09-02"), eq(1L), eq(20L)))
eq("2026-09-02"), eq(List.of(998L)), eq(1L), eq(20L)))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(row), 1, 20, 1, 1)));
var result = service.inboundCandidates(query);
@@ -329,26 +334,27 @@ class SmartLoadingServiceTest {
});
verify(repository, never()).globalCandidateUnits(any(), anySet());
verify(repository, never()).globalCandidatesByUnits(anyList());
verify(repository).occupiedOrderIds(eq(query), anySet());
}
@Test
void assignsPendingReviewOrderAsAProvisionalContainerAllocation() {
void manuallyAssignsCrossChannelPendingReviewOrderAsAProvisionalContainerAllocation() {
long customerId = 2092797167699816450L;
long orderId = 2095052002489335810L;
String inboundCode = "EXXHK2609020361";
var pending = new PendingReviewOrderClient.Row(orderId, "FL-361", inboundCode,
customerId, "客户", 100L, "FBA", "FBA渠道", 5,
customerId, "客户", 100L, "SLOW", "慢船", 5,
LocalDateTime.parse("2026-09-02T15:31:36"), "PENDING_REVIEW");
var snapshot = new com.baiyue.firstleg.dto.OrderApprovedInboundCommand(
customerId, orderId, "FL-361", inboundCode, customerId, "客户", 100L, "义乌仓",
null, null, null, "FIRST_LEG", "FBA", "FBA渠道", null, null, null,
null, null, null, "FIRST_LEG", "SLOW", "慢船", null, null, null,
null, null, false, "FBA", 200L, "FBA1", "FBA仓", null, null, null, 5);
var label = new OrderCargoClient.CargoLabelBinding(20L, orderId, 1, "货物", 5,
null, null, null);
var cargo = new OrderCargoClient.CargoBinding(20L, orderId, 1, "货物", 5,
new BigDecimal("50"), new BigDecimal("2.5"));
when(pendingReviewOrders.page(anyString(), isNull(), isNull(), eq(customerId),
eq(inboundCode), isNull(), isNull(), eq(1L), eq(2L)))
eq(inboundCode), isNull(), isNull(), isNull(), eq(1L), eq(2L)))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(pending), 1, 2, 1, 1)));
when(cargoClient.inboundSnapshot(anyString(), eq(customerId), eq(orderId)))
.thenReturn(ApiResponse.success(snapshot));
@@ -381,7 +387,7 @@ class SmartLoadingServiceTest {
}
@Test
void assignsWaitMeasureInboundUsingDeclaredFactsWithoutTreatingStatusAsAGate() {
void manuallyAssignsCrossChannelWaitMeasureInboundUsingDeclaredFacts() {
long customerId = 9L;
long inboundId = 10L;
long orderId = 30L;
@@ -391,11 +397,11 @@ class SmartLoadingServiceTest {
projection.setOrderId(orderId);
projection.setInboundCode("I10");
projection.setWarehouseId(100L);
projection.setChannelCode("FBA");
projection.setChannelCode("SLOW");
projection.setProcessStatus("WAIT_MEASURE");
var snapshot = new com.baiyue.firstleg.dto.OrderApprovedInboundCommand(
customerId, orderId, "O30", "I10", customerId, "客户", 100L, "义乌仓",
null, null, null, "FIRST_LEG", "FBA", "FBA渠道", null, null, null,
null, null, null, "FIRST_LEG", "SLOW", "慢船", null, null, null,
null, null, false, "FBA", 200L, "FBA1", "FBA仓", null, null, null, 5);
var label = new OrderCargoClient.CargoLabelBinding(20L, orderId, 1, "货物", 5,
null, null, null);
@@ -430,7 +436,7 @@ class SmartLoadingServiceTest {
List.of("EXX"), null, null, null, null, null,
List.of(CandidateOrderStatus.PENDING_REVIEW), 1, 20);
when(pendingReviewOrders.page(anyString(), anyList(), isNull(), isNull(), isNull(),
isNull(), isNull(), eq(1L), eq(20L))).thenThrow(new RuntimeException("offline"));
isNull(), isNull(), anyList(), eq(1L), eq(20L))).thenThrow(new RuntimeException("offline"));
assertThatThrownBy(() -> service.inboundCandidates(query))
.isInstanceOf(BizException.class)
@@ -575,8 +581,8 @@ class SmartLoadingServiceTest {
@Test
void atomicallyAssignsEveryInboundFragmentOfSameOrderAcrossDifferentDestinations() {
Candidate first = candidate(9L, 10L, 20L, 30L, 4);
Candidate second = candidate(9L, 11L, 21L, 30L, 6, "FBA2");
Candidate first = withChannel(candidate(9L, 10L, 20L, 30L, 4), "SLOW", "慢船");
Candidate second = withChannel(candidate(9L, 11L, 21L, 30L, 6, "FBA2"), "SLOW", "慢船");
GroupSnapshot active = group("ACTIVE", null,
new Member(9L, 30L, 10L, "IN-10", 4), new Member(9L, 30L, 11L, "IN-11", 6));
GroupSnapshot locked = group("LOCKED", 1L,
@@ -796,6 +802,94 @@ class SmartLoadingServiceTest {
org.mockito.ArgumentMatchers.startsWith("UNLOCK-"), eq(7L), eq("u"));
}
@Test
void removesAllUnpickedItemsWithOnePlanVersionChange() {
LoadingPlanEntity initial = plan("EDITING", 2);
LoadingPlanEntity after = plan("EDITING", 3);
LoadingPlanItemEntity first = item(8L, 9L, 20L, 4);
LoadingPlanItemEntity second = item(9L, 19L, 21L, 6);
second.setInboundId(11L);
when(repository.plan(1L)).thenReturn(initial, after);
when(repository.booking(99L)).thenReturn(booking());
when(repository.items(1L)).thenReturn(List.of(first, second), List.of());
when(repository.touch(1L, 2, 7L, "u")).thenReturn(1);
when(repository.remove(eq(1L), anyLong(), eq(0), eq(7L), eq("u"))).thenReturn(1);
Plan result = service.removeUnpicked(1L,
new com.baiyue.firstleg.dto.SmartLoadingDtos.RemoveUnpickedRequest(2, "remove-unpicked"),
7L, "u");
assertThat(result.items()).isEmpty();
verify(repository).touch(1L, 2, 7L, "u");
verify(repository, times(2)).remove(eq(1L), anyLong(), eq(0), eq(7L), eq("u"));
verify(loadingPickService, times(2)).remove(anyLong());
}
@Test
void bulkRemovalKeepsWholeGroupWhenAnyMemberWasPicked() {
LoadingPlanEntity initial = plan("LOADING", 2);
LoadingPlanEntity after = plan("LOADING", 3);
LoadingPlanItemEntity first = item(8L, 9L, 20L, 4);
LoadingPlanItemEntity second = item(9L, 19L, 21L, 6);
second.setInboundId(11L);
LoadingPlanItemEntity standalone = item(10L, 29L, 22L, 3);
standalone.setInboundId(12L);
GroupSnapshot group = group("LOCKED", 1L,
new Member(9L, 30L, 10L, "IN-10", 4), new Member(19L, 31L, 11L, "IN-11", 6));
when(repository.plan(1L)).thenReturn(initial, after);
when(repository.booking(99L)).thenReturn(booking());
when(repository.items(1L)).thenReturn(List.of(first, second, standalone), List.of(first, second));
when(repository.touch(1L, 2, 7L, "u")).thenReturn(1);
when(repository.remove(1L, 10L, 0, 7L, "u")).thenReturn(1);
when(coloadGroups.findByInbound(9L, 10L)).thenReturn(group);
when(coloadGroups.findByInbound(19L, 11L)).thenReturn(group);
when(loadingPickService.mutationSnapshot(1L)).thenReturn(
new LoadingPickService.MutationSnapshot(77L, "LOADING", 5, Map.of(8L, 1)));
Plan result = service.removeUnpicked(1L,
new com.baiyue.firstleg.dto.SmartLoadingDtos.RemoveUnpickedRequest(2, "keep-picked-group"),
7L, "u");
assertThat(result.items()).extracting(LoadingPlanItemEntity::getId).containsExactly(8L, 9L);
verify(repository).remove(1L, 10L, 0, 7L, "u");
verify(repository, never()).remove(eq(1L), eq(8L), anyInt(), anyLong(), anyString());
verify(repository, never()).remove(eq(1L), eq(9L), anyInt(), anyLong(), anyString());
verify(coloadGroups, never()).unlockWholeGroupAfterRemoval(anyLong(), anyLong(), anyString(), anyLong(), anyString());
}
@Test
void bulkRemovalRemovesAndUnlocksACompletelyUnpickedGroupOnce() {
LoadingPlanEntity initial = plan("EDITING", 2);
LoadingPlanEntity after = plan("EDITING", 3);
LoadingPlanItemEntity first = item(8L, 9L, 20L, 4);
LoadingPlanItemEntity second = item(9L, 19L, 21L, 6);
second.setInboundId(11L);
GroupSnapshot group = group("LOCKED", 1L,
new Member(9L, 30L, 10L, "IN-10", 4), new Member(19L, 31L, 11L, "IN-11", 6));
when(repository.plan(1L)).thenReturn(initial, after);
when(repository.booking(99L)).thenReturn(booking());
when(repository.items(1L)).thenReturn(List.of(first, second), List.of());
when(repository.activeItemsByInbound(9L, 10L)).thenReturn(List.of(first));
when(repository.activeItemsByInbound(19L, 11L)).thenReturn(List.of(second));
when(repository.touch(1L, 2, 7L, "u")).thenReturn(1);
when(repository.remove(eq(1L), anyLong(), eq(0), eq(7L), eq("u"))).thenReturn(1);
when(coloadGroups.findByInbound(9L, 10L)).thenReturn(group);
when(coloadGroups.findByInbound(19L, 11L)).thenReturn(group);
GroupSnapshot unlocked = group("ACTIVE", null,
new Member(9L, 30L, 10L, "IN-10", 4), new Member(19L, 31L, 11L, "IN-11", 6));
when(coloadGroups.unlockWholeGroupAfterRemoval(eq(700L), eq(1L), anyString(), eq(7L), eq("u")))
.thenReturn(unlocked);
Plan result = service.removeUnpicked(1L,
new com.baiyue.firstleg.dto.SmartLoadingDtos.RemoveUnpickedRequest(2, "remove-unpicked-group"),
7L, "u");
assertThat(result.items()).isEmpty();
verify(repository, times(2)).remove(eq(1L), anyLong(), eq(0), eq(7L), eq("u"));
verify(coloadGroups).unlockWholeGroupAfterRemoval(eq(700L), eq(1L),
org.mockito.ArgumentMatchers.startsWith("UNLOCK-"), eq(7L), eq("u"));
}
@Test
void proportionallyDistributesInboundQuantityWithoutLosingCartons() {
Candidate first = new Candidate(9L, 10L, 20L, 30L, "O", "I", 100L,
@@ -1319,6 +1413,44 @@ class SmartLoadingServiceTest {
assertThat(updated.getValue().getWarehouseRemark()).isEqualTo("装柜时注意外箱");
}
@Test
void quantityAdjustmentCanDecreaseWhenCalculatedRemainingQuantityIsNegative() {
LoadingPlanEntity plan = plan("LOADING", 93);
LoadingPlanItemEntity line = item(8, 9, 20, 50);
when(repository.plan(1L)).thenReturn(plan, plan("LOADING", 94));
when(repository.item(1L, 8L)).thenReturn(line);
when(repository.booking(99L)).thenReturn(booking());
when(repository.candidate(1L, 100L, 10L, 20L)).thenReturn(candidate(-25));
when(repository.items(1L)).thenReturn(List.of(line));
when(repository.touch(1L, 93, 7L, "u")).thenReturn(1);
when(repository.update(line)).thenReturn(1);
service.adjust(1L, 8L, new AdjustRequest(30, 93, 0, "adjust-down"), 7L, "u");
ArgumentCaptor<LoadingPlanItemEntity> updated = ArgumentCaptor.forClass(LoadingPlanItemEntity.class);
verify(repository).update(updated.capture());
assertThat(updated.getValue().getPackageCount()).isEqualTo(30);
}
@Test
void quantityAdjustmentOnlyAppliesRemainingQuantityLimitToTheIncrease() {
LoadingPlanEntity plan = plan("LOADING", 2);
LoadingPlanItemEntity line = item(8, 9, 20, 50);
when(repository.plan(1L)).thenReturn(plan, plan("LOADING", 3));
when(repository.item(1L, 8L)).thenReturn(line);
when(repository.booking(99L)).thenReturn(booking());
when(repository.candidate(1L, 100L, 10L, 20L)).thenReturn(candidate(10));
when(repository.items(1L)).thenReturn(List.of(line));
when(repository.touch(1L, 2, 7L, "u")).thenReturn(1);
when(repository.update(line)).thenReturn(1);
service.adjust(1L, 8L, new AdjustRequest(60, 2, 0, "adjust-up"), 7L, "u");
ArgumentCaptor<LoadingPlanItemEntity> updated = ArgumentCaptor.forClass(LoadingPlanItemEntity.class);
verify(repository).update(updated.capture());
assertThat(updated.getValue().getPackageCount()).isEqualTo(60);
}
@Test
void unchangedQuantityAndCargoSnapshotDoNotWriteAdjustmentHistory() {
LoadingPlanEntity plan = plan("LOADING", 2);
@@ -97,11 +97,13 @@ class TaillegRealProducerAcceptanceTest {
private void seedAllocation(long id, long inboundId, long palletId, long locationId,
int packages, String suffix) {
jdbc.update("INSERT IGNORE INTO fl_inbound_pallet_allocation(id,tenant_id,inbound_id,allocation_no," +
"warehouse_id,warehouse_resource_tenant_id,pallet_id,pallet_code,pallet_capacity_cbm," +
"block_id,slot_no,weight_result_id,pallet_sequence,warehouse_id,warehouse_resource_tenant_id,pallet_id,pallet_code,pallet_capacity_cbm," +
"allocated_volume_cbm,allocated_package_count,picked_package_count,location_id,location_code," +
"reservation_id,reservation_no,allocation_status) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED')",
id, CUSTOMER, inboundId, 1, 92L, 0L, palletId, MARKER + "-PALLET-" + suffix,
1.2, 0.4, packages, packages, locationId, "A-01", id + 1000, MARKER + "-RES-" + suffix);
"shared_overflow,reservation_id,slot_status,allocation_status) " +
"VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'BOUND','CONFIRMED')",
id, CUSTOMER, inboundId, 1, id + 3000, 1, id + 2000, 1, 92L, 0L,
palletId, MARKER + "-PALLET-" + suffix, 1.2, 0.4, packages, packages,
locationId, "A-01", false, id + 1000);
}
private void seedPickItem(long id, long planItemId, long inboundId, long detailId, int packages) {