装柜回归测试

This commit is contained in:
2026-08-09 20:35:11 +08:00
parent b20cd9bd77
commit 0ce264a257
67 changed files with 3329 additions and 208 deletions
@@ -30,6 +30,14 @@ public interface WarehousePalletLocationClient {
@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody SelectiveReleaseRequest request);
@PostMapping("/transfer")
ApiResponse<List<TransferResult>> transfer(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody TransferRequest request);
@PostMapping("/settlements")
ApiResponse<SettlementResult> settle(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody SettlementRequest request);
record ReserveRequest(long tenantId, long warehouseId, String sourceService, long businessId,
String inboundCode, String idempotencyKey, long operatorId,
BigDecimal totalVolumeCbm, BigDecimal palletCapacityCbm, String palletType,
@@ -39,8 +47,8 @@ public interface WarehousePalletLocationClient {
record ReleaseRequest(long tenantId, String sourceService, long businessId, long operatorId,
String reason) {}
record SelectiveReleaseRequest(long tenantId, String sourceService, long businessId,
long operatorId, String reason, int totalPackageCount,
List<Long> locationIds, Integer releaseLocationCount) {}
String idempotencyKey, long operatorId, String reason,
List<Long> locationIds) {}
record AllocationResult(long reservationId, String reservationNo, int allocationNo,
long palletId, String palletCode, long locationId, String locationCode,
BigDecimal allocatedVolumeCbm, String state) {}
@@ -49,5 +57,21 @@ public interface WarehousePalletLocationClient {
long locationId, String locationCode, String state) {}
record SelectiveReleaseResult(long reservationId, long occupancyId, int allocationNo,
long palletId, long locationId, String locationCode,
int releasedPackageCount) {}
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) {}
}
@@ -29,7 +29,7 @@ public class MerchantInboundBillingController {
@GetMapping("/{orderId}/inbound-billing")
@PreAuthorize("hasAuthority('merchant:order:view')")
@Operation(summary = "查询订单入库结果和最新费用",
description = "客户只读查看已达到 WAIT_SPLIT + APPROVED + CONFIRMED 的二期费用,不需要确认费用。")
description = "客户只读查看已达到 INBOUNDED + APPROVED + CONFIRMED 的二期费用,不需要确认费用。")
public ApiResponse<MerchantInboundBillingResponse> find(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long orderId) {
return ApiResponse.success(service.find(FirstlegScopeGuard.merchant(user), orderId));
@@ -20,7 +20,10 @@ public final class InboundProcessStatus {
public static final String INBOUNDING = "INBOUNDING";
/** 已入库完成,费用已发送给客户。 */
public static final String INBOUNDED = "INBOUNDED";
/** 二期处理完成,等待三期分柜。 */
/**
* 历史兼容状态。二、三期已统一使用 {@link #INBOUNDED},不得再写入或对外返回此值。
*/
@Deprecated(forRemoval = false)
public static final String WAIT_SPLIT = "WAIT_SPLIT";
/** 已进入装柜作业,PDA 可执行拣货。 */
public static final String LOADING = "LOADING";
@@ -33,7 +36,7 @@ public final class InboundProcessStatus {
public static final Set<String> VALUES = Set.of(
WAIT_MEASURE, MEASURING, MEASURE_PENDING, MEASURE_REJECTED,
WAIT_PRICING, FEE_CONFIRMING, INBOUNDING, INBOUNDED, WAIT_SPLIT,
WAIT_PRICING, FEE_CONFIRMING, INBOUNDING, INBOUNDED,
LOADING, LOADED, DELIVERED, VOIDED
);
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonAlias;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.util.List;
@@ -17,20 +18,30 @@ 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 明细上。 */
@@ -14,7 +14,7 @@ public record UnifiedInboundQuery(
Long tenantId,
@Size(max = 64) String keyword,
@Size(max = 64) String orderNo,
@Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT",
@Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED",
"LOADING", "LOADED", "DELIVERED"})
@Size(max = 32) String mainStatus,
Long customerId,
@@ -29,6 +29,8 @@ public class InboundPalletAllocationEntity {
private Integer pickedPackageCount;
private Long locationId;
private String locationCode;
/** 仓库资源唯一预占标识;A0 多托盘场景不能使用 locationId 作为唯一标识。 */
private Long reservationId;
private String reservationNo;
private String allocationStatus;
private LocalDateTime createdAt;
@@ -23,6 +23,10 @@ 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;
@@ -37,4 +37,22 @@ 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
""")
int markTransferred(@Param("id") long id, @Param("tenantId") long tenantId,
@Param("inboundId") long inboundId);
@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'
""")
int markReleased(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("reservationId") long reservationId);
}
@@ -2,7 +2,9 @@ package com.baiyue.firstleg.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@@ -11,12 +13,13 @@ import java.util.List;
public interface InboundSplitSettlementMapper {
@Select("""
SELECT id, tenant_id AS tenantId, inbound_code AS inboundCode, inbound_role AS inboundRole,
process_status AS processStatus, version
process_status AS processStatus, total_package_count AS totalPackageCount,
package_count AS packageCount, version
FROM fl_inbound_order
WHERE tenant_id = #{accountId} AND id = #{inboundId} AND deleted = 0
WHERE id = #{inboundId} AND deleted = 0
FOR UPDATE
""")
ParentRow lockParent(@Param("accountId") long accountId, @Param("inboundId") long inboundId);
ParentRow lockParent(@Param("inboundId") long inboundId);
@Select("""
SELECT id
@@ -28,6 +31,179 @@ public interface InboundSplitSettlementMapper {
""")
Long childId(@Param("accountId") long accountId, @Param("parentInboundId") long parentInboundId);
@Insert("""
INSERT INTO fl_inbound_order (
id, tenant_id, task_id, order_id, order_no, inbound_code, process_status,
order_status_snapshot, measure_review_status, fee_status, fee_review_status, notification_status,
expected_arrival_at, customer_id, customer_name, company_id, company_name,
warehouse_id, warehouse_name, delivery_warehouse_id, delivery_warehouse_name,
business_type, channel_code, channel_name, carrier_id, carrier_name, tax_mode,
pricing_tax_mode, container_mode, customs_inspection, destination_type,
destination_warehouse_id, destination_code, destination_name, hold_flag,
current_hold_reason, total_package_count, actual_gross_weight_kg,
chargeable_weight_kg, actual_volume_cbm, chargeable_volume_cbm, pallet_count,
volume_weight_kg, actual_net_weight_kg, measure_completed_at, inbounded_at,
express_company_code, express_company_name, express_tracking_no, public_remark,
created_by, created_by_name, created_at, updated_by, updated_at, deleted, version,
inbound_role, parent_inbound_id, root_inbound_id, split_sequence, split_status, package_count
)
SELECT #{childId}, tenant_id, task_id, order_id, order_no, #{childInboundCode}, 'INBOUNDED',
order_status_snapshot, 'APPROVED', fee_status, fee_review_status, notification_status,
expected_arrival_at, customer_id, customer_name, company_id, company_name,
warehouse_id, warehouse_name, delivery_warehouse_id, delivery_warehouse_name,
business_type, channel_code, channel_name, carrier_id, carrier_name, tax_mode,
pricing_tax_mode, container_mode, customs_inspection, destination_type,
destination_warehouse_id, destination_code, destination_name, hold_flag,
current_hold_reason, #{packageCount}, actual_gross_weight_kg,
chargeable_weight_kg, actual_volume_cbm, chargeable_volume_cbm, pallet_count,
volume_weight_kg, actual_net_weight_kg, measure_completed_at, inbounded_at,
express_company_code, express_company_name, express_tracking_no, public_remark,
#{operatorId}, #{operatorName}, CURRENT_TIMESTAMP(3), #{operatorId}, CURRENT_TIMESTAMP(3), 0, 0,
'CHILD', id, id, 1, 'NOT_SPLIT', #{packageCount}
FROM fl_inbound_order
WHERE id = #{parentInboundId} AND tenant_id = #{accountId} AND deleted = 0
""")
int cloneChild(@Param("accountId") long accountId, @Param("parentInboundId") long parentInboundId,
@Param("childId") long childId, @Param("childInboundCode") String childInboundCode,
@Param("packageCount") int packageCount, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Insert("""
INSERT INTO fl_inbound_split_batch
(id, tenant_id, parent_inbound_id, operation_id, idempotency_key, expected_parent_version,
package_count, status, remark, created_by, created_by_name, created_at, version)
VALUES
(#{batchId}, #{accountId}, #{parentInboundId}, #{operationId}, #{idempotencyKey},
#{expectedParentVersion}, #{packageCount}, 'PREPARED', #{remark}, #{operatorId},
#{operatorName}, CURRENT_TIMESTAMP(3), 0)
""")
int insertBatch(@Param("batchId") long batchId, @Param("accountId") long accountId,
@Param("parentInboundId") long parentInboundId, @Param("operationId") String operationId,
@Param("idempotencyKey") String idempotencyKey,
@Param("expectedParentVersion") int expectedParentVersion,
@Param("packageCount") int packageCount, @Param("remark") String remark,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Insert("""
INSERT INTO fl_inbound_split_item
(id, tenant_id, split_batch_id, parent_inbound_id, child_inbound_id, line_no,
source_inbound_detail_id, source_measurement_id, source_measurement_item_id, package_count, created_at)
VALUES
(#{splitItemId}, #{accountId}, #{batchId}, #{parentInboundId}, #{childInboundId}, #{lineNo},
#{inboundDetailId}, #{measurementId}, #{measurementItemId}, #{packageCount}, CURRENT_TIMESTAMP(3))
""")
int insertSplitItem(@Param("splitItemId") long splitItemId, @Param("accountId") long accountId,
@Param("batchId") long batchId, @Param("parentInboundId") long parentInboundId,
@Param("childInboundId") long childInboundId, @Param("lineNo") int lineNo,
@Param("inboundDetailId") long inboundDetailId, @Param("measurementId") long measurementId,
@Param("measurementItemId") long measurementItemId,
@Param("packageCount") int packageCount);
@Insert("""
INSERT INTO fl_inbound_measurement
(id, tenant_id, inbound_id, split_batch_id, source_measurement_id, source_type, source_code,
external_event_no, external_request_hash, device_code, warehouse_code, measured_at, status,
raw_payload_json, original_document_no, original_gross_weight_kg, original_volume_cbm,
actual_goods_name, total_package_count, total_volume_raw_cbm, total_volume_cbm,
volume_weight_kg, created_by, created_at, updated_at, version)
SELECT #{childMeasurementId}, tenant_id, #{childInboundId}, #{batchId}, id, source_type, source_code,
NULL, NULL, device_code, warehouse_code, measured_at, 'APPROVED',
raw_payload_json, original_document_no, original_gross_weight_kg, original_volume_cbm,
actual_goods_name, #{packageCount}, NULL, NULL, NULL, #{operatorId},
CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3), 0
FROM fl_inbound_measurement
WHERE id = #{sourceMeasurementId} AND tenant_id = #{accountId} AND inbound_id = #{parentInboundId}
AND status = 'APPROVED'
""")
int cloneMeasurement(@Param("accountId") long accountId, @Param("parentInboundId") long parentInboundId,
@Param("sourceMeasurementId") long sourceMeasurementId,
@Param("childInboundId") long childInboundId, @Param("childMeasurementId") long childMeasurementId,
@Param("batchId") long batchId, @Param("packageCount") int packageCount,
@Param("operatorId") long operatorId);
@Insert("""
INSERT INTO fl_inbound_measurement_item
(id, tenant_id, measurement_id, split_item_id, source_measurement_item_id, inbound_detail_id,
external_detail_no, box_code, item_no, box_specification, package_count, length_cm, width_cm,
height_cm, girth_cm, unit_weight_kg, unit_volume_cbm, volume_raw_cbm, volume_cbm,
total_weight_kg, total_volume_cbm, volume_weight_kg, calculation_rule_version,
source_image_file_id, machine_no, device_measured_at, over_limit_warning,
outer_length_cm, outer_width_cm, outer_height_cm, remark, created_at, updated_at)
SELECT #{childMeasurementItemId}, tenant_id, #{childMeasurementId}, #{splitItemId}, id, inbound_detail_id,
NULL, NULL, CAST(#{lineNo} AS CHAR), box_specification, #{packageCount}, length_cm, width_cm,
height_cm, girth_cm, unit_weight_kg, unit_volume_cbm, volume_raw_cbm, volume_cbm,
CASE WHEN unit_weight_kg IS NULL THEN NULL ELSE unit_weight_kg * #{packageCount} END,
unit_volume_cbm * #{packageCount}, volume_weight_kg / NULLIF(package_count, 0) * #{packageCount},
calculation_rule_version, source_image_file_id, machine_no, device_measured_at,
over_limit_warning, outer_length_cm, outer_width_cm, outer_height_cm, remark,
CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3)
FROM fl_inbound_measurement_item
WHERE id = #{sourceMeasurementItemId} AND tenant_id = #{accountId}
AND measurement_id = #{sourceMeasurementId}
""")
int cloneMeasurementItem(@Param("accountId") long accountId, @Param("sourceMeasurementId") long sourceMeasurementId,
@Param("sourceMeasurementItemId") long sourceMeasurementItemId,
@Param("childMeasurementId") long childMeasurementId, @Param("splitItemId") long splitItemId,
@Param("childMeasurementItemId") long childMeasurementItemId,
@Param("lineNo") int lineNo, @Param("packageCount") int packageCount);
@Update("""
UPDATE fl_inbound_order
SET package_count = GREATEST(COALESCE(package_count, total_package_count) - #{packageCount}, 0),
split_status = 'PARTIALLY_SPLIT', updated_by = #{operatorId}, version = version + 1
WHERE id = #{parentInboundId} AND tenant_id = #{accountId} AND deleted = 0
AND inbound_role = 'MASTER' AND process_status = 'INBOUNDED' AND version = #{expectedVersion}
""")
int markParentPartiallySplit(@Param("accountId") long accountId, @Param("parentInboundId") long parentInboundId,
@Param("packageCount") int packageCount, @Param("operatorId") long operatorId,
@Param("expectedVersion") int expectedVersion);
@Update("""
UPDATE fl_inbound_split_batch
SET status = 'APPLIED', applied_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{batchId} AND tenant_id = #{accountId} AND status = 'PREPARED'
""")
int applyBatch(@Param("accountId") long accountId, @Param("batchId") long batchId);
@Insert("""
INSERT INTO fl_inbound_split_history
(id, tenant_id, split_batch_id, operation_id, action_type, after_snapshot,
operator_id, operator_name, operation_reason, created_at)
VALUES
(#{historyId}, #{accountId}, #{batchId}, #{operationId}, 'APPLY', #{afterSnapshot},
#{operatorId}, #{operatorName}, #{reason}, CURRENT_TIMESTAMP(3))
""")
int insertHistory(@Param("historyId") long historyId, @Param("accountId") long accountId,
@Param("batchId") long batchId, @Param("operationId") String operationId,
@Param("afterSnapshot") String afterSnapshot, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName, @Param("reason") String reason);
@Update("""
UPDATE fl_loading_plan_item plan_item
JOIN fl_loading_pick_item pick_item ON pick_item.loading_plan_item_id = plan_item.id
SET plan_item.package_count = pick_item.picked_package_count,
plan_item.updated_by = #{operatorId}, plan_item.updated_by_name = #{operatorName},
plan_item.updated_at = CURRENT_TIMESTAMP(3), plan_item.version = plan_item.version + 1
WHERE plan_item.id = #{planItemId} AND plan_item.deleted = 0
AND pick_item.item_status = 'PARTIAL'
AND pick_item.picked_package_count > 0
""")
int convergePartialPlanItem(@Param("planItemId") long planItemId, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_loading_plan_item plan_item
JOIN fl_loading_pick_item pick_item ON pick_item.loading_plan_item_id = plan_item.id
SET plan_item.deleted = 1, plan_item.removed_by = #{operatorId},
plan_item.removed_by_name = #{operatorName}, plan_item.removed_at = CURRENT_TIMESTAMP(3),
plan_item.updated_by = #{operatorId}, plan_item.updated_by_name = #{operatorName},
plan_item.updated_at = CURRENT_TIMESTAMP(3), plan_item.version = plan_item.version + 1
WHERE plan_item.id = #{planItemId} AND plan_item.deleted = 0
AND pick_item.item_status = 'SKIPPED' AND pick_item.picked_package_count = 0
""")
int removeSkippedPlanItem(@Param("planItemId") long planItemId, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Select({"<script>", """
SELECT item.id AS measurementItemId, measurement.id AS measurementId,
item.inbound_detail_id AS inboundDetailId, item.package_count AS packageCount
@@ -46,8 +222,47 @@ public interface InboundSplitSettlementMapper {
@Param("inboundId") long inboundId,
@Param("detailIds") List<Long> detailIds);
@Select("""
SELECT item.id AS measurementItemId, measurement.id AS measurementId,
item.inbound_detail_id AS inboundDetailId, item.package_count AS packageCount
FROM fl_inbound_measurement measurement
JOIN fl_inbound_measurement_item item ON item.measurement_id = measurement.id
WHERE measurement.tenant_id = #{accountId} AND measurement.inbound_id = #{inboundId}
AND measurement.status = 'APPROVED' AND item.package_count > 0
AND measurement.id = (
SELECT MAX(latest.id) FROM fl_inbound_measurement latest
WHERE latest.tenant_id = #{accountId} AND latest.inbound_id = #{inboundId}
AND latest.status = 'APPROVED')
ORDER BY item.id ASC
FOR UPDATE
""")
List<MeasurementItemRow> lockLatestApprovedItemsForInbound(@Param("accountId") long accountId,
@Param("inboundId") long inboundId);
@Update("""
UPDATE fl_inbound_measurement
SET total_package_count = #{packageCount}, updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{measurementId} AND tenant_id = #{accountId} AND inbound_id = #{inboundId}
AND status = 'APPROVED'
""")
int updateSourceMeasurementPackageCount(@Param("accountId") long accountId,
@Param("inboundId") long inboundId,
@Param("measurementId") long measurementId,
@Param("packageCount") int packageCount);
@Update("""
UPDATE fl_inbound_measurement_item
SET package_count = #{packageCount}, updated_at = CURRENT_TIMESTAMP(3)
WHERE id = #{measurementItemId} AND measurement_id = #{measurementId} AND tenant_id = #{accountId}
""")
int updateSourceMeasurementItemPackageCount(@Param("accountId") long accountId,
@Param("measurementId") long measurementId,
@Param("measurementItemId") long measurementItemId,
@Param("packageCount") int packageCount);
record ParentRow(Long id, Long tenantId, String inboundCode, String inboundRole,
String processStatus, Integer version) {
String processStatus, Integer totalPackageCount, Integer packageCount,
Integer version) {
}
record MeasurementItemRow(Long measurementItemId, Long measurementId, Long inboundDetailId,
@@ -97,6 +97,7 @@ 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
@@ -106,12 +107,17 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0)
AND picked_package_count + #{count} <= planned_package_count
""")
int pick(@Param("id") long id, @Param("count") int count, @Param("version") int version,
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', version = version + 1
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
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
@@ -122,7 +128,10 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
AND picked_package_count &lt; #{finalCount}
AND #{finalCount} &lt; planned_package_count
""")
int partial(@Param("id") long id, @Param("finalCount") int finalCount, @Param("version") int version,
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);
@Update("""
@@ -53,6 +53,7 @@ public class InboundPalletAllocationRepository {
remaining -= allocatedPackages;
value.setLocationId(source.locationId());
value.setLocationCode(source.locationCode());
value.setReservationId(source.reservationId());
// 仓储确认响应只返回 reservationId;沿用预占阶段的预占单号快照。
value.setReservationNo(source.reservationNo() != null ? source.reservationNo()
: reservationNos.get(source.palletId()));
@@ -73,6 +74,72 @@ 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 || mapper.markTransferred(allocation.getId(), tenantId, inboundId) != 1) {
throw new IllegalStateException("库位资源转移状态已被并发修改");
}
}
}
public void markReleased(long tenantId, long inboundId, List<Long> reservationIds) {
for (Long reservationId : reservationIds) {
if (reservationId == null || mapper.markReleased(tenantId, inboundId, reservationId) != 1) {
throw new IllegalStateException("库位释放状态已被并发修改");
}
}
}
/**
* 依据已经锁定且已扣减本次拣货数量的父单分配,预先固化子单快照。
* 仓库 transfer 是本地事务最后一个外部动作,成功后不再执行本地写入,避免跨服务重试时目标子单变化。
*/
@Transactional
public void copyRemainingToChild(long tenantId, long inboundId, List<InboundPalletAllocationEntity> parent) {
mapper.delete(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
.eq(InboundPalletAllocationEntity::getTenantId, tenantId)
.eq(InboundPalletAllocationEntity::getInboundId, inboundId));
LocalDateTime now = LocalDateTime.now();
for (InboundPalletAllocationEntity source : parent) {
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);
value.setAllocationNo(source.getAllocationNo());
value.setWarehouseId(source.getWarehouseId());
value.setWarehouseResourceTenantId(source.getWarehouseResourceTenantId());
value.setPalletId(source.getPalletId());
value.setPalletCode(source.getPalletCode());
value.setPalletCapacityCbm(source.getPalletCapacityCbm());
value.setAllocatedVolumeCbm(source.getAllocatedVolumeCbm());
value.setAllocatedPackageCount(remaining);
value.setPickedPackageCount(0);
value.setLocationId(source.getLocationId());
value.setLocationCode(source.getLocationCode());
value.setReservationId(source.getReservationId());
value.setReservationNo(source.getReservationNo());
value.setAllocationStatus("CONFIRMED");
value.setCreatedAt(now);
value.setUpdatedAt(now);
mapper.insert(value);
}
}
private static String normalize(String state) {
return "OCCUPIED".equals(state) ? "CONFIRMED" : state;
}
@@ -99,12 +99,14 @@ public class LoadingPickRepository {
.eq(LoadingPickItemEntity::getLoadingPlanItemId, planItemId));
}
public int pick(long itemId, int count, int version, long warehouseId) {
return items.pick(itemId, count, version, warehouseId);
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 partial(long itemId, int finalCount, int version, long warehouseId) {
return items.partial(itemId, finalCount, 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 skip(long itemId, int version, long warehouseId) {
@@ -119,6 +121,18 @@ public class LoadingPickRepository {
return items.unfinishedCount(planId) == 0;
}
/**
* 跳拣释放候选前,必须保证同一入库单没有已拣或部分拣货的明细,避免把仍在装柜的货物
* 重新暴露给智能装柜。
*/
public boolean hasPickedOrPartialItem(long taskId, Long inboundId) {
if (inboundId == null) {
return false;
}
return items(taskId).stream().anyMatch(item -> inboundId.equals(item.getInboundId())
&& ("PICKED".equals(item.getItemStatus()) || "PARTIAL".equals(item.getItemStatus())));
}
public LoadingPickHistoryEntity history(String operationId) {
return histories.selectOne(new LambdaQueryWrapper<LoadingPickHistoryEntity>()
.eq(LoadingPickHistoryEntity::getOperationId, operationId));
@@ -31,6 +31,7 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.List;
import java.util.HashSet;
/** 装柜中 PDA 拣货任务及明细同步。 */
@Service
@@ -39,6 +40,8 @@ public class LoadingPickService {
private LoadingPickRepository repository;
@Resource
private LoadingListService loadingListService;
@Resource
private PdaPickSettlementService settlementService;
@Transactional
public void begin(long planId, String containerNo, long operatorId, String operatorName,
@@ -138,9 +141,10 @@ 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());
+ "|" + request.pickedPackageCount() + "|" + release.mode() + "|" + release.json());
requireNoTaskOperation(operationId);
LoadingPickHistoryEntity replay = repository.history(operationId);
if (replay != null) {
@@ -155,7 +159,8 @@ public class LoadingPickService {
if (item == null) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在");
}
if (repository.pick(itemId, request.pickedPackageCount(), request.version(), request.warehouseId()) != 1) {
if (repository.pick(itemId, request.pickedPackageCount(), release.mode(), release.json(),
request.version(), request.warehouseId()) != 1) {
throw conflict("拣货数量超过计划箱数或明细版本已变化");
}
LoadingPickHistoryEntity history = new LoadingPickHistoryEntity();
@@ -168,16 +173,17 @@ public class LoadingPickService {
history.setOperatorId(operatorId);
history.setOperatorName(operatorName);
repository.insert(history);
touchAndSync(task, operatorId, operatorName);
touchSettleFinishAndSync(task, operatorId, operatorName);
return view(requiredTask(taskId), request.warehouseId());
}
@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());
+ "|" + request.finalPickedPackageCount() + "|" + release.mode() + "|" + release.json());
requireNoTaskOperation(operationId);
LoadingPickHistoryEntity replay = repository.history(operationId);
if (replay != null) {
@@ -186,12 +192,13 @@ public class LoadingPickService {
}
LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId);
requireWarehouseItem(taskId, itemId, request.warehouseId());
if (repository.partial(itemId, request.finalPickedPackageCount(), request.version(), request.warehouseId()) != 1) {
if (repository.partial(itemId, request.finalPickedPackageCount(), release.mode(), release.json(),
request.version(), request.warehouseId()) != 1) {
throw conflict("部分拣货箱数必须大于已拣箱数且小于计划箱数,或明细版本已变化");
}
insertHistory(taskId, itemId, operationId, requestHash, "PARTIAL",
request.finalPickedPackageCount(), null, operatorId, operatorName);
touchAndSync(task, operatorId, operatorName);
touchSettleFinishAndSync(task, operatorId, operatorName);
return view(requiredTask(taskId), request.warehouseId());
}
@@ -208,13 +215,16 @@ public class LoadingPickService {
return view(requiredTask(taskId), request.warehouseId());
}
LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId);
requireWarehouseItem(taskId, itemId, request.warehouseId());
LoadingPickItemEntity item = requireWarehouseItem(taskId, itemId, request.warehouseId());
if (repository.hasPickedOrPartialItem(taskId, item.getInboundId())) {
throw conflict("同一入库单已存在已拣或部分拣货明细,不能跳拣并恢复智能装柜候选");
}
if (repository.skip(itemId, request.version(), request.warehouseId()) != 1) {
throw conflict("跳拣仅允许未开始拣货的待拣明细,或明细版本已变化");
}
insertHistory(taskId, itemId, operationId, requestHash, "SKIP", 0, reason,
operatorId, operatorName);
touchAndSync(task, operatorId, operatorName);
touchSettleFinishAndSync(task, operatorId, operatorName);
return view(requiredTask(taskId), request.warehouseId());
}
@@ -300,6 +310,16 @@ public class LoadingPickService {
repository.update(item);
}
private void settleAndFinish(LoadingPickTaskEntity task, long operatorId, String operatorName) {
if (!repository.allTaskItemsResolved(task.getId())) {
return;
}
settlementService.settle(task.getId(), operatorId, operatorName);
if (repository.allPlanItemsPicked(task.getLoadingPlanId())) {
loadingListService.finishFromPda(task.getLoadingPlanId(), task.getId(), operatorId, operatorName);
}
}
private LoadingPickTaskEntity requiredTask(long taskId) {
LoadingPickTaskEntity task = repository.task(taskId);
if (task == null) {
@@ -333,10 +353,12 @@ public class LoadingPickService {
return task;
}
private void requireWarehouseItem(long taskId, long itemId, long warehouseId) {
if (repository.item(taskId, itemId, warehouseId) == null) {
private LoadingPickItemEntity requireWarehouseItem(long taskId, long itemId, long warehouseId) {
LoadingPickItemEntity item = repository.item(taskId, itemId, warehouseId);
if (item == null) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在或不属于当前仓库");
}
return item;
}
private Task view(LoadingPickTaskEntity task, Long warehouseId) {
@@ -360,10 +382,15 @@ public class LoadingPickService {
workflowStatus, task.getVersion(), assignment, review, items);
}
private void touchAndSync(LoadingPickTaskEntity task, long operatorId, String operatorName) {
/**
* 先触碰仍处于 LOADING 的任务,再结算并推进装柜计划。计划完成触发器会同步把任务改为
* COMPLETED,因此绝不能在 finishFromPda 之后再以 LOADING 状态触碰任务。
*/
private void touchSettleFinishAndSync(LoadingPickTaskEntity task, long operatorId, String operatorName) {
if (repository.touchTask(task.getId(), task.getVersion()) != 1) {
throw conflict("PDA任务版本已变化");
}
settleAndFinish(task, operatorId, operatorName);
repository.syncInboundProjections(task.getId(), operatorId, operatorName);
}
@@ -434,6 +461,25 @@ public class LoadingPickService {
}
}
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 ReleaseSelection(String mode, String json) {}
private static String trim(String value) {
return value == null ? null : value.trim();
}
@@ -114,7 +114,8 @@ public class MerchantInboundBillingService {
}
private static boolean isPublished(InboundOrderEntity inbound) {
return InboundProcessStatus.WAIT_SPLIT.equals(inbound.getProcessStatus())
return (InboundProcessStatus.INBOUNDED.equals(inbound.getProcessStatus())
|| "WAIT_SPLIT".equals(inbound.getProcessStatus()))
&& "APPROVED".equals(inbound.getMeasureReviewStatus())
&& "CONFIRMED".equals(inbound.getFeeStatus());
}
@@ -6,6 +6,7 @@ import com.baiyue.firstleg.entity.LoadingPickItemEntity;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/** PDA任务结束时的纯结算规则,不包含数据库写入。 */
@@ -26,8 +27,14 @@ final class PdaPickSettlementPlan {
.filter(item -> "PARTIAL".equals(item.getItemStatus()))
.map(PdaPickSettlementPlan::splitLine)
.collect(Collectors.groupingBy(SplitLine::inboundId));
Set<Long> inboundsWithPickedOrPartial = items.stream()
.filter(item -> "PICKED".equals(item.getItemStatus()) || "PARTIAL".equals(item.getItemStatus()))
.map(LoadingPickItemEntity::getInboundId)
.filter(java.util.Objects::nonNull)
.collect(java.util.stream.Collectors.toSet());
List<Long> skippedPlanItemIds = items.stream()
.filter(item -> "SKIPPED".equals(item.getItemStatus()))
.filter(item -> !inboundsWithPickedOrPartial.contains(item.getInboundId()))
.map(LoadingPickItemEntity::getLoadingPlanItemId)
.toList();
return new Resolution(partialByInbound, skippedPlanItemIds);
@@ -0,0 +1,368 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehousePalletLocationClient;
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
import com.baiyue.firstleg.mapper.InboundSplitSettlementMapper;
import com.baiyue.firstleg.mapper.InboundSplitSettlementMapper.MeasurementItemRow;
import com.baiyue.firstleg.mapper.InboundSplitSettlementMapper.ParentRow;
import com.baiyue.firstleg.repository.InboundPalletAllocationRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/** PDA 任务结算:未装柜箱数生成二级子入库单,并转移其库位资源归属。 */
@Service
public class PdaPickSettlementService {
private static final long PLATFORM_WAREHOUSE_RESOURCE_TENANT_ID = 0L;
private static final String SOURCE_SERVICE = "firstleg-service";
@Resource
private LoadingPickRepository picks;
@Resource
private InboundSplitSettlementMapper splits;
@Resource
private InboundPalletAllocationRepository allocations;
@Resource
private WarehousePalletLocationClient warehouse;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
/**
* 同一父单最多生成一张子单。子单承接该父单所有未装柜数量,而不是只承接 PARTIAL 行的差额。
* 纯跳单才恢复智能装柜候选;同单存在已拣或部分拣货时,跳单行随未装柜数量一并归入子单。
*/
@Transactional
public void settle(long taskId, long operatorId, String operatorName) {
if (!picks.allTaskItemsResolved(taskId)) {
return;
}
List<LoadingPickItemEntity> taskItems = picks.items(taskId);
PdaPickSettlementPlan.Resolution resolution = PdaPickSettlementPlan.resolve(taskItems);
for (Long planItemId : resolution.skippedPlanItemIds()) {
// 重复 settle(例如复核)时该行已逻辑删除,0 行是可接受的幂等结果。
int changed = splits.removeSkippedPlanItem(planItemId, operatorId, operatorName);
if (changed != 0 && changed != 1) {
throw conflict("跳单明细已被并发修改,请刷新后重试");
}
}
Map<Long, Integer> pickedByInbound = pickedByInbound(taskItems);
Set<Long> newlySplitInbounds = new java.util.HashSet<>();
pickedByInbound.entrySet().stream().sorted(Map.Entry.comparingByKey())
.forEach(entry -> {
if (settleInbound(entry.getKey(), entry.getValue(), taskItems, taskId, operatorId, operatorName)) {
newlySplitInbounds.add(entry.getKey());
}
});
for (List<PdaPickSettlementPlan.SplitLine> lines : resolution.partialByInbound().values()) {
for (PdaPickSettlementPlan.SplitLine line : lines) {
if (newlySplitInbounds.contains(line.inboundId())) {
requireAffected(splits.convergePartialPlanItem(line.loadingPlanItemId(), operatorId, operatorName),
"部分拣货装柜明细");
}
}
}
}
private boolean settleInbound(long parentInboundId, int pickedPackageCount,
List<LoadingPickItemEntity> taskItems, long taskId,
long operatorId, String operatorName) {
ParentRow parent = splits.lockParent(parentInboundId);
if (parent == null || parent.tenantId() == null || parent.tenantId() <= 0
|| !"MASTER".equals(parent.inboundRole()) || !"INBOUNDED".equals(parent.processStatus())) {
throw conflict("待拆分主入库单不在可拆分状态");
}
Long existingChild = splits.childId(parent.tenantId(), parentInboundId);
if (existingChild != null) {
return false;
}
int parentAvailable = count(parent.packageCount(), "主入库单可用箱数");
if (pickedPackageCount <= 0 || pickedPackageCount > parentAvailable) {
throw conflict("本次已拣箱数与主入库单可用箱数不一致");
}
int childPackageCount = parentAvailable - pickedPackageCount;
List<InboundPalletAllocationEntity> confirmedAllocations = new ArrayList<>(allocations.lockConfirmed(
parent.tenantId(), parentInboundId));
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();
if (allocationRemaining != childPackageCount) {
throw conflict("父入库单库位剩余箱数与未装柜箱数不一致");
}
long warehouseId = warehouseId(confirmedAllocations);
if (childPackageCount == 0) {
if (!transferItems.isEmpty() || releasedReservationIds.isEmpty()) {
throw conflict("全量拣货后库位资源结算不完整");
}
allocations.markReleased(parent.tenantId(), parentInboundId, releasedReservationIds);
settleWarehouseResources(parentInboundId, 0L, "", taskId, operatorId, operatorName,
warehouseId, parentAvailable, 0, releasedReservationIds, List.of());
return false;
}
List<MeasurementItemRow> measurements = splits.lockLatestApprovedItemsForInbound(
parent.tenantId(), parentInboundId);
if (measurements.isEmpty()) {
throw conflict("主入库单缺少已审核测量明细,不能生成子入库单");
}
long sourceMeasurementId = measurements.get(0).measurementId();
if (measurements.stream().anyMatch(item -> !Objects.equals(sourceMeasurementId, item.measurementId()))) {
throw conflict("主入库单存在多个已审核测量批次,不能自动拆分");
}
Map<Long, Integer> pickedByDetail = pickedByDetail(taskItems, parentInboundId);
int pickedByMeasurement = pickedByDetail.values().stream().mapToInt(Integer::intValue).sum();
if (pickedByMeasurement != pickedPackageCount) {
throw conflict("PDA 已拣明细与入库单已拣箱数不一致");
}
List<MeasurementSplitLine> measurementLines = measurementLines(measurements, pickedByDetail);
int measuredRemaining = measurementLines.stream().mapToInt(MeasurementSplitLine::remainingPackageCount).sum();
if (measuredRemaining != childPackageCount) {
throw conflict("主入库单测量明细剩余箱数与未装柜箱数不一致");
}
if (transferItems.isEmpty()) {
throw conflict("父入库单可转移库位剩余箱数与未装柜箱数不一致");
}
long childId = IdWorker.getId();
long batchId = IdWorker.getId();
String operationId = "PDA-SPLIT-" + taskId + '-' + parentInboundId;
String idempotencyKey = "pda-task-" + taskId;
String childCode = childInboundCode(parent.inboundCode(), childId);
int parentVersion = parent.version() == null ? 0 : parent.version();
requireAffected(splits.cloneChild(parent.tenantId(), parentInboundId, childId, childCode, childPackageCount,
operatorId, operatorName), "子入库单");
requireAffected(splits.insertBatch(batchId, parent.tenantId(), parentInboundId, operationId,
idempotencyKey, parentVersion, childPackageCount, "PDA拣货后未装柜箱数", operatorId, operatorName), "拆分批次");
long childMeasurementId = IdWorker.getId();
requireAffected(splits.cloneMeasurement(parent.tenantId(), parentInboundId, sourceMeasurementId,
childId, childMeasurementId, batchId, childPackageCount, operatorId), "子入库测量批次");
int lineNo = 1;
for (MeasurementSplitLine line : measurementLines) {
if (line.remainingPackageCount() <= 0) {
continue;
}
long splitItemId = IdWorker.getId();
requireAffected(splits.insertSplitItem(splitItemId, parent.tenantId(), batchId, parentInboundId,
childId, lineNo, line.item().inboundDetailId(), sourceMeasurementId,
line.item().measurementItemId(), line.remainingPackageCount()), "拆分明细");
requireAffected(splits.cloneMeasurementItem(parent.tenantId(), sourceMeasurementId,
line.item().measurementItemId(), childMeasurementId, splitItemId, IdWorker.getId(), lineNo,
line.remainingPackageCount()), "子入库测量明细");
lineNo++;
}
requireAffected(splits.updateSourceMeasurementPackageCount(parent.tenantId(), parentInboundId,
sourceMeasurementId, pickedPackageCount), "主入库测量批次");
for (MeasurementSplitLine line : measurementLines) {
requireAffected(splits.updateSourceMeasurementItemPackageCount(parent.tenantId(), sourceMeasurementId,
line.item().measurementItemId(), line.pickedPackageCount()), "主入库测量明细");
}
requireAffected(splits.markParentPartiallySplit(parent.tenantId(), parentInboundId, childPackageCount,
operatorId, parentVersion), "主入库单拆分状态");
requireAffected(splits.applyBatch(parent.tenantId(), batchId), "拆分批次生效");
requireAffected(splits.insertHistory(IdWorker.getId(), parent.tenantId(), batchId,
operationId + "-APPLY", "{\"childInboundId\":" + childId + ",\"packageCount\":"
+ childPackageCount + "}", operatorId, operatorName, "PDA拣货自动生成子入库单"), "拆分审计");
allocations.copyRemainingToChild(parent.tenantId(), childId, confirmedAllocations);
allocations.markTransferred(parent.tenantId(), parentInboundId, confirmedAllocations,
releasedReservationIds);
allocations.markReleased(parent.tenantId(), parentInboundId, releasedReservationIds);
settleWarehouseResources(parentInboundId, childId, childCode, taskId, operatorId, operatorName,
warehouseId, parentAvailable, childPackageCount, releasedReservationIds, transferItems);
return true;
}
private static Map<Long, Integer> pickedByInbound(List<LoadingPickItemEntity> taskItems) {
return taskItems.stream().filter(item -> "PICKED".equals(item.getItemStatus())
|| "PARTIAL".equals(item.getItemStatus()))
.collect(Collectors.groupingBy(LoadingPickItemEntity::getInboundId,
Collectors.summingInt(item -> count(item.getPickedPackageCount(), "PDA已拣箱数"))));
}
private static Map<Long, Integer> pickedByDetail(List<LoadingPickItemEntity> taskItems, long inboundId) {
Map<Long, Integer> result = new HashMap<>();
for (LoadingPickItemEntity item : taskItems) {
if (item.getInboundId() == null || item.getInboundId() != inboundId
|| !("PICKED".equals(item.getItemStatus()) || "PARTIAL".equals(item.getItemStatus()))) {
continue;
}
if (item.getInboundDetailId() == null) {
throw conflict("PDA 已拣明细缺少入库单明细标识");
}
result.merge(item.getInboundDetailId(), count(item.getPickedPackageCount(), "PDA已拣箱数"), Integer::sum);
}
return result;
}
private static List<MeasurementSplitLine> measurementLines(List<MeasurementItemRow> measurements,
Map<Long, Integer> pickedByDetail) {
Set<Long> measuredDetailIds = measurements.stream().map(MeasurementItemRow::inboundDetailId)
.collect(Collectors.toSet());
if (!measuredDetailIds.containsAll(pickedByDetail.keySet())) {
throw conflict("PDA 已拣明细未匹配已审核测量明细");
}
List<MeasurementSplitLine> result = new ArrayList<>();
for (MeasurementItemRow item : measurements) {
int measured = count(item.packageCount(), "测量明细箱数");
int picked = pickedByDetail.getOrDefault(item.inboundDetailId(), 0);
if (picked > measured) {
throw conflict("PDA 已拣箱数超过测量明细箱数");
}
result.add(new MeasurementSplitLine(item, picked, measured - picked));
}
return result;
}
private void consumePickedAllocations(long accountId, long inboundId,
List<InboundPalletAllocationEntity> confirmed, int pickedCount) {
int toConsume = pickedCount;
for (InboundPalletAllocationEntity allocation : confirmed) {
int allocated = count(allocation.getAllocatedPackageCount(), "库位已分配箱数");
int picked = allocation.getPickedPackageCount() == null ? 0 : allocation.getPickedPackageCount();
int available = allocated - picked;
if (available <= 0 || toConsume == 0) {
continue;
}
int consumed = Math.min(available, toConsume);
if (allocation.getId() == null) {
throw conflict("父入库单库位分配快照缺少标识");
}
allocations.consume(accountId, inboundId, allocation.getId(), consumed);
allocation.setPickedPackageCount(picked + consumed);
toConsume -= consumed;
}
if (toConsume != 0) {
throw conflict("父入库单已确认库位可用箱数不足以扣减本次已拣箱数");
}
}
private static List<Long> releasedReservationIds(List<InboundPalletAllocationEntity> allocations) {
return allocations.stream().filter(item -> remaining(item) == 0).map(PdaPickSettlementService::reservationId)
.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,请先重新确认托盘库位分配");
}
return allocation.getReservationId();
}
private static long warehouseId(List<InboundPalletAllocationEntity> allocations) {
if (allocations.isEmpty() || allocations.get(0).getWarehouseId() == null || allocations.get(0).getWarehouseId() <= 0
|| allocations.stream().anyMatch(item -> !Objects.equals(allocations.get(0).getWarehouseId(), item.getWarehouseId()))) {
throw conflict("PDA结算要求父入库单分配资源属于同一仓库");
}
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;
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));
} catch (FeignException exception) {
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())) {
ResultCode code = response == null ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.of(response.code());
throw new BizException(code, response == null || response.message() == null
? "仓储服务原子结算PDA库位资源失败" : response.message());
}
}
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);
}
private static String childInboundCode(String parentCode, long childId) {
String prefix = parentCode == null ? "INBOUND" : parentCode.trim();
if (prefix.isEmpty()) {
prefix = "INBOUND";
}
String suffix = "-S" + Long.toUnsignedString(childId).substring(0, 6);
return prefix.substring(0, Math.min(prefix.length(), 40 - suffix.length())) + suffix;
}
private static int count(Integer value, String resource) {
if (value == null || value < 0) {
throw conflict(resource + "不合法");
}
return value;
}
private static void requireAffected(int affected, String resource) {
if (affected != 1) {
throw conflict(resource + "已被并发修改,请刷新后重试");
}
}
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
private record MeasurementSplitLine(MeasurementItemRow item, int pickedPackageCount,
int remainingPackageCount) {
}
}
@@ -28,7 +28,9 @@ import java.util.stream.Collectors;
@Service
public class UnifiedInboundQueryService {
private static final Set<String> MAIN_STATUSES = Set.of(
"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED");
"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "LOADING", "LOADED", "DELIVERED");
/** 仅兼容旧客户端筛选值,响应和 OpenAPI 均只使用 INBOUNDED。 */
private static final String LEGACY_WAIT_SPLIT = "WAIT_SPLIT";
private static final Set<String> REVIEW_STATUSES = Set.of("UNRECORDED", "PENDING", "APPROVED", "REJECTED");
@Resource private InboundOrderRepository inbounds;
@@ -134,8 +136,7 @@ public class UnifiedInboundQueryService {
if (status == null) return null;
return switch (status) {
case "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED" -> "WAIT_MEASURE";
case "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED" -> "INBOUNDED";
case "WAIT_SPLIT" -> "WAIT_SPLIT";
case "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED", LEGACY_WAIT_SPLIT -> "INBOUNDED";
case "LOADING", "LOADED", "DELIVERED" -> status;
default -> null;
};
@@ -146,10 +147,9 @@ public class UnifiedInboundQueryService {
return switch (mainStatus) {
case "PENDING_REVIEW" -> "APPROVE_ORDER";
case "WAIT_MEASURE" -> "MEASURE_PENDING".equals(processStatus) ? "CONFIRM_MEASUREMENT" : null;
case "INBOUNDED" -> !"INBOUNDED".equals(processStatus) ? null
case "INBOUNDED" -> !("INBOUNDED".equals(processStatus) || LEGACY_WAIT_SPLIT.equals(processStatus)) ? null
: "CONFIRMED".equals(preloadStatus) ? null
: preloadStatus == null ? "SMART_SPLIT" : "CONFIRM_PRELOAD";
case "WAIT_SPLIT" -> null;
case "LOADING" -> "COMPLETE_LOADING";
case "LOADED" -> "COMPLETE_DELIVERY";
case "DELIVERED" -> null;
@@ -165,8 +165,7 @@ public class UnifiedInboundQueryService {
if (!StringUtils.hasText(mainStatus)) return List.of();
return switch (mainStatus) {
case "WAIT_MEASURE" -> List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED");
case "INBOUNDED" -> List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED");
case "WAIT_SPLIT" -> List.of("WAIT_SPLIT");
case "INBOUNDED", LEGACY_WAIT_SPLIT -> List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED", LEGACY_WAIT_SPLIT);
case "LOADING", "LOADED", "DELIVERED" -> List.of(mainStatus);
default -> List.of("__NO_MATCH__");
};
@@ -183,7 +182,9 @@ public class UnifiedInboundQueryService {
}
private void validate(UnifiedInboundQuery query) {
if (StringUtils.hasText(query.mainStatus()) && !MAIN_STATUSES.contains(query.mainStatus())) {
if (StringUtils.hasText(query.mainStatus())
&& !MAIN_STATUSES.contains(query.mainStatus())
&& !LEGACY_WAIT_SPLIT.equals(query.mainStatus())) {
throw new com.baiyue.common.core.exception.BizException(
com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "统一订单主状态不合法");
}