装柜回归测试
This commit is contained in:
@@ -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;
|
||||
|
||||
-- ============================================================================
|
||||
@@ -8600,6 +8667,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;
|
||||
|
||||
@@ -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`。
|
||||
|
||||
+27
-3
@@ -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) {}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
|
||||
+219
-4
@@ -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 < #{finalCount}
|
||||
AND #{finalCount} < 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("""
|
||||
|
||||
+67
@@ -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();
|
||||
}
|
||||
|
||||
+2
-1
@@ -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:
|
||||
|
||||
+1
-1
@@ -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");
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+4
-4
@@ -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'");
|
||||
|
||||
+2
-1
@@ -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}")
|
||||
|
||||
+2
-2
@@ -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);
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
+6
-8
@@ -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)));
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
+27
@@ -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)) {
|
||||
|
||||
+21
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
+27
@@ -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;
|
||||
}
|
||||
+28
@@ -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;
|
||||
}
|
||||
+28
@@ -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);
|
||||
}
|
||||
|
||||
+39
@@ -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);
|
||||
}
|
||||
+40
@@ -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);
|
||||
}
|
||||
+41
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+35
@@ -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);
|
||||
}
|
||||
}
|
||||
+37
@@ -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);
|
||||
}
|
||||
}
|
||||
+36
@@ -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,
|
||||
|
||||
+385
-6
@@ -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='托盘库位归属转移的持久化幂等操作记录';
|
||||
+78
@@ -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)));
|
||||
}
|
||||
}
|
||||
+29
@@ -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();
|
||||
}
|
||||
}
|
||||
+48
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+105
-4
@@ -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(
|
||||
|
||||
+302
-4
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user