From ac6d3289a6288f7615e0feaf41cf9221c0967bae Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 29 Aug 2026 10:51:50 +0800 Subject: [PATCH] feat(firstleg): add indivisible coload groups --- deploy/sql/头程数据库统一增量-MySQL8.sql | 51 +++ .../PlatformColoadGroupController.java | 89 +++++ .../coload/model/ColoadGroupModels.java | 79 ++++ .../coload/persistence/ColoadGroupMapper.java | 193 +++++++++ .../persistence/ColoadPersistenceRows.java | 78 ++++ .../port/ColoadGroupAssignmentPort.java | 23 ++ .../coload/service/ColoadGroupService.java | 366 ++++++++++++++++++ .../db/phase3-coload-group-schema.sql | 94 +++++ .../resources/openapi/coload-groups-v1.yaml | 161 ++++++++ .../coload/ColoadGroupContractTest.java | 58 +++ .../ColoadGroupOpenApiContractTest.java | 37 ++ .../coload/ColoadGroupServiceTest.java | 217 +++++++++++ .../coload/ColoadGroupSqlContractTest.java | 48 +++ 13 files changed, 1494 insertions(+) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/loading/coload/controller/PlatformColoadGroupController.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/loading/coload/model/ColoadGroupModels.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadGroupMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadPersistenceRows.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/loading/coload/port/ColoadGroupAssignmentPort.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/loading/coload/service/ColoadGroupService.java create mode 100644 firstleg/src/main/resources/db/phase3-coload-group-schema.sql create mode 100644 firstleg/src/main/resources/openapi/coload-groups-v1.yaml create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupOpenApiContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupServiceTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupSqlContractTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 03dc36ecc..37632a5b4 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -16411,6 +16411,57 @@ CREATE TABLE IF NOT EXISTS fl_customs_command( created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),completed_at DATETIME(3) NULL,PRIMARY KEY(id), UNIQUE KEY uk_customs_command(actor_id,action,idempotency_key),CONSTRAINT chk_customs_command_status CHECK(status IN('PROCESSING','SUCCESS')) )ENGINE=InnoDB COMMENT='整柜报关写命令幂等事实'; +-- 同柜且不可拆组:跨客户但同国内仓、渠道和目的仓港,整组锁定/解锁/消费。 +USE baiyue_firstleg; +CREATE TABLE IF NOT EXISTS fl_coload_group ( + id BIGINT UNSIGNED NOT NULL COMMENT '同柜不可拆组ID',group_no VARCHAR(40) NOT NULL COMMENT '稳定组号CLG-{id}', + group_status VARCHAR(16) NOT NULL COMMENT 'ACTIVE、LOCKED、CONSUMED、CANCELLED', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '国内收货仓ID快照',channel_code VARCHAR(64) NOT NULL COMMENT '物流渠道编码快照', + destination_type VARCHAR(32) NOT NULL COMMENT '目的类型快照',destination_warehouse_id BIGINT UNSIGNED NULL COMMENT '目的仓ID快照', + destination_code VARCHAR(64) NULL COMMENT '目的仓或目的港编码快照',locked_plan_id BIGINT UNSIGNED NULL COMMENT 'LOCKED/CONSUMED绑定装柜计划ID', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观版本',created_by BIGINT UNSIGNED NOT NULL,created_by_name VARCHAR(64) NULL, + 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), + locked_by BIGINT UNSIGNED NULL,locked_by_name VARCHAR(64) NULL,locked_at DATETIME(3) NULL, + consumed_by BIGINT UNSIGNED NULL,consumed_by_name VARCHAR(64) NULL,consumed_at DATETIME(3) NULL, + cancelled_by BIGINT UNSIGNED NULL,cancelled_by_name VARCHAR(64) NULL,cancelled_at DATETIME(3) NULL, + PRIMARY KEY(id),UNIQUE KEY uk_coload_group_no(group_no),KEY idx_coload_group_status(group_status,updated_at,id), + KEY idx_coload_group_plan(locked_plan_id,group_status), + CONSTRAINT fk_coload_group_plan FOREIGN KEY(locked_plan_id) REFERENCES fl_loading_plan(id), + CONSTRAINT chk_coload_group_status CHECK(group_status IN('ACTIVE','LOCKED','CONSUMED','CANCELLED')), + CONSTRAINT chk_coload_group_destination CHECK(destination_warehouse_id IS NOT NULL OR destination_code IS NOT NULL), + CONSTRAINT chk_coload_group_lock CHECK((group_status IN('ACTIVE','CANCELLED') AND locked_plan_id IS NULL) + OR(group_status IN('LOCKED','CONSUMED') AND locked_plan_id IS NOT NULL)) +)ENGINE=InnoDB COMMENT='同柜且不可拆业务组聚合根'; +CREATE TABLE IF NOT EXISTS fl_coload_group_member ( + id BIGINT UNSIGNED NOT NULL,group_id BIGINT UNSIGNED NOT NULL,tenant_id BIGINT UNSIGNED NOT NULL COMMENT '成员客户ID;允许跨客户成组', + order_id BIGINT UNSIGNED NOT NULL,inbound_id BIGINT UNSIGNED NOT NULL,full_package_count INT UNSIGNED NOT NULL, + active_flag TINYINT UNSIGNED NULL DEFAULT 1 COMMENT '当前约束唯一标识;取消或消费后置NULL', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),closed_at DATETIME(3) NULL, + PRIMARY KEY(id),UNIQUE KEY uk_coload_group_member(group_id,inbound_id), + UNIQUE KEY uk_coload_active_inbound(tenant_id,inbound_id,active_flag),KEY idx_coload_member_order(order_id,created_at,group_id), + CONSTRAINT fk_coload_member_group FOREIGN KEY(group_id) REFERENCES fl_coload_group(id), + CONSTRAINT fk_coload_member_inbound FOREIGN KEY(inbound_id) REFERENCES fl_inbound_order(id), + CONSTRAINT chk_coload_member_packages CHECK(full_package_count>0), + CONSTRAINT chk_coload_member_active CHECK(active_flag IS NULL OR active_flag=1) +)ENGINE=InnoDB COMMENT='同柜不可拆组成员与满箱数快照'; +CREATE TABLE IF NOT EXISTS fl_coload_group_history ( + id BIGINT UNSIGNED NOT NULL,group_id BIGINT UNSIGNED NOT NULL,action_type VARCHAR(16) NOT NULL, + from_status VARCHAR(16) NULL,to_status VARCHAR(16) NOT NULL,plan_id BIGINT UNSIGNED NULL, + operation_id VARCHAR(128) NOT NULL,remark VARCHAR(500) NULL,operator_id BIGINT UNSIGNED NOT NULL,operator_name VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),PRIMARY KEY(id), + UNIQUE KEY uk_coload_history_operation(group_id,action_type,operation_id),KEY idx_coload_history_group(group_id,created_at,id), + CONSTRAINT fk_coload_history_group FOREIGN KEY(group_id) REFERENCES fl_coload_group(id), + CONSTRAINT chk_coload_history_action CHECK(action_type IN('CREATE','CANCEL','LOCK','UNLOCK','CONSUME')) +)ENGINE=InnoDB COMMENT='同柜不可拆组不可变状态历史'; +CREATE TABLE IF NOT EXISTS fl_coload_command ( + id BIGINT UNSIGNED NOT NULL,actor_id BIGINT UNSIGNED NOT NULL,action_type VARCHAR(16) NOT NULL, + idempotency_key VARCHAR(128) NOT NULL,request_hash CHAR(64) NOT NULL,command_status VARCHAR(16) NOT NULL, + result_group_id BIGINT UNSIGNED NULL,created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),completed_at DATETIME(3) NULL, + PRIMARY KEY(id),UNIQUE KEY uk_coload_command(actor_id,action_type,idempotency_key), + CONSTRAINT fk_coload_command_group FOREIGN KEY(result_group_id) REFERENCES fl_coload_group(id), + CONSTRAINT chk_coload_command_status CHECK(command_status IN('PROCESSING','SUCCESS')) +)ENGINE=InnoDB COMMENT='同柜不可拆组幂等写命令'; + CALL baiyue_firstleg.assert_unified_final_schema(); DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; SELECT 'PASS' AS migration_status, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/controller/PlatformColoadGroupController.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/controller/PlatformColoadGroupController.java new file mode 100644 index 000000000..dee304bbc --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/controller/PlatformColoadGroupController.java @@ -0,0 +1,89 @@ +package com.baiyue.firstleg.loading.coload.controller; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.BatchQueryRequest; +import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.CancelRequest; +import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.CreateRequest; +import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.GroupSnapshot; +import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.OrderGroupInfo; +import com.baiyue.firstleg.loading.coload.service.ColoadGroupService; +import com.baiyue.firstleg.service.FirstlegScopeGuard; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** 平台订单列表使用的同柜不可拆组管理接口。 */ +@Validated +@RestController +@RequestMapping("/api/platform/v1/first-leg/coload-groups") +@Tag(name = "平台同柜不可拆组", description = "跨客户、同仓同渠道同目的仓港的整组装柜约束") +public class PlatformColoadGroupController { + @Resource + private ColoadGroupService service; + + @PostMapping + @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") + @BusinessAudit(event = "COLOAD_GROUP_CREATE", resourceType = "COLOAD_GROUP", + description = "创建同柜不可拆组", captureBefore = false) + @Operation(summary = "创建同柜不可拆组", + description = "至少选择两个订单;服务端解析当前权威入库单并冻结全部成员满箱数。") + public ApiResponse create( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody CreateRequest request) { + requirePlatform(user); + return ApiResponse.success(service.create(request.orderIds(), idempotencyKey, + user.userId(), user.username())); + } + + @PostMapping("/{groupId}/cancel") + @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") + @BusinessAudit(event = "COLOAD_GROUP_CANCEL", resourceType = "COLOAD_GROUP", + resourceId = "#groupId", description = "整组取消同柜不可拆组") + @Operation(summary = "整组取消同柜不可拆组", + description = "仅 ACTIVE 组允许取消;不提供单成员编辑,修改成员必须取消后重建。") + public ApiResponse cancel( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Min(1) long groupId, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody CancelRequest request) { + requirePlatform(user); + return ApiResponse.success(service.cancel(groupId, request.expectedVersion(), request.reason(), + idempotencyKey, user.userId(), user.username())); + } + + @PostMapping("/batch-query") + @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") + @Operation(summary = "按订单ID批量查询同柜不可拆组", + description = "供平台订单列表批量补齐组号、状态、全体成员和整组满箱数;最多200个订单。") + public ApiResponse> batchQuery( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody BatchQueryRequest request) { + requirePlatform(user); + return ApiResponse.success(service.queryByOrderIds(request.orderIds())); + } + + private static void requirePlatform(AuthenticatedUser user) { + FirstlegScopeGuard.platform(user); + if (user.userId() == null || user.userId() <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效平台操作人"); + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/model/ColoadGroupModels.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/model/ColoadGroupModels.java new file mode 100644 index 000000000..dc8a2b57d --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/model/ColoadGroupModels.java @@ -0,0 +1,79 @@ +package com.baiyue.firstleg.loading.coload.model; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +/** 同柜不可拆组的稳定请求与读模型。 */ +public final class ColoadGroupModels { + private ColoadGroupModels() { + } + + public record CreateRequest( + @NotEmpty @Size(min = 2, max = 100) List<@NotNull @Min(1) Long> orderIds + ) { + } + + public record CancelRequest( + @NotNull @Min(0) Integer expectedVersion, + @Size(max = 500) String reason + ) { + } + + public record BatchQueryRequest( + @NotEmpty @Size(max = 200) List<@NotNull @Min(1) Long> orderIds + ) { + } + + public record Member( + long tenantId, + long orderId, + long inboundId, + int fullPackageCount + ) { + } + + public record GroupSnapshot( + long groupId, + String groupNo, + String status, + long warehouseId, + String channelCode, + String destinationType, + Long destinationWarehouseId, + String destinationCode, + Long lockedPlanId, + int version, + List members, + int fullPackageCount, + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + public GroupSnapshot { + members = members == null ? List.of() : List.copyOf(members); + int calculated = members.stream().mapToInt(Member::fullPackageCount).sum(); + if (fullPackageCount != calculated) fullPackageCount = calculated; + } + + public GroupSnapshot(long groupId, String groupNo, String status, long warehouseId, + String channelCode, String destinationType, Long destinationWarehouseId, + String destinationCode, Long lockedPlanId, int version, + List members, LocalDateTime createdAt, LocalDateTime updatedAt) { + this(groupId, groupNo, status, warehouseId, channelCode, destinationType, + destinationWarehouseId, destinationCode, lockedPlanId, version, members, + members == null ? 0 : members.stream().mapToInt(Member::fullPackageCount).sum(), + createdAt, updatedAt); + } + } + + public record OrderGroupInfo( + long orderId, + long inboundId, + GroupSnapshot group + ) { + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadGroupMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadGroupMapper.java new file mode 100644 index 000000000..e7a65c56d --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadGroupMapper.java @@ -0,0 +1,193 @@ +package com.baiyue.firstleg.loading.coload.persistence; + +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; + +import java.util.List; + +import static com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.*; + +/** 同柜不可拆组在 Firstleg 单库内的唯一持久化入口。 */ +@Mapper +public interface ColoadGroupMapper { + @Select({""}) + List lockCandidates(@Param("orderIds") List orderIds); + + @Insert(""" + INSERT INTO fl_coload_group + (id,group_no,group_status,warehouse_id,channel_code,destination_type, + destination_warehouse_id,destination_code,locked_plan_id,version, + created_by,created_by_name,created_at,updated_at) + VALUES + (#{id},#{groupNo},'ACTIVE',#{warehouseId},#{channelCode},#{destinationType}, + #{destinationWarehouseId},#{destinationCode},NULL,0, + #{operatorId},#{operatorName},CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)) + """) + int insertGroup(@Param("id") long id, @Param("groupNo") String groupNo, + @Param("warehouseId") long warehouseId, + @Param("channelCode") String channelCode, + @Param("destinationType") String destinationType, + @Param("destinationWarehouseId") Long destinationWarehouseId, + @Param("destinationCode") String destinationCode, + @Param("operatorId") long operatorId, + @Param("operatorName") String operatorName); + + @Insert(""" + INSERT INTO fl_coload_group_member + (id,group_id,tenant_id,order_id,inbound_id,full_package_count,active_flag,created_at) + VALUES + (#{id},#{groupId},#{tenantId},#{orderId},#{inboundId},#{fullPackageCount},1,CURRENT_TIMESTAMP(3)) + """) + int insertMember(@Param("id") long id, @Param("groupId") long groupId, + @Param("tenantId") long tenantId, @Param("orderId") long orderId, + @Param("inboundId") long inboundId, + @Param("fullPackageCount") int fullPackageCount); + + @Select("SELECT id,group_no groupNo,group_status groupStatus,warehouse_id warehouseId," + + "channel_code channelCode,destination_type destinationType," + + "destination_warehouse_id destinationWarehouseId,destination_code destinationCode," + + "locked_plan_id lockedPlanId,version,created_at createdAt,updated_at updatedAt " + + "FROM fl_coload_group WHERE id=#{groupId}") + GroupRow group(@Param("groupId") long groupId); + + @Select("SELECT id,group_no groupNo,group_status groupStatus,warehouse_id warehouseId," + + "channel_code channelCode,destination_type destinationType," + + "destination_warehouse_id destinationWarehouseId,destination_code destinationCode," + + "locked_plan_id lockedPlanId,version,created_at createdAt,updated_at updatedAt " + + "FROM fl_coload_group WHERE id=#{groupId} FOR UPDATE") + GroupRow lockGroup(@Param("groupId") long groupId); + + @Select("SELECT tenant_id tenantId,order_id orderId,inbound_id inboundId," + + "full_package_count fullPackageCount FROM fl_coload_group_member " + + "WHERE group_id=#{groupId} ORDER BY order_id,inbound_id") + List members(@Param("groupId") long groupId); + + @Select(""" + SELECT group_row.id,group_row.group_no groupNo,group_row.group_status groupStatus, + group_row.warehouse_id warehouseId,group_row.channel_code channelCode, + group_row.destination_type destinationType, + group_row.destination_warehouse_id destinationWarehouseId, + group_row.destination_code destinationCode,group_row.locked_plan_id lockedPlanId, + group_row.version,group_row.created_at createdAt,group_row.updated_at updatedAt + FROM fl_coload_group_member member + JOIN fl_coload_group group_row ON group_row.id=member.group_id + WHERE member.tenant_id=#{tenantId} AND member.inbound_id=#{inboundId} + AND member.active_flag=1 AND group_row.group_status IN ('ACTIVE','LOCKED') + LIMIT 1 + """) + GroupRow activeGroupByInbound(@Param("tenantId") long tenantId, + @Param("inboundId") long inboundId); + + @Select({""}) + List linksByOrderIds(@Param("orderIds") List orderIds); + + @Update(""" + UPDATE fl_coload_group + SET group_status='CANCELLED',cancelled_by=#{operatorId}, + cancelled_by_name=#{operatorName},cancelled_at=CURRENT_TIMESTAMP(3), + updated_at=CURRENT_TIMESTAMP(3),version=version+1 + WHERE id=#{groupId} AND group_status='ACTIVE' AND version=#{expectedVersion} + """) + int cancel(@Param("groupId") long groupId, @Param("expectedVersion") int expectedVersion, + @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); + + @Update("UPDATE fl_coload_group_member SET active_flag=NULL,closed_at=CURRENT_TIMESTAMP(3) " + + "WHERE group_id=#{groupId} AND active_flag=1") + int closeMembers(@Param("groupId") long groupId); + + @Update(""" + UPDATE fl_coload_group + SET group_status='LOCKED',locked_plan_id=#{planId},locked_by=#{operatorId}, + locked_by_name=#{operatorName},locked_at=CURRENT_TIMESTAMP(3), + updated_at=CURRENT_TIMESTAMP(3),version=version+1 + WHERE id=#{groupId} AND group_status='ACTIVE' + """) + int lockToPlan(@Param("groupId") long groupId, @Param("planId") long planId, + @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); + + @Update(""" + UPDATE fl_coload_group + SET group_status='ACTIVE',locked_plan_id=NULL,locked_by=NULL,locked_by_name=NULL, + locked_at=NULL,updated_at=CURRENT_TIMESTAMP(3),version=version+1 + WHERE id=#{groupId} AND group_status='LOCKED' AND locked_plan_id=#{planId} + """) + int unlockFromPlan(@Param("groupId") long groupId, @Param("planId") long planId); + + @Update(""" + UPDATE fl_coload_group + SET group_status='CONSUMED',consumed_by=#{operatorId},consumed_by_name=#{operatorName}, + consumed_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),version=version+1 + WHERE id=#{groupId} AND group_status='LOCKED' AND locked_plan_id=#{planId} + """) + int consume(@Param("groupId") long groupId, @Param("planId") long planId, + @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); + + @Insert(""" + INSERT INTO fl_coload_group_history + (id,group_id,action_type,from_status,to_status,plan_id,operation_id,remark, + operator_id,operator_name,created_at) + VALUES + (#{id},#{groupId},#{action},#{fromStatus},#{toStatus},#{planId},#{operationId}, + #{remark},#{operatorId},#{operatorName},CURRENT_TIMESTAMP(3)) + """) + int insertHistory(@Param("id") long id, @Param("groupId") long groupId, + @Param("action") String action, @Param("fromStatus") String fromStatus, + @Param("toStatus") String toStatus, @Param("planId") Long planId, + @Param("operationId") String operationId, @Param("remark") String remark, + @Param("operatorId") long operatorId, + @Param("operatorName") String operatorName); + + @Insert(""" + INSERT INTO fl_coload_command + (id,actor_id,action_type,idempotency_key,request_hash,command_status, + result_group_id,created_at) + VALUES + (#{id},#{actorId},#{action},#{key},#{requestHash},'PROCESSING',NULL,CURRENT_TIMESTAMP(3)) + """) + int insertCommand(@Param("id") long id, @Param("actorId") long actorId, + @Param("action") String action, @Param("key") String key, + @Param("requestHash") String requestHash); + + @Select("SELECT id,request_hash requestHash,command_status commandStatus," + + "result_group_id resultGroupId FROM fl_coload_command " + + "WHERE actor_id=#{actorId} AND action_type=#{action} AND idempotency_key=#{key}") + CommandRow command(@Param("actorId") long actorId, @Param("action") String action, + @Param("key") String key); + + @Update("UPDATE fl_coload_command SET command_status='SUCCESS',result_group_id=#{groupId}," + + "completed_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND command_status='PROCESSING'") + int completeCommand(@Param("id") long id, @Param("groupId") long groupId); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadPersistenceRows.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadPersistenceRows.java new file mode 100644 index 000000000..90399d426 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadPersistenceRows.java @@ -0,0 +1,78 @@ +package com.baiyue.firstleg.loading.coload.persistence; + +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** MyBatis 持久化投影,不向 coload 子域外暴露。 */ +public final class ColoadPersistenceRows { + private ColoadPersistenceRows() { + } + + @Getter + @Setter + public static class InboundCandidateRow { + private Long tenantId; + private Long orderId; + private Long inboundId; + private String processStatus; + private String inboundRole; + private String splitStatus; + private String splitPurpose; + private Long parentInboundId; + private Long warehouseId; + private String channelCode; + private String destinationType; + private Long destinationWarehouseId; + private String destinationCode; + private Integer fullPackageCount; + private Integer activeAllocationCount; + private Integer childCount; + private Integer splitBatchCount; + private Long activeGroupId; + } + + @Getter + @Setter + public static class GroupRow { + private Long id; + private String groupNo; + private String groupStatus; + private Long warehouseId; + private String channelCode; + private String destinationType; + private Long destinationWarehouseId; + private String destinationCode; + private Long lockedPlanId; + private Integer version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + } + + @Getter + @Setter + public static class MemberRow { + private Long tenantId; + private Long orderId; + private Long inboundId; + private Integer fullPackageCount; + } + + @Getter + @Setter + public static class OrderGroupLinkRow { + private Long orderId; + private Long inboundId; + private Long groupId; + } + + @Getter + @Setter + public static class CommandRow { + private Long id; + private String requestHash; + private String commandStatus; + private Long resultGroupId; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/port/ColoadGroupAssignmentPort.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/port/ColoadGroupAssignmentPort.java new file mode 100644 index 000000000..281739668 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/port/ColoadGroupAssignmentPort.java @@ -0,0 +1,23 @@ +package com.baiyue.firstleg.loading.coload.port; + +import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.GroupSnapshot; + +/** + * 后端6整组 assign/remove 的稳定协作边界。 + * 调用方不得逐成员分配或移除;成功锁定后必须使用返回的全部成员及满箱数。 + */ +public interface ColoadGroupAssignmentPort { + GroupSnapshot findByInbound(long tenantId, long inboundId); + + GroupSnapshot lockWholeGroupForAssignment(long tenantId, long inboundId, long planId, + String operationId, long operatorId, + String operatorName); + + GroupSnapshot unlockWholeGroupAfterRemoval(long groupId, long planId, + String operationId, long operatorId, + String operatorName); + + GroupSnapshot consumeWholeGroup(long groupId, long planId, + String operationId, long operatorId, + String operatorName); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/service/ColoadGroupService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/service/ColoadGroupService.java new file mode 100644 index 000000000..ad655ad22 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/service/ColoadGroupService.java @@ -0,0 +1,366 @@ +package com.baiyue.firstleg.loading.coload.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.firstleg.loading.coload.model.ColoadGroupModels.GroupSnapshot; +import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.Member; +import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.OrderGroupInfo; +import com.baiyue.firstleg.loading.coload.persistence.ColoadGroupMapper; +import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.CommandRow; +import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.GroupRow; +import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.InboundCandidateRow; +import com.baiyue.firstleg.loading.coload.port.ColoadGroupAssignmentPort; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import jakarta.annotation.Resource; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** 同柜不可拆组的唯一状态机与平台编排服务。 */ +@Service +public class ColoadGroupService implements ColoadGroupAssignmentPort { + private static final Set CURRENT_GROUP_STATUSES = Set.of("ACTIVE", "LOCKED", "CONSUMED"); + + @Resource + private ColoadGroupMapper mapper; + + @Transactional + public GroupSnapshot create(List requestedOrderIds, String idempotencyKey, + long operatorId, String operatorName) { + List orderIds = normalizedOrderIds(requestedOrderIds, 2, 100); + String key = key(idempotencyKey); + String requestHash = sha256("CREATE|" + orderIds); + CommandClaim claim = claim(operatorId, "CREATE", key, requestHash); + if (claim.replayGroupId() != null) return requiredSnapshot(claim.replayGroupId()); + + List candidates = authoritativeCandidates(orderIds, null); + validateSameRoute(candidates); + long groupId = IdWorker.getId(); + String groupNo = "CLG-" + groupId; + InboundCandidateRow first = candidates.get(0); + mapper.insertGroup(groupId, groupNo, first.getWarehouseId(), first.getChannelCode().trim(), + normalized(first.getDestinationType()), first.getDestinationWarehouseId(), + trim(first.getDestinationCode()), operatorId, trim(operatorName)); + for (InboundCandidateRow candidate : candidates) { + mapper.insertMember(IdWorker.getId(), groupId, candidate.getTenantId(), candidate.getOrderId(), + candidate.getInboundId(), candidate.getFullPackageCount()); + } + history(groupId, "CREATE", null, "ACTIVE", null, key, null, operatorId, operatorName); + complete(claim.commandId(), groupId); + return requiredSnapshot(groupId); + } + + @Transactional + public GroupSnapshot cancel(long groupId, int expectedVersion, String reason, + String idempotencyKey, long operatorId, String operatorName) { + if (groupId <= 0 || expectedVersion < 0) throw badRequest("组ID或版本不合法"); + String key = key(idempotencyKey); + String normalizedReason = trim(reason); + String requestHash = sha256("CANCEL|" + groupId + '|' + expectedVersion + '|' + + Objects.toString(normalizedReason, "")); + CommandClaim claim = claim(operatorId, "CANCEL", key, requestHash); + if (claim.replayGroupId() != null) return requiredSnapshot(claim.replayGroupId()); + + GroupRow group = requiredLocked(groupId); + if (!"ACTIVE".equals(group.getGroupStatus())) { + throw conflict("只有未锁定的同柜不可拆组可以整组取消"); + } + if (!Objects.equals(group.getVersion(), expectedVersion) + || mapper.cancel(groupId, expectedVersion, operatorId, trim(operatorName)) != 1) { + throw conflict("组版本已变化,请刷新后重试"); + } + mapper.closeMembers(groupId); + history(groupId, "CANCEL", "ACTIVE", "CANCELLED", null, key, + normalizedReason, operatorId, operatorName); + complete(claim.commandId(), groupId); + return requiredSnapshot(groupId); + } + + @Transactional(readOnly = true) + public List queryByOrderIds(List requestedOrderIds) { + List orderIds = normalizedOrderIds(requestedOrderIds, 1, 200); + Map latest = new LinkedHashMap<>(); + Map groups = new LinkedHashMap<>(); + for (var link : mapper.linksByOrderIds(orderIds)) { + if (latest.containsKey(link.getOrderId())) continue; + GroupSnapshot group = groups.computeIfAbsent(link.getGroupId(), this::requiredSnapshot); + if (!CURRENT_GROUP_STATUSES.contains(group.status())) continue; + latest.put(link.getOrderId(), new OrderGroupInfo(link.getOrderId(), link.getInboundId(), group)); + } + return orderIds.stream().map(latest::get).filter(Objects::nonNull).toList(); + } + + @Override + @Transactional(readOnly = true) + public GroupSnapshot findByInbound(long tenantId, long inboundId) { + if (tenantId <= 0 || inboundId <= 0) throw badRequest("客户ID和入库单ID必须大于零"); + GroupRow group = mapper.activeGroupByInbound(tenantId, inboundId); + return group == null ? null : snapshot(group); + } + + @Override + @Transactional + public GroupSnapshot lockWholeGroupForAssignment(long tenantId, long inboundId, long planId, + String operationId, long operatorId, + String operatorName) { + if (tenantId <= 0 || inboundId <= 0 || planId <= 0) throw badRequest("整组分配参数不合法"); + GroupRow active = mapper.activeGroupByInbound(tenantId, inboundId); + if (active == null) throw conflict("入库单不属于可分配的同柜不可拆组"); + String key = internalKey(operationId); + String requestHash = sha256("LOCK|" + active.getId() + '|' + planId); + CommandClaim claim = claim(operatorId, "LOCK", key, requestHash); + if (claim.replayGroupId() != null) return requiredSnapshot(claim.replayGroupId()); + + GroupRow group = requiredLocked(active.getId()); + if ("LOCKED".equals(group.getGroupStatus()) && Objects.equals(group.getLockedPlanId(), planId)) { + complete(claim.commandId(), group.getId()); + return snapshot(group); + } + if (!"ACTIVE".equals(group.getGroupStatus())) throw conflict("同柜不可拆组已锁定或已消费"); + GroupSnapshot before = snapshot(group); + validateStillAssignable(before); + if (mapper.lockToPlan(group.getId(), planId, operatorId, trim(operatorName)) != 1) { + throw conflict("同柜不可拆组状态已变化,请刷新后重试"); + } + history(group.getId(), "LOCK", "ACTIVE", "LOCKED", planId, key, null, operatorId, operatorName); + complete(claim.commandId(), group.getId()); + return requiredSnapshot(group.getId()); + } + + @Override + @Transactional + public GroupSnapshot unlockWholeGroupAfterRemoval(long groupId, long planId, + String operationId, long operatorId, + String operatorName) { + return transitionFromLocked(groupId, planId, "UNLOCK", "ACTIVE", operationId, + operatorId, operatorName, false); + } + + @Override + @Transactional + public GroupSnapshot consumeWholeGroup(long groupId, long planId, + String operationId, long operatorId, + String operatorName) { + return transitionFromLocked(groupId, planId, "CONSUME", "CONSUMED", operationId, + operatorId, operatorName, true); + } + + private GroupSnapshot transitionFromLocked(long groupId, long planId, String action, String target, + String operationId, long operatorId, + String operatorName, boolean closeMembers) { + if (groupId <= 0 || planId <= 0) throw badRequest("组ID和计划ID必须大于零"); + String key = internalKey(operationId); + String requestHash = sha256(action + '|' + groupId + '|' + planId); + CommandClaim claim = claim(operatorId, action, key, requestHash); + if (claim.replayGroupId() != null) return requiredSnapshot(claim.replayGroupId()); + GroupRow group = requiredLocked(groupId); + if (!"LOCKED".equals(group.getGroupStatus()) || !Objects.equals(group.getLockedPlanId(), planId)) { + throw conflict("同柜不可拆组未锁定到当前装柜计划"); + } + int changed = "UNLOCK".equals(action) + ? mapper.unlockFromPlan(groupId, planId) + : mapper.consume(groupId, planId, operatorId, trim(operatorName)); + if (changed != 1) throw conflict("同柜不可拆组状态已变化,请刷新后重试"); + if (closeMembers) mapper.closeMembers(groupId); + history(groupId, action, "LOCKED", target, planId, key, null, operatorId, operatorName); + complete(claim.commandId(), groupId); + return requiredSnapshot(groupId); + } + + private List authoritativeCandidates(List orderIds, Long allowedGroupId) { + Map> byOrder = new LinkedHashMap<>(); + mapper.lockCandidates(orderIds).forEach(row -> byOrder + .computeIfAbsent(row.getOrderId(), ignored -> new ArrayList<>()).add(row)); + List resolved = new ArrayList<>(); + for (Long orderId : orderIds) { + List eligible = byOrder.getOrDefault(orderId, List.of()).stream() + .filter(row -> eligible(row, allowedGroupId)).toList(); + if (eligible.size() != 1) { + throw conflict("订单 " + orderId + " 没有唯一可创建同柜不可拆组的当前入库单"); + } + resolved.add(eligible.get(0)); + } + return resolved; + } + + private boolean eligible(InboundCandidateRow row, Long allowedGroupId) { + return row.getTenantId() != null && row.getTenantId() > 0 + && row.getInboundId() != null && row.getInboundId() > 0 + && "WAIT_SPLIT".equals(row.getProcessStatus()) + && "MASTER".equals(row.getInboundRole()) + && "NOT_SPLIT".equals(row.getSplitStatus()) + && "MASTER".equals(row.getSplitPurpose()) + && row.getParentInboundId() == null + && row.getFullPackageCount() != null && row.getFullPackageCount() > 0 + && Objects.equals(row.getActiveAllocationCount(), 0) + && Objects.equals(row.getChildCount(), 0) + && Objects.equals(row.getSplitBatchCount(), 0) + && (row.getActiveGroupId() == null || Objects.equals(row.getActiveGroupId(), allowedGroupId)); + } + + private void validateSameRoute(List rows) { + InboundCandidateRow first = rows.get(0); + if (first.getWarehouseId() == null || first.getWarehouseId() <= 0 + || !StringUtils.hasText(first.getChannelCode()) + || !StringUtils.hasText(first.getDestinationType()) + || destinationKey(first) == null) { + throw conflict("订单缺少仓库、渠道或目的仓港权威快照"); + } + for (InboundCandidateRow row : rows) { + if (!Objects.equals(first.getWarehouseId(), row.getWarehouseId()) + || !normalized(first.getChannelCode()).equals(normalized(row.getChannelCode())) + || !normalized(first.getDestinationType()).equals(normalized(row.getDestinationType())) + || !Objects.equals(destinationKey(first), destinationKey(row))) { + throw conflict("同柜不可拆组成员必须属于同一国内仓、渠道和目的仓港"); + } + } + } + + private void validateStillAssignable(GroupSnapshot group) { + List orderIds = group.members().stream().map(Member::orderId).toList(); + List current = authoritativeCandidates(orderIds, group.groupId()); + Map frozen = new LinkedHashMap<>(); + group.members().forEach(member -> frozen.put(member.orderId(), member)); + for (InboundCandidateRow row : current) { + Member member = frozen.get(row.getOrderId()); + if (member == null || member.tenantId() != row.getTenantId() + || member.inboundId() != row.getInboundId() + || member.fullPackageCount() != row.getFullPackageCount()) { + throw conflict("同柜不可拆组成员的当前入库单或满箱数已变化"); + } + } + validateSameRoute(current); + } + + private GroupSnapshot requiredSnapshot(long groupId) { + GroupRow row = mapper.group(groupId); + if (row == null) throw new BizException(ResultCode.NOT_FOUND, "同柜不可拆组不存在"); + return snapshot(row); + } + + private GroupSnapshot snapshot(GroupRow row) { + List members = mapper.members(row.getId()).stream() + .map(value -> new Member(value.getTenantId(), value.getOrderId(), value.getInboundId(), + value.getFullPackageCount())) + .toList(); + return new GroupSnapshot(row.getId(), row.getGroupNo(), row.getGroupStatus(), row.getWarehouseId(), + row.getChannelCode(), row.getDestinationType(), row.getDestinationWarehouseId(), + row.getDestinationCode(), row.getLockedPlanId(), row.getVersion(), members, + members.stream().mapToInt(Member::fullPackageCount).sum(), + row.getCreatedAt(), row.getUpdatedAt()); + } + + private GroupRow requiredLocked(long groupId) { + GroupRow group = mapper.lockGroup(groupId); + if (group == null) throw new BizException(ResultCode.NOT_FOUND, "同柜不可拆组不存在"); + return group; + } + + private CommandClaim claim(long actorId, String action, String key, String requestHash) { + CommandRow existing = mapper.command(actorId, action, key); + if (existing != null) return replay(existing, requestHash); + long commandId = IdWorker.getId(); + try { + mapper.insertCommand(commandId, actorId, action, key, requestHash); + return new CommandClaim(commandId, null); + } catch (DuplicateKeyException exception) { + CommandRow concurrent = mapper.command(actorId, action, key); + if (concurrent == null) throw conflict("幂等命令并发冲突,请重试"); + return replay(concurrent, requestHash); + } + } + + private CommandClaim replay(CommandRow existing, String requestHash) { + if (!Objects.equals(existing.getRequestHash(), requestHash)) { + throw conflict("Idempotency-Key已用于不同请求"); + } + if (!"SUCCESS".equals(existing.getCommandStatus()) || existing.getResultGroupId() == null) { + throw conflict("同一幂等命令正在处理中,请稍后重试"); + } + return new CommandClaim(existing.getId(), existing.getResultGroupId()); + } + + private void complete(long commandId, long groupId) { + if (mapper.completeCommand(commandId, groupId) != 1) throw conflict("幂等命令完成状态冲突"); + } + + private void history(long groupId, String action, String fromStatus, String toStatus, Long planId, + String operationId, String remark, long operatorId, String operatorName) { + mapper.insertHistory(IdWorker.getId(), groupId, action, fromStatus, toStatus, planId, + operationId, trim(remark), operatorId, trim(operatorName)); + } + + private static String destinationKey(InboundCandidateRow row) { + if (row.getDestinationWarehouseId() != null && row.getDestinationWarehouseId() > 0) { + return "ID:" + row.getDestinationWarehouseId(); + } + String code = trim(row.getDestinationCode()); + return code == null ? null : "CODE:" + code.toUpperCase(Locale.ROOT); + } + + private static List normalizedOrderIds(List source, int minimum, int maximum) { + if (source == null) throw badRequest("orderIds不能为空"); + List values = source.stream().filter(Objects::nonNull).distinct().sorted().toList(); + if (values.size() != source.size() || values.size() < minimum || values.size() > maximum + || values.stream().anyMatch(value -> value <= 0)) { + throw badRequest("orderIds数量、重复项或ID值不合法"); + } + return values; + } + + private static String key(String value) { + String normalized = trim(value); + if (normalized == null || normalized.length() > 128) { + throw badRequest("Idempotency-Key不能为空且不能超过128字符"); + } + return normalized; + } + + private static String internalKey(String value) { + String normalized = trim(value); + if (normalized == null) throw badRequest("内部整组操作ID不能为空"); + return normalized.length() <= 128 ? normalized : "COL-" + sha256(normalized); + } + + private static String normalized(String value) { + String result = trim(value); + return result == null ? "" : result.toUpperCase(Locale.ROOT); + } + + private static String trim(String value) { + return StringUtils.hasText(value) ? value.trim() : null; + } + + private static String sha256(String value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } + + private static BizException badRequest(String message) { + return new BizException(ResultCode.BAD_REQUEST, message); + } + + private static BizException conflict(String message) { + return new BizException(ResultCode.CONFLICT, message); + } + + private record CommandClaim(long commandId, Long replayGroupId) { + } +} diff --git a/firstleg/src/main/resources/db/phase3-coload-group-schema.sql b/firstleg/src/main/resources/db/phase3-coload-group-schema.sql new file mode 100644 index 000000000..8593b375c --- /dev/null +++ b/firstleg/src/main/resources/db/phase3-coload-group-schema.sql @@ -0,0 +1,94 @@ +USE baiyue_firstleg; + +CREATE TABLE IF NOT EXISTS fl_coload_group ( + id BIGINT UNSIGNED NOT NULL COMMENT '同柜不可拆组ID', + group_no VARCHAR(40) NOT NULL COMMENT '稳定组号CLG-{id}', + group_status VARCHAR(16) NOT NULL COMMENT 'ACTIVE、LOCKED、CONSUMED、CANCELLED', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '国内收货仓ID快照', + channel_code VARCHAR(64) NOT NULL COMMENT '物流渠道编码快照', + destination_type VARCHAR(32) NOT NULL COMMENT '目的类型快照', + destination_warehouse_id BIGINT UNSIGNED NULL COMMENT '目的仓ID快照', + destination_code VARCHAR(64) NULL COMMENT '目的仓或目的港编码快照', + locked_plan_id BIGINT UNSIGNED NULL COMMENT 'LOCKED/CONSUMED绑定装柜计划ID', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观版本', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', + created_by_name VARCHAR(64) NULL 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), + locked_by BIGINT UNSIGNED NULL, + locked_by_name VARCHAR(64) NULL, + locked_at DATETIME(3) NULL, + consumed_by BIGINT UNSIGNED NULL, + consumed_by_name VARCHAR(64) NULL, + consumed_at DATETIME(3) NULL, + cancelled_by BIGINT UNSIGNED NULL, + cancelled_by_name VARCHAR(64) NULL, + cancelled_at DATETIME(3) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_coload_group_no (group_no), + KEY idx_coload_group_status (group_status,updated_at,id), + KEY idx_coload_group_plan (locked_plan_id,group_status), + CONSTRAINT fk_coload_group_plan FOREIGN KEY (locked_plan_id) REFERENCES fl_loading_plan(id), + CONSTRAINT chk_coload_group_status CHECK (group_status IN ('ACTIVE','LOCKED','CONSUMED','CANCELLED')), + CONSTRAINT chk_coload_group_destination CHECK ( + destination_warehouse_id IS NOT NULL OR destination_code IS NOT NULL), + CONSTRAINT chk_coload_group_lock CHECK ( + (group_status IN ('ACTIVE','CANCELLED') AND locked_plan_id IS NULL) + OR (group_status IN ('LOCKED','CONSUMED') AND locked_plan_id IS NOT NULL)) +) ENGINE=InnoDB COMMENT='同柜且不可拆业务组聚合根'; + +CREATE TABLE IF NOT EXISTS fl_coload_group_member ( + id BIGINT UNSIGNED NOT NULL COMMENT '组成员ID', + group_id BIGINT UNSIGNED NOT NULL COMMENT '同柜不可拆组ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '成员客户ID;允许跨客户成组', + order_id BIGINT UNSIGNED NOT NULL COMMENT '订单ID', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '创建时权威入库单ID', + full_package_count INT UNSIGNED NOT NULL COMMENT '必须整组分配的满箱数快照', + active_flag TINYINT UNSIGNED NULL DEFAULT 1 COMMENT '当前约束唯一标识;取消或消费后置NULL', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + closed_at DATETIME(3) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_coload_group_member (group_id,inbound_id), + UNIQUE KEY uk_coload_active_inbound (tenant_id,inbound_id,active_flag), + KEY idx_coload_member_order (order_id,created_at,group_id), + CONSTRAINT fk_coload_member_group FOREIGN KEY (group_id) REFERENCES fl_coload_group(id), + CONSTRAINT fk_coload_member_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order(id), + CONSTRAINT chk_coload_member_packages CHECK (full_package_count > 0), + CONSTRAINT chk_coload_member_active CHECK (active_flag IS NULL OR active_flag = 1) +) ENGINE=InnoDB COMMENT='同柜不可拆组成员与满箱数快照'; + +CREATE TABLE IF NOT EXISTS fl_coload_group_history ( + id BIGINT UNSIGNED NOT NULL COMMENT '状态历史ID', + group_id BIGINT UNSIGNED NOT NULL, + action_type VARCHAR(16) NOT NULL COMMENT 'CREATE、CANCEL、LOCK、UNLOCK、CONSUME', + from_status VARCHAR(16) NULL, + to_status VARCHAR(16) NOT NULL, + plan_id BIGINT UNSIGNED NULL, + operation_id VARCHAR(128) NOT NULL COMMENT '稳定幂等操作ID', + remark VARCHAR(500) NULL, + operator_id BIGINT UNSIGNED NOT NULL, + operator_name VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_coload_history_operation (group_id,action_type,operation_id), + KEY idx_coload_history_group (group_id,created_at,id), + CONSTRAINT fk_coload_history_group FOREIGN KEY (group_id) REFERENCES fl_coload_group(id), + CONSTRAINT chk_coload_history_action CHECK ( + action_type IN ('CREATE','CANCEL','LOCK','UNLOCK','CONSUME')) +) ENGINE=InnoDB COMMENT='同柜不可拆组不可变状态历史'; + +CREATE TABLE IF NOT EXISTS fl_coload_command ( + id BIGINT UNSIGNED NOT NULL COMMENT '幂等命令ID', + actor_id BIGINT UNSIGNED NOT NULL COMMENT '平台或内部操作人ID', + action_type VARCHAR(16) NOT NULL, + idempotency_key VARCHAR(128) NOT NULL, + request_hash CHAR(64) NOT NULL, + command_status VARCHAR(16) NOT NULL COMMENT 'PROCESSING、SUCCESS', + result_group_id BIGINT UNSIGNED NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + completed_at DATETIME(3) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_coload_command (actor_id,action_type,idempotency_key), + CONSTRAINT fk_coload_command_group FOREIGN KEY (result_group_id) REFERENCES fl_coload_group(id), + CONSTRAINT chk_coload_command_status CHECK (command_status IN ('PROCESSING','SUCCESS')) +) ENGINE=InnoDB COMMENT='同柜不可拆组幂等写命令'; diff --git a/firstleg/src/main/resources/openapi/coload-groups-v1.yaml b/firstleg/src/main/resources/openapi/coload-groups-v1.yaml new file mode 100644 index 000000000..75915c61d --- /dev/null +++ b/firstleg/src/main/resources/openapi/coload-groups-v1.yaml @@ -0,0 +1,161 @@ +openapi: 3.0.3 +info: + title: Firstleg Coload Group API + version: 1.0.0 + description: 同柜且不可拆组平台管理契约;跨客户成组,但仓库、渠道和目的仓港必须一致。 +paths: + /api/platform/v1/first-leg/coload-groups: + post: + summary: 创建同柜不可拆组 + description: >- + 需要 platform:firstleg:smart-loading:assign。orderIds 至少2项;服务端解析每个订单唯一当前权威入库单, + 要求 WAIT_SPLIT、原始MASTER、未拆分、未分配、无子单或拆分批次,并冻结全部成员满箱数。 + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/CreateColoadGroupRequest'} + responses: + '200': {$ref: '#/components/responses/ColoadGroupResponse'} + '403': {description: 缺少智能排柜分配权限} + /api/platform/v1/first-leg/coload-groups/{groupId}/cancel: + post: + summary: 整组取消同柜不可拆组 + description: 仅 ACTIVE 组允许取消;不提供单成员编辑,修改成员必须取消后重建。 + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + - {name: groupId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/CancelColoadGroupRequest'} + responses: + '200': {$ref: '#/components/responses/ColoadGroupResponse'} + '403': {description: 缺少智能排柜分配权限} + /api/platform/v1/first-leg/coload-groups/batch-query: + post: + summary: 按订单ID批量查询同柜不可拆组 + description: >- + 需要 platform:firstleg:smart-loading:assign。最多200个订单;返回当前非取消组的组号、状态、 + 全部成员和整组满箱数,供平台订单列表批量补齐,不按单成员修改。 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/BatchQueryColoadGroupRequest'} + responses: + '200': + description: 业务响应 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/ApiEnvelope'} + - type: object + properties: + data: + type: array + maxItems: 200 + items: {$ref: '#/components/schemas/OrderColoadGroupInfo'} + '403': {description: 缺少智能排柜分配权限} +components: + parameters: + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + schema: {type: string, minLength: 1, maxLength: 128} + responses: + ColoadGroupResponse: + description: 成功或业务失败,按响应体 code 判断 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/ApiEnvelope'} + - type: object + properties: + data: {$ref: '#/components/schemas/ColoadGroup'} + schemas: + ApiEnvelope: + type: object + required: [code, message, timestamp] + properties: + code: {type: string, example: '200'} + message: {type: string} + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} + timestamp: {type: string, format: date-time} + CreateColoadGroupRequest: + type: object + additionalProperties: false + required: [orderIds] + properties: + orderIds: + type: array + minItems: 2 + maxItems: 100 + uniqueItems: true + items: {type: string, pattern: '^[1-9][0-9]*$'} + CancelColoadGroupRequest: + type: object + additionalProperties: false + required: [expectedVersion] + properties: + expectedVersion: {type: integer, minimum: 0} + reason: {type: string, maxLength: 500, nullable: true} + BatchQueryColoadGroupRequest: + type: object + additionalProperties: false + required: [orderIds] + properties: + orderIds: + type: array + minItems: 1 + maxItems: 200 + uniqueItems: true + items: {type: string, pattern: '^[1-9][0-9]*$'} + ColoadGroupMember: + type: object + additionalProperties: false + required: [tenantId, orderId, inboundId, fullPackageCount] + properties: + tenantId: {type: string, pattern: '^[1-9][0-9]*$'} + orderId: {type: string, pattern: '^[1-9][0-9]*$'} + inboundId: {type: string, pattern: '^[1-9][0-9]*$'} + fullPackageCount: {type: integer, minimum: 1} + ColoadGroup: + type: object + additionalProperties: false + required: [groupId, groupNo, status, warehouseId, channelCode, destinationType, + version, members, fullPackageCount, createdAt, updatedAt] + properties: + groupId: {type: string, pattern: '^[1-9][0-9]*$'} + groupNo: {type: string, pattern: '^CLG-[1-9][0-9]*$'} + status: {type: string, enum: [ACTIVE, LOCKED, CONSUMED, CANCELLED]} + warehouseId: {type: string, pattern: '^[1-9][0-9]*$'} + channelCode: {type: string} + destinationType: {type: string} + destinationWarehouseId: {type: string, nullable: true} + destinationCode: {type: string, nullable: true} + lockedPlanId: {type: string, nullable: true} + version: {type: integer, minimum: 0} + members: + type: array + minItems: 2 + maxItems: 100 + items: {$ref: '#/components/schemas/ColoadGroupMember'} + fullPackageCount: {type: integer, minimum: 2, description: 全部成员满箱数之和} + createdAt: {type: string, format: date-time} + updatedAt: {type: string, format: date-time} + OrderColoadGroupInfo: + type: object + additionalProperties: false + required: [orderId, inboundId, group] + properties: + orderId: {type: string, pattern: '^[1-9][0-9]*$'} + inboundId: {type: string, pattern: '^[1-9][0-9]*$'} + group: {$ref: '#/components/schemas/ColoadGroup'} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupContractTest.java new file mode 100644 index 000000000..3f66f4b9e --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupContractTest.java @@ -0,0 +1,58 @@ +package com.baiyue.firstleg.loading.coload; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.loading.coload.controller.PlatformColoadGroupController; +import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.BatchQueryRequest; +import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.CancelRequest; +import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.CreateRequest; +import com.baiyue.firstleg.loading.coload.port.ColoadGroupAssignmentPort; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class ColoadGroupContractTest { + @Test + void platformApiReusesSmartLoadingAssignPermissionWithoutMemberEditEndpoint() throws Exception { + RequestMapping base = PlatformColoadGroupController.class.getAnnotation(RequestMapping.class); + assertThat(base.value()).containsExactly("/api/platform/v1/first-leg/coload-groups"); + + assertEndpoint("create", "", AuthenticatedUser.class, String.class, CreateRequest.class); + assertEndpoint("cancel", "/{groupId}/cancel", AuthenticatedUser.class, long.class, + String.class, CancelRequest.class); + assertEndpoint("batchQuery", "/batch-query", AuthenticatedUser.class, BatchQueryRequest.class); + assertThat(Arrays.stream(PlatformColoadGroupController.class.getDeclaredMethods()) + .map(Method::getName)).doesNotContain("addMember", "removeMember", "updateMember"); + } + + @Test + void backendSixPortFreezesWholeGroupAssignRemoveAndCompletionMethods() throws Exception { + assertThat(ColoadGroupAssignmentPort.class.getDeclaredMethod( + "findByInbound", long.class, long.class)).isNotNull(); + assertThat(ColoadGroupAssignmentPort.class.getDeclaredMethod( + "lockWholeGroupForAssignment", long.class, long.class, long.class, + String.class, long.class, String.class)).isNotNull(); + assertThat(ColoadGroupAssignmentPort.class.getDeclaredMethod( + "unlockWholeGroupAfterRemoval", long.class, long.class, String.class, + long.class, String.class)).isNotNull(); + assertThat(ColoadGroupAssignmentPort.class.getDeclaredMethod( + "consumeWholeGroup", long.class, long.class, String.class, + long.class, String.class)).isNotNull(); + } + + private void assertEndpoint(String methodName, String path, Class... parameterTypes) throws Exception { + Method method = PlatformColoadGroupController.class.getDeclaredMethod(methodName, parameterTypes); + if (path.isEmpty()) { + assertThat(method.getAnnotation(PostMapping.class).value()).isEmpty(); + } else { + assertThat(method.getAnnotation(PostMapping.class).value()).containsExactly(path); + } + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('platform:firstleg:smart-loading:assign')"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupOpenApiContractTest.java new file mode 100644 index 000000000..988c9dc59 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupOpenApiContractTest.java @@ -0,0 +1,37 @@ +package com.baiyue.firstleg.loading.coload; + +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class ColoadGroupOpenApiContractTest { + @SuppressWarnings("unchecked") + @Test + void publishesBoundedLongIdGroupContractWithoutNewPermission() throws Exception { + String source = Files.readString(Path.of("src/main/resources/openapi/coload-groups-v1.yaml")); + Map yaml = new Yaml().load(source); + Map paths = (Map) yaml.get("paths"); + + assertThat(paths).containsKeys( + "/api/platform/v1/first-leg/coload-groups", + "/api/platform/v1/first-leg/coload-groups/{groupId}/cancel", + "/api/platform/v1/first-leg/coload-groups/batch-query"); + assertThat(source) + .contains("minItems: 2") + .contains("maxItems: 100") + .contains("maxItems: 200") + .contains("uniqueItems: true") + .contains("pattern: '^[1-9][0-9]*$'") + .contains("enum: [ACTIVE, LOCKED, CONSUMED, CANCELLED]") + .contains("Idempotency-Key") + .contains("expectedVersion") + .contains("fullPackageCount") + .contains("platform:firstleg:smart-loading:assign") + .doesNotContain("platform:firstleg:coload"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupServiceTest.java new file mode 100644 index 000000000..f01940c2f --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupServiceTest.java @@ -0,0 +1,217 @@ +package com.baiyue.firstleg.loading.coload; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.loading.coload.persistence.ColoadGroupMapper; +import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.GroupRow; +import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.InboundCandidateRow; +import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.MemberRow; +import com.baiyue.firstleg.loading.coload.service.ColoadGroupService; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.time.LocalDateTime; +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.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(ColoadGroupService.class) +class ColoadGroupServiceTest { + @MockBean + private ColoadGroupMapper mapper; + @Resource + private ColoadGroupService service; + + @Test + void createsCrossCustomerGroupAndFreezesEveryMembersFullPackageCount() { + when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(List.of( + candidate(101L, 11L, 1001L, 30), candidate(202L, 12L, 1002L, 50))); + when(mapper.insertGroup(anyLong(), anyString(), eq(9L), eq("CH"), eq("FBA"), + eq(88L), eq("ONT8"), eq(7L), eq("operator"))).thenReturn(1); + when(mapper.insertMember(anyLong(), anyLong(), anyLong(), anyLong(), anyLong(), anyInt())) + .thenReturn(1); + when(mapper.insertHistory(anyLong(), anyLong(), anyString(), any(), anyString(), any(), + anyString(), any(), anyLong(), any())).thenReturn(1); + when(mapper.insertCommand(anyLong(), eq(7L), eq("CREATE"), eq("create-key"), anyString())) + .thenReturn(1); + when(mapper.completeCommand(anyLong(), anyLong())).thenReturn(1); + when(mapper.group(anyLong())).thenReturn(group(500L, "ACTIVE", null, 0)); + when(mapper.members(anyLong())).thenReturn(List.of( + member(101L, 11L, 1001L, 30), member(202L, 12L, 1002L, 50))); + + var result = service.create(List.of(12L, 11L), "create-key", 7L, "operator"); + + assertThat(result.members()).extracting("tenantId", "orderId", "inboundId", "fullPackageCount") + .containsExactly(tuple(101L, 11L, 1001L, 30), tuple(202L, 12L, 1002L, 50)); + assertThat(result.fullPackageCount()).isEqualTo(80); + ArgumentCaptor groupId = ArgumentCaptor.forClass(Long.class); + verify(mapper).insertGroup(groupId.capture(), anyString(), eq(9L), eq("CH"), eq("FBA"), + eq(88L), eq("ONT8"), eq(7L), eq("operator")); + verify(mapper).insertMember(anyLong(), eq(groupId.getValue()), eq(101L), eq(11L), eq(1001L), eq(30)); + verify(mapper).insertMember(anyLong(), eq(groupId.getValue()), eq(202L), eq(12L), eq(1002L), eq(50)); + } + + @Test + void rejectsSingleOrderBeforeAnyDatabaseWrite() { + assertThatThrownBy(() -> service.create(List.of(11L), "key", 7L, "operator")) + .isInstanceOf(BizException.class) + .hasMessageContaining("orderIds"); + verify(mapper, never()).insertCommand(anyLong(), anyLong(), anyString(), anyString(), anyString()); + } + + @Test + void rejectsMemberThatIsAllocatedSplitOrAlreadyGrouped() { + InboundCandidateRow invalid = candidate(101L, 11L, 1001L, 30); + invalid.setActiveAllocationCount(1); + invalid.setChildCount(1); + invalid.setSplitBatchCount(1); + invalid.setActiveGroupId(900L); + when(mapper.insertCommand(anyLong(), eq(7L), eq("CREATE"), eq("key"), anyString())).thenReturn(1); + when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(List.of( + invalid, candidate(202L, 12L, 1002L, 50))); + + assertThatThrownBy(() -> service.create(List.of(11L, 12L), "key", 7L, "operator")) + .isInstanceOf(BizException.class) + .hasMessageContaining("唯一可创建"); + verify(mapper, never()).insertGroup(anyLong(), anyString(), anyLong(), anyString(), anyString(), + any(), any(), anyLong(), any()); + } + + @Test + void rejectsDifferentWarehouseChannelOrDestination() { + InboundCandidateRow second = candidate(202L, 12L, 1002L, 50); + second.setDestinationWarehouseId(99L); + second.setDestinationCode("LAX9"); + when(mapper.insertCommand(anyLong(), eq(7L), eq("CREATE"), eq("route-key"), anyString())) + .thenReturn(1); + when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(List.of( + candidate(101L, 11L, 1001L, 30), second)); + + assertThatThrownBy(() -> service.create(List.of(11L, 12L), "route-key", 7L, "operator")) + .isInstanceOf(BizException.class) + .hasMessageContaining("同一国内仓、渠道和目的仓港"); + } + + @Test + void lockedGroupCannotBeCancelled() { + when(mapper.insertCommand(anyLong(), eq(7L), eq("CANCEL"), eq("cancel-key"), anyString())) + .thenReturn(1); + when(mapper.lockGroup(500L)).thenReturn(group(500L, "LOCKED", 700L, 2)); + + assertThatThrownBy(() -> service.cancel(500L, 2, "change", "cancel-key", 7L, "operator")) + .isInstanceOf(BizException.class) + .hasMessageContaining("未锁定"); + verify(mapper, never()).cancel(anyLong(), anyInt(), anyLong(), any()); + } + + @Test + void activeGroupCancelsAsAWholeAndReleasesEveryMembership() { + when(mapper.insertCommand(anyLong(), eq(7L), eq("CANCEL"), eq("cancel-all"), anyString())) + .thenReturn(1); + when(mapper.lockGroup(500L)).thenReturn(group(500L, "ACTIVE", null, 2)); + when(mapper.cancel(500L, 2, 7L, "operator")).thenReturn(1); + when(mapper.closeMembers(500L)).thenReturn(2); + when(mapper.insertHistory(anyLong(), eq(500L), eq("CANCEL"), eq("ACTIVE"), + eq("CANCELLED"), any(), eq("cancel-all"), eq("rebuild"), eq(7L), eq("operator"))) + .thenReturn(1); + when(mapper.completeCommand(anyLong(), eq(500L))).thenReturn(1); + when(mapper.group(500L)).thenReturn(group(500L, "CANCELLED", null, 3)); + when(mapper.members(500L)).thenReturn(List.of( + member(101L, 11L, 1001L, 30), member(202L, 12L, 1002L, 50))); + + var result = service.cancel(500L, 2, "rebuild", "cancel-all", 7L, "operator"); + + assertThat(result.status()).isEqualTo("CANCELLED"); + verify(mapper).closeMembers(500L); + } + + @Test + void backendPortLocksAllFrozenMembersToOnePlan() { + when(mapper.activeGroupByInbound(101L, 1001L)).thenReturn(group(500L, "ACTIVE", null, 0)); + when(mapper.insertCommand(anyLong(), eq(7L), eq("LOCK"), eq("assign-whole"), anyString())) + .thenReturn(1); + when(mapper.lockGroup(500L)).thenReturn(group(500L, "ACTIVE", null, 0)); + when(mapper.members(500L)).thenReturn(List.of( + member(101L, 11L, 1001L, 30), member(202L, 12L, 1002L, 50))); + InboundCandidateRow first = candidate(101L, 11L, 1001L, 30); first.setActiveGroupId(500L); + InboundCandidateRow second = candidate(202L, 12L, 1002L, 50); second.setActiveGroupId(500L); + when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(List.of(first, second)); + when(mapper.lockToPlan(500L, 700L, 7L, "operator")).thenReturn(1); + when(mapper.completeCommand(anyLong(), eq(500L))).thenReturn(1); + when(mapper.group(500L)).thenReturn(group(500L, "LOCKED", 700L, 1)); + + var result = service.lockWholeGroupForAssignment( + 101L, 1001L, 700L, "assign-whole", 7L, "operator"); + + assertThat(result.status()).isEqualTo("LOCKED"); + assertThat(result.lockedPlanId()).isEqualTo(700L); + assertThat(result.members()).hasSize(2); + assertThat(result.fullPackageCount()).isEqualTo(80); + } + + @Test + void completionConsumesLockedGroupAndClosesMemberships() { + when(mapper.insertCommand(anyLong(), eq(7L), eq("CONSUME"), eq("consume-whole"), anyString())) + .thenReturn(1); + when(mapper.lockGroup(500L)).thenReturn(group(500L, "LOCKED", 700L, 1)); + when(mapper.consume(500L, 700L, 7L, "operator")).thenReturn(1); + when(mapper.closeMembers(500L)).thenReturn(2); + when(mapper.insertHistory(anyLong(), eq(500L), eq("CONSUME"), eq("LOCKED"), + eq("CONSUMED"), eq(700L), eq("consume-whole"), any(), eq(7L), eq("operator"))) + .thenReturn(1); + when(mapper.completeCommand(anyLong(), eq(500L))).thenReturn(1); + when(mapper.group(500L)).thenReturn(group(500L, "CONSUMED", 700L, 2)); + when(mapper.members(500L)).thenReturn(List.of( + member(101L, 11L, 1001L, 30), member(202L, 12L, 1002L, 50))); + + var result = service.consumeWholeGroup(500L, 700L, "consume-whole", 7L, "operator"); + + assertThat(result.status()).isEqualTo("CONSUMED"); + verify(mapper).closeMembers(500L); + } + + private static InboundCandidateRow candidate(long tenantId, long orderId, long inboundId, int packages) { + InboundCandidateRow value = new InboundCandidateRow(); + value.setTenantId(tenantId); value.setOrderId(orderId); value.setInboundId(inboundId); + value.setProcessStatus("WAIT_SPLIT"); value.setInboundRole("MASTER"); + value.setSplitStatus("NOT_SPLIT"); value.setSplitPurpose("MASTER"); + value.setWarehouseId(9L); value.setChannelCode("CH"); value.setDestinationType("FBA"); + value.setDestinationWarehouseId(88L); value.setDestinationCode("ONT8"); + value.setFullPackageCount(packages); value.setActiveAllocationCount(0); + value.setChildCount(0); value.setSplitBatchCount(0); + return value; + } + + private static GroupRow group(long id, String status, Long planId, int version) { + GroupRow value = new GroupRow(); + value.setId(id); value.setGroupNo("CLG-" + id); value.setGroupStatus(status); + value.setWarehouseId(9L); value.setChannelCode("CH"); value.setDestinationType("FBA"); + value.setDestinationWarehouseId(88L); value.setDestinationCode("ONT8"); + value.setLockedPlanId(planId); value.setVersion(version); + value.setCreatedAt(LocalDateTime.of(2026, 8, 29, 9, 0)); + value.setUpdatedAt(value.getCreatedAt()); + return value; + } + + private static MemberRow member(long tenantId, long orderId, long inboundId, int packages) { + MemberRow value = new MemberRow(); + value.setTenantId(tenantId); value.setOrderId(orderId); value.setInboundId(inboundId); + value.setFullPackageCount(packages); + return value; + } + + private static org.assertj.core.groups.Tuple tuple(Object... values) { + return org.assertj.core.groups.Tuple.tuple(values); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupSqlContractTest.java new file mode 100644 index 000000000..b128bf678 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupSqlContractTest.java @@ -0,0 +1,48 @@ +package com.baiyue.firstleg.loading.coload; + +import com.baiyue.firstleg.loading.coload.persistence.ColoadGroupMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class ColoadGroupSqlContractTest { + @Test + void baselineAndUnifiedSqlFreezeAggregateMemberHistoryAndIdempotencyTables() throws Exception { + String baseline = Files.readString(Path.of("src/main/resources/db/phase3-coload-group-schema.sql")); + String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); + for (String source : new String[]{baseline, unified}) { + assertThat(source) + .contains("CREATE TABLE IF NOT EXISTS fl_coload_group (") + .contains("CREATE TABLE IF NOT EXISTS fl_coload_group_member (") + .contains("CREATE TABLE IF NOT EXISTS fl_coload_group_history (") + .contains("CREATE TABLE IF NOT EXISTS fl_coload_command (") + .contains("ACTIVE','LOCKED','CONSUMED','CANCELLED") + .contains("uk_coload_active_inbound") + .contains("full_package_count") + .contains("uk_coload_command") + .contains("locked_plan_id"); + } + } + + @Test + void mapperLocksAuthoritativeInboundAndRejectsAllocationChildrenAndSplitFacts() throws Exception { + assertThat(ColoadGroupMapper.class.getAnnotation(Mapper.class)).isNotNull(); + Method method = ColoadGroupMapper.class.getDeclaredMethod("lockCandidates", java.util.List.class); + String sql = String.join(" ", method.getAnnotation(Select.class).value()); + assertThat(sql) + .contains("FROM fl_inbound_order o") + .contains("FROM fl_loading_plan_item item") + .contains("item.deleted=0") + .contains("child.parent_inbound_id=o.id") + .contains("FROM fl_inbound_split_batch batch") + .contains("batch.status IN ('PREPARED','APPLIED')") + .contains("member.active_flag=1") + .contains("FOR UPDATE"); + } +}