This commit is contained in:
2026-08-10 09:11:57 +08:00
67 changed files with 3329 additions and 208 deletions
@@ -7,6 +7,73 @@
-- 使用 MySQL Client、Workbench、DataGrip 或 Navicat 的“运行脚本”完整执行,客户端必须支持 DELIMITER。
SET NAMES utf8mb4;
SET SESSION sql_mode = 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION';
-- 来源:warehouse\src\main\resources\db\phase3-pallet-location-transfer-idempotency.sql
CREATE DATABASE IF NOT EXISTS baiyue_warehouse
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE baiyue_warehouse;
CREATE TABLE IF NOT EXISTS wh_pallet_location_transfer_operation (
id BIGINT UNSIGNED NOT NULL COMMENT '调拨幂等操作ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓储公共资源域,固定为0',
source_service VARCHAR(40) NOT NULL COMMENT '来源服务',
source_business_id BIGINT UNSIGNED NOT NULL COMMENT '来源入仓单ID',
target_business_id BIGINT UNSIGNED NOT NULL COMMENT '目标子入仓单ID',
idempotency_key VARCHAR(128) NOT NULL COMMENT '调用方幂等键',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求SHA-256摘要',
operation_status VARCHAR(20) NOT NULL DEFAULT 'PROCESSING' COMMENT 'PROCESSING处理中、COMPLETED已完成',
response_snapshot JSON NULL COMMENT '完成后的调拨结果快照,用于重复请求重放',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
completed_at DATETIME(3) NULL COMMENT '完成时间',
PRIMARY KEY (id),
UNIQUE KEY uk_pallet_location_transfer_idempotency
(tenant_id, source_service, source_business_id, target_business_id, idempotency_key),
KEY idx_pallet_location_transfer_source (tenant_id, source_service, source_business_id, operation_status)
) ENGINE=InnoDB COMMENT='托盘库位归属转移的持久化幂等操作记录';
-- 来源:warehouse\src\main\resources\db\phase3-pallet-location-release-idempotency.sql
CREATE TABLE IF NOT EXISTS wh_pallet_location_release_operation (
id BIGINT UNSIGNED NOT NULL COMMENT '精确释放幂等操作ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓储公共资源作用域,固定为0',
source_service VARCHAR(40) NOT NULL COMMENT '来源服务',
business_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
idempotency_key VARCHAR(128) NOT NULL COMMENT '调用方幂等键',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求SHA-256摘要',
operation_status VARCHAR(20) NOT NULL DEFAULT 'PROCESSING' COMMENT 'PROCESSING处理中,COMPLETED已完成',
response_snapshot JSON NULL COMMENT '完成后的精确释放结果快照,用于重复请求重放',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
completed_at DATETIME(3) NULL COMMENT '完成时间',
PRIMARY KEY (id),
UNIQUE KEY uk_pallet_location_release_idempotency
(tenant_id, source_service, business_id, idempotency_key),
KEY idx_pallet_location_release_business (tenant_id, source_service, business_id, operation_status)
) ENGINE=InnoDB COMMENT='PDA已拣满托盘库位精确释放的持久化幂等操作记录';
-- 来源:warehouse\src\main\resources\db\phase3-pallet-location-settlement-idempotency.sql
CREATE TABLE IF NOT EXISTS wh_pallet_location_settlement_operation (
id BIGINT UNSIGNED NOT NULL COMMENT 'PDA资源结算幂等操作ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓储公共资源作用域,固定为0',
source_service VARCHAR(40) NOT NULL COMMENT '来源服务',
source_business_id BIGINT UNSIGNED NOT NULL COMMENT '主入库单ID',
target_business_id BIGINT UNSIGNED NOT NULL COMMENT '二级子入库单ID;全拣完成时为0,仅作审计快照',
idempotency_key VARCHAR(128) NOT NULL COMMENT 'PDA结算幂等键',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求SHA-256摘要',
operation_status VARCHAR(20) NOT NULL DEFAULT 'PROCESSING' COMMENT 'PROCESSING处理中,COMPLETED已完成',
response_snapshot JSON NULL COMMENT '完成后的释放和转移组合结果快照,用于重复请求重放',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
completed_at DATETIME(3) NULL COMMENT '完成时间',
PRIMARY KEY (id),
UNIQUE KEY uk_pallet_location_settlement_idempotency
(tenant_id, source_service, source_business_id, idempotency_key),
KEY idx_pallet_location_settlement_source
(tenant_id, source_service, source_business_id, operation_status)
) ENGINE=InnoDB COMMENT='PDA拣货资源原子结算的持久化幂等操作记录';
-- 兼容已执行过旧脚本的测试库:目标子单不属于幂等键,同一来源单据和幂等键只能对应一条请求。
ALTER TABLE wh_pallet_location_settlement_operation
DROP INDEX uk_pallet_location_settlement_idempotency,
ADD UNIQUE KEY uk_pallet_location_settlement_idempotency
(tenant_id, source_service, source_business_id, idempotency_key);
SELECT VERSION() AS mysql_version, 'START' AS unified_migration_status;
-- ============================================================================
@@ -8632,6 +8699,14 @@ DELIMITER ;
CALL assert_phase3_final_schema();
DROP PROCEDURE IF EXISTS assert_phase3_final_schema;
-- WAIT_SPLIT 是二期当前有效终点;统一迁移不得改写主表或历史状态。
-- 二、三期对外统一使用 INBOUNDED;历史 WAIT_SPLIT 仅由查询适配为 INBOUNDED,统一迁移不改写主表或历史状态。
SELECT 'PASS' AS migration_status,
'统一增量全部执行完成;箱号映射、单箱测量、三期订舱、拖车预约、出仓、报关、船期、装柜模型桥接、PDA收敛、费用、公共资源及可靠审计结构校验通过' AS message;
-- PDA 部分拣货释放策略:NULL/AUTO 按比例释放,MANUAL + [] 表示人工明确不释放。
USE baiyue_firstleg;
ALTER TABLE fl_loading_pick_item
ADD COLUMN IF NOT EXISTS release_mode VARCHAR(10) NULL COMMENT 'AUTO按比例释放,MANUAL按人工选择释放' AFTER item_status,
ADD COLUMN IF NOT EXISTS release_location_ids JSON NULL COMMENT '人工选择释放库位ID;空数组表示明确释放0个' AFTER release_mode;
ALTER TABLE fl_inbound_pallet_allocation
ADD COLUMN IF NOT EXISTS reservation_id BIGINT UNSIGNED NULL COMMENT '仓库预占资源唯一ID,A0共享场景用于区分托盘' AFTER location_code;
+9
View File
@@ -26,6 +26,15 @@
- 第三方回传必须签名、时间窗、防重放、请求哈希,并绑定订单货物和 File 证据;不得信任外部客户字段。
- 测量审核→计费确认→费用发布→入库完成不得跳过门禁;状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。
## 三期装柜与 PDA 资源结算
- 智能装柜候选只能按同一国内中转仓合柜;推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。
- `releaseMode=AUTO` 时,释放资源数为 `ceil(实际拣货箱数 × 原资源数 / 原可用箱数)`;`MANUAL` 按 `locationIds` 选择,空数组表示明确释放 0 个。未传模式时,`locationIds=null` 兼容为 AUTO。
- 头程快照必须保存仓库 `reservation_id`。A0 是共享临时库位,多个托盘可具有相同 `location_id`,跨服务结算、转移、释放必须使用 `reservationId`,不得把 `locationId` 当作唯一资源标识。
- 部分拣货资源结算必须调用 Warehouse 的 `/internal/warehouse/v1/pallet-locations/settlements` 原子接口;禁止拆成独立释放和转移调用。全部释放但子单仍有数量时,由仓库服务重新选择同仓普通库位,不足才落 A0。
- 数据库字段变更同时维护 `src/main/resources/db/phase3-pda-release-allocation-migration.sql` 和 `../deploy/sql/头程数据库统一增量-MySQL8.sql`。
## 分期导航
- 一期:履约任务与分配;先读 `../../docs/一期/README.md`。
@@ -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, "统一订单主状态不合法");
}
@@ -646,6 +646,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_pallet_allocation (
allocated_package_count INT UNSIGNED NULL COMMENT '本托盘分配箱数',
location_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务库位ID',
location_code VARCHAR(50) NOT NULL COMMENT '库位编码快照',
reservation_id BIGINT UNSIGNED NULL COMMENT '仓库预占资源唯一ID,A0共享场景用于区分托盘',
reservation_no VARCHAR(64) NOT NULL COMMENT '仓库库位预占单号',
allocation_status VARCHAR(24) NOT NULL DEFAULT 'RESERVED' COMMENT '分配状态',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -654,6 +655,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_pallet_allocation (
UNIQUE KEY uk_pallet_allocation_no (tenant_id, inbound_id, allocation_no),
UNIQUE KEY uk_pallet_allocation_pallet (tenant_id, inbound_id, pallet_id),
KEY idx_allocation_location (tenant_id, warehouse_id, location_id, allocation_status),
KEY idx_allocation_reservation (reservation_id),
CONSTRAINT fk_allocation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='头程侧托盘、库位和仓库预占结果引用';
@@ -1,9 +1,10 @@
-- WAIT_SPLIT 是二期当前有效终点。本脚本仅校验兼容结构,禁止改写主表或历史状态。
-- 二、三期对外统一使用 INBOUNDED。历史 WAIT_SPLIT 仅由查询适配为 INBOUNDED,
-- 本脚本不改写业务主表或历史状态,避免影响既有审计记录。
USE baiyue_firstleg;
DROP PROCEDURE IF EXISTS assert_wait_split_compatibility;
DROP PROCEDURE IF EXISTS assert_inbounded_compatibility;
DELIMITER $$
CREATE PROCEDURE assert_wait_split_compatibility()
CREATE PROCEDURE assert_inbounded_compatibility()
BEGIN
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_order'
@@ -12,9 +13,9 @@ BEGIN
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_status_history'
AND column_name IN ('from_status', 'to_status')) <> 2 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'WAIT_SPLIT 兼容校验失败:入库状态字段不完整';
SET MESSAGE_TEXT = 'INBOUNDED 兼容校验失败:入库状态字段不完整';
END IF;
END$$
DELIMITER ;
CALL assert_wait_split_compatibility();
DROP PROCEDURE assert_wait_split_compatibility;
CALL assert_inbounded_compatibility();
DROP PROCEDURE assert_inbounded_compatibility;
@@ -0,0 +1,37 @@
-- 三期 PDA 部分拣货释放策略与 A0 共享资源唯一标识。
SET NAMES utf8mb4;
DROP PROCEDURE IF EXISTS phase3_pda_add_column;
DELIMITER $$
CREATE PROCEDURE phase3_pda_add_column(IN p_table VARCHAR(64), IN p_column VARCHAR(64), IN p_ddl TEXT)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = p_table AND column_name = p_column
) THEN
SET @phase3_pda_ddl = CONCAT('ALTER TABLE `', p_table, '` ADD COLUMN ', p_ddl);
PREPARE phase3_pda_stmt FROM @phase3_pda_ddl;
EXECUTE phase3_pda_stmt;
DEALLOCATE PREPARE phase3_pda_stmt;
END IF;
END$$
DELIMITER ;
CALL phase3_pda_add_column('fl_loading_pick_item', 'release_mode',
'`release_mode` VARCHAR(10) NULL COMMENT ''AUTO按比例释放,MANUAL按人工选择释放'' AFTER `item_status`');
CALL phase3_pda_add_column('fl_loading_pick_item', 'release_location_ids',
'`release_location_ids` JSON NULL COMMENT ''人工选择释放库位ID;空数组表示明确释放0个'' AFTER `release_mode`');
CALL phase3_pda_add_column('fl_inbound_pallet_allocation', 'reservation_id',
'`reservation_id` BIGINT UNSIGNED NULL COMMENT ''仓库预占资源唯一ID,A0共享场景用于区分托盘'' AFTER `location_code`');
SET @phase3_pda_index_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_pallet_allocation'
AND index_name = 'idx_allocation_reservation'),
'SELECT 1',
'ALTER TABLE fl_inbound_pallet_allocation ADD INDEX idx_allocation_reservation (reservation_id)');
PREPARE phase3_pda_index_stmt FROM @phase3_pda_index_ddl;
EXECUTE phase3_pda_index_stmt;
DEALLOCATE PREPARE phase3_pda_index_stmt;
DROP PROCEDURE IF EXISTS phase3_pda_add_column;
@@ -429,6 +429,8 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_item (
planned_package_count INT UNSIGNED NOT NULL COMMENT '计划拣货箱数;上层拆分按业务规则向上取整',
picked_package_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已拣货箱数',
item_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、PICKING、PICKED、PARTIAL、SKIPPED',
release_mode VARCHAR(10) NULL COMMENT 'AUTO按比例释放,MANUAL按人工选择释放',
release_location_ids JSON NULL COMMENT '人工选择释放库位ID;空数组表示明确释放0个',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
@@ -438,7 +438,7 @@ paths:
/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing:
get:
summary: 客户查询已完成入库结果和最新已发布费用
description: 需要 merchant:order:view 权限;客户只读查看,不需要确认费用,仅 WAIT_SPLIT + APPROVED + CONFIRMED 的二期终态可见。
description: 需要 merchant:order:view 权限;客户只读查看,不需要确认费用,仅 INBOUNDED + APPROVED + CONFIRMED 的二期终态可见。
parameters:
- {name: orderId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses:
@@ -54,7 +54,7 @@ class InboundOrderControllerContractTest {
.contains("[WAIT_MEASURE, WAIT_REVIEW, WAIT_FEE_SEND, INBOUNDED]")
.contains("platform:firstleg:inbound:list")
.doesNotContain("WAIT_CUSTOMER_CONFIRM")
.contains("WAIT_SPLIT");
.doesNotContain("WAIT_SPLIT");
assertThat(inboundList).contains("#/components/schemas/InboundOrderPageEnvelope");
assertThat(taskList).doesNotContain("InboundOrderPageEnvelope");
}
@@ -38,7 +38,7 @@ class MerchantInboundBillingControllerContractTest {
String openapi = new ClassPathResource("openapi/firstleg-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(openapi).contains("/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing:")
.contains("仅 WAIT_SPLIT + APPROVED + CONFIRMED 的二期终态可见");
.contains("INBOUNDED + APPROVED + CONFIRMED");
}
@Test
@@ -11,18 +11,18 @@ import static org.assertj.core.api.Assertions.assertThat;
class InboundStatusUnificationSqlContractTest {
@Test
void preservesWaitSplitWithoutDataRewrite() throws Exception {
void preservesHistoricalWaitSplitWithoutDataRewrite() throws Exception {
String module = normalize(new ClassPathResource("db/phase3-inbounded-status-unification.sql")
.getContentAsString(StandardCharsets.UTF_8)).trim();
String unified = normalize(Files.readString(unifiedPath(), StandardCharsets.UTF_8));
assertThat(module)
.contains("CREATE PROCEDURE assert_wait_split_compatibility()")
.contains("WAIT_SPLIT 是二期当前有效终点")
.contains("CREATE PROCEDURE assert_inbounded_compatibility()")
.contains("二、三期对外统一使用 INBOUNDED")
.doesNotContain("UPDATE fl_inbound_order")
.doesNotContain("UPDATE fl_inbound_status_history");
assertThat(unified)
.contains("WAIT_SPLIT 是二期当前有效终点;统一迁移不得改写主表或历史状态")
.contains("二、三期对外统一使用 INBOUNDED")
.doesNotContain("SET process_status = 'INBOUNDED'")
.doesNotContain("SET from_status = 'INBOUNDED'")
.doesNotContain("SET to_status = 'INBOUNDED'");
@@ -37,7 +37,8 @@ class LoadingPickItemMapperContractTest {
@Test
void pdaWritesAndReadsRequireWarehouseScope() throws Exception {
Method list = LoadingPickItemMapper.class.getDeclaredMethod("selectTaskItemsWithAccount", long.class, long.class);
Method pick = LoadingPickItemMapper.class.getDeclaredMethod("pick", long.class, int.class, int.class, long.class);
Method pick = LoadingPickItemMapper.class.getDeclaredMethod("pick", long.class, int.class,
String.class, String.class, int.class, long.class);
assertThat(list.getAnnotation(Select.class).value()[0])
.contains("booking.warehouse_id = #{warehouseId}")
@@ -41,8 +41,8 @@ class InboundOrderQueryServiceTest {
}
@Test
void shouldAcceptCurrentPhase2TerminalStatusFilters() {
InboundOrderQuery query = new InboundOrderQuery(null, null, "WAIT_SPLIT", null,
void shouldAcceptInboundedAsThePhase2TerminalStatusFilter() {
InboundOrderQuery query = new InboundOrderQuery(null, null, "INBOUNDED", null,
"APPROVED", "CONFIRMED", null, null, null, null, null, null, null,
null, null, null, null, 1, 200);
PageResult<InboundOrderSummary> expected = new PageResult<>(List.of(), 1, 200, 0, 0);
@@ -1,23 +1,27 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest;
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPickOperationEntity;
import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest;
import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
import com.baiyue.firstleg.entity.LoadingPickOperationEntity;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -28,6 +32,8 @@ class LoadingPickServiceTest {
private LoadingPickRepository repository;
@MockBean
private LoadingListService loadingListService;
@MockBean
private PdaPickSettlementService settlementService;
@Resource
private LoadingPickService service;
@@ -35,78 +41,77 @@ class LoadingPickServiceTest {
void keepsPlanLoadingWhenItemsRemain() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
when(repository.history("pick-1")).thenReturn(null);
when(repository.task(20L)).thenReturn(task);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.item(20L, 30L, 101L)).thenReturn(item);
when(repository.pick(30L, 2, 0, 101L)).thenReturn(1);
when(repository.allPlanItemsPicked(10L)).thenReturn(false);
when(repository.touchTask(20L, 0)).thenReturn(1);
when(repository.items(20L, 101L)).thenReturn(List.of(item));
preparePick(task, item, "pick-1");
when(repository.allTaskItemsResolved(20L)).thenReturn(false);
service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-1"), 9L, "operator");
verify(repository).touchTask(20L, 0);
verify(repository).syncInboundProjections(20L, 9L, "operator");
verify(settlementService, never()).settle(20L, 9L, "operator");
verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator");
}
@Test
void completesPlanWhenLastItemIsPicked() {
void touchesTaskBeforeCompletingLastPickedItem() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
when(repository.history("pick-2")).thenReturn(null);
when(repository.task(20L)).thenReturn(task);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.item(20L, 30L, 101L)).thenReturn(item);
when(repository.pick(30L, 2, 0, 101L)).thenReturn(1);
when(repository.items(20L, 101L)).thenReturn(List.of(item));
when(repository.touchTask(20L, 0)).thenReturn(1);
preparePick(task, item, "pick-2");
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-2"), 9L, "operator");
verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator");
InOrder order = inOrder(repository, settlementService, loadingListService);
order.verify(repository).touchTask(20L, 0);
order.verify(settlementService).settle(20L, 9L, "operator");
order.verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
order.verify(repository).syncInboundProjections(20L, 9L, "operator");
verify(repository).insert(any(LoadingPickHistoryEntity.class));
}
@Test
void completesPlanWhenLastItemIsPartiallyPicked() {
void touchesTaskBeforeCompletingLastPartialItem() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
when(repository.history("partial-1")).thenReturn(null);
when(repository.task(20L)).thenReturn(task);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.item(20L, 30L, 101L)).thenReturn(item);
when(repository.partial(30L, 1, 0, 101L)).thenReturn(1);
when(repository.items(20L, 101L)).thenReturn(List.of(item));
when(repository.touchTask(20L, 0)).thenReturn(1);
preparePartial(task, item, "partial-1");
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
service.partial(20L, 30L, new PartialPickRequest(101L, 1, List.of(), 0, "partial-1"), 9L, "operator");
service.partial(20L, 30L, new PartialPickRequest(101L, 1, List.of(), 0, "partial-1"),
9L, "operator");
verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator");
InOrder order = inOrder(repository, settlementService, loadingListService);
order.verify(repository).touchTask(20L, 0);
order.verify(settlementService).settle(20L, 9L, "operator");
order.verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
order.verify(repository).syncInboundProjections(20L, 9L, "operator");
}
@Test
void completesPlanWhenLastItemIsSkipped() {
void rejectsSkipWhenInboundAlreadyHasPickedItem() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
LoadingPickItemEntity skipped = item();
LoadingPickItemEntity picked = item();
picked.setId(31L);
picked.setItemStatus("PICKED");
picked.setPickedPackageCount(2);
when(repository.history("skip-1")).thenReturn(null);
when(repository.task(20L)).thenReturn(task);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.item(20L, 30L, 101L)).thenReturn(item);
when(repository.skip(30L, 0, 101L)).thenReturn(1);
when(repository.items(20L, 101L)).thenReturn(List.of(item));
when(repository.touchTask(20L, 0)).thenReturn(1);
when(repository.item(20L, 30L, 101L)).thenReturn(skipped);
when(repository.hasPickedOrPartialItem(20L, 50L)).thenReturn(true);
service.skip(20L, 30L, new SkipRequest(101L, 0, "skip-1", "fixture unavailable"), 9L, "operator");
assertThatThrownBy(() -> service.skip(20L, 30L,
new SkipRequest(101L, 0, "skip-1", "fixture unavailable"), 9L, "operator"))
.isInstanceOf(BizException.class);
verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator");
verify(repository, never()).skip(30L, 0, 101L);
verify(settlementService, never()).settle(20L, 9L, "operator");
}
@Test
void reviewSettlesPartialItemsBeforeCompletingPlan() {
void reviewSettlesResolvedItemsAndCompletesPlan() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
item.setPlannedPackageCount(5);
@@ -115,6 +120,7 @@ class LoadingPickServiceTest {
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.task(20L)).thenReturn(task);
when(repository.items(20L)).thenReturn(List.of(item));
when(repository.items(20L, 101L)).thenReturn(List.of(item));
when(repository.settlePartial(40L, 30L, 3, 5, 9L, "operator")).thenReturn(1);
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1);
@@ -123,9 +129,32 @@ class LoadingPickServiceTest {
verify(repository).insert(any(LoadingPickOperationEntity.class));
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
verify(settlementService, never()).settle(20L, 9L, "operator");
verify(repository).syncInboundProjections(20L, 9L, "operator");
}
private void preparePick(LoadingPickTaskEntity task, LoadingPickItemEntity item, String operationId) {
when(repository.history(operationId)).thenReturn(null);
when(repository.task(20L)).thenReturn(task);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.item(20L, 30L, 101L)).thenReturn(item);
when(repository.pick(30L, 2, "MANUAL", "[]", 0, 101L)).thenReturn(1);
when(repository.touchTask(20L, 0)).thenReturn(1);
when(repository.items(20L, 101L)).thenReturn(List.of(item));
}
private void preparePartial(LoadingPickTaskEntity task, LoadingPickItemEntity item, String operationId) {
when(repository.history(operationId)).thenReturn(null);
when(repository.task(20L)).thenReturn(task);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.item(20L, 30L, 101L)).thenReturn(item);
when(repository.partial(30L, 1, "MANUAL", "[]", 0, 101L)).thenReturn(1);
when(repository.touchTask(20L, 0)).thenReturn(1);
when(repository.items(20L, 101L)).thenReturn(List.of(item));
}
private static LoadingPickTaskEntity task() {
LoadingPickTaskEntity task = new LoadingPickTaskEntity();
task.setId(20L);
@@ -36,7 +36,7 @@ class MerchantInboundBillingServiceTest {
@Test
void shouldHandleCase01() {
InboundOrderEntity inbound = inbound("WAIT_SPLIT", "APPROVED", "CONFIRMED");
InboundOrderEntity inbound = inbound("INBOUNDED", "APPROVED", "CONFIRMED");
InboundOrderDetail detail = new InboundOrderDetail(mock(com.baiyue.firstleg.dto.InboundOrderSummary.class),
"客户备注", List.of(), List.of(
new InboundOrderDetail.FileSummary(1L, "RELATED", "public.pdf", "PUBLIC", 1, 1L, null),
@@ -0,0 +1,206 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
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 jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(PdaPickSettlementService.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
class PdaPickSettlementServiceTest {
@MockBean private LoadingPickRepository picks;
@MockBean private InboundSplitSettlementMapper splits;
@MockBean private InboundPalletAllocationRepository allocations;
@MockBean private WarehousePalletLocationClient warehouse;
@Resource private PdaPickSettlementService service;
@Test
void createsChildForAllUnloadedPackagesAndTransfersRemainingAllocations() {
LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 10, 8, "PARTIAL");
resolved(List.of(item));
when(splits.lockParent(31L)).thenReturn(parent(20));
when(splits.childId(10001L, 31L)).thenReturn(null);
when(splits.lockLatestApprovedItemsForInbound(10001L, 31L))
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20)));
when(allocations.lockConfirmed(10001L, 31L))
.thenReturn(List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10)));
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new WarehousePalletLocationClient.SettlementResult(List.of(), List.of(
transfer(1, 501L, 2), transfer(2, 502L, 10)))));
writes(12, 8);
service.settle(1L, 9L, "operator");
verify(splits).cloneChild(eq(10001L), eq(31L), anyLong(), anyString(), eq(12), eq(9L), eq("operator"));
verify(splits).updateSourceMeasurementPackageCount(10001L, 31L, 61L, 8);
verify(splits).updateSourceMeasurementItemPackageCount(10001L, 61L, 51L, 8);
verify(allocations).consume(10001L, 31L, 1L, 8);
verify(warehouse).settle(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request ->
request.warehouseId() == 701L && request.sourceBusinessId() == 31L
&& request.idempotencyKey().equals("PDA-SETTLE-1-31")
&& request.releasedReservationIds().isEmpty()
&& request.remainingItems().equals(List.of(new WarehousePalletLocationClient.SettlementTransferItem(91L, 2),
new WarehousePalletLocationClient.SettlementTransferItem(92L, 10)))));
verify(allocations).copyRemainingToChild(eq(10001L), anyLong(), any());
verify(allocations).markTransferred(eq(10001L), eq(31L), any(), eq(List.of()));
verify(splits).convergePartialPlanItem(21L, 9L, "operator");
}
@Test
void repeatedSettleWithExistingChildDoesNotConsumeOrTransferAgain() {
LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 10, 6, "PARTIAL");
resolved(List.of(item));
when(splits.lockParent(31L)).thenReturn(parent(10));
when(splits.childId(10001L, 31L)).thenReturn(71L);
service.settle(1L, 9L, "operator");
verify(warehouse, never()).settle(anyString(), any());
verify(splits, never()).convergePartialPlanItem(anyLong(), anyLong(), anyString());
}
@Test
void releasesFullyPickedAllocationBeforeTransferringChildResources() {
LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 10, 10, "PICKED");
resolved(List.of(item));
when(splits.lockParent(31L)).thenReturn(parent(20));
when(splits.childId(10001L, 31L)).thenReturn(null);
when(splits.lockLatestApprovedItemsForInbound(10001L, 31L))
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20)));
when(allocations.lockConfirmed(10001L, 31L))
.thenReturn(List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10)));
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new WarehousePalletLocationClient.SettlementResult(List.of(released(91L, 501L)),
List.of(transfer(2, 502L, 10)))));
writes(10, 10);
service.settle(1L, 9L, "operator");
verify(warehouse).settle(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request ->
request.idempotencyKey().equals("PDA-SETTLE-1-31")
&& request.releasedReservationIds().equals(List.of(91L))
&& request.remainingItems().equals(List.of(new WarehousePalletLocationClient.SettlementTransferItem(92L, 10)))));
}
@Test
void fullyPickedInboundReleasesAllResourcesWithoutCreatingChild() {
LoadingPickItemEntity item = item(11L, 21L, 31L, 41L, 20, 20, "PICKED");
resolved(List.of(item));
when(splits.lockParent(31L)).thenReturn(parent(20));
when(splits.childId(10001L, 31L)).thenReturn(null);
when(allocations.lockConfirmed(10001L, 31L))
.thenReturn(List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10)));
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new WarehousePalletLocationClient.SettlementResult(List.of(released(91L, 501L), released(92L, 502L)),
List.of())));
service.settle(1L, 9L, "operator");
verify(allocations).consume(10001L, 31L, 1L, 10);
verify(allocations).consume(10001L, 31L, 2L, 10);
verify(splits, never()).cloneChild(anyLong(), anyLong(), anyLong(), anyString(), anyInt(), anyLong(), anyString());
verify(warehouse).settle(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request ->
request.targetBusinessId() == 0L && request.targetPackageCount() == 0
&& request.releasedReservationIds().equals(List.of(91L, 92L))
&& request.remainingItems().isEmpty()));
}
@Test
void onlyPureSkippedInboundRestoresCandidate() {
LoadingPickItemEntity item = item(12L, 22L, 32L, 42L, 8, 0, "SKIPPED");
resolved(List.of(item));
when(splits.removeSkippedPlanItem(22L, 9L, "operator")).thenReturn(1);
service.settle(1L, 9L, "operator");
verify(splits).removeSkippedPlanItem(22L, 9L, "operator");
}
@Test
void mixedSkippedInboundIsNotRestored() {
LoadingPickItemEntity picked = item(11L, 21L, 31L, 41L, 8, 8, "PICKED");
LoadingPickItemEntity skipped = item(12L, 22L, 31L, 42L, 8, 0, "SKIPPED");
PdaPickSettlementPlan.Resolution resolution = PdaPickSettlementPlan.resolve(List.of(picked, skipped));
assertThat(resolution.skippedPlanItemIds()).isEmpty();
}
private void resolved(List<LoadingPickItemEntity> items) {
when(picks.allTaskItemsResolved(1L)).thenReturn(true);
when(picks.items(1L)).thenReturn(items);
}
private void writes(int childCount, int pickedCount) {
when(splits.cloneChild(eq(10001L), eq(31L), anyLong(), anyString(), eq(childCount), eq(9L), eq("operator")))
.thenReturn(1);
when(splits.insertBatch(anyLong(), eq(10001L), eq(31L), anyString(), anyString(), eq(3),
eq(childCount), anyString(), eq(9L), eq("operator"))).thenReturn(1);
when(splits.cloneMeasurement(eq(10001L), eq(31L), eq(61L), anyLong(), anyLong(), anyLong(),
eq(childCount), eq(9L))).thenReturn(1);
when(splits.insertSplitItem(anyLong(), eq(10001L), anyLong(), eq(31L), anyLong(), eq(1),
eq(41L), eq(61L), eq(51L), eq(childCount))).thenReturn(1);
when(splits.cloneMeasurementItem(eq(10001L), eq(61L), eq(51L), anyLong(), anyLong(), anyLong(),
eq(1), eq(childCount))).thenReturn(1);
when(splits.updateSourceMeasurementPackageCount(10001L, 31L, 61L, pickedCount)).thenReturn(1);
when(splits.updateSourceMeasurementItemPackageCount(10001L, 61L, 51L, pickedCount)).thenReturn(1);
when(splits.markParentPartiallySplit(10001L, 31L, childCount, 9L, 3)).thenReturn(1);
when(splits.applyBatch(eq(10001L), anyLong())).thenReturn(1);
when(splits.insertHistory(anyLong(), eq(10001L), anyLong(), anyString(), anyString(), eq(9L),
eq("operator"), anyString())).thenReturn(1);
when(splits.convergePartialPlanItem(21L, 9L, "operator")).thenReturn(1);
}
private static ParentRow parent(int packageCount) {
return new ParentRow(31L, 10001L, "IN-001", "MASTER", "INBOUNDED", packageCount, packageCount, 3);
}
private static InboundPalletAllocationEntity allocation(long id, int no, long locationId, int allocated) {
InboundPalletAllocationEntity value = new InboundPalletAllocationEntity();
value.setId(id); value.setAllocationNo(no); value.setLocationId(locationId); value.setWarehouseId(701L);
value.setReservationId(90L + no);
value.setAllocatedPackageCount(allocated); value.setPickedPackageCount(0);
return value;
}
private static WarehousePalletLocationClient.TransferResult transfer(int no, long locationId, int remaining) {
return new WarehousePalletLocationClient.TransferResult(90L + no, "RES-" + no, 92L + no, no,
0L, 701L, 801L + no, "PALLET-" + no, null, locationId, "L-" + no, null, remaining, "OCCUPIED");
}
private static WarehousePalletLocationClient.SelectiveReleaseResult released(long reservationId, long locationId) {
return new WarehousePalletLocationClient.SelectiveReleaseResult(reservationId, 92L, 1, 801L,
locationId, "L-1", 10);
}
private static LoadingPickItemEntity item(long id, long planItemId, long inboundId, long detailId,
int planned, int picked, String status) {
LoadingPickItemEntity value = new LoadingPickItemEntity();
value.setId(id); value.setLoadingPlanItemId(planItemId); value.setInboundId(inboundId);
value.setInboundDetailId(detailId); value.setPlannedPackageCount(planned);
value.setPickedPackageCount(picked); value.setItemStatus(status);
return value;
}
}
@@ -31,7 +31,7 @@ class UnifiedInboundQueryServiceTest {
assertThat(UnifiedInboundQueryService.mainStatus("WAIT_PRICING")).isEqualTo("INBOUNDED");
assertThat(UnifiedInboundQueryService.mainStatus("FEE_CONFIRMING")).isEqualTo("INBOUNDED");
assertThat(UnifiedInboundQueryService.mainStatus("INBOUNDING")).isEqualTo("INBOUNDED");
assertThat(UnifiedInboundQueryService.mainStatus("WAIT_SPLIT")).isEqualTo("WAIT_SPLIT");
assertThat(UnifiedInboundQueryService.mainStatus("WAIT_SPLIT")).isEqualTo("INBOUNDED");
assertThat(UnifiedInboundQueryService.mainStatus("INBOUNDED")).isEqualTo("INBOUNDED");
assertThat(UnifiedInboundQueryService.mainStatus("LOADING")).isEqualTo("LOADING");
assertThat(UnifiedInboundQueryService.mainStatus("LOADED")).isEqualTo("LOADED");
@@ -51,29 +51,27 @@ class UnifiedInboundQueryServiceTest {
assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDING", null)).isNull();
assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDED", "CONFIRMED")).isNull();
assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "WAIT_SPLIT", null))
.isNull();
assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "WAIT_SPLIT", null)).isNull();
.isEqualTo("SMART_SPLIT");
assertThat(UnifiedInboundQueryService.nextAction("LOADED", "LOADED", null))
.isEqualTo("COMPLETE_DELIVERY");
assertThat(UnifiedInboundQueryService.nextAction("DELIVERED", "DELIVERED", null)).isNull();
}
@Test
void waitSplitFilterRemainsSeparateFromInbounded() throws Exception {
void inboundedFilterIncludesHistoricalWaitSplitRows() throws Exception {
var method = UnifiedInboundQueryService.class.getDeclaredMethod("internalStatuses", String.class);
method.setAccessible(true);
assertThat(method.invoke(new UnifiedInboundQueryService(), "WAIT_SPLIT"))
.isEqualTo(java.util.List.of("WAIT_SPLIT"));
assertThat(method.invoke(new UnifiedInboundQueryService(), "INBOUNDED"))
.isEqualTo(java.util.List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED"));
.isEqualTo(java.util.List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED", "WAIT_SPLIT"));
assertThat(method.invoke(new UnifiedInboundQueryService(), "WAIT_SPLIT"))
.isEqualTo(java.util.List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED", "WAIT_SPLIT"));
}
@Test
void phaseTwoMeasurementStatusesDoNotRequireUndeployedPhaseThreeTables() {
assertThat(UnifiedInboundQueryService.requiresPhaseThreeSummary("WAIT_MEASURE")).isFalse();
assertThat(UnifiedInboundQueryService.requiresPhaseThreeSummary("INBOUNDED")).isFalse();
assertThat(UnifiedInboundQueryService.requiresPhaseThreeSummary("WAIT_SPLIT")).isFalse();
assertThat(UnifiedInboundQueryService.requiresPhaseThreeSummary("LOADING")).isTrue();
assertThat(UnifiedInboundQueryService.requiresPhaseThreeSummary("LOADED")).isTrue();
assertThat(UnifiedInboundQueryService.requiresPhaseThreeSummary("DELIVERED")).isTrue();
@@ -12,7 +12,7 @@ import java.time.LocalDateTime;
public record UnifiedOrderQuery(
@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,
@@ -78,37 +78,6 @@ public class OrderMasterDataGuard {
throw downstreamFailure(exception);
}
}
public PartnerClient.Partner validateCarrier(long id) {
try {
ApiResponse<PartnerClient.Partner> response;
try {
response = partners.active(token, publicTenantId, id);
} catch (FeignException.NotFound exception) {
throw new BizException(ResultCode.NOT_FOUND, "承运商不存在或不可用");
}
requireSuccessful(response, "承运商");
if (response.data() == null) throw unavailable("承运商");
var value = response.data();
if (!Long.valueOf(id).equals(value.id())) throw unavailable("承运商");
if (!Long.valueOf(publicTenantId).equals(value.tenantId()))
throw new BizException(ResultCode.FORBIDDEN, "承运商不属于平台公共主数据");
if (!"ACTIVE".equals(value.status()))
throw new BizException(ResultCode.CONFLICT, "承运商已停用");
boolean hasCarrierType = value.types() != null && value.types().contains("CARRIER");
boolean legacyCarrierType = (value.types() == null || value.types().isEmpty())
&& "CARRIER".equals(value.partnerType());
if (!hasCarrierType && !legacyCarrierType)
throw new BizException(ResultCode.VALIDATION_FAILED, "所选往来单位不具备承运商类型");
if (!StringUtils.hasText(value.partnerName())) throw unavailable("承运商");
return value;
} catch (FeignException exception) {
throw downstreamFailure(exception);
} catch (BizException exception) {
throw exception;
} catch (RuntimeException exception) {
throw downstreamFailure(exception);
}
}
private PartnerClient.Partner loadPartner(long id) {
ApiResponse<PartnerClient.Partner> response;
try {
@@ -31,7 +31,8 @@ import java.util.stream.Collectors;
@Service
public class UnifiedOrderQueryService {
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");
private static final String LEGACY_WAIT_SPLIT = "WAIT_SPLIT";
private static final Set<String> REVIEW_STATUSES = Set.of("UNRECORDED", "PENDING", "APPROVED", "REJECTED");
private static final List<OrderStatus> ACTIVE_ORDER_STATUSES = List.copyOf(EnumSet.of(
OrderStatus.SUBMITTED, OrderStatus.APPROVED, OrderStatus.ACCEPTED,
@@ -92,7 +93,7 @@ public class UnifiedOrderQueryService {
return new PageResult<>(List.of(), query.page(), query.size(), 0, 0);
}
FirstlegUnifiedOrderClient.Query request = new FirstlegUnifiedOrderClient.Query(
tenantId, query.keyword(), query.orderNo(), query.mainStatus(), query.customerId(),
tenantId, query.keyword(), query.orderNo(), canonicalMainStatus(query.mainStatus()), query.customerId(),
query.receivingWarehouseId(), query.destinationWarehouseId(), query.inboundCode(),
query.internalOrderStatus(), query.expectedArrivalDate(), query.feeReviewStatus(), query.channelName(),
query.containerNo(), query.cabinetNo(), query.sailingDate(), query.loaded(), query.fullContainer(),
@@ -199,8 +200,7 @@ public class UnifiedOrderQueryService {
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;
};
@@ -211,7 +211,9 @@ public class UnifiedOrderQueryService {
}
private void validate(UnifiedOrderQuery 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 BizException(ResultCode.BAD_REQUEST, "统一订单主状态不合法");
}
if (query.createdFrom() != null && query.createdTo() != null
@@ -241,6 +243,10 @@ public class UnifiedOrderQueryService {
|| StringUtils.hasText(query.measurementReviewStatus());
}
private static String canonicalMainStatus(String status) {
return LEGACY_WAIT_SPLIT.equals(status) ? "INBOUNDED" : status;
}
private <T> ApiResponse<T> call(java.util.concurrent.Callable<ApiResponse<T>> invocation) {
try { return invocation.call(); }
catch (FeignException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程服务暂时不可用"); }
@@ -311,7 +311,7 @@ components:
required: false
schema:
type: string
enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED]
enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, LOADING, LOADED, DELIVERED]
UnifiedInboundCode:
{name: inboundCode, in: query, required: false, description: 客户提交后生成的入仓码,待审核订单同样可查询, schema: {type: string, maxLength: 40}}
UnifiedInternalOrderStatus:
@@ -25,7 +25,7 @@ class UnifiedOrderApiContractTest {
}
@Test
void documentsPhaseTwoWaitSplitStatus() throws Exception {
void documentsInboundedAsThePhaseTwoTerminalStatus() throws Exception {
String yaml = new ClassPathResource("openapi/order-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
Map<String, Object> document = stringMap(new Yaml().load(yaml));
@@ -39,8 +39,7 @@ class UnifiedOrderApiContractTest {
Map<String, Object> mainStatus = stringMap(parameters.get("UnifiedMainStatus"));
Map<String, Object> schema = stringMap(mainStatus.get("schema"));
assertThat(stringList(schema.get("enum"))).containsExactly(
"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT",
"LOADING", "LOADED", "DELIVERED");
"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "LOADING", "LOADED", "DELIVERED");
}
@Test
@@ -45,31 +45,6 @@ class OrderMasterDataGuardTest {
assertThat(result.receiving().warehouse().warehouseName()).isEqualTo("深圳仓");
}
@Test
void carrierValidationReturnsTrustedPublicCarrierSnapshot() {
var carrier = new PartnerClient.Partner(9L, PUBLIC_TENANT_ID, "服务端船司名", "ACTIVE", "CARRIER", List.of("SUPPLIER", "CARRIER"));
when(partners.active("token", PUBLIC_TENANT_ID, 9L)).thenReturn(ApiResponse.success(carrier));
assertThat(guard.validateCarrier(9L).partnerName()).isEqualTo("服务端船司名");
}
@Test
void carrierValidationSupportsLegacySingleTypeSnapshot() {
var carrier = new PartnerClient.Partner(9L, PUBLIC_TENANT_ID, "旧数据船司", "ACTIVE", "CARRIER", null);
when(partners.active("token", PUBLIC_TENANT_ID, 9L)).thenReturn(ApiResponse.success(carrier));
assertThat(guard.validateCarrier(9L).partnerName()).isEqualTo("旧数据船司");
}
@Test
void partnerWithoutCarrierTypeCannotBeAssignedToOrder() {
var supplier = new PartnerClient.Partner(9L, PUBLIC_TENANT_ID, "普通供应商", "ACTIVE", "SUPPLIER", List.of("SUPPLIER"));
when(partners.active("token", PUBLIC_TENANT_ID, 9L)).thenReturn(ApiResponse.success(supplier));
assertThatThrownBy(() -> guard.validateCarrier(9L))
.isInstanceOf(BizException.class)
.hasMessageContaining("不具备承运商类型")
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.VALIDATION_FAILED);
}
@Test
void inactiveOrCrossTenantMasterDataIsRejected() {
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(null));
assertThatThrownBy(() -> guard.validate(10002,request(7L,8L)))
@@ -255,7 +230,7 @@ class OrderMasterDataGuardTest {
var cargo=new OrderRequests.Cargo("货物",null,"CARTON",1,BigDecimal.ONE,BigDecimal.ONE,
null,null,null,null,null,false,null,null,null,null,null,null,null,null,null,null,null,null,null,
null,null,null,null,null,null);
return new OrderRequests.SaveRequest(partner,"不可信客户名","SEA",null,"CN",destinationWarehouseId,"私人地址",null,null,party,party,List.of(cargo),"PICKUP",warehouse,"不可信收货仓","CH","渠道",9L,"船司","BUY_DECLARATION",null,"LCL",false,destinationType,null,null,null);
return new OrderRequests.SaveRequest(partner,"不可信客户名","SEA",null,"CN",destinationWarehouseId,"私人地址",null,null,party,party,List.of(cargo),"PICKUP",warehouse,"不可信收货仓","CH","渠道",null,null,"BUY_DECLARATION",null,"LCL",false,destinationType,null,null,null);
}
private WarehouseClient.Detail warehouse(long id,String type,boolean receiving) {
var value=new WarehouseClient.Warehouse(id,PUBLIC_TENANT_ID,"WH-"+id,"仓库"+id,type,"CN","ACTIVE");
@@ -141,9 +141,9 @@ class UnifiedOrderQueryServiceTest {
}
@Test
void waitSplitFilterForwardsExactStatusAndPreservesSummaryForMerchantScope() {
void historicalWaitSplitFilterForwardsInboundedAndReturnsInbounded() {
UnifiedOrderQuery query = query("WAIT_SPLIT");
var summary = summary("WAIT_SPLIT", null);
var summary = summary("INBOUNDED", null);
when(firstleg.page(eq("internal-token"), any()))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1)));
when(orders.findByIds(eq(10001L), eq(List.of(77L)))).thenReturn(List.of(order(OrderStatus.PROCESSING)));
@@ -151,18 +151,18 @@ class UnifiedOrderQueryServiceTest {
var result = service.page(10001L, query);
assertThat(result.records()).singleElement()
.extracting(row -> row.mainStatus()).isEqualTo("WAIT_SPLIT");
.extracting(row -> row.mainStatus()).isEqualTo("INBOUNDED");
ArgumentCaptor<FirstlegUnifiedOrderClient.Query> captor =
ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class);
verify(firstleg).page(eq("internal-token"), captor.capture());
assertThat(captor.getValue().tenantId()).isEqualTo(10001L);
assertThat(captor.getValue().mainStatus()).isEqualTo("WAIT_SPLIT");
assertThat(captor.getValue().mainStatus()).isEqualTo("INBOUNDED");
}
@Test
void fulfillmentTabsForwardStatusSearchAndPaginationToFirstleg() {
List<String> statuses = List.of(
"WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED");
"WAIT_MEASURE", "INBOUNDED", "LOADING", "LOADED", "DELIVERED");
when(firstleg.page(eq("internal-token"), any()))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(), 3, 20, 0, 0)));
+9
View File
@@ -24,6 +24,15 @@
- 内部仓储接口使用 `X-Internal-Service-Token`,不加入 Gateway 公网路由;资源停用/不存在遵循统一 body code。
- 导入先全量校验再原子写入;调整 DTO、类型或容量规则时同步 Order/Firstleg 客户端和契约测试。
## 三期托盘、库位与 A0 规则
- 普通库位保持一托一位;A0 是系统创建的共享临时溢出位,可被多个托盘占用,但每个托盘占用仍必须通过 `reservation_id`、`occupancy_id` 独立识别。
- 头程 PDA 结算接口 `/internal/warehouse/v1/pallet-locations/settlements` 是释放、子单转移和全部释放后重新分配的原子边界。不得将其拆成多个可独立提交的远程操作。
- 部分拣货的资源操作必须按 `reservationId` 定位,不能按 `locationId` 定位;A0 多托盘共享时,按 `locationId` 会误释放或合并资源。
- 当父单全部资源释放但子单仍有剩余数量时,在同一仓库事务内优先分配自然 A-Z 顺序的普通空闲库位;普通库位不足时使用 A0,并保持托盘状态、预占、占用和子单归属一致。
- A0 换绑入口包括内部接口和平台接口:平台人员使用 `/api/platform/v1/locations/temporary-occupancies/{occupancyId}/rebind`,需要 `platform:location:edit`。目标必须为同仓 `AVAILABLE` 的普通库位;A0 不允许人工编辑、停用或删除。
- 资源结算、释放、转移和换绑必须保存可重放的幂等记录或具有等价的状态幂等语义,并在测试中覆盖并发状态变更。
## 分期导航
- 一期:公共仓与主数据;先读 `../../docs/一期/README.md`。
@@ -72,6 +72,33 @@ public class InternalPalletLocationController {
return ApiResponse.success(service.releaseSelected(request));
}
@PostMapping("/settlements")
@Operation(summary = "PDA 资源原子结算", description = "同一事务精确释放已拣满库位,并将未拣满资源转给二级子入库单")
public ApiResponse<PalletAllocationDtos.SettlementResult> settle(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody PalletAllocationDtos.SettlementRequest request) {
verify(token);
return ApiResponse.success(service.settle(request));
}
@PostMapping("/rebind-temporary-location")
@Operation(summary = "A0临时库位换绑", description = "将一条具体A0托盘占用换绑到同仓空闲普通库位")
public ApiResponse<PalletAllocationDtos.RebindTemporaryLocationResult> rebindTemporaryLocation(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody PalletAllocationDtos.RebindTemporaryLocationRequest request) {
verify(token);
return ApiResponse.success(service.rebindTemporaryLocation(request));
}
@PostMapping("/transfer")
@Operation(summary = "将未拣货库位转给子入库单", description = "仅更新业务归属,托盘和库位继续保持已占用")
public ApiResponse<List<PalletAllocationDtos.TransferResult>> transfer(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody PalletAllocationDtos.TransferRequest request) {
verify(token);
return ApiResponse.success(service.transfer(request));
}
private void verify(String token) {
byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8);
if (!MessageDigest.isEqual(expectedToken, supplied)) {
@@ -9,8 +9,10 @@ import com.baiyue.warehouse.dto.LocationDtos.Detail;
import com.baiyue.warehouse.dto.LocationDtos.PageQuery;
import com.baiyue.warehouse.dto.LocationDtos.SaveRequest;
import com.baiyue.warehouse.dto.LocationDtos.StatusRequest;
import com.baiyue.warehouse.dto.PalletAllocationDtos;
import com.baiyue.warehouse.entity.LocationEntity;
import com.baiyue.warehouse.service.LocationApplicationService;
import com.baiyue.warehouse.service.PalletLocationAllocationService;
import jakarta.annotation.Resource;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -44,6 +46,8 @@ import java.util.List;
public class PlatformLocationController {
@Resource
private LocationApplicationService service;
@Resource
private PalletLocationAllocationService palletLocationService;
@GetMapping
@PreAuthorize("hasAuthority('platform:location:list')")
@@ -131,6 +135,18 @@ public class PlatformLocationController {
return ApiResponse.success(null);
}
@PostMapping("/temporary-occupancies/{occupancyId}/rebind")
@PreAuthorize("hasAuthority('platform:location:edit')")
@Operation(summary = "A0临时库位换绑", description = "将指定A0托盘占用换绑到同仓空闲普通库位")
public ApiResponse<PalletAllocationDtos.RebindTemporaryLocationResult> rebindTemporaryLocation(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Min(1) long occupancyId,
@Valid @RequestBody RebindRequest request) {
requirePlatform(user);
return ApiResponse.success(palletLocationService.rebindTemporaryLocation(
new PalletAllocationDtos.RebindTemporaryLocationRequest(0L, occupancyId, request.targetLocationId(),
requireUserId(user), request.idempotencyKey(), request.reason())));
}
@PatchMapping("/{id}/status")
@PreAuthorize("hasAuthority('platform:location:status')")
@Operation(summary = "启用或停用库位", description = "按 version 条件更新;活动预占或占用中的库位不能停用")
@@ -189,4 +205,9 @@ public class PlatformLocationController {
throw new BizException(ResultCode.BAD_REQUEST, "平台公共资源作用域必须为0");
}
}
public record RebindRequest(@Min(1) long targetLocationId,
@jakarta.validation.constraints.NotBlank String idempotencyKey,
@jakarta.validation.constraints.Size(max = 500) String reason) {
}
}
@@ -77,7 +77,7 @@ public final class LocationDtos {
/** 原子选择并预占 N 个可用库位的命令。 */
public record ReserveCommand(
@Min(1) long tenantId,
@Min(0) long tenantId,
@Min(1) long warehouseId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long businessId,
@@ -109,7 +109,7 @@ public final class LocationDtos {
}
public record ConfirmCommand(
@Min(1) long tenantId,
@Min(0) long tenantId,
@Min(1) long warehouseId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long businessId,
@@ -130,7 +130,7 @@ public final class LocationDtos {
}
public record ReleaseCommand(
@Min(1) long tenantId,
@Min(0) long tenantId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long businessId,
@Min(1) long operatorId,
@@ -139,7 +139,7 @@ public final class LocationDtos {
}
public record SelectiveReleaseCommand(
@Min(1) long tenantId,
@Min(0) long tenantId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long businessId,
@Min(1) long operatorId,
@@ -162,6 +162,126 @@ public final class LocationDtos {
) {
}
/** PDA 已拣满一个库位时按占用事实精确释放,箱数不得重新均分。 */
public record ExactSelectiveReleaseCommand(
@Min(0) long tenantId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long businessId,
@Min(1) long operatorId,
@NotBlank @Size(max = 500) String reason,
@NotEmpty @Size(max = 200) List<@Min(1) Long> locationIds
) {
}
public record ReservationReleaseCommand(
@Min(0) long tenantId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long businessId,
@Min(1) long operatorId,
@NotBlank @Size(max = 500) String reason,
@NotEmpty @Size(max = 200) List<@Min(1) Long> reservationIds
) {
}
public record ExactSelectiveReleaseResult(
long reservationId,
long occupancyId,
int allocationNo,
long warehouseId,
long palletId,
long locationId,
String locationCode,
int allocatedPackageCount
) {
}
/**
* PDA 原子结算前锁定读取的已占用资源事实。
* reservationId 是跨服务结算时唯一可信的资源标识,不能使用可能复用的库位编号。
*/
public record OccupiedResource(
long reservationId,
long occupancyId,
int allocationNo,
long warehouseId,
long palletId,
long locationId,
String locationCode,
boolean sharedOverflow,
int allocatedPackageCount
) {
}
/** 将未拣出的物理占用从主入库单转交给子入库单的单个库位数量。 */
public record TransferItem(
@Min(1) long locationId,
@Min(1) int remainingPackageCount
) {
}
public record ReservationTransferItem(
@Min(1) long reservationId,
@Min(1) int remainingPackageCount
) {
}
public record ReservationTransferCommand(
@Min(0) long tenantId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long sourceBusinessId,
@Min(1) long targetBusinessId,
@NotBlank @Size(max = 40) String targetInboundCode,
@Min(1) long operatorId,
@NotEmpty @Size(max = 200) List<@NotNull @jakarta.validation.Valid ReservationTransferItem> items
) {
}
public record RebindTemporaryLocationCommand(@Min(0) long tenantId, @Min(1) long occupancyId,
@Min(1) long targetLocationId, @Min(1) long operatorId) {
}
public record RebindTemporaryLocationResult(long occupancyId, long reservationId, long warehouseId,
long palletId, long previousLocationId, String previousLocationCode,
long locationId, String locationCode) {
}
/**
* 仅变更已占用托盘/库位的业务归属,不释放物理资源。
* {@code items} 只可包含未拣出的库位和对应剩余箱数。
*/
public record TransferCommand(
@Min(0) long tenantId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long sourceBusinessId,
@Min(1) long targetBusinessId,
@NotBlank @Size(max = 40) String targetInboundCode,
@Min(1) long operatorId,
@NotEmpty @Size(max = 200) List<@Valid TransferItem> items
) {
}
public record ReactivateReleasedResourceCommand(@Min(0) long tenantId, @Min(1) long warehouseId,
@Min(1) long reservationId, @Min(1) long targetBusinessId, @NotBlank @Size(max = 40) String targetInboundCode,
@Min(1) int packageCount, @Min(1) long operatorId) {
}
/** 子入库单写入仓储分配快照所需的已占用资源数据。 */
public record TransferResult(
long reservationId,
String reservationNo,
long occupancyId,
int allocationNo,
long warehouseId,
long palletId,
String palletCode,
long locationId,
String locationCode,
BigDecimal allocatedVolumeCbm,
int remainingPackageCount,
String state
) {
}
public record ActiveResource(
long reservationId,
String reservationNo,
@@ -17,7 +17,7 @@ public final class PalletAllocationDtos {
}
public record ReserveRequest(
@Min(1) long tenantId,
@Min(0) long tenantId,
@Min(1) long warehouseId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long businessId,
@@ -33,7 +33,7 @@ public final class PalletAllocationDtos {
}
public record ConfirmRequest(
@Min(1) long tenantId,
@Min(0) long tenantId,
@Min(1) long warehouseId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long businessId,
@@ -44,7 +44,7 @@ public final class PalletAllocationDtos {
}
public record ReleaseRequest(
@Min(1) long tenantId,
@Min(0) long tenantId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long businessId,
@Min(1) long operatorId,
@@ -57,14 +57,13 @@ public final class PalletAllocationDtos {
* the first {@code releaseLocationCount} allocations are selected.
*/
public record SelectiveReleaseRequest(
@Min(1) long tenantId,
@Min(0) long tenantId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long businessId,
@NotBlank @Size(max = 128) String idempotencyKey,
@Min(1) long operatorId,
@NotBlank @Size(max = 500) String reason,
@Min(1) int totalPackageCount,
@Size(max = 200) List<@Min(1) Long> locationIds,
@Min(1) Integer releaseLocationCount
@NotNull @Size(min = 1, max = 200) List<@Min(1) Long> locationIds
) {
}
@@ -75,7 +74,115 @@ public final class PalletAllocationDtos {
long palletId,
long locationId,
String locationCode,
int releasedPackageCount
int allocatedPackageCount
) {
}
/** PDA 拣货资源结算:已拣满资源释放,未拣满资源转移至二级子入库单。 */
public record SettlementRequest(
@Min(1) long warehouseId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long sourceBusinessId,
@Min(0) long targetBusinessId,
@Min(0) int sourcePackageCount,
@Min(0) int targetPackageCount,
@Size(max = 40) String targetInboundCode,
@NotBlank @Size(max = 128) String idempotencyKey,
@Min(1) long operatorId,
@Size(max = 64) String operatorName,
@NotBlank @Size(max = 128) String requestId,
@NotNull @Size(max = 200) List<@Min(1) Long> releasedReservationIds,
@NotNull @Size(max = 200) List<@NotNull @jakarta.validation.Valid SettlementTransferItem> remainingItems
) {
/** 兼容旧内部调用;新调用必须传入仓库与数量守恒字段。 */
public SettlementRequest(long tenantId, String sourceService, long sourceBusinessId, long targetBusinessId,
String targetInboundCode, String idempotencyKey, long operatorId, String reason,
List<Long> releasedReservationIds, List<TransferItem> remainingItems) {
this(1L, sourceService, sourceBusinessId, targetBusinessId,
remainingItems.stream().mapToInt(TransferItem::remainingPackageCount).sum(),
remainingItems.stream().mapToInt(TransferItem::remainingPackageCount).sum(),
targetInboundCode, idempotencyKey, operatorId, reason, idempotencyKey, releasedReservationIds,
remainingItems.stream().map(item -> new SettlementTransferItem(item.locationId(),
item.remainingPackageCount())).toList());
}
/** 兼容已有测试及旧内部调用;服务端不再接受租户范围。 */
public SettlementRequest(long tenantId, long warehouseId, String sourceService, long sourceBusinessId,
long targetBusinessId, int sourcePackageCount, int targetPackageCount,
String targetInboundCode, String idempotencyKey, long operatorId, String reason,
List<Long> releasedReservationIds, List<SettlementTransferItem> remainingItems) {
this(warehouseId, sourceService, sourceBusinessId, targetBusinessId, sourcePackageCount,
targetPackageCount, targetInboundCode, idempotencyKey, operatorId, reason, idempotencyKey,
releasedReservationIds, remainingItems);
}
}
/** 使用 reservationId 唯一定位资源,兼容多个托盘共用 A0。 */
public record SettlementTransferItem(
@Min(1) long reservationId,
@Min(1) int remainingPackageCount
) {
}
public record SettlementResult(
List<SelectiveReleaseResult> released,
List<TransferResult> transferred
) {
}
/** 将共享临时库位 A0 中的一条具体托盘占用换绑到同仓空闲普通库位。 */
public record RebindTemporaryLocationRequest(
@Min(0) long tenantId,
@Min(1) long occupancyId,
@Min(1) long targetLocationId,
@Min(1) long operatorId,
@NotBlank @Size(max = 128) String idempotencyKey,
@Size(max = 500) String reason
) {
}
public record RebindTemporaryLocationResult(long occupancyId, long reservationId,
long warehouseId, long palletId,
long previousLocationId, String previousLocationCode,
long locationId, String locationCode) {
}
/** 主入库单向子入库单转移的单个未拣出库位。 */
public record TransferItem(
@Min(1) long locationId,
@Min(1) int remainingPackageCount
) {
}
/** 仅转移占用归属和未拣出箱数,不释放库位或托盘。 */
public record TransferRequest(
@Min(0) long tenantId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long sourceBusinessId,
@Min(1) long targetBusinessId,
@NotBlank @Size(max = 40) String targetInboundCode,
@NotBlank @Size(max = 128) String idempotencyKey,
@Min(1) long operatorId,
@NotNull @Size(min = 1, max = 200) List<@NotNull @jakarta.validation.Valid TransferItem> items
) {
}
/** 子入库单保存托盘库位快照所需的完整仓储分配结果。 */
public 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
) {
}
@@ -0,0 +1,27 @@
package com.baiyue.warehouse.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** 已占用托盘库位选择性释放的持久化幂等操作记录。 */
@Getter
@Setter
@TableName("wh_pallet_location_release_operation")
public class PalletLocationReleaseOperationEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String sourceService;
private Long businessId;
private String idempotencyKey;
private String requestHash;
private String operationStatus;
private String responseSnapshot;
private LocalDateTime createdAt;
private LocalDateTime completedAt;
}
@@ -0,0 +1,28 @@
package com.baiyue.warehouse.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** PDA 拣货资源原子结算的持久化幂等操作记录。 */
@Getter
@Setter
@TableName("wh_pallet_location_settlement_operation")
public class PalletLocationSettlementOperationEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String sourceService;
private Long sourceBusinessId;
private Long targetBusinessId;
private String idempotencyKey;
private String requestHash;
private String operationStatus;
private String responseSnapshot;
private LocalDateTime createdAt;
private LocalDateTime completedAt;
}
@@ -0,0 +1,28 @@
package com.baiyue.warehouse.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** 托盘库位归属转移的持久化幂等操作记录。 */
@Getter
@Setter
@TableName("wh_pallet_location_transfer_operation")
public class PalletLocationTransferOperationEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String sourceService;
private Long sourceBusinessId;
private Long targetBusinessId;
private String idempotencyKey;
private String requestHash;
private String operationStatus;
private String responseSnapshot;
private LocalDateTime createdAt;
private LocalDateTime completedAt;
}
@@ -12,6 +12,28 @@ import java.util.List;
/** 库位占用事实 Mapper。 */
@Mapper
public interface LocationOccupancyMapper extends BaseMapper<LocationOccupancyEntity> {
@Select("SELECT * FROM wh_location_occupancy WHERE id=#{id} AND tenant_id=#{tenantId} FOR UPDATE")
LocationOccupancyEntity lockById(@Param("tenantId") long tenantId, @Param("id") long id);
@Update("""
UPDATE wh_location_occupancy SET location_id=#{locationId}, location_code=#{locationCode}, shared_overflow=0,
updated_at=CURRENT_TIMESTAMP(3), version=version+1
WHERE id=#{id} AND tenant_id=#{tenantId} AND occupancy_status='OCCUPIED' AND version=#{version}
""")
int rebindLocation(@Param("tenantId") long tenantId, @Param("id") long id, @Param("locationId") long locationId,
@Param("locationCode") String locationCode, @Param("version") int version);
@Update("""
UPDATE wh_location_occupancy SET occupancy_status='OCCUPIED', business_id=#{businessId}, inbound_code=#{inboundCode},
location_id=#{locationId}, location_code=#{locationCode}, shared_overflow=#{sharedOverflow},
allocated_package_count=#{packageCount}, released_at=NULL, release_reason=NULL,
updated_at=CURRENT_TIMESTAMP(3), version=version+1
WHERE id=#{id} AND tenant_id=#{tenantId} AND occupancy_status='RELEASED' AND version=#{version}
""")
int reactivateForChild(@Param("tenantId") long tenantId, @Param("id") long id, @Param("businessId") long businessId,
@Param("inboundCode") String inboundCode, @Param("locationId") long locationId,
@Param("locationCode") String locationCode, @Param("sharedOverflow") boolean sharedOverflow,
@Param("packageCount") int packageCount, @Param("version") int version);
@Select("""
SELECT COUNT(1) FROM wh_location_occupancy
WHERE tenant_id = #{tenantId} AND location_id = #{locationId}
@@ -29,10 +51,28 @@ public interface LocationOccupancyMapper extends BaseMapper<LocationOccupancyEnt
int release(@Param("tenantId") long tenantId, @Param("id") long id,
@Param("version") int version, @Param("reason") String reason);
@Update("""
UPDATE wh_location_occupancy
SET business_id = #{targetBusinessId}, inbound_code = #{targetInboundCode},
allocated_package_count = #{remainingPackageCount},
updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{id} AND tenant_id = #{tenantId}
AND source_service = #{sourceService} AND business_id = #{sourceBusinessId}
AND occupancy_status = 'OCCUPIED' AND version = #{version}
""")
int transferOccupied(@Param("tenantId") long tenantId, @Param("id") long id,
@Param("sourceService") String sourceService,
@Param("sourceBusinessId") long sourceBusinessId,
@Param("targetBusinessId") long targetBusinessId,
@Param("targetInboundCode") String targetInboundCode,
@Param("remainingPackageCount") int remainingPackageCount,
@Param("version") int version);
@Select("""
SELECT * FROM wh_location_occupancy
WHERE tenant_id = #{tenantId} AND source_service = #{sourceService}
AND business_id = #{businessId} AND occupancy_status = 'OCCUPIED'
-- 预占创建顺序与头程入库单 allocation_no 的生成顺序一致,默认释放必须按该顺序。
ORDER BY reservation_id ASC, id ASC
FOR UPDATE
""")
@@ -10,6 +10,24 @@ import org.apache.ibatis.annotations.Update;
/** 库位预占 Mapper。 */
@Mapper
public interface LocationReservationMapper extends BaseMapper<LocationReservationEntity> {
@Update("""
UPDATE wh_location_reservation SET location_id=#{locationId}, location_code=#{locationCode}, shared_overflow=0,
updated_at=CURRENT_TIMESTAMP(3), version=version+1
WHERE id=#{id} AND tenant_id=#{tenantId} AND reservation_status='CONFIRMED' AND version=#{version}
""")
int rebindLocation(@Param("tenantId") long tenantId, @Param("id") long id, @Param("locationId") long locationId,
@Param("locationCode") String locationCode, @Param("version") int version);
@Update("""
UPDATE wh_location_reservation SET business_id=#{businessId}, inbound_code=#{inboundCode},
location_id=#{locationId}, location_code=#{locationCode}, shared_overflow=#{sharedOverflow},
updated_at=CURRENT_TIMESTAMP(3), version=version+1
WHERE id=#{id} AND tenant_id=#{tenantId} AND reservation_status='CONFIRMED' AND version=#{version}
""")
int reactivateForChild(@Param("tenantId") long tenantId, @Param("id") long id, @Param("businessId") long businessId,
@Param("inboundCode") String inboundCode, @Param("locationId") long locationId,
@Param("locationCode") String locationCode, @Param("sharedOverflow") boolean sharedOverflow,
@Param("version") int version);
@Select("""
SELECT COUNT(1) FROM wh_location_reservation
WHERE tenant_id = #{tenantId} AND location_id = #{locationId}
@@ -42,4 +60,19 @@ public interface LocationReservationMapper extends BaseMapper<LocationReservatio
""")
int release(@Param("tenantId") long tenantId, @Param("id") long id,
@Param("version") int version, @Param("reason") String reason);
@Update("""
UPDATE wh_location_reservation
SET business_id = #{targetBusinessId}, inbound_code = #{targetInboundCode},
updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{id} AND tenant_id = #{tenantId}
AND source_service = #{sourceService} AND business_id = #{sourceBusinessId}
AND reservation_status = 'CONFIRMED' AND version = #{version}
""")
int transferConfirmed(@Param("tenantId") long tenantId, @Param("id") long id,
@Param("sourceService") String sourceService,
@Param("sourceBusinessId") long sourceBusinessId,
@Param("targetBusinessId") long targetBusinessId,
@Param("targetInboundCode") String targetInboundCode,
@Param("version") int version);
}
@@ -0,0 +1,39 @@
package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.entity.PalletLocationReleaseOperationEntity;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
/** 已占用托盘库位选择性释放的幂等操作 Mapper。 */
@Mapper
public interface PalletLocationReleaseOperationMapper extends BaseMapper<PalletLocationReleaseOperationEntity> {
@Insert("""
INSERT IGNORE INTO wh_pallet_location_release_operation
(id, tenant_id, source_service, business_id, idempotency_key, request_hash, operation_status)
VALUES (#{entity.id}, #{entity.tenantId}, #{entity.sourceService}, #{entity.businessId},
#{entity.idempotencyKey}, #{entity.requestHash}, 'PROCESSING')
""")
int insertIfAbsent(@Param("entity") PalletLocationReleaseOperationEntity entity);
@Select("""
SELECT * FROM wh_pallet_location_release_operation
WHERE tenant_id = #{tenantId} AND source_service = #{sourceService}
AND business_id = #{businessId} AND idempotency_key = #{idempotencyKey}
FOR UPDATE
""")
PalletLocationReleaseOperationEntity lockByKey(@Param("tenantId") long tenantId,
@Param("sourceService") String sourceService, @Param("businessId") long businessId,
@Param("idempotencyKey") String idempotencyKey);
@Update("""
UPDATE wh_pallet_location_release_operation
SET operation_status = 'COMPLETED', response_snapshot = #{responseSnapshot},
completed_at = CURRENT_TIMESTAMP(3)
WHERE id = #{id} AND operation_status = 'PROCESSING'
""")
int complete(@Param("id") long id, @Param("responseSnapshot") String responseSnapshot);
}
@@ -0,0 +1,40 @@
package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.entity.PalletLocationSettlementOperationEntity;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
/** PDA 拣货资源原子结算幂等操作 Mapper。 */
@Mapper
public interface PalletLocationSettlementOperationMapper extends BaseMapper<PalletLocationSettlementOperationEntity> {
@Insert("""
INSERT IGNORE INTO wh_pallet_location_settlement_operation
(id, tenant_id, source_service, source_business_id, target_business_id, idempotency_key,
request_hash, operation_status)
VALUES (#{entity.id}, #{entity.tenantId}, #{entity.sourceService}, #{entity.sourceBusinessId},
#{entity.targetBusinessId}, #{entity.idempotencyKey}, #{entity.requestHash}, 'PROCESSING')
""")
int insertIfAbsent(@Param("entity") PalletLocationSettlementOperationEntity entity);
@Select("""
SELECT * FROM wh_pallet_location_settlement_operation
WHERE tenant_id = #{tenantId} AND source_service = #{sourceService}
AND source_business_id = #{sourceBusinessId} AND idempotency_key = #{idempotencyKey}
FOR UPDATE
""")
PalletLocationSettlementOperationEntity lockByKey(@Param("tenantId") long tenantId,
@Param("sourceService") String sourceService, @Param("sourceBusinessId") long sourceBusinessId,
@Param("idempotencyKey") String idempotencyKey);
@Update("""
UPDATE wh_pallet_location_settlement_operation
SET operation_status = 'COMPLETED', response_snapshot = #{responseSnapshot},
completed_at = CURRENT_TIMESTAMP(3)
WHERE id = #{id} AND operation_status = 'PROCESSING'
""")
int complete(@Param("id") long id, @Param("responseSnapshot") String responseSnapshot);
}
@@ -0,0 +1,41 @@
package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.entity.PalletLocationTransferOperationEntity;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
/** 托盘库位转移幂等操作 Mapper。 */
@Mapper
public interface PalletLocationTransferOperationMapper extends BaseMapper<PalletLocationTransferOperationEntity> {
@Insert("""
INSERT IGNORE INTO wh_pallet_location_transfer_operation
(id, tenant_id, source_service, source_business_id, target_business_id, idempotency_key,
request_hash, operation_status)
VALUES (#{entity.id}, #{entity.tenantId}, #{entity.sourceService}, #{entity.sourceBusinessId},
#{entity.targetBusinessId}, #{entity.idempotencyKey}, #{entity.requestHash}, 'PROCESSING')
""")
int insertIfAbsent(@Param("entity") PalletLocationTransferOperationEntity entity);
@Select("""
SELECT * FROM wh_pallet_location_transfer_operation
WHERE tenant_id = #{tenantId} AND source_service = #{sourceService}
AND source_business_id = #{sourceBusinessId} AND target_business_id = #{targetBusinessId}
AND idempotency_key = #{idempotencyKey}
FOR UPDATE
""")
PalletLocationTransferOperationEntity lockByKey(@Param("tenantId") long tenantId,
@Param("sourceService") String sourceService, @Param("sourceBusinessId") long sourceBusinessId,
@Param("targetBusinessId") long targetBusinessId, @Param("idempotencyKey") String idempotencyKey);
@Update("""
UPDATE wh_pallet_location_transfer_operation
SET operation_status = 'COMPLETED', response_snapshot = #{responseSnapshot},
completed_at = CURRENT_TIMESTAMP(3)
WHERE id = #{id} AND operation_status = 'PROCESSING'
""")
int complete(@Param("id") long id, @Param("responseSnapshot") String responseSnapshot);
}
@@ -129,6 +129,13 @@ public class LocationRepository {
return reservations.release(entity.getTenantId(), entity.getId(), entity.getVersion(), reason);
}
public int transferConfirmedReservation(LocationReservationEntity entity, String sourceService,
long sourceBusinessId, long targetBusinessId,
String targetInboundCode) {
return reservations.transferConfirmed(entity.getTenantId(), entity.getId(), sourceService,
sourceBusinessId, targetBusinessId, targetInboundCode, entity.getVersion());
}
public List<LocationOccupancyEntity> findOccupanciesByBusiness(long tenantId, String sourceService,
long businessId) {
return occupancies.selectList(new LambdaQueryWrapper<LocationOccupancyEntity>()
@@ -143,6 +150,33 @@ public class LocationRepository {
.eq(LocationOccupancyEntity::getReservationId, reservationId));
}
public LocationOccupancyEntity lockOccupancy(long tenantId, long occupancyId) {
return occupancies.lockById(tenantId, occupancyId);
}
public int rebindOccupancy(LocationOccupancyEntity entity, LocationEntity target) {
return occupancies.rebindLocation(entity.getTenantId(), entity.getId(), target.getId(), target.getLocationCode(),
entity.getVersion());
}
public int rebindReservation(LocationReservationEntity entity, LocationEntity target) {
return reservations.rebindLocation(entity.getTenantId(), entity.getId(), target.getId(), target.getLocationCode(),
entity.getVersion());
}
public int reactivateOccupancy(LocationOccupancyEntity entity, long businessId, String inboundCode,
LocationEntity target, int packageCount) {
return occupancies.reactivateForChild(entity.getTenantId(), entity.getId(), businessId, inboundCode,
target.getId(), target.getLocationCode(), Boolean.TRUE.equals(target.getSharedOverflow()), packageCount,
entity.getVersion());
}
public int reactivateReservation(LocationReservationEntity entity, long businessId, String inboundCode,
LocationEntity target) {
return reservations.reactivateForChild(entity.getTenantId(), entity.getId(), businessId, inboundCode,
target.getId(), target.getLocationCode(), Boolean.TRUE.equals(target.getSharedOverflow()), entity.getVersion());
}
public List<LocationOccupancyEntity> lockOccupiedByBusiness(long tenantId, String sourceService,
long businessId) {
return occupancies.lockOccupiedByBusiness(tenantId, sourceService, businessId);
@@ -155,4 +189,10 @@ public class LocationRepository {
public int releaseOccupancy(LocationOccupancyEntity entity, String reason) {
return occupancies.release(entity.getTenantId(), entity.getId(), entity.getVersion(), reason);
}
public int transferOccupied(LocationOccupancyEntity entity, String sourceService, long sourceBusinessId,
long targetBusinessId, String targetInboundCode, int remainingPackageCount) {
return occupancies.transferOccupied(entity.getTenantId(), entity.getId(), sourceService, sourceBusinessId,
targetBusinessId, targetInboundCode, remainingPackageCount, entity.getVersion());
}
}
@@ -0,0 +1,35 @@
package com.baiyue.warehouse.repository;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.warehouse.entity.PalletLocationReleaseOperationEntity;
import com.baiyue.warehouse.mapper.PalletLocationReleaseOperationMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
/** 已占用托盘库位选择性释放的持久化幂等边界。 */
@Repository
public class PalletLocationReleaseOperationRepository {
@Resource
private PalletLocationReleaseOperationMapper mapper;
public boolean createIfAbsent(long tenantId, String sourceService, long businessId,
String idempotencyKey, String requestHash) {
PalletLocationReleaseOperationEntity entity = new PalletLocationReleaseOperationEntity();
entity.setId(IdWorker.getId());
entity.setTenantId(tenantId);
entity.setSourceService(sourceService);
entity.setBusinessId(businessId);
entity.setIdempotencyKey(idempotencyKey);
entity.setRequestHash(requestHash);
return mapper.insertIfAbsent(entity) == 1;
}
public PalletLocationReleaseOperationEntity lockByKey(long tenantId, String sourceService,
long businessId, String idempotencyKey) {
return mapper.lockByKey(tenantId, sourceService, businessId, idempotencyKey);
}
public void complete(long id, String responseSnapshot) {
mapper.complete(id, responseSnapshot);
}
}
@@ -0,0 +1,37 @@
package com.baiyue.warehouse.repository;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.warehouse.entity.PalletLocationSettlementOperationEntity;
import com.baiyue.warehouse.mapper.PalletLocationSettlementOperationMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
/** PDA 拣货资源原子结算的持久化幂等边界。 */
@Repository
public class PalletLocationSettlementOperationRepository {
private static final long PLATFORM_RESOURCE_TENANT_ID = 0L;
private static final String FIRSTLEG_SOURCE_SERVICE = "firstleg-service";
@Resource
private PalletLocationSettlementOperationMapper mapper;
public boolean createIfAbsent(long sourceBusinessId, long targetBusinessId, String idempotencyKey,
String requestHash) {
PalletLocationSettlementOperationEntity entity = new PalletLocationSettlementOperationEntity();
entity.setId(IdWorker.getId());
entity.setTenantId(PLATFORM_RESOURCE_TENANT_ID);
entity.setSourceService(FIRSTLEG_SOURCE_SERVICE);
entity.setSourceBusinessId(sourceBusinessId);
entity.setTargetBusinessId(targetBusinessId);
entity.setIdempotencyKey(idempotencyKey);
entity.setRequestHash(requestHash);
return mapper.insertIfAbsent(entity) == 1;
}
public PalletLocationSettlementOperationEntity lockByKey(long sourceBusinessId, String idempotencyKey) {
return mapper.lockByKey(PLATFORM_RESOURCE_TENANT_ID, FIRSTLEG_SOURCE_SERVICE, sourceBusinessId, idempotencyKey);
}
public void complete(long id, String responseSnapshot) {
mapper.complete(id, responseSnapshot);
}
}
@@ -0,0 +1,36 @@
package com.baiyue.warehouse.repository;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.warehouse.entity.PalletLocationTransferOperationEntity;
import com.baiyue.warehouse.mapper.PalletLocationTransferOperationMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
/** 托盘库位归属转移的幂等操作持久化边界。 */
@Repository
public class PalletLocationTransferOperationRepository {
@Resource
private PalletLocationTransferOperationMapper mapper;
public boolean createIfAbsent(long tenantId, String sourceService, long sourceBusinessId,
long targetBusinessId, String idempotencyKey, String requestHash) {
PalletLocationTransferOperationEntity entity = new PalletLocationTransferOperationEntity();
entity.setId(IdWorker.getId());
entity.setTenantId(tenantId);
entity.setSourceService(sourceService);
entity.setSourceBusinessId(sourceBusinessId);
entity.setTargetBusinessId(targetBusinessId);
entity.setIdempotencyKey(idempotencyKey);
entity.setRequestHash(requestHash);
return mapper.insertIfAbsent(entity) == 1;
}
public PalletLocationTransferOperationEntity lockByKey(long tenantId, String sourceService,
long sourceBusinessId, long targetBusinessId, String idempotencyKey) {
return mapper.lockByKey(tenantId, sourceService, sourceBusinessId, targetBusinessId, idempotencyKey);
}
public void complete(long id, String responseSnapshot) {
mapper.complete(id, responseSnapshot);
}
}
@@ -3,10 +3,14 @@ package com.baiyue.warehouse.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.LocationDtos;
import com.baiyue.warehouse.dto.LocationDtos.ActiveResource;
import com.baiyue.warehouse.dto.LocationDtos.ConfirmCommand;
import com.baiyue.warehouse.dto.LocationDtos.ExactSelectiveReleaseCommand;
import com.baiyue.warehouse.dto.LocationDtos.ExactSelectiveReleaseResult;
import com.baiyue.warehouse.dto.LocationDtos.OccupancyResult;
import com.baiyue.warehouse.dto.LocationDtos.OccupancyTarget;
import com.baiyue.warehouse.dto.LocationDtos.OccupiedResource;
import com.baiyue.warehouse.dto.LocationDtos.ReleaseCommand;
import com.baiyue.warehouse.dto.LocationDtos.ReservationResult;
import com.baiyue.warehouse.dto.LocationDtos.ReservationTarget;
@@ -31,6 +35,7 @@ import jakarta.validation.constraints.NotBlank;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.stream.Collectors;
@@ -187,11 +192,13 @@ public class LocationResourceService {
@Transactional
public List<SelectiveReleaseResult> releaseSelected(@Valid SelectiveReleaseCommand command) {
List<LocationOccupancyEntity> active = repository.lockOccupiedByBusiness(
command.tenantId(), command.sourceService(), command.businessId());
List<LocationOccupancyEntity> active = new ArrayList<>(repository.lockOccupiedByBusiness(
command.tenantId(), command.sourceService(), command.businessId()));
if (active.isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "No occupied locations are available for this inbound order");
}
active.sort(Comparator.comparingLong(LocationOccupancyEntity::getReservationId)
.thenComparingLong(LocationOccupancyEntity::getId));
requireLimit(active.size());
List<Integer> packages = packageAllocator.allocate(command.totalPackageCount(), active.size());
Set<Long> requestedIds = command.locationIds() == null
@@ -245,6 +252,314 @@ public class LocationResourceService {
return List.copyOf(released);
}
/**
* Releases only the occupied locations explicitly reported as fully picked by PDA.
* The persisted allocated package count is returned verbatim: it must never be recalculated
* from the current active location count.
*/
@Transactional
public List<ExactSelectiveReleaseResult> releaseSelectedExactly(@Valid ExactSelectiveReleaseCommand command) {
List<LocationOccupancyEntity> active = new ArrayList<>(repository.lockOccupiedByBusiness(
command.tenantId(), command.sourceService(), command.businessId()));
if (active.isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "No occupied locations are available for this inbound order");
}
active.sort(Comparator.comparingLong(LocationOccupancyEntity::getReservationId)
.thenComparingLong(LocationOccupancyEntity::getId));
Set<Long> requestedIds = new HashSet<>(command.locationIds());
if (requestedIds.size() != command.locationIds().size()) {
throw new BizException(ResultCode.BAD_REQUEST, "Selected location IDs must be unique");
}
Map<Long, LocationOccupancyEntity> byLocation = active.stream()
.collect(Collectors.toMap(LocationOccupancyEntity::getLocationId, item -> item));
if (!requestedIds.stream().allMatch(byLocation::containsKey)) {
throw new BizException(ResultCode.FORBIDDEN,
"A selected location does not belong to the current inbound order");
}
List<ExactSelectiveReleaseResult> released = new ArrayList<>(requestedIds.size());
for (int index = 0; index < active.size(); index++) {
LocationOccupancyEntity occupancy = active.get(index);
if (!requestedIds.contains(occupancy.getLocationId())) {
continue;
}
Integer allocatedPackageCount = occupancy.getAllocatedPackageCount();
if (allocatedPackageCount == null || allocatedPackageCount < 1) {
throw new BizException(ResultCode.CONFLICT,
"Occupied location has no valid allocated package count and cannot be released exactly");
}
LocationEntity location = repository.findOwned(command.tenantId(), occupancy.getWarehouseId(),
occupancy.getLocationId());
if (location == null || repository.releaseOccupancy(occupancy, command.reason()) != 1
|| (!Boolean.TRUE.equals(occupancy.getSharedOverflow())
&& repository.changeStatus(command.tenantId(), occupancy.getWarehouseId(), occupancy.getLocationId(),
"OCCUPIED", "AVAILABLE", location.getVersion(), command.operatorId()) != 1)) {
throw new BizException(ResultCode.CONFLICT,
"Location occupancy changed concurrently while releasing selected locations");
}
released.add(new ExactSelectiveReleaseResult(occupancy.getReservationId(), occupancy.getId(), index + 1,
occupancy.getWarehouseId(), occupancy.getPalletId(), occupancy.getLocationId(),
occupancy.getLocationCode(), allocatedPackageCount));
}
return List.copyOf(released);
}
/** 按 reservationId 释放,避免 A0 多托盘共享同一个 locationId 时误操作。 */
@Transactional
public List<ExactSelectiveReleaseResult> releaseSelectedReservations(
@Valid LocationDtos.ReservationReleaseCommand command) {
List<LocationOccupancyEntity> active = new ArrayList<>(repository.lockOccupiedByBusiness(
command.tenantId(), command.sourceService(), command.businessId()));
Map<Long, LocationOccupancyEntity> byReservation = active.stream()
.collect(Collectors.toMap(LocationOccupancyEntity::getReservationId, item -> item));
Set<Long> requested = new HashSet<>(command.reservationIds());
if (requested.size() != command.reservationIds().size() || !requested.stream().allMatch(byReservation::containsKey)) {
throw new BizException(ResultCode.BAD_REQUEST, "释放预占资源不存在或重复");
}
List<ExactSelectiveReleaseResult> result = new ArrayList<>();
for (int index = 0; index < active.size(); index++) {
LocationOccupancyEntity occupancy = active.get(index);
if (!requested.contains(occupancy.getReservationId())) continue;
Integer allocated = occupancy.getAllocatedPackageCount();
LocationEntity location = repository.findOwned(command.tenantId(), occupancy.getWarehouseId(), occupancy.getLocationId());
if (allocated == null || allocated < 1 || location == null || repository.releaseOccupancy(occupancy, command.reason()) != 1
|| (!Boolean.TRUE.equals(occupancy.getSharedOverflow()) && repository.changeStatus(command.tenantId(),
occupancy.getWarehouseId(), occupancy.getLocationId(), "OCCUPIED", "AVAILABLE", location.getVersion(),
command.operatorId()) != 1)) {
throw new BizException(ResultCode.CONFLICT, "释放库位资源时发生并发变更");
}
result.add(new ExactSelectiveReleaseResult(occupancy.getReservationId(), occupancy.getId(), index + 1,
occupancy.getWarehouseId(), occupancy.getPalletId(), occupancy.getLocationId(), occupancy.getLocationCode(), allocated));
}
return List.copyOf(result);
}
/**
* 将未拣货的已占用库位从主入库单转给二级子入库单。
* 转移只改变业务归属和剩余箱数,不释放库位或托盘。
*/
@Transactional
public List<LocationDtos.TransferResult> transferToChild(@Valid LocationDtos.TransferCommand command) {
if (command.sourceBusinessId() == command.targetBusinessId()) {
throw new BizException(ResultCode.BAD_REQUEST, "来源和目标入库单不能相同");
}
validateTransferItems(command.items());
List<LocationOccupancyEntity> active = new ArrayList<>(repository.lockOccupiedByBusiness(
command.tenantId(), command.sourceService(), command.sourceBusinessId()));
if (active.isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "来源入库单没有可转移的已占用库位");
}
active.sort(Comparator.comparingLong(LocationOccupancyEntity::getReservationId)
.thenComparingLong(LocationOccupancyEntity::getId));
Map<Long, LocationDtos.TransferItem> requested = command.items().stream()
.collect(Collectors.toMap(LocationDtos.TransferItem::locationId, item -> item));
Map<Long, LocationOccupancyEntity> byLocation = active.stream()
.collect(Collectors.toMap(LocationOccupancyEntity::getLocationId, item -> item));
if (!requested.keySet().stream().allMatch(byLocation::containsKey)) {
throw new BizException(ResultCode.FORBIDDEN, "转移库位不属于来源入库单");
}
List<LocationDtos.TransferResult> results = new ArrayList<>(requested.size());
for (int index = 0; index < active.size(); index++) {
LocationOccupancyEntity occupancy = active.get(index);
LocationDtos.TransferItem item = requested.get(occupancy.getLocationId());
if (item == null) {
continue;
}
LocationReservationEntity reservation = repository.lockReservation(
command.tenantId(), occupancy.getReservationId());
if (reservation == null || !"CONFIRMED".equals(reservation.getReservationStatus())
|| !command.sourceService().equals(reservation.getSourceService())
|| reservation.getBusinessId() != command.sourceBusinessId()
|| !command.sourceService().equals(occupancy.getSourceService())
|| occupancy.getBusinessId() != command.sourceBusinessId()) {
throw new BizException(ResultCode.CONFLICT, "库位占用不是可转移的已确认资源");
}
Integer allocated = occupancy.getAllocatedPackageCount();
if (allocated == null || item.remainingPackageCount() > allocated) {
throw new BizException(ResultCode.BAD_REQUEST, "剩余箱数不能超过库位原分配箱数");
}
if (repository.transferConfirmedReservation(reservation, command.sourceService(),
command.sourceBusinessId(), command.targetBusinessId(), command.targetInboundCode()) != 1) {
throw new BizException(ResultCode.CONFLICT, "库位预占归属并发变更,请刷新后重试");
}
if (repository.transferOccupied(occupancy, command.sourceService(), command.sourceBusinessId(),
command.targetBusinessId(), command.targetInboundCode(), item.remainingPackageCount()) != 1) {
throw new BizException(ResultCode.CONFLICT, "库位占用归属并发变更,请刷新后重试");
}
results.add(new LocationDtos.TransferResult(reservation.getId(), reservation.getReservationNo(),
occupancy.getId(), index + 1, occupancy.getWarehouseId(), occupancy.getPalletId(),
occupancy.getPalletCode(), occupancy.getLocationId(), occupancy.getLocationCode(),
occupancy.getAllocatedVolumeCbm(), item.remainingPackageCount(), "OCCUPIED"));
}
return List.copyOf(results);
}
/** PDA 结算按预约记录转移未拣货资源,物理托盘和库位继续保持占用。 */
@Transactional
public List<LocationDtos.TransferResult> transferToChildByReservation(
@Valid LocationDtos.ReservationTransferCommand command) {
if (command.sourceBusinessId() == command.targetBusinessId()) {
throw new BizException(ResultCode.BAD_REQUEST, "来源和目标入库单不能相同");
}
validateReservationTransferItems(command.items());
List<LocationOccupancyEntity> active = occupiedResources(command.tenantId(), command.sourceService(),
command.sourceBusinessId());
Map<Long, LocationDtos.ReservationTransferItem> requested = command.items().stream()
.collect(Collectors.toMap(LocationDtos.ReservationTransferItem::reservationId, item -> item));
Map<Long, LocationOccupancyEntity> byReservation = active.stream()
.collect(Collectors.toMap(LocationOccupancyEntity::getReservationId, item -> item));
if (!requested.keySet().stream().allMatch(byReservation::containsKey)) {
throw new BizException(ResultCode.FORBIDDEN, "转移预约记录不属于来源入库单");
}
List<LocationDtos.TransferResult> results = new ArrayList<>(requested.size());
for (int index = 0; index < active.size(); index++) {
LocationOccupancyEntity occupancy = active.get(index);
LocationDtos.ReservationTransferItem item = requested.get(occupancy.getReservationId());
if (item == null) {
continue;
}
LocationReservationEntity reservation = repository.lockReservation(command.tenantId(),
occupancy.getReservationId());
if (reservation == null || !"CONFIRMED".equals(reservation.getReservationStatus())
|| !command.sourceService().equals(reservation.getSourceService())
|| reservation.getBusinessId() != command.sourceBusinessId()
|| !command.sourceService().equals(occupancy.getSourceService())
|| occupancy.getBusinessId() != command.sourceBusinessId()) {
throw new BizException(ResultCode.CONFLICT, "预约记录不是可转移的已确认资源");
}
Integer allocated = occupancy.getAllocatedPackageCount();
if (allocated == null || item.remainingPackageCount() > allocated) {
throw new BizException(ResultCode.BAD_REQUEST, "剩余箱数不能超过预约记录原分配箱数");
}
if (repository.transferConfirmedReservation(reservation, command.sourceService(),
command.sourceBusinessId(), command.targetBusinessId(), command.targetInboundCode()) != 1
|| repository.transferOccupied(occupancy, command.sourceService(), command.sourceBusinessId(),
command.targetBusinessId(), command.targetInboundCode(), item.remainingPackageCount()) != 1) {
throw new BizException(ResultCode.CONFLICT, "预约记录归属并发变更,请刷新后重试");
}
results.add(new LocationDtos.TransferResult(reservation.getId(), reservation.getReservationNo(),
occupancy.getId(), index + 1, occupancy.getWarehouseId(), occupancy.getPalletId(),
occupancy.getPalletCode(), occupancy.getLocationId(), occupancy.getLocationCode(),
occupancy.getAllocatedVolumeCbm(), item.remainingPackageCount(), "OCCUPIED"));
}
return List.copyOf(results);
}
/**
* 锁定读取来源入库单的全部已占用资源,供外层原子结算先校验数量守恒后再写入。
* 调用方必须处于同一个写事务中,锁会持续至结算完成或回滚。
*/
public List<OccupiedResource> lockOccupiedResources(long tenantId, String sourceService, long businessId) {
return occupiedResources(tenantId, sourceService, businessId).stream()
.map(item -> new OccupiedResource(item.getReservationId(), item.getId(), 0,
item.getWarehouseId(), item.getPalletId(), item.getLocationId(), item.getLocationCode(),
Boolean.TRUE.equals(item.getSharedOverflow()), requireAllocatedPackageCount(item)))
.toList();
}
/** 将 A0 的一条托盘占用换绑到同仓空闲普通库位。 */
@Transactional
public LocationDtos.RebindTemporaryLocationResult rebindTemporaryLocation(
@Valid LocationDtos.RebindTemporaryLocationCommand command) {
LocationOccupancyEntity occupancy = repository.lockOccupancy(command.tenantId(), command.occupancyId());
if (occupancy == null || !"OCCUPIED".equals(occupancy.getOccupancyStatus())) {
throw new BizException(ResultCode.NOT_FOUND, "临时库位占用不存在");
}
LocationEntity target = repository.findOwned(command.tenantId(), occupancy.getWarehouseId(), command.targetLocationId());
if (target == null || Boolean.TRUE.equals(target.getSharedOverflow())) {
throw new BizException(ResultCode.BAD_REQUEST, "目标库位必须是同仓普通库位");
}
if (!Boolean.TRUE.equals(occupancy.getSharedOverflow())) {
if (occupancy.getLocationId().equals(target.getId())) {
return new LocationDtos.RebindTemporaryLocationResult(occupancy.getId(), occupancy.getReservationId(),
occupancy.getWarehouseId(), occupancy.getPalletId(), occupancy.getLocationId(), occupancy.getLocationCode(),
target.getId(), target.getLocationCode());
}
throw new BizException(ResultCode.CONFLICT, "只有A0临时库位允许换绑");
}
if (!"AVAILABLE".equals(target.getStatus())
|| repository.changeStatus(command.tenantId(), occupancy.getWarehouseId(), target.getId(), "AVAILABLE", "OCCUPIED",
target.getVersion(), command.operatorId()) != 1) {
throw new BizException(ResultCode.CONFLICT, "目标库位已被占用");
}
LocationReservationEntity reservation = repository.lockReservation(command.tenantId(), occupancy.getReservationId());
long oldLocationId = occupancy.getLocationId();
String oldLocationCode = occupancy.getLocationCode();
if (reservation == null || repository.rebindReservation(reservation, target) != 1
|| repository.rebindOccupancy(occupancy, target) != 1) {
throw new BizException(ResultCode.CONFLICT, "临时库位换绑时发生并发变更");
}
return new LocationDtos.RebindTemporaryLocationResult(occupancy.getId(), occupancy.getReservationId(),
occupancy.getWarehouseId(), occupancy.getPalletId(), oldLocationId, oldLocationCode,
target.getId(), target.getLocationCode());
}
/** 全部释放后为子单重新占用同仓普通库位;普通库位不足时使用共享 A0。 */
@Transactional
public LocationDtos.TransferResult reactivateReleasedForChild(
@Valid LocationDtos.ReactivateReleasedResourceCommand command) {
LocationOccupancyEntity found = repository.findOccupancyByReservation(command.tenantId(), command.reservationId());
LocationOccupancyEntity occupancy = found == null ? null : repository.lockOccupancy(command.tenantId(), found.getId());
if (occupancy == null || !"RELEASED".equals(occupancy.getOccupancyStatus())
|| occupancy.getWarehouseId() != command.warehouseId()) {
throw new BizException(ResultCode.CONFLICT, "待重新分配的已释放资源不存在");
}
List<LocationEntity> available = repository.lockAvailable(command.tenantId(), command.warehouseId(), 1);
LocationEntity target = available.isEmpty() ? repository.lockOrCreateSharedOverflow(command.tenantId(),
command.warehouseId(), command.operatorId()) : available.get(0);
if (!Boolean.TRUE.equals(target.getSharedOverflow()) && repository.changeStatus(command.tenantId(), command.warehouseId(),
target.getId(), "AVAILABLE", "OCCUPIED", target.getVersion(), command.operatorId()) != 1) {
throw new BizException(ResultCode.CONFLICT, "重新分配的目标库位发生并发变更");
}
LocationReservationEntity reservation = repository.lockReservation(command.tenantId(), occupancy.getReservationId());
if (reservation == null || repository.reactivateReservation(reservation, command.targetBusinessId(),
command.targetInboundCode(), target) != 1 || repository.reactivateOccupancy(occupancy,
command.targetBusinessId(), command.targetInboundCode(), target, command.packageCount()) != 1) {
throw new BizException(ResultCode.CONFLICT, "重新分配子单库位时发生并发变更");
}
return new LocationDtos.TransferResult(reservation.getId(), reservation.getReservationNo(), occupancy.getId(), 1,
command.warehouseId(), occupancy.getPalletId(), occupancy.getPalletCode(), target.getId(), target.getLocationCode(),
occupancy.getAllocatedVolumeCbm(), command.packageCount(), "OCCUPIED");
}
private static void validateTransferItems(List<LocationDtos.TransferItem> items) {
Set<Long> ids = new HashSet<>();
for (LocationDtos.TransferItem item : items) {
if (!ids.add(item.locationId())) {
throw new BizException(ResultCode.BAD_REQUEST, "转移库位不能重复");
}
}
}
private static void validateReservationTransferItems(List<LocationDtos.ReservationTransferItem> items) {
Set<Long> ids = new HashSet<>();
for (LocationDtos.ReservationTransferItem item : items) {
if (!ids.add(item.reservationId())) {
throw new BizException(ResultCode.BAD_REQUEST, "转移预约记录不能重复");
}
}
}
private List<LocationOccupancyEntity> occupiedResources(long tenantId, String sourceService, long businessId) {
List<LocationOccupancyEntity> active = new ArrayList<>(repository.lockOccupiedByBusiness(tenantId,
sourceService, businessId));
if (active.isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "来源入库单没有可用的已占用库位资源");
}
active.sort(Comparator.comparingLong(LocationOccupancyEntity::getReservationId)
.thenComparingLong(LocationOccupancyEntity::getId));
return active;
}
private static int requireAllocatedPackageCount(LocationOccupancyEntity occupancy) {
Integer allocated = occupancy.getAllocatedPackageCount();
if (allocated == null || allocated < 1) {
throw new BizException(ResultCode.CONFLICT, "已占用资源缺少有效分配箱数");
}
return allocated;
}
@Transactional(readOnly = true)
public List<ActiveResource> findActiveResources(@Min(1) long tenantId,
@NotBlank String sourceService,
@@ -6,7 +6,15 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.LocationDtos;
import com.baiyue.warehouse.dto.PalletAllocationDtos;
import com.baiyue.warehouse.entity.PalletEntity;
import com.baiyue.warehouse.entity.PalletLocationTransferOperationEntity;
import com.baiyue.warehouse.entity.PalletLocationReleaseOperationEntity;
import com.baiyue.warehouse.entity.PalletLocationSettlementOperationEntity;
import com.baiyue.warehouse.repository.PalletRepository;
import com.baiyue.warehouse.repository.PalletLocationTransferOperationRepository;
import com.baiyue.warehouse.repository.PalletLocationReleaseOperationRepository;
import com.baiyue.warehouse.repository.PalletLocationSettlementOperationRepository;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -14,6 +22,10 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/**
* 托盘拆分和一托一位编排。库位资源操作全部委托给任务6的 {@link LocationResourceService}。
@@ -22,12 +34,24 @@ import java.util.List;
public class PalletLocationAllocationService {
/** 测量完成后默认推荐的普通托盘类型编码。 */
public static final String DEFAULT_PALLET_TYPE = "STANDARD";
/** 仓库、托盘和库位属于平台公共资源域。 */
private static final long PLATFORM_RESOURCE_TENANT_ID = 0L;
/** 仅头程服务可结算头程入库单占用的仓储资源。 */
private static final String FIRSTLEG_SOURCE_SERVICE = "firstleg-service";
@Resource
private PalletRepository palletRepository;
@Resource
private PalletVolumeSplitter splitter;
@Resource
private LocationResourceService locationResourceService;
@Resource
private PalletLocationTransferOperationRepository transferOperations;
@Resource
private PalletLocationReleaseOperationRepository releaseOperations;
@Resource
private PalletLocationSettlementOperationRepository settlementOperations;
@Resource
private ObjectMapper objectMapper;
@Transactional
@BusinessAudit(event = "PALLET_LOCATION_RESERVE", resourceType = "INBOUND", resourceId = "#request.businessId", description = "预占托盘和库位", captureBefore = false)
@@ -134,23 +158,378 @@ public class PalletLocationAllocationService {
resourceId = "#request.businessId", description = "Release selected occupied locations for picking")
public List<PalletAllocationDtos.SelectiveReleaseResult> releaseSelected(
PalletAllocationDtos.SelectiveReleaseRequest request) {
List<LocationDtos.SelectiveReleaseResult> released = locationResourceService.releaseSelected(
new LocationDtos.SelectiveReleaseCommand(request.tenantId(), request.sourceService(), request.businessId(),
request.operatorId(), request.reason(), request.totalPackageCount(), request.locationIds(),
request.releaseLocationCount()));
String key = request.idempotencyKey().trim();
String requestHash = selectiveReleaseRequestHash(request);
boolean created = releaseOperations.createIfAbsent(request.tenantId(), request.sourceService(),
request.businessId(), key, requestHash);
PalletLocationReleaseOperationEntity operation = releaseOperations.lockByKey(request.tenantId(),
request.sourceService(), request.businessId(), key);
if (operation == null) {
throw new BizException(ResultCode.CONFLICT, "Selected release idempotency operation is missing");
}
if (!requestHash.equals(operation.getRequestHash())) {
throw new BizException(ResultCode.CONFLICT, "Idempotency key has already been used for a different selected release request");
}
if (!created) {
if ("COMPLETED".equals(operation.getOperationStatus())) {
return replaySelectiveReleaseResult(operation.getResponseSnapshot());
}
throw new BizException(ResultCode.CONFLICT, "Selected release request is being processed, retry later");
}
List<LocationDtos.ExactSelectiveReleaseResult> released = locationResourceService.releaseSelectedExactly(
new LocationDtos.ExactSelectiveReleaseCommand(request.tenantId(), request.sourceService(), request.businessId(),
request.operatorId(), request.reason(), request.locationIds()));
List<PalletAllocationDtos.SelectiveReleaseResult> result = new ArrayList<>(released.size());
for (LocationDtos.SelectiveReleaseResult resource : released) {
for (LocationDtos.ExactSelectiveReleaseResult resource : released) {
if (palletRepository.transitionStatus(request.tenantId(), resource.warehouseId(), resource.palletId(),
List.of("OCCUPIED"), "AVAILABLE") != 1) {
throw new BizException(ResultCode.CONFLICT, "Pallet changed concurrently during selected release");
}
result.add(new PalletAllocationDtos.SelectiveReleaseResult(resource.reservationId(),
resource.occupancyId(), resource.allocationNo(), resource.palletId(), resource.locationId(),
resource.locationCode(), resource.releasedPackageCount()));
resource.locationCode(), resource.allocatedPackageCount()));
}
List<PalletAllocationDtos.SelectiveReleaseResult> immutable = List.copyOf(result);
releaseOperations.complete(operation.getId(), serializeSelectiveReleaseResult(immutable));
return immutable;
}
/**
* PDA resource settlement is deliberately one transaction. A resource cannot be released
* successfully while the matching remaining resources fail to transfer to the child inbound.
*/
@Transactional
@BusinessAudit(event = "PALLET_LOCATION_SETTLEMENT", resourceType = "INBOUND",
resourceId = "#request.sourceBusinessId", description = "PDA拣货后原子结算托盘库位资源")
public PalletAllocationDtos.SettlementResult settle(PalletAllocationDtos.SettlementRequest request) {
validateSettlementScope(request);
String key = request.idempotencyKey().trim();
String requestHash = settlementRequestHash(request);
boolean created = settlementOperations.createIfAbsent(request.sourceBusinessId(), request.targetBusinessId(),
key, requestHash);
PalletLocationSettlementOperationEntity operation = settlementOperations.lockByKey(request.sourceBusinessId(), key);
if (operation == null) {
throw new BizException(ResultCode.CONFLICT, "PDA settlement idempotency operation is missing");
}
if (!requestHash.equals(operation.getRequestHash())) {
throw new BizException(ResultCode.CONFLICT, "Idempotency key has already been used for a different PDA settlement request");
}
if (!created) {
if ("COMPLETED".equals(operation.getOperationStatus())) {
return replaySettlementResult(operation.getResponseSnapshot());
}
throw new BizException(ResultCode.CONFLICT, "PDA settlement request is being processed, retry later");
}
List<LocationDtos.OccupiedResource> sourceResources = locationResourceService.lockOccupiedResources(
PLATFORM_RESOURCE_TENANT_ID, FIRSTLEG_SOURCE_SERVICE, request.sourceBusinessId());
validateSettlement(request, sourceResources);
List<PalletAllocationDtos.SelectiveReleaseResult> released = releasePickedResources(request);
List<PalletAllocationDtos.TransferResult> transferred = transferRemainingResources(request);
PalletAllocationDtos.SettlementResult result = new PalletAllocationDtos.SettlementResult(released, transferred);
settlementOperations.complete(operation.getId(), serializeSettlementResult(result));
return result;
}
@Transactional
@BusinessAudit(event = "PALLET_LOCATION_REBIND_TEMPORARY", resourceType = "LOCATION_OCCUPANCY",
resourceId = "#request.occupancyId", description = "A0临时库位换绑到普通库位")
public PalletAllocationDtos.RebindTemporaryLocationResult rebindTemporaryLocation(
PalletAllocationDtos.RebindTemporaryLocationRequest request) {
LocationDtos.RebindTemporaryLocationResult result = locationResourceService.rebindTemporaryLocation(
new LocationDtos.RebindTemporaryLocationCommand(request.tenantId(), request.occupancyId(),
request.targetLocationId(), request.operatorId()));
return new PalletAllocationDtos.RebindTemporaryLocationResult(result.occupancyId(), result.reservationId(),
result.warehouseId(), result.palletId(), result.previousLocationId(), result.previousLocationCode(),
result.locationId(), result.locationCode());
}
private List<PalletAllocationDtos.SelectiveReleaseResult> releasePickedResources(
PalletAllocationDtos.SettlementRequest request) {
if (request.releasedReservationIds().isEmpty()) {
return List.of();
}
List<LocationDtos.ExactSelectiveReleaseResult> released = locationResourceService.releaseSelectedReservations(
new LocationDtos.ReservationReleaseCommand(PLATFORM_RESOURCE_TENANT_ID, FIRSTLEG_SOURCE_SERVICE,
request.sourceBusinessId(), request.operatorId(), settlementReason(request), request.releasedReservationIds()));
List<PalletAllocationDtos.SelectiveReleaseResult> result = new ArrayList<>(released.size());
for (LocationDtos.ExactSelectiveReleaseResult resource : released) {
if (palletRepository.transitionStatus(PLATFORM_RESOURCE_TENANT_ID, resource.warehouseId(), resource.palletId(),
List.of("OCCUPIED"), "AVAILABLE") != 1) {
throw new BizException(ResultCode.CONFLICT, "Pallet changed concurrently during PDA settlement");
}
result.add(new PalletAllocationDtos.SelectiveReleaseResult(resource.reservationId(),
resource.occupancyId(), resource.allocationNo(), resource.palletId(), resource.locationId(),
resource.locationCode(), resource.allocatedPackageCount()));
}
return List.copyOf(result);
}
private List<PalletAllocationDtos.TransferResult> transferRemainingResources(
PalletAllocationDtos.SettlementRequest request) {
if (request.remainingItems().isEmpty()) {
return List.of();
}
List<LocationDtos.TransferResult> transferred = locationResourceService.transferToChildByReservation(
new LocationDtos.ReservationTransferCommand(PLATFORM_RESOURCE_TENANT_ID, FIRSTLEG_SOURCE_SERVICE,
request.sourceBusinessId(), request.targetBusinessId(), request.targetInboundCode(), request.operatorId(),
request.remainingItems().stream()
.map(item -> new LocationDtos.ReservationTransferItem(item.reservationId(),
item.remainingPackageCount()))
.toList()));
return transferResults(PLATFORM_RESOURCE_TENANT_ID, transferred);
}
/** Same idempotency key must represent the same resource selection, regardless of request list ordering. */
private static String selectiveReleaseRequestHash(PalletAllocationDtos.SelectiveReleaseRequest request) {
String locationIds = request.locationIds() == null ? "" : request.locationIds().stream()
.sorted()
.map(String::valueOf)
.collect(Collectors.joining(","));
String canonical = request.tenantId() + "|" + request.sourceService().trim() + "|"
+ request.businessId() + "|" + request.operatorId() + "|" + request.reason().trim() + "|"
+ locationIds;
return sha256(canonical);
}
/** 将未拣货的库位和托盘分配转给二级子入库单,物理资源继续保持占用。 */
@Transactional
@BusinessAudit(event = "PALLET_LOCATION_TRANSFER", resourceType = "INBOUND",
resourceId = "#request.sourceBusinessId", description = "将未拣货库位转给子入库单")
public List<PalletAllocationDtos.TransferResult> transfer(PalletAllocationDtos.TransferRequest request) {
String key = request.idempotencyKey().trim();
String requestHash = transferRequestHash(request);
boolean created = transferOperations.createIfAbsent(request.tenantId(), request.sourceService(),
request.sourceBusinessId(), request.targetBusinessId(), key, requestHash);
PalletLocationTransferOperationEntity operation = transferOperations.lockByKey(request.tenantId(),
request.sourceService(), request.sourceBusinessId(), request.targetBusinessId(), key);
if (operation == null) {
throw new BizException(ResultCode.CONFLICT, "调拨幂等操作记录不存在");
}
if (!requestHash.equals(operation.getRequestHash())) {
throw new BizException(ResultCode.CONFLICT, "幂等键已用于不同的调拨请求");
}
if (!created) {
if ("COMPLETED".equals(operation.getOperationStatus())) {
return replayTransferResult(operation.getResponseSnapshot());
}
throw new BizException(ResultCode.CONFLICT, "调拨请求正在处理中,请稍后重试");
}
List<PalletAllocationDtos.TransferResult> immutable = transferResources(request.tenantId(), request.sourceService(),
request.sourceBusinessId(), request.targetBusinessId(), request.targetInboundCode(), request.operatorId(),
request.items());
transferOperations.complete(operation.getId(), serializeTransferResult(immutable));
return immutable;
}
private List<PalletAllocationDtos.TransferResult> transferResources(long tenantId, String sourceService,
long sourceBusinessId, long targetBusinessId, String targetInboundCode, long operatorId,
List<PalletAllocationDtos.TransferItem> items) {
List<LocationDtos.TransferResult> transferred = locationResourceService.transferToChild(
new LocationDtos.TransferCommand(tenantId, sourceService, sourceBusinessId, targetBusinessId,
targetInboundCode, operatorId, items.stream()
.map(item -> new LocationDtos.TransferItem(item.locationId(), item.remainingPackageCount()))
.toList()));
return transferResults(tenantId, transferred);
}
private List<PalletAllocationDtos.TransferResult> transferResults(long tenantId,
List<LocationDtos.TransferResult> transferred) {
List<PalletAllocationDtos.TransferResult> result = new ArrayList<>(transferred.size());
for (LocationDtos.TransferResult item : transferred) {
PalletEntity pallet = palletRepository.findOwned(tenantId, item.warehouseId(), item.palletId());
if (pallet == null || !"OCCUPIED".equals(pallet.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "转移托盘不存在或未处于占用状态");
}
result.add(new PalletAllocationDtos.TransferResult(item.reservationId(), item.reservationNo(),
item.occupancyId(), item.allocationNo(), tenantId, item.warehouseId(), item.palletId(),
item.palletCode(), pallet.getCapacityCbm(), item.locationId(), item.locationCode(),
item.allocatedVolumeCbm(), item.remainingPackageCount(), item.state()));
}
return List.copyOf(result);
}
/** 同一幂等键必须携带完全一致的调拨目标和库位箱数。 */
private static String transferRequestHash(PalletAllocationDtos.TransferRequest request) {
String items = request.items().stream()
.sorted(Comparator.comparingLong(PalletAllocationDtos.TransferItem::locationId))
.map(item -> item.locationId() + ":" + item.remainingPackageCount())
.collect(Collectors.joining(","));
String canonical = request.tenantId() + "|" + request.sourceService().trim() + "|"
+ request.sourceBusinessId() + "|" + request.targetBusinessId() + "|"
+ request.targetInboundCode().trim() + "|" + request.operatorId() + "|" + items;
return sha256(canonical);
}
private static String settlementRequestHash(PalletAllocationDtos.SettlementRequest request) {
String released = request.releasedReservationIds().stream().sorted().map(String::valueOf)
.collect(Collectors.joining(","));
String remaining = request.remainingItems().stream()
.sorted(Comparator.comparingLong(PalletAllocationDtos.SettlementTransferItem::reservationId))
.map(item -> item.reservationId() + ":" + item.remainingPackageCount())
.collect(Collectors.joining(","));
return sha256(request.warehouseId() + "|" + request.sourceService().trim() + "|"
+ request.sourceBusinessId() + "|" + request.targetBusinessId() + "|" + request.sourcePackageCount()
+ "|" + request.targetPackageCount() + "|" + normalize(request.targetInboundCode()) + "|"
+ request.operatorId() + "|" + normalize(request.operatorName()) + "|"
+ request.requestId().trim() + "|" + released + "|" + remaining);
}
private static void validateSettlementScope(PalletAllocationDtos.SettlementRequest request) {
if (!FIRSTLEG_SOURCE_SERVICE.equals(request.sourceService())) {
throw new BizException(ResultCode.FORBIDDEN, "PDA仓储资源仅允许头程服务结算");
}
}
private static String settlementReason(PalletAllocationDtos.SettlementRequest request) {
String operator = normalize(request.operatorName());
return operator.isEmpty() ? "PDA拣货资源结算:" + request.requestId().trim()
: "PDA拣货资源结算:" + request.requestId().trim() + ",操作人:" + operator;
}
private static void validateSettlement(PalletAllocationDtos.SettlementRequest request,
List<LocationDtos.OccupiedResource> sourceResources) {
java.util.Set<Long> releasedIds = new java.util.HashSet<>(request.releasedReservationIds());
if (releasedIds.size() != request.releasedReservationIds().size()) {
throw new BizException(ResultCode.BAD_REQUEST, "已拣满预约记录不能重复");
}
java.util.Map<Long, PalletAllocationDtos.SettlementTransferItem> remainingByReservation =
request.remainingItems().stream().collect(Collectors.toMap(
PalletAllocationDtos.SettlementTransferItem::reservationId, item -> item,
(first, duplicate) -> {
throw new BizException(ResultCode.BAD_REQUEST, "剩余预约记录不能重复");
}));
if (releasedIds.stream().anyMatch(remainingByReservation::containsKey)) {
throw new BizException(ResultCode.BAD_REQUEST, "同一预约记录不能同时释放和转移");
}
if (releasedIds.isEmpty() && remainingByReservation.isEmpty()) {
throw new BizException(ResultCode.BAD_REQUEST, "PDA结算必须指定已拣满或剩余的预约记录");
}
java.util.Map<Long, LocationDtos.OccupiedResource> sourceByReservation = sourceResources.stream()
.collect(Collectors.toMap(LocationDtos.OccupiedResource::reservationId, item -> item,
(first, duplicate) -> {
throw new BizException(ResultCode.CONFLICT, "同一预约记录存在多条活动占用");
}));
java.util.Set<Long> selectedIds = new java.util.HashSet<>(releasedIds);
selectedIds.addAll(remainingByReservation.keySet());
if (!sourceByReservation.keySet().equals(selectedIds)) {
throw new BizException(ResultCode.FORBIDDEN, "PDA结算预约记录必须完整且属于来源入库单");
}
int allocatedTotal = 0;
int pickedTotal = 0;
int remainingTotal = 0;
for (LocationDtos.OccupiedResource resource : sourceResources) {
if (resource.warehouseId() != request.warehouseId()) {
throw new BizException(ResultCode.FORBIDDEN, "PDA结算预约记录不属于指定仓库");
}
allocatedTotal = addPackages(allocatedTotal, resource.allocatedPackageCount());
PalletAllocationDtos.SettlementTransferItem remaining = remainingByReservation.get(resource.reservationId());
if (releasedIds.contains(resource.reservationId())) {
pickedTotal = addPackages(pickedTotal, resource.allocatedPackageCount());
continue;
}
if (remaining.remainingPackageCount() > resource.allocatedPackageCount()) {
throw new BizException(ResultCode.BAD_REQUEST, "剩余箱数不能超过预约记录原分配箱数");
}
remainingTotal = addPackages(remainingTotal, remaining.remainingPackageCount());
pickedTotal = addPackages(pickedTotal, resource.allocatedPackageCount() - remaining.remainingPackageCount());
}
if (request.sourcePackageCount() != allocatedTotal) {
throw new BizException(ResultCode.CONFLICT, "来源入库单箱数与当前库位分配箱数不一致");
}
if (request.targetPackageCount() != remainingTotal) {
throw new BizException(ResultCode.BAD_REQUEST, "目标入库单箱数必须等于剩余预约记录箱数合计");
}
if (request.sourcePackageCount() != addPackages(pickedTotal, request.targetPackageCount())) {
throw new BizException(ResultCode.BAD_REQUEST, "来源箱数必须等于已拣箱数与目标箱数之和");
}
if (request.targetPackageCount() > 0
&& (request.targetBusinessId() < 1 || request.sourceBusinessId() == request.targetBusinessId())) {
throw new BizException(ResultCode.BAD_REQUEST, "存在剩余箱数时必须指定不同的子入库单");
}
if (request.targetPackageCount() > 0 && !org.springframework.util.StringUtils.hasText(request.targetInboundCode())) {
throw new BizException(ResultCode.BAD_REQUEST, "存在剩余箱数时必须指定子入库单号");
}
}
private static int addPackages(int current, int increment) {
try {
return Math.addExact(current, increment);
} catch (ArithmeticException exception) {
throw new BizException(ResultCode.BAD_REQUEST, "箱数合计超出允许范围");
}
}
private static String normalize(String value) {
return value == null ? "" : value.trim();
}
private static String sha256(String canonical) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(canonical.getBytes(StandardCharsets.UTF_8));
StringBuilder value = new StringBuilder(64);
for (byte item : digest) {
value.append(String.format("%02x", item));
}
return value.toString();
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 算法不可用", exception);
}
}
private String serializeSelectiveReleaseResult(List<PalletAllocationDtos.SelectiveReleaseResult> result) {
try {
return objectMapper.writeValueAsString(result);
} catch (Exception exception) {
throw new BizException(ResultCode.CONFLICT, "Selected release result snapshot could not be saved");
}
}
private List<PalletAllocationDtos.SelectiveReleaseResult> replaySelectiveReleaseResult(String snapshot) {
try {
return List.copyOf(objectMapper.readValue(snapshot,
new TypeReference<List<PalletAllocationDtos.SelectiveReleaseResult>>() { }));
} catch (Exception exception) {
throw new BizException(ResultCode.CONFLICT, "Selected release idempotency result snapshot is invalid");
}
}
private String serializeSettlementResult(PalletAllocationDtos.SettlementResult result) {
try {
return objectMapper.writeValueAsString(result);
} catch (Exception exception) {
throw new BizException(ResultCode.CONFLICT, "PDA settlement result snapshot could not be saved");
}
}
private PalletAllocationDtos.SettlementResult replaySettlementResult(String snapshot) {
try {
return objectMapper.readValue(snapshot, PalletAllocationDtos.SettlementResult.class);
} catch (Exception exception) {
throw new BizException(ResultCode.CONFLICT, "PDA settlement idempotency result snapshot is invalid");
}
}
private String serializeTransferResult(List<PalletAllocationDtos.TransferResult> result) {
try {
return objectMapper.writeValueAsString(result);
} catch (Exception exception) {
throw new BizException(ResultCode.CONFLICT, "调拨结果快照保存失败");
}
}
private List<PalletAllocationDtos.TransferResult> replayTransferResult(String snapshot) {
try {
return List.copyOf(objectMapper.readValue(snapshot,
new TypeReference<List<PalletAllocationDtos.TransferResult>>() { }));
} catch (Exception exception) {
throw new BizException(ResultCode.CONFLICT, "调拨幂等结果快照无效");
}
}
private List<LocationDtos.ActiveResource> active(long tenantId, String sourceService, long businessId) {
return new ArrayList<>(locationResourceService.findActiveResources(tenantId, sourceService, businessId));
}
@@ -0,0 +1,19 @@
-- PDA 已拣满库位/托盘精确释放的持久化幂等契约;可在二期库位表建表后重复执行。
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS wh_pallet_location_release_operation (
id BIGINT UNSIGNED NOT NULL COMMENT '精确释放幂等操作ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓储公共资源作用域,固定为0',
source_service VARCHAR(40) NOT NULL COMMENT '来源服务',
business_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
idempotency_key VARCHAR(128) NOT NULL COMMENT '调用方幂等键',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求SHA-256摘要',
operation_status VARCHAR(20) NOT NULL DEFAULT 'PROCESSING' COMMENT 'PROCESSING处理中,COMPLETED已完成',
response_snapshot JSON NULL COMMENT '完成后的精确释放结果快照,用于重复请求重放',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
completed_at DATETIME(3) NULL COMMENT '完成时间',
PRIMARY KEY (id),
UNIQUE KEY uk_pallet_location_release_idempotency
(tenant_id, source_service, business_id, idempotency_key),
KEY idx_pallet_location_release_business (tenant_id, source_service, business_id, operation_status)
) ENGINE=InnoDB COMMENT='PDA已拣满托盘库位精确释放的持久化幂等操作记录';
@@ -0,0 +1,27 @@
-- PDA 拣货资源原子结算幂等契约;同一数据库事务内完成精确释放和剩余资源转移。
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS wh_pallet_location_settlement_operation (
id BIGINT UNSIGNED NOT NULL COMMENT 'PDA资源结算幂等操作ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓储公共资源作用域,固定为0',
source_service VARCHAR(40) NOT NULL COMMENT '来源服务',
source_business_id BIGINT UNSIGNED NOT NULL COMMENT '主入库单ID',
target_business_id BIGINT UNSIGNED NOT NULL COMMENT '二级子入库单ID;全拣完成时为0,仅作审计快照',
idempotency_key VARCHAR(128) NOT NULL COMMENT 'PDA结算幂等键',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求SHA-256摘要',
operation_status VARCHAR(20) NOT NULL DEFAULT 'PROCESSING' COMMENT 'PROCESSING处理中,COMPLETED已完成',
response_snapshot JSON NULL COMMENT '完成后的释放和转移组合结果快照,用于重复请求重放',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
completed_at DATETIME(3) NULL COMMENT '完成时间',
PRIMARY KEY (id),
UNIQUE KEY uk_pallet_location_settlement_idempotency
(tenant_id, source_service, source_business_id, idempotency_key),
KEY idx_pallet_location_settlement_source
(tenant_id, source_service, source_business_id, operation_status)
) ENGINE=InnoDB COMMENT='PDA拣货资源原子结算的持久化幂等操作记录';
-- 兼容已执行过旧脚本的测试库:目标子单不属于幂等键,同一来源单据和幂等键只能对应一条请求。
ALTER TABLE wh_pallet_location_settlement_operation
DROP INDEX uk_pallet_location_settlement_idempotency,
ADD UNIQUE KEY uk_pallet_location_settlement_idempotency
(tenant_id, source_service, source_business_id, idempotency_key);
@@ -0,0 +1,20 @@
-- 托盘库位转移的持久化幂等契约;可在二期托盘库位表建表后重复执行。
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS wh_pallet_location_transfer_operation (
id BIGINT UNSIGNED NOT NULL COMMENT '调拨幂等操作ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓储公共资源域,固定为0',
source_service VARCHAR(40) NOT NULL COMMENT '来源服务',
source_business_id BIGINT UNSIGNED NOT NULL COMMENT '来源入仓单ID',
target_business_id BIGINT UNSIGNED NOT NULL COMMENT '目标子入仓单ID',
idempotency_key VARCHAR(128) NOT NULL COMMENT '调用方幂等键',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求SHA-256摘要',
operation_status VARCHAR(20) NOT NULL DEFAULT 'PROCESSING' COMMENT 'PROCESSING处理中、COMPLETED已完成',
response_snapshot JSON NULL COMMENT '完成后的调拨结果快照,用于重复请求重放',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
completed_at DATETIME(3) NULL COMMENT '完成时间',
PRIMARY KEY (id),
UNIQUE KEY uk_pallet_location_transfer_idempotency
(tenant_id, source_service, source_business_id, target_business_id, idempotency_key),
KEY idx_pallet_location_transfer_source (tenant_id, source_service, source_business_id, operation_status)
) ENGINE=InnoDB COMMENT='托盘库位归属转移的持久化幂等操作记录';
@@ -0,0 +1,78 @@
package com.baiyue.warehouse.controller;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.PalletAllocationDtos;
import com.baiyue.warehouse.service.PalletLocationAllocationService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class InternalPalletLocationControllerTest {
private static final String INTERNAL_TOKEN = "test-internal-service-token";
private final PalletLocationAllocationService service = mock(PalletLocationAllocationService.class);
private final InternalPalletLocationController controller = new InternalPalletLocationController();
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(controller, "service", service);
ReflectionTestUtils.setField(controller, "internalToken", INTERNAL_TOKEN);
controller.initializeToken();
}
@Test
void transfersOnlyForInternalCaller() {
PalletAllocationDtos.TransferRequest request = request();
List<PalletAllocationDtos.TransferResult> expected = List.of(new PalletAllocationDtos.TransferResult(
101L, "LR101", 201L, 1, 10001L, 20L, 30L, "TP-001", null,
40L, "A-01", null, 5, "OCCUPIED"));
when(service.transfer(request)).thenReturn(expected);
var response = controller.transfer(INTERNAL_TOKEN, request);
assertThat(response.data()).isEqualTo(expected);
verify(service).transfer(request);
}
@Test
void rejectsInvalidInternalTokenBeforeTransfer() {
assertThatThrownBy(() -> controller.transfer("invalid", request()))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.FORBIDDEN);
verifyNoInteractions(service);
}
@Test
void settlesPdaResourcesOnlyForInternalCaller() {
PalletAllocationDtos.SettlementRequest request = settlementRequest();
PalletAllocationDtos.SettlementResult expected = new PalletAllocationDtos.SettlementResult(List.of(), List.of());
when(service.settle(request)).thenReturn(expected);
var response = controller.settle(INTERNAL_TOKEN, request);
assertThat(response.data()).isEqualTo(expected);
verify(service).settle(request);
}
private static PalletAllocationDtos.TransferRequest request() {
return new PalletAllocationDtos.TransferRequest(10001L, "FIRSTLEG", 88L, 89L,
"RK-001-S1", "transfer-88-89", 9001L, List.of(new PalletAllocationDtos.TransferItem(40L, 5)));
}
private static PalletAllocationDtos.SettlementRequest settlementRequest() {
return new PalletAllocationDtos.SettlementRequest(0L, 20L, "firstleg-service", 88L, 89L,
20, 12, "RK-001-S1", "PDA-SETTLEMENT-88-1", 9001L, "PDA settlement", List.of(101L),
List.of(new PalletAllocationDtos.SettlementTransferItem(102L, 12)));
}
}
@@ -0,0 +1,29 @@
package com.baiyue.warehouse.dto;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class PalletAllocationDtosValidationTest {
private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
@Test
void acceptsPublicResourceScope() {
LocalDateTime expiresAt = LocalDateTime.now().plusMinutes(30);
assertThat(validator.validate(new PalletAllocationDtos.ReserveRequest(0L, 20L, "FIRSTLEG", 88L,
"RK-001", "measure-88", 9L, BigDecimal.ONE, BigDecimal.ONE, "STANDARD", expiresAt, true))).isEmpty();
assertThat(validator.validate(new PalletAllocationDtos.ConfirmRequest(0L, 20L, "FIRSTLEG", 88L,
9L, BigDecimal.ONE, BigDecimal.ONE))).isEmpty();
assertThat(validator.validate(new PalletAllocationDtos.ReleaseRequest(0L, "FIRSTLEG", 88L, 9L,
"入库补偿"))).isEmpty();
assertThat(validator.validate(new PalletAllocationDtos.SelectiveReleaseRequest(0L, "FIRSTLEG", 88L,
"pda-release-88-1", 9L, "PDA 部分拣货", List.of(40L)))).isEmpty();
}
}
@@ -0,0 +1,48 @@
package com.baiyue.warehouse.repository;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class PalletLocationReleaseIdempotencySqlContractTest {
@Test
void containsPersistentSelectedReleaseIdempotencySchema() throws IOException {
String moduleSql = Files.readString(Path.of("src", "main", "resources", "db",
"phase3-pallet-location-release-idempotency.sql"));
String unifiedSql = Files.readString(Path.of("..", "deploy", "sql",
"头程数据库统一增量-MySQL8.sql"));
for (String sql : new String[] {moduleSql, unifiedSql}) {
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS wh_pallet_location_release_operation")
.contains("idempotency_key VARCHAR(128) NOT NULL")
.contains("request_hash CHAR(64) NOT NULL")
.contains("response_snapshot JSON NULL")
.contains("uk_pallet_location_release_idempotency")
.contains("tenant_id, source_service, business_id, idempotency_key");
}
}
@Test
void containsAtomicPdaSettlementSchema() throws IOException {
String moduleSql = Files.readString(Path.of("src", "main", "resources", "db",
"phase3-pallet-location-settlement-idempotency.sql"));
String unifiedSql = Files.readString(Path.of("..", "deploy", "sql",
"头程数据库统一增量-MySQL8.sql"));
for (String sql : new String[] {moduleSql, unifiedSql}) {
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS wh_pallet_location_settlement_operation")
.contains("source_business_id BIGINT UNSIGNED NOT NULL")
.contains("target_business_id BIGINT UNSIGNED NOT NULL")
.contains("response_snapshot JSON NULL")
.contains("uk_pallet_location_settlement_idempotency")
.contains("tenant_id, source_service, source_business_id, idempotency_key")
.contains("DROP INDEX uk_pallet_location_settlement_idempotency");
}
}
}
@@ -146,12 +146,17 @@ class LocationResourceServiceTest {
occupancy(201L, 101L, 9L, "A1", 501L),
occupancy(202L, 102L, 10L, "A2", 502L),
occupancy(203L, 103L, 11L, "A3", 503L));
when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(active);
when(repository.findOwned(10001L, 88L, 10L)).thenReturn(occupiedLocation(10L, "A2"));
active.forEach(item -> {
item.setTenantId(0L);
item.setSourceService("firstleg-service");
item.setBusinessId(901L);
});
when(repository.lockOccupiedByBusiness(0L, "firstleg-service", 901L)).thenReturn(active);
when(repository.findOwned(0L, 88L, 10L)).thenReturn(occupiedLocation(10L, "A2"));
when(repository.releaseOccupancy(active.get(1), "PDA partial pick")).thenReturn(1);
when(repository.changeStatus(10001L, 88L, 10L, "OCCUPIED", "AVAILABLE", 0, 7L)).thenReturn(1);
when(repository.changeStatus(0L, 88L, 10L, "OCCUPIED", "AVAILABLE", 0, 7L)).thenReturn(1);
var result = service.releaseSelected(new SelectiveReleaseCommand(10001L, "FIRSTLEG", 301L, 7L,
var result = service.releaseSelected(new SelectiveReleaseCommand(0L, "firstleg-service", 901L, 7L,
"PDA partial pick", 10, List.of(10L), null));
assertThat(result).singleElement().satisfies(item -> {
@@ -163,6 +168,56 @@ class LocationResourceServiceTest {
verify(repository, never()).releaseOccupancy(active.get(2), "PDA partial pick");
}
@Test
void shouldReleaseSelectedLocationsWithPersistedAllocatedPackageCount() {
LocationOccupancyEntity first = occupancy(201L, 101L, 9L, "A1", 501L);
first.setAllocatedPackageCount(3);
LocationOccupancyEntity second = occupancy(202L, 102L, 10L, "A2", 502L);
second.setAllocatedPackageCount(7);
when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(List.of(first, second));
when(repository.findOwned(10001L, 88L, 10L)).thenReturn(occupiedLocation(10L, "A2"));
when(repository.releaseOccupancy(second, "PDA full pick")).thenReturn(1);
when(repository.changeStatus(10001L, 88L, 10L, "OCCUPIED", "AVAILABLE", 0, 7L)).thenReturn(1);
var result = service.releaseSelectedExactly(new com.baiyue.warehouse.dto.LocationDtos.ExactSelectiveReleaseCommand(
10001L, "FIRSTLEG", 301L, 7L, "PDA full pick", List.of(10L)));
assertThat(result).singleElement().satisfies(item -> {
assertThat(item.locationId()).isEqualTo(10L);
assertThat(item.allocatedPackageCount()).isEqualTo(7);
});
verify(repository, never()).releaseOccupancy(first, "PDA full pick");
}
@Test
void shouldReleasePublicResourcesInAllocationOrderWhenLocationsAreNotSpecified() {
LocationOccupancyEntity allocationThree = occupancy(203L, 103L, 11L, "A3", 503L);
LocationOccupancyEntity allocationOne = occupancy(201L, 101L, 9L, "A1", 501L);
LocationOccupancyEntity allocationTwo = occupancy(202L, 102L, 10L, "A2", 502L);
List<LocationOccupancyEntity> active = List.of(allocationThree, allocationOne, allocationTwo);
active.forEach(item -> {
item.setTenantId(0L);
item.setSourceService("firstleg-service");
item.setBusinessId(901L);
});
when(repository.lockOccupiedByBusiness(0L, "firstleg-service", 901L)).thenReturn(active);
for (LocationOccupancyEntity item : List.of(allocationOne, allocationTwo)) {
when(repository.findOwned(0L, 88L, item.getLocationId()))
.thenReturn(occupiedLocation(item.getLocationId(), item.getLocationCode()));
when(repository.releaseOccupancy(item, "PDA default pick")).thenReturn(1);
when(repository.changeStatus(0L, 88L, item.getLocationId(), "OCCUPIED", "AVAILABLE", 0, 7L))
.thenReturn(1);
}
var result = service.releaseSelected(new SelectiveReleaseCommand(0L, "firstleg-service", 901L, 7L,
"PDA default pick", 8, List.of(), 2));
assertThat(result).extracting(item -> item.allocationNo()).containsExactly(1, 2);
assertThat(result).extracting(item -> item.locationCode()).containsExactly("A1", "A2");
assertThat(result).extracting(item -> item.releasedPackageCount()).containsExactly(3, 3);
verify(repository, never()).releaseOccupancy(allocationThree, "PDA default pick");
}
@Test
void shouldReleaseFirstNAllocationsWhenLocationsAreNotSpecified() {
List<LocationOccupancyEntity> active = List.of(
@@ -213,6 +268,52 @@ class LocationResourceServiceTest {
any(String.class), any(String.class), anyInt(), anyLong());
}
@Test
void shouldTransferConfirmedOccupiedResourcesToChildInbound() {
LocationOccupancyEntity occupancy = occupancy(201L, 101L, 9L, "A1", 501L);
occupancy.setAllocatedPackageCount(8);
occupancy.setAllocatedVolumeCbm(new BigDecimal("1.200000"));
LocationReservationEntity reservation = reservation("CONFIRMED");
reservation.setReservationNo("LR101");
when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(List.of(occupancy));
when(repository.lockReservation(10001L, 101L)).thenReturn(reservation);
when(repository.transferConfirmedReservation(reservation, "FIRSTLEG", 301L, 302L, "RK001-S1"))
.thenReturn(1);
when(repository.transferOccupied(occupancy, "FIRSTLEG", 301L, 302L, "RK001-S1", 5))
.thenReturn(1);
var result = service.transferToChild(new com.baiyue.warehouse.dto.LocationDtos.TransferCommand(
10001L, "FIRSTLEG", 301L, 302L, "RK001-S1", 7L,
List.of(new com.baiyue.warehouse.dto.LocationDtos.TransferItem(9L, 5))));
assertThat(result).singleElement().satisfies(item -> {
assertThat(item.reservationNo()).isEqualTo("LR101");
assertThat(item.occupancyId()).isEqualTo(201L);
assertThat(item.remainingPackageCount()).isEqualTo(5);
assertThat(item.state()).isEqualTo("OCCUPIED");
});
verify(repository).transferConfirmedReservation(reservation, "FIRSTLEG", 301L, 302L, "RK001-S1");
verify(repository).transferOccupied(occupancy, "FIRSTLEG", 301L, 302L, "RK001-S1", 5);
verify(repository, never()).releaseOccupancy(any(), any(String.class));
verify(repository, never()).releaseReservation(any(), any(String.class));
}
@Test
void shouldRejectTransferWhenRemainingPackagesExceedAllocation() {
LocationOccupancyEntity occupancy = occupancy(201L, 101L, 9L, "A1", 501L);
occupancy.setAllocatedPackageCount(4);
LocationReservationEntity reservation = reservation("CONFIRMED");
when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(List.of(occupancy));
when(repository.lockReservation(10001L, 101L)).thenReturn(reservation);
assertThatThrownBy(() -> service.transferToChild(new com.baiyue.warehouse.dto.LocationDtos.TransferCommand(
10001L, "FIRSTLEG", 301L, 302L, "RK001-S1", 7L,
List.of(new com.baiyue.warehouse.dto.LocationDtos.TransferItem(9L, 5)))))
.isInstanceOf(com.baiyue.common.core.exception.BizException.class);
verify(repository, never()).transferConfirmedReservation(any(), any(String.class), anyLong(), anyLong(), any());
verify(repository, never()).transferOccupied(any(), any(String.class), anyLong(), anyLong(), any(), anyInt());
}
private static ReserveCommand reserveCommand() {
return new ReserveCommand(10001L, 88L, "FIRSTLEG", 301L, "RK001", 7L,
LocalDateTime.now().plusMinutes(30), List.of(
@@ -4,7 +4,14 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.LocationDtos;
import com.baiyue.warehouse.dto.PalletAllocationDtos;
import com.baiyue.warehouse.entity.PalletEntity;
import com.baiyue.warehouse.entity.PalletLocationTransferOperationEntity;
import com.baiyue.warehouse.entity.PalletLocationReleaseOperationEntity;
import com.baiyue.warehouse.entity.PalletLocationSettlementOperationEntity;
import com.baiyue.warehouse.repository.PalletRepository;
import com.baiyue.warehouse.repository.PalletLocationTransferOperationRepository;
import com.baiyue.warehouse.repository.PalletLocationReleaseOperationRepository;
import com.baiyue.warehouse.repository.PalletLocationSettlementOperationRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import jakarta.annotation.Resource;
import org.springframework.boot.test.mock.mockito.MockBean;
@@ -19,20 +26,28 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(classes = {PalletLocationAllocationService.class, PalletLocationAllocationServiceTest.TestConfig.class})
class PalletLocationAllocationServiceTest {
@MockBean private PalletRepository pallets;
@MockBean private LocationResourceService locations;
@MockBean private PalletLocationTransferOperationRepository transferOperations;
@MockBean private PalletLocationReleaseOperationRepository releaseOperations;
@MockBean private PalletLocationSettlementOperationRepository settlementOperations;
@Resource private PalletLocationAllocationService service;
@Configuration(proxyBeanMethods = false)
static class TestConfig {
@Bean PalletVolumeSplitter splitter() { return new PalletVolumeSplitter(); }
@Bean ObjectMapper objectMapper() { return new ObjectMapper(); }
}
@Test
@@ -106,21 +121,304 @@ class PalletLocationAllocationServiceTest {
@Test
void shouldReleaseOnlyPalletsReturnedBySelectedLocationRelease() {
var resource = new LocationDtos.SelectiveReleaseResult(
var resource = new LocationDtos.ExactSelectiveReleaseResult(
102L, 202L, 2, 20L, 30L, 40L, "A-02", 4);
when(locations.releaseSelected(any())).thenReturn(List.of(resource));
PalletLocationReleaseOperationEntity operation = new PalletLocationReleaseOperationEntity();
operation.setId(701L);
operation.setOperationStatus("PROCESSING");
when(releaseOperations.createIfAbsent(anyLong(), anyString(), anyLong(), anyString(), anyString()))
.thenAnswer(invocation -> {
operation.setRequestHash(invocation.getArgument(4));
return true;
});
when(releaseOperations.lockByKey(anyLong(), anyString(), anyLong(), anyString())).thenReturn(operation);
when(locations.releaseSelectedExactly(any())).thenReturn(List.of(resource));
when(pallets.transitionStatus(10001L, 20L, 30L, List.of("OCCUPIED"), "AVAILABLE"))
.thenReturn(1);
var result = service.releaseSelected(new PalletAllocationDtos.SelectiveReleaseRequest(
10001L, "FIRSTLEG", 88L, 9001L, "PDA partial pick", 10, List.of(40L), null));
10001L, "FIRSTLEG", 88L, "pda-release-88-202", 9001L, "PDA partial pick", List.of(40L)));
assertThat(result).singleElement().satisfies(item -> {
assertThat(item.allocationNo()).isEqualTo(2);
assertThat(item.locationId()).isEqualTo(40L);
assertThat(item.releasedPackageCount()).isEqualTo(4);
assertThat(item.allocatedPackageCount()).isEqualTo(4);
});
verify(pallets).transitionStatus(10001L, 20L, 30L, List.of("OCCUPIED"), "AVAILABLE");
verify(releaseOperations).complete(org.mockito.ArgumentMatchers.eq(701L), anyString());
}
@Test
void shouldReplayCompletedSelectedReleaseForSameIdempotencyKey() throws Exception {
var expected = List.of(new PalletAllocationDtos.SelectiveReleaseResult(101L, 201L, 1,
30L, 40L, "A-01", 6));
PalletLocationReleaseOperationEntity operation = new PalletLocationReleaseOperationEntity();
operation.setOperationStatus("COMPLETED");
operation.setResponseSnapshot(new ObjectMapper().writeValueAsString(expected));
String[] requestHash = new String[1];
when(releaseOperations.createIfAbsent(anyLong(), anyString(), anyLong(), anyString(), anyString()))
.thenAnswer(invocation -> {
requestHash[0] = invocation.getArgument(4);
return false;
});
when(releaseOperations.lockByKey(anyLong(), anyString(), anyLong(), anyString()))
.thenAnswer(invocation -> {
operation.setRequestHash(requestHash[0]);
return operation;
});
assertThat(service.releaseSelected(selectiveReleaseRequest())).isEqualTo(expected);
verify(locations, never()).releaseSelectedExactly(any());
verify(releaseOperations, never()).complete(anyLong(), anyString());
}
@Test
void shouldRejectSelectedReleaseWhenIdempotencyKeyHasDifferentRequest() {
PalletLocationReleaseOperationEntity operation = new PalletLocationReleaseOperationEntity();
operation.setOperationStatus("COMPLETED");
operation.setRequestHash("different-request-hash");
when(releaseOperations.createIfAbsent(anyLong(), anyString(), anyLong(), anyString(), anyString()))
.thenReturn(false);
when(releaseOperations.lockByKey(anyLong(), anyString(), anyLong(), anyString())).thenReturn(operation);
assertThatThrownBy(() -> service.releaseSelected(selectiveReleaseRequest())).isInstanceOf(BizException.class);
verify(locations, never()).releaseSelectedExactly(any());
}
@Test
void shouldSettlePdaResourcesAtomicallyAndPersistCombinedSnapshot() {
PalletLocationSettlementOperationEntity operation = new PalletLocationSettlementOperationEntity();
operation.setId(801L);
operation.setOperationStatus("PROCESSING");
when(locations.lockOccupiedResources(0L, "firstleg-service", 88L)).thenReturn(sourceResources());
when(settlementOperations.createIfAbsent(anyLong(), anyLong(), anyString(), anyString()))
.thenAnswer(invocation -> {
operation.setRequestHash(invocation.getArgument(3));
return true;
});
when(settlementOperations.lockByKey(anyLong(), anyString()))
.thenReturn(operation);
when(locations.releaseSelectedReservations(any())).thenReturn(List.of(new LocationDtos.ExactSelectiveReleaseResult(
101L, 201L, 1, 20L, 30L, 40L, "A-01", 8)));
when(pallets.transitionStatus(0L, 20L, 30L, List.of("OCCUPIED"), "AVAILABLE")).thenReturn(1);
when(locations.transferToChildByReservation(any())).thenReturn(List.of(new LocationDtos.TransferResult(
102L, "LR102", 202L, 2, 20L, 31L, "TP-002", 41L, "A-02",
new BigDecimal("1.200000"), 12, "OCCUPIED")));
PalletEntity pallet = pallet(31L, "TP-002");
pallet.setStatus("OCCUPIED");
pallet.setCapacityCbm(new BigDecimal("1.500000"));
when(pallets.findOwned(0L, 20L, 31L)).thenReturn(pallet);
var result = service.settle(settlementRequest());
assertThat(result.released()).singleElement().satisfies(item ->
assertThat(item.allocatedPackageCount()).isEqualTo(8));
assertThat(result.transferred()).singleElement().satisfies(item ->
assertThat(item.remainingPackageCount()).isEqualTo(12));
verify(settlementOperations).complete(org.mockito.ArgumentMatchers.eq(801L), anyString());
org.mockito.ArgumentCaptor<LocationDtos.ReservationReleaseCommand> releaseCommand =
org.mockito.ArgumentCaptor.forClass(LocationDtos.ReservationReleaseCommand.class);
verify(locations).releaseSelectedReservations(releaseCommand.capture());
assertThat(releaseCommand.getValue().tenantId()).isZero();
assertThat(releaseCommand.getValue().sourceService()).isEqualTo("firstleg-service");
assertThat(releaseCommand.getValue().reservationIds()).containsExactly(101L);
org.mockito.ArgumentCaptor<LocationDtos.ReservationTransferCommand> transferCommand =
org.mockito.ArgumentCaptor.forClass(LocationDtos.ReservationTransferCommand.class);
verify(locations).transferToChildByReservation(transferCommand.capture());
assertThat(transferCommand.getValue().items()).extracting(LocationDtos.ReservationTransferItem::reservationId)
.containsExactly(102L);
}
@Test
void shouldReplayCompletedPdaSettlementForSameIdempotencyKey() throws Exception {
var expected = new PalletAllocationDtos.SettlementResult(
List.of(new PalletAllocationDtos.SelectiveReleaseResult(101L, 201L, 1, 30L, 40L, "A-01", 8)),
List.of(new PalletAllocationDtos.TransferResult(102L, "LR102", 202L, 2,
0L, 20L, 31L, "TP-002", new BigDecimal("1.5"), 41L, "A-02",
new BigDecimal("1.2"), 12, "OCCUPIED")));
PalletLocationSettlementOperationEntity operation = new PalletLocationSettlementOperationEntity();
operation.setOperationStatus("COMPLETED");
operation.setResponseSnapshot(new ObjectMapper().writeValueAsString(expected));
String[] requestHash = new String[1];
when(locations.lockOccupiedResources(0L, "firstleg-service", 88L)).thenReturn(sourceResources());
when(settlementOperations.createIfAbsent(anyLong(), anyLong(), anyString(), anyString()))
.thenAnswer(invocation -> {
requestHash[0] = invocation.getArgument(3);
return false;
});
when(settlementOperations.lockByKey(anyLong(), anyString()))
.thenAnswer(invocation -> {
operation.setRequestHash(requestHash[0]);
return operation;
});
assertThat(service.settle(settlementRequest())).isEqualTo(expected);
verify(locations, never()).releaseSelectedReservations(any());
verify(locations, never()).transferToChildByReservation(any());
}
@Test
void shouldRejectPdaSettlementWhenIdempotencyKeyHasDifferentRequest() {
PalletLocationSettlementOperationEntity operation = new PalletLocationSettlementOperationEntity();
operation.setOperationStatus("COMPLETED");
operation.setRequestHash("different-request-hash");
when(locations.lockOccupiedResources(0L, "firstleg-service", 88L)).thenReturn(sourceResources());
when(settlementOperations.createIfAbsent(anyLong(), anyLong(), anyString(), anyString()))
.thenReturn(false);
when(settlementOperations.lockByKey(anyLong(), anyString()))
.thenReturn(operation);
assertThatThrownBy(() -> service.settle(settlementRequest())).isInstanceOf(BizException.class);
verify(locations, never()).releaseSelectedReservations(any());
verify(locations, never()).transferToChildByReservation(any());
}
@Test
void shouldRejectPdaSettlementFromNonFirstlegService() {
PalletAllocationDtos.SettlementRequest invalid = new PalletAllocationDtos.SettlementRequest(
0L, 20L, "other-service", 88L, 89L, 20, 12, "RK-001-S1",
"PDA-SETTLEMENT-88-1", 9001L, "PDA settlement", List.of(101L),
List.of(new PalletAllocationDtos.SettlementTransferItem(102L, 12)));
assertThatThrownBy(() -> service.settle(invalid)).isInstanceOf(BizException.class);
verifyNoInteractions(settlementOperations);
verify(locations, never()).lockOccupiedResources(anyLong(), anyString(), anyLong());
}
@Test
void shouldRejectPdaSettlementWhenPackageCountsDoNotBalance() {
PalletAllocationDtos.SettlementRequest invalid = new PalletAllocationDtos.SettlementRequest(
0L, 20L, "firstleg-service", 88L, 89L, 20, 11, "RK-001-S1",
"PDA-SETTLEMENT-88-1", 9001L, "PDA settlement", List.of(101L),
List.of(new PalletAllocationDtos.SettlementTransferItem(102L, 12)));
PalletLocationSettlementOperationEntity operation = new PalletLocationSettlementOperationEntity();
operation.setOperationStatus("PROCESSING");
when(locations.lockOccupiedResources(0L, "firstleg-service", 88L)).thenReturn(sourceResources());
when(settlementOperations.createIfAbsent(anyLong(), anyLong(), anyString(), anyString()))
.thenAnswer(invocation -> {
operation.setRequestHash(invocation.getArgument(3));
return true;
});
when(settlementOperations.lockByKey(88L, "PDA-SETTLEMENT-88-1")).thenReturn(operation);
assertThatThrownBy(() -> service.settle(invalid)).isInstanceOf(BizException.class);
verify(locations, never()).releaseSelectedReservations(any());
verify(locations, never()).transferToChildByReservation(any());
}
@Test
void shouldRejectChangedChildForSamePdaSettlementKey() {
PalletLocationSettlementOperationEntity operation = new PalletLocationSettlementOperationEntity();
operation.setOperationStatus("COMPLETED");
operation.setRequestHash("request-for-another-child");
PalletAllocationDtos.SettlementRequest changedChild = new PalletAllocationDtos.SettlementRequest(
0L, 20L, "firstleg-service", 88L, 90L, 20, 12, "RK-001-S2",
"PDA-SETTLEMENT-88-1", 9001L, "PDA settlement", List.of(101L),
List.of(new PalletAllocationDtos.SettlementTransferItem(102L, 12)));
when(locations.lockOccupiedResources(0L, "firstleg-service", 88L)).thenReturn(sourceResources());
when(settlementOperations.createIfAbsent(eq(88L), eq(90L), eq("PDA-SETTLEMENT-88-1"), anyString()))
.thenReturn(false);
when(settlementOperations.lockByKey(88L, "PDA-SETTLEMENT-88-1")).thenReturn(operation);
assertThatThrownBy(() -> service.settle(changedChild)).isInstanceOf(BizException.class);
verify(settlementOperations).lockByKey(88L, "PDA-SETTLEMENT-88-1");
verify(locations, never()).releaseSelectedReservations(any());
verify(locations, never()).transferToChildByReservation(any());
}
@Test
void shouldTransferOccupiedResourceWithoutReleasingPallet() {
PalletLocationTransferOperationEntity operation = new PalletLocationTransferOperationEntity();
operation.setId(501L);
operation.setOperationStatus("PROCESSING");
when(transferOperations.createIfAbsent(anyLong(), anyString(), anyLong(), anyLong(), anyString(), anyString()))
.thenAnswer(invocation -> {
operation.setRequestHash(invocation.getArgument(5));
return true;
});
when(transferOperations.lockByKey(anyLong(), anyString(), anyLong(), anyLong(), anyString()))
.thenReturn(operation);
when(locations.transferToChild(any())).thenReturn(List.of(new LocationDtos.TransferResult(
101L, "LR101", 201L, 1, 20L, 30L, "TP-001", 40L, "A-01",
new BigDecimal("1.200000"), 5, "OCCUPIED")));
PalletEntity pallet = pallet(30L, "TP-001");
pallet.setStatus("OCCUPIED");
pallet.setCapacityCbm(new BigDecimal("1.500000"));
when(pallets.findOwned(10001L, 20L, 30L)).thenReturn(pallet);
var result = service.transfer(new PalletAllocationDtos.TransferRequest(
10001L, "FIRSTLEG", 88L, 89L, "RK-001-S1", "transfer-88-89", 9001L,
List.of(new PalletAllocationDtos.TransferItem(40L, 5))));
assertThat(result).singleElement().satisfies(item -> {
assertThat(item.occupancyId()).isEqualTo(201L);
assertThat(item.palletCapacityCbm()).isEqualByComparingTo("1.500000");
assertThat(item.remainingPackageCount()).isEqualTo(5);
assertThat(item.state()).isEqualTo("OCCUPIED");
});
verify(pallets, never()).transitionStatus(10001L, 20L, 30L, List.of("OCCUPIED"), "AVAILABLE");
verify(transferOperations).complete(org.mockito.ArgumentMatchers.eq(501L), anyString());
}
@Test
void shouldReplayCompletedTransferForSameIdempotencyKey() throws Exception {
var expected = List.of(new PalletAllocationDtos.TransferResult(101L, "LR101", 201L, 1,
0L, 20L, 30L, "TP-001", new BigDecimal("1.5"), 40L, "A-01",
new BigDecimal("1.2"), 5, "OCCUPIED"));
PalletLocationTransferOperationEntity operation = new PalletLocationTransferOperationEntity();
operation.setOperationStatus("COMPLETED");
operation.setResponseSnapshot(new ObjectMapper().writeValueAsString(expected));
String[] requestHash = new String[1];
when(transferOperations.createIfAbsent(anyLong(), anyString(), anyLong(), anyLong(), anyString(), anyString()))
.thenAnswer(invocation -> {
requestHash[0] = invocation.getArgument(5);
return false;
});
when(transferOperations.lockByKey(anyLong(), anyString(), anyLong(), anyLong(), anyString()))
.thenAnswer(invocation -> {
operation.setRequestHash(requestHash[0]);
return operation;
});
assertThat(service.transfer(transferRequest())).isEqualTo(expected);
verify(locations, never()).transferToChild(any());
verify(transferOperations, never()).complete(anyLong(), anyString());
}
@Test
void shouldRejectSameIdempotencyKeyWithDifferentRequestHash() {
PalletLocationTransferOperationEntity operation = new PalletLocationTransferOperationEntity();
operation.setOperationStatus("COMPLETED");
operation.setRequestHash("different-request-hash");
when(transferOperations.createIfAbsent(anyLong(), anyString(), anyLong(), anyLong(), anyString(), anyString()))
.thenReturn(false);
when(transferOperations.lockByKey(anyLong(), anyString(), anyLong(), anyLong(), anyString()))
.thenReturn(operation);
assertThatThrownBy(() -> service.transfer(transferRequest())).isInstanceOf(BizException.class);
verify(locations, never()).transferToChild(any());
}
private static PalletAllocationDtos.TransferRequest transferRequest() {
return new PalletAllocationDtos.TransferRequest(10001L, "FIRSTLEG", 88L, 89L, "RK-001-S1",
"transfer-88-89", 9001L, List.of(new PalletAllocationDtos.TransferItem(40L, 5)));
}
private static PalletAllocationDtos.SelectiveReleaseRequest selectiveReleaseRequest() {
return new PalletAllocationDtos.SelectiveReleaseRequest(10001L, "FIRSTLEG", 88L,
"pda-release-88-201", 9001L, "PDA full pick", List.of(40L));
}
private static PalletAllocationDtos.SettlementRequest settlementRequest() {
return new PalletAllocationDtos.SettlementRequest(0L, 20L, "firstleg-service", 88L, 89L, 20, 12,
"RK-001-S1", "PDA-SETTLEMENT-88-1", 9001L, "PDA settlement", List.of(101L),
List.of(new PalletAllocationDtos.SettlementTransferItem(102L, 12)));
}
private static List<LocationDtos.OccupiedResource> sourceResources() {
return List.of(
new LocationDtos.OccupiedResource(101L, 201L, 1, 20L, 30L, 40L, "A-01", false, 8),
new LocationDtos.OccupiedResource(102L, 202L, 2, 20L, 31L, 41L, "A-02", false, 12));
}
private static PalletAllocationDtos.ReserveRequest reserve(long businessId) {