feat(firstleg): add indivisible coload groups

This commit is contained in:
2026-08-29 10:51:50 +08:00
parent e6ee9cb442
commit ac6d3289a6
13 changed files with 1494 additions and 0 deletions
@@ -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,
@@ -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<GroupSnapshot> 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<GroupSnapshot> 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<List<OrderGroupInfo>> 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, "必须指定有效平台操作人");
}
}
}
@@ -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<Member> 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<Member> 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
) {
}
}
@@ -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({"<script>", """
SELECT o.tenant_id tenantId,o.order_id orderId,o.id inboundId,
o.process_status processStatus,o.inbound_role inboundRole,
o.split_status splitStatus,o.split_purpose splitPurpose,
o.parent_inbound_id parentInboundId,o.warehouse_id warehouseId,
o.channel_code channelCode,o.destination_type destinationType,
o.destination_warehouse_id destinationWarehouseId,
o.destination_code destinationCode,o.total_package_count fullPackageCount,
(SELECT COUNT(*) FROM fl_loading_plan_item item
WHERE item.account_id=o.tenant_id AND item.inbound_id=o.id AND item.deleted=0)
activeAllocationCount,
(SELECT COUNT(*) FROM fl_inbound_order child
WHERE child.tenant_id=o.tenant_id AND child.parent_inbound_id=o.id AND child.deleted=0)
childCount,
(SELECT COUNT(*) FROM fl_inbound_split_batch batch
WHERE batch.tenant_id=o.tenant_id AND batch.parent_inbound_id=o.id
AND batch.status IN ('PREPARED','APPLIED')) splitBatchCount,
(SELECT member.group_id FROM fl_coload_group_member member
WHERE member.tenant_id=o.tenant_id AND member.inbound_id=o.id
AND member.active_flag=1 LIMIT 1) activeGroupId
FROM fl_inbound_order o
WHERE o.deleted=0 AND o.order_id IN
""", "<foreach collection='orderIds' item='orderId' open='(' separator=',' close=')'>",
"#{orderId}", "</foreach>", "ORDER BY o.order_id,o.created_at DESC,o.id DESC FOR UPDATE", "</script>"})
List<InboundCandidateRow> lockCandidates(@Param("orderIds") List<Long> 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<MemberRow> 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({"<script>", """
SELECT member.order_id orderId,member.inbound_id inboundId,member.group_id groupId
FROM fl_coload_group_member member
JOIN fl_coload_group group_row ON group_row.id=member.group_id
WHERE member.order_id IN
""", "<foreach collection='orderIds' item='orderId' open='(' separator=',' close=')'>",
"#{orderId}", "</foreach>",
"AND group_row.group_status&lt;&gt;'CANCELLED' ORDER BY member.order_id,group_row.created_at DESC", "</script>"})
List<OrderGroupLinkRow> linksByOrderIds(@Param("orderIds") List<Long> 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);
}
@@ -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;
}
}
@@ -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);
}
@@ -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<String> CURRENT_GROUP_STATUSES = Set.of("ACTIVE", "LOCKED", "CONSUMED");
@Resource
private ColoadGroupMapper mapper;
@Transactional
public GroupSnapshot create(List<Long> requestedOrderIds, String idempotencyKey,
long operatorId, String operatorName) {
List<Long> 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<InboundCandidateRow> 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<OrderGroupInfo> queryByOrderIds(List<Long> requestedOrderIds) {
List<Long> orderIds = normalizedOrderIds(requestedOrderIds, 1, 200);
Map<Long, OrderGroupInfo> latest = new LinkedHashMap<>();
Map<Long, GroupSnapshot> 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<InboundCandidateRow> authoritativeCandidates(List<Long> orderIds, Long allowedGroupId) {
Map<Long, List<InboundCandidateRow>> byOrder = new LinkedHashMap<>();
mapper.lockCandidates(orderIds).forEach(row -> byOrder
.computeIfAbsent(row.getOrderId(), ignored -> new ArrayList<>()).add(row));
List<InboundCandidateRow> resolved = new ArrayList<>();
for (Long orderId : orderIds) {
List<InboundCandidateRow> 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<InboundCandidateRow> 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<Long> orderIds = group.members().stream().map(Member::orderId).toList();
List<InboundCandidateRow> current = authoritativeCandidates(orderIds, group.groupId());
Map<Long, Member> 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<Member> 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<Long> normalizedOrderIds(List<Long> source, int minimum, int maximum) {
if (source == null) throw badRequest("orderIds不能为空");
List<Long> 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) {
}
}
@@ -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='同柜不可拆组幂等写命令';
@@ -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'}
@@ -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')");
}
}
@@ -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<String, Object> yaml = new Yaml().load(source);
Map<String, Object> paths = (Map<String, Object>) 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");
}
}
@@ -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<Long> 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);
}
}
@@ -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");
}
}