diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 108460225..e2020fb45 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -7,6 +7,73 @@ -- 使用 MySQL Client、Workbench、DataGrip 或 Navicat 的“运行脚本”完整执行,客户端必须支持 DELIMITER。 SET NAMES utf8mb4; SET SESSION sql_mode = 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'; +-- 来源:warehouse\src\main\resources\db\phase3-pallet-location-transfer-idempotency.sql +CREATE DATABASE IF NOT EXISTS baiyue_warehouse + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_warehouse; + +CREATE TABLE IF NOT EXISTS wh_pallet_location_transfer_operation ( + id BIGINT UNSIGNED NOT NULL COMMENT '调拨幂等操作ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓储公共资源域,固定为0', + source_service VARCHAR(40) NOT NULL COMMENT '来源服务', + source_business_id BIGINT UNSIGNED NOT NULL COMMENT '来源入仓单ID', + target_business_id BIGINT UNSIGNED NOT NULL COMMENT '目标子入仓单ID', + idempotency_key VARCHAR(128) NOT NULL COMMENT '调用方幂等键', + request_hash CHAR(64) NOT NULL COMMENT '规范化请求SHA-256摘要', + operation_status VARCHAR(20) NOT NULL DEFAULT 'PROCESSING' COMMENT 'PROCESSING处理中、COMPLETED已完成', + response_snapshot JSON NULL COMMENT '完成后的调拨结果快照,用于重复请求重放', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + completed_at DATETIME(3) NULL COMMENT '完成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_pallet_location_transfer_idempotency + (tenant_id, source_service, source_business_id, target_business_id, idempotency_key), + KEY idx_pallet_location_transfer_source (tenant_id, source_service, source_business_id, operation_status) +) ENGINE=InnoDB COMMENT='托盘库位归属转移的持久化幂等操作记录'; + +-- 来源:warehouse\src\main\resources\db\phase3-pallet-location-release-idempotency.sql +CREATE TABLE IF NOT EXISTS wh_pallet_location_release_operation ( + id BIGINT UNSIGNED NOT NULL COMMENT '精确释放幂等操作ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓储公共资源作用域,固定为0', + source_service VARCHAR(40) NOT NULL COMMENT '来源服务', + business_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + idempotency_key VARCHAR(128) NOT NULL COMMENT '调用方幂等键', + request_hash CHAR(64) NOT NULL COMMENT '规范化请求SHA-256摘要', + operation_status VARCHAR(20) NOT NULL DEFAULT 'PROCESSING' COMMENT 'PROCESSING处理中,COMPLETED已完成', + response_snapshot JSON NULL COMMENT '完成后的精确释放结果快照,用于重复请求重放', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + completed_at DATETIME(3) NULL COMMENT '完成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_pallet_location_release_idempotency + (tenant_id, source_service, business_id, idempotency_key), + KEY idx_pallet_location_release_business (tenant_id, source_service, business_id, operation_status) +) ENGINE=InnoDB COMMENT='PDA已拣满托盘库位精确释放的持久化幂等操作记录'; + +-- 来源:warehouse\src\main\resources\db\phase3-pallet-location-settlement-idempotency.sql +CREATE TABLE IF NOT EXISTS wh_pallet_location_settlement_operation ( + id BIGINT UNSIGNED NOT NULL COMMENT 'PDA资源结算幂等操作ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓储公共资源作用域,固定为0', + source_service VARCHAR(40) NOT NULL COMMENT '来源服务', + source_business_id BIGINT UNSIGNED NOT NULL COMMENT '主入库单ID', + target_business_id BIGINT UNSIGNED NOT NULL COMMENT '二级子入库单ID;全拣完成时为0,仅作审计快照', + idempotency_key VARCHAR(128) NOT NULL COMMENT 'PDA结算幂等键', + request_hash CHAR(64) NOT NULL COMMENT '规范化请求SHA-256摘要', + operation_status VARCHAR(20) NOT NULL DEFAULT 'PROCESSING' COMMENT 'PROCESSING处理中,COMPLETED已完成', + response_snapshot JSON NULL COMMENT '完成后的释放和转移组合结果快照,用于重复请求重放', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + completed_at DATETIME(3) NULL COMMENT '完成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_pallet_location_settlement_idempotency + (tenant_id, source_service, source_business_id, idempotency_key), + KEY idx_pallet_location_settlement_source + (tenant_id, source_service, source_business_id, operation_status) +) ENGINE=InnoDB COMMENT='PDA拣货资源原子结算的持久化幂等操作记录'; + +-- 兼容已执行过旧脚本的测试库:目标子单不属于幂等键,同一来源单据和幂等键只能对应一条请求。 +ALTER TABLE wh_pallet_location_settlement_operation + DROP INDEX uk_pallet_location_settlement_idempotency, + ADD UNIQUE KEY uk_pallet_location_settlement_idempotency + (tenant_id, source_service, source_business_id, idempotency_key); + SELECT VERSION() AS mysql_version, 'START' AS unified_migration_status; -- ============================================================================ @@ -8632,6 +8699,14 @@ DELIMITER ; CALL assert_phase3_final_schema(); DROP PROCEDURE IF EXISTS assert_phase3_final_schema; --- WAIT_SPLIT 是二期当前有效终点;统一迁移不得改写主表或历史状态。 +-- 二、三期对外统一使用 INBOUNDED;历史 WAIT_SPLIT 仅由查询适配为 INBOUNDED,统一迁移不改写主表或历史状态。 SELECT 'PASS' AS migration_status, '统一增量全部执行完成;箱号映射、单箱测量、三期订舱、拖车预约、出仓、报关、船期、装柜模型桥接、PDA收敛、费用、公共资源及可靠审计结构校验通过' AS message; + +-- PDA 部分拣货释放策略:NULL/AUTO 按比例释放,MANUAL + [] 表示人工明确不释放。 +USE baiyue_firstleg; +ALTER TABLE fl_loading_pick_item + ADD COLUMN IF NOT EXISTS release_mode VARCHAR(10) NULL COMMENT 'AUTO按比例释放,MANUAL按人工选择释放' AFTER item_status, + ADD COLUMN IF NOT EXISTS release_location_ids JSON NULL COMMENT '人工选择释放库位ID;空数组表示明确释放0个' AFTER release_mode; +ALTER TABLE fl_inbound_pallet_allocation + ADD COLUMN IF NOT EXISTS reservation_id BIGINT UNSIGNED NULL COMMENT '仓库预占资源唯一ID,A0共享场景用于区分托盘' AFTER location_code; diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 35cb2fb8a..21e68af56 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -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`。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java index eee112077..ef0f51e13 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java @@ -30,6 +30,14 @@ public interface WarehousePalletLocationClient { @RequestHeader("X-Internal-Service-Token") String token, @RequestBody SelectiveReleaseRequest request); + @PostMapping("/transfer") + ApiResponse> transfer(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody TransferRequest request); + + @PostMapping("/settlements") + ApiResponse 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 locationIds, Integer releaseLocationCount) {} + String idempotencyKey, long operatorId, String reason, + List 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 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 releasedReservationIds, + List remainingItems) {} + record SettlementResult(List released, List transferred) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/MerchantInboundBillingController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/MerchantInboundBillingController.java index 854814203..3e0176848 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/MerchantInboundBillingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/MerchantInboundBillingController.java @@ -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 find( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long orderId) { return ApiResponse.success(service.find(FirstlegScopeGuard.merchant(user), orderId)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java index 9309bc9b5..f5414da4b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java @@ -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 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 ); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java index bfa426126..6f5f1619c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java @@ -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 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 locationIds, + Integer version, String operationId) { + this(warehouseId, finalPickedPackageCount, null, locationIds, version, operationId); + } } /** 跳拣只能发生在尚未拣出任何箱数的 PDA 明细上。 */ diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java index 003165aa6..5b77f89be 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java @@ -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, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPalletAllocationEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPalletAllocationEntity.java index abb1e4afc..58c636deb 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPalletAllocationEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPalletAllocationEntity.java @@ -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; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickItemEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickItemEntity.java index 1c8349f7b..b271e9f78 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickItemEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPickItemEntity.java @@ -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; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundPalletAllocationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundPalletAllocationMapper.java index 52506815a..0bb0299f5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundPalletAllocationMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundPalletAllocationMapper.java @@ -37,4 +37,22 @@ public interface InboundPalletAllocationMapper extends BaseMapper 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({"