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();
}