fix(firstleg): align coload group eligibility

This commit is contained in:
2026-08-29 12:44:42 +08:00
parent 0cf76df168
commit bfcb80b326
11 changed files with 97 additions and 140 deletions
@@ -16417,8 +16417,7 @@ CREATE TABLE IF NOT EXISTS fl_coload_group (
id BIGINT UNSIGNED NOT NULL COMMENT '同柜不可拆组ID',group_no VARCHAR(40) NOT NULL COMMENT '稳定组号CLG-{id}', 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', 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 '物流渠道编码快照', 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快照', locked_plan_id BIGINT UNSIGNED NULL COMMENT 'LOCKED/CONSUMED绑定装柜计划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 '创建人名称快照', 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) COMMENT '创建时间',updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
locked_by BIGINT UNSIGNED NULL COMMENT '锁定操作人ID',locked_by_name VARCHAR(64) NULL COMMENT '锁定操作人名称快照',locked_at DATETIME(3) NULL COMMENT '锁定时间', locked_by BIGINT UNSIGNED NULL COMMENT '锁定操作人ID',locked_by_name VARCHAR(64) NULL COMMENT '锁定操作人名称快照',locked_at DATETIME(3) NULL COMMENT '锁定时间',
@@ -16428,7 +16427,6 @@ CREATE TABLE IF NOT EXISTS fl_coload_group (
KEY idx_coload_group_plan(locked_plan_id,group_status), 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 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_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) 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)) OR(group_status IN('LOCKED','CONSUMED') AND locked_plan_id IS NOT NULL))
)ENGINE=InnoDB COMMENT='同柜且不可拆业务组聚合根'; )ENGINE=InnoDB COMMENT='同柜且不可拆业务组聚合根';
@@ -33,7 +33,7 @@ import java.util.List;
@Validated @Validated
@RestController @RestController
@RequestMapping("/api/platform/v1/first-leg/coload-groups") @RequestMapping("/api/platform/v1/first-leg/coload-groups")
@Tag(name = "平台同柜不可拆组", description = "跨客户、同仓同渠道同目的仓港的整组装柜约束") @Tag(name = "平台同柜不可拆组", description = "跨客户、同国内仓同渠道的整组装柜约束")
public class PlatformColoadGroupController { public class PlatformColoadGroupController {
@Resource @Resource
private ColoadGroupService service; private ColoadGroupService service;
@@ -43,9 +43,6 @@ public final class ColoadGroupModels {
String status, String status,
long warehouseId, long warehouseId,
String channelCode, String channelCode,
String destinationType,
Long destinationWarehouseId,
String destinationCode,
Long lockedPlanId, Long lockedPlanId,
int version, int version,
List<Member> members, List<Member> members,
@@ -60,11 +57,9 @@ public final class ColoadGroupModels {
} }
public GroupSnapshot(long groupId, String groupNo, String status, long warehouseId, public GroupSnapshot(long groupId, String groupNo, String status, long warehouseId,
String channelCode, String destinationType, Long destinationWarehouseId, String channelCode, Long lockedPlanId, int version,
String destinationCode, Long lockedPlanId, int version,
List<Member> members, LocalDateTime createdAt, LocalDateTime updatedAt) { List<Member> members, LocalDateTime createdAt, LocalDateTime updatedAt) {
this(groupId, groupNo, status, warehouseId, channelCode, destinationType, this(groupId, groupNo, status, warehouseId, channelCode, lockedPlanId, version, members,
destinationWarehouseId, destinationCode, lockedPlanId, version, members,
members == null ? 0 : members.stream().mapToInt(Member::fullPackageCount).sum(), members == null ? 0 : members.stream().mapToInt(Member::fullPackageCount).sum(),
createdAt, updatedAt); createdAt, updatedAt);
} }
@@ -72,7 +67,6 @@ public final class ColoadGroupModels {
public record OrderGroupInfo( public record OrderGroupInfo(
long orderId, long orderId,
long inboundId,
GroupSnapshot group GroupSnapshot group
) { ) {
} }
@@ -15,21 +15,11 @@ import static com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRo
public interface ColoadGroupMapper { public interface ColoadGroupMapper {
@Select({"<script>", """ @Select({"<script>", """
SELECT o.tenant_id tenantId,o.order_id orderId,o.id inboundId, SELECT o.tenant_id tenantId,o.order_id orderId,o.id inboundId,
o.process_status processStatus,o.inbound_role inboundRole, o.process_status processStatus,o.warehouse_id warehouseId,
o.split_status splitStatus,o.split_purpose splitPurpose, o.channel_code channelCode,o.total_package_count fullPackageCount,
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 (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) WHERE item.account_id=o.tenant_id AND item.inbound_id=o.id AND item.deleted=0)
activeAllocationCount, 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 (SELECT member.group_id FROM fl_coload_group_member member
WHERE member.tenant_id=o.tenant_id AND member.inbound_id=o.id WHERE member.tenant_id=o.tenant_id AND member.inbound_id=o.id
AND member.active_flag=1 LIMIT 1) activeGroupId AND member.active_flag=1 LIMIT 1) activeGroupId
@@ -41,20 +31,15 @@ public interface ColoadGroupMapper {
@Insert(""" @Insert("""
INSERT INTO fl_coload_group INSERT INTO fl_coload_group
(id,group_no,group_status,warehouse_id,channel_code,destination_type, (id,group_no,group_status,warehouse_id,channel_code,locked_plan_id,version,
destination_warehouse_id,destination_code,locked_plan_id,version,
created_by,created_by_name,created_at,updated_at) created_by,created_by_name,created_at,updated_at)
VALUES VALUES
(#{id},#{groupNo},'ACTIVE',#{warehouseId},#{channelCode},#{destinationType}, (#{id},#{groupNo},'ACTIVE',#{warehouseId},#{channelCode},NULL,0,
#{destinationWarehouseId},#{destinationCode},NULL,0,
#{operatorId},#{operatorName},CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)) #{operatorId},#{operatorName},CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))
""") """)
int insertGroup(@Param("id") long id, @Param("groupNo") String groupNo, int insertGroup(@Param("id") long id, @Param("groupNo") String groupNo,
@Param("warehouseId") long warehouseId, @Param("warehouseId") long warehouseId,
@Param("channelCode") String channelCode, @Param("channelCode") String channelCode,
@Param("destinationType") String destinationType,
@Param("destinationWarehouseId") Long destinationWarehouseId,
@Param("destinationCode") String destinationCode,
@Param("operatorId") long operatorId, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName); @Param("operatorName") String operatorName);
@@ -70,16 +55,14 @@ public interface ColoadGroupMapper {
@Param("fullPackageCount") int fullPackageCount); @Param("fullPackageCount") int fullPackageCount);
@Select("SELECT id,group_no groupNo,group_status groupStatus,warehouse_id warehouseId," @Select("SELECT id,group_no groupNo,group_status groupStatus,warehouse_id warehouseId,"
+ "channel_code channelCode,destination_type destinationType," + "channel_code channelCode,locked_plan_id lockedPlanId,version,"
+ "destination_warehouse_id destinationWarehouseId,destination_code destinationCode," + "created_at createdAt,updated_at updatedAt "
+ "locked_plan_id lockedPlanId,version,created_at createdAt,updated_at updatedAt "
+ "FROM fl_coload_group WHERE id=#{groupId}") + "FROM fl_coload_group WHERE id=#{groupId}")
GroupRow group(@Param("groupId") long groupId); GroupRow group(@Param("groupId") long groupId);
@Select("SELECT id,group_no groupNo,group_status groupStatus,warehouse_id warehouseId," @Select("SELECT id,group_no groupNo,group_status groupStatus,warehouse_id warehouseId,"
+ "channel_code channelCode,destination_type destinationType," + "channel_code channelCode,locked_plan_id lockedPlanId,version,"
+ "destination_warehouse_id destinationWarehouseId,destination_code destinationCode," + "created_at createdAt,updated_at updatedAt "
+ "locked_plan_id lockedPlanId,version,created_at createdAt,updated_at updatedAt "
+ "FROM fl_coload_group WHERE id=#{groupId} FOR UPDATE") + "FROM fl_coload_group WHERE id=#{groupId} FOR UPDATE")
GroupRow lockGroup(@Param("groupId") long groupId); GroupRow lockGroup(@Param("groupId") long groupId);
@@ -91,9 +74,7 @@ public interface ColoadGroupMapper {
@Select(""" @Select("""
SELECT group_row.id,group_row.group_no groupNo,group_row.group_status groupStatus, 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.warehouse_id warehouseId,group_row.channel_code channelCode,
group_row.destination_type destinationType, group_row.locked_plan_id lockedPlanId,
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 group_row.version,group_row.created_at createdAt,group_row.updated_at updatedAt
FROM fl_coload_group_member member FROM fl_coload_group_member member
JOIN fl_coload_group group_row ON group_row.id=member.group_id JOIN fl_coload_group group_row ON group_row.id=member.group_id
@@ -105,7 +86,7 @@ public interface ColoadGroupMapper {
@Param("inboundId") long inboundId); @Param("inboundId") long inboundId);
@Select({"<script>", """ @Select({"<script>", """
SELECT member.order_id orderId,member.inbound_id inboundId,member.group_id groupId SELECT member.order_id orderId,member.group_id groupId
FROM fl_coload_group_member member FROM fl_coload_group_member member
JOIN fl_coload_group group_row ON group_row.id=member.group_id JOIN fl_coload_group group_row ON group_row.id=member.group_id
WHERE member.order_id IN WHERE member.order_id IN
@@ -17,19 +17,10 @@ public final class ColoadPersistenceRows {
private Long orderId; private Long orderId;
private Long inboundId; private Long inboundId;
private String processStatus; private String processStatus;
private String inboundRole;
private String splitStatus;
private String splitPurpose;
private Long parentInboundId;
private Long warehouseId; private Long warehouseId;
private String channelCode; private String channelCode;
private String destinationType;
private Long destinationWarehouseId;
private String destinationCode;
private Integer fullPackageCount; private Integer fullPackageCount;
private Integer activeAllocationCount; private Integer activeAllocationCount;
private Integer childCount;
private Integer splitBatchCount;
private Long activeGroupId; private Long activeGroupId;
} }
@@ -41,9 +32,6 @@ public final class ColoadPersistenceRows {
private String groupStatus; private String groupStatus;
private Long warehouseId; private Long warehouseId;
private String channelCode; private String channelCode;
private String destinationType;
private Long destinationWarehouseId;
private String destinationCode;
private Long lockedPlanId; private Long lockedPlanId;
private Integer version; private Integer version;
private LocalDateTime createdAt; private LocalDateTime createdAt;
@@ -63,7 +51,6 @@ public final class ColoadPersistenceRows {
@Setter @Setter
public static class OrderGroupLinkRow { public static class OrderGroupLinkRow {
private Long orderId; private Long orderId;
private Long inboundId;
private Long groupId; private Long groupId;
} }
@@ -52,8 +52,7 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
String groupNo = "CLG-" + groupId; String groupNo = "CLG-" + groupId;
InboundCandidateRow first = candidates.get(0); InboundCandidateRow first = candidates.get(0);
mapper.insertGroup(groupId, groupNo, first.getWarehouseId(), first.getChannelCode().trim(), mapper.insertGroup(groupId, groupNo, first.getWarehouseId(), first.getChannelCode().trim(),
normalized(first.getDestinationType()), first.getDestinationWarehouseId(), operatorId, trim(operatorName));
trim(first.getDestinationCode()), operatorId, trim(operatorName));
for (InboundCandidateRow candidate : candidates) { for (InboundCandidateRow candidate : candidates) {
mapper.insertMember(IdWorker.getId(), groupId, candidate.getTenantId(), candidate.getOrderId(), mapper.insertMember(IdWorker.getId(), groupId, candidate.getTenantId(), candidate.getOrderId(),
candidate.getInboundId(), candidate.getFullPackageCount()); candidate.getInboundId(), candidate.getFullPackageCount());
@@ -98,7 +97,7 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
if (latest.containsKey(link.getOrderId())) continue; if (latest.containsKey(link.getOrderId())) continue;
GroupSnapshot group = groups.computeIfAbsent(link.getGroupId(), this::requiredSnapshot); GroupSnapshot group = groups.computeIfAbsent(link.getGroupId(), this::requiredSnapshot);
if (!CURRENT_GROUP_STATUSES.contains(group.status())) continue; if (!CURRENT_GROUP_STATUSES.contains(group.status())) continue;
latest.put(link.getOrderId(), new OrderGroupInfo(link.getOrderId(), link.getInboundId(), group)); latest.put(link.getOrderId(), new OrderGroupInfo(link.getOrderId(), group));
} }
return orderIds.stream().map(latest::get).filter(Objects::nonNull).toList(); return orderIds.stream().map(latest::get).filter(Objects::nonNull).toList();
} }
@@ -188,10 +187,10 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
for (Long orderId : orderIds) { for (Long orderId : orderIds) {
List<InboundCandidateRow> eligible = byOrder.getOrDefault(orderId, List.of()).stream() List<InboundCandidateRow> eligible = byOrder.getOrDefault(orderId, List.of()).stream()
.filter(row -> eligible(row, allowedGroupId)).toList(); .filter(row -> eligible(row, allowedGroupId)).toList();
if (eligible.size() != 1) { if (eligible.isEmpty()) {
throw conflict("订单 " + orderId + " 没有唯一可创建同柜不可拆组的当前入库单"); throw conflict("订单 " + orderId + " 不存在可创建同柜不可拆组的可用入库片段");
} }
resolved.add(eligible.get(0)); resolved.addAll(eligible);
} }
return resolved; return resolved;
} }
@@ -200,46 +199,39 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
return row.getTenantId() != null && row.getTenantId() > 0 return row.getTenantId() != null && row.getTenantId() > 0
&& row.getInboundId() != null && row.getInboundId() > 0 && row.getInboundId() != null && row.getInboundId() > 0
&& "WAIT_SPLIT".equals(row.getProcessStatus()) && "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 && row.getFullPackageCount() != null && row.getFullPackageCount() > 0
&& Objects.equals(row.getActiveAllocationCount(), 0) && Objects.equals(row.getActiveAllocationCount(), 0)
&& Objects.equals(row.getChildCount(), 0)
&& Objects.equals(row.getSplitBatchCount(), 0)
&& (row.getActiveGroupId() == null || Objects.equals(row.getActiveGroupId(), allowedGroupId)); && (row.getActiveGroupId() == null || Objects.equals(row.getActiveGroupId(), allowedGroupId));
} }
private void validateSameRoute(List<InboundCandidateRow> rows) { private void validateSameRoute(List<InboundCandidateRow> rows) {
InboundCandidateRow first = rows.get(0); InboundCandidateRow first = rows.get(0);
if (first.getWarehouseId() == null || first.getWarehouseId() <= 0 if (first.getWarehouseId() == null || first.getWarehouseId() <= 0
|| !StringUtils.hasText(first.getChannelCode()) || !StringUtils.hasText(first.getChannelCode())) {
|| !StringUtils.hasText(first.getDestinationType()) throw conflict("订单缺少国内仓或渠道权威快照");
|| destinationKey(first) == null) {
throw conflict("订单缺少仓库、渠道或目的仓港权威快照");
} }
for (InboundCandidateRow row : rows) { for (InboundCandidateRow row : rows) {
if (!Objects.equals(first.getWarehouseId(), row.getWarehouseId()) if (!Objects.equals(first.getWarehouseId(), row.getWarehouseId())
|| !normalized(first.getChannelCode()).equals(normalized(row.getChannelCode())) || !normalized(first.getChannelCode()).equals(normalized(row.getChannelCode()))) {
|| !normalized(first.getDestinationType()).equals(normalized(row.getDestinationType())) throw conflict("同柜不可拆组成员必须属于同一国内仓和渠道");
|| !Objects.equals(destinationKey(first), destinationKey(row))) {
throw conflict("同柜不可拆组成员必须属于同一国内仓、渠道和目的仓港");
} }
} }
} }
private void validateStillAssignable(GroupSnapshot group) { private void validateStillAssignable(GroupSnapshot group) {
List<Long> orderIds = group.members().stream().map(Member::orderId).toList(); List<Long> orderIds = group.members().stream().map(Member::orderId).distinct().sorted().toList();
List<InboundCandidateRow> current = authoritativeCandidates(orderIds, group.groupId()); List<InboundCandidateRow> current = authoritativeCandidates(orderIds, group.groupId());
Map<Long, Member> frozen = new LinkedHashMap<>(); Map<Long, Member> frozen = new LinkedHashMap<>();
group.members().forEach(member -> frozen.put(member.orderId(), member)); group.members().forEach(member -> frozen.put(member.inboundId(), member));
if (current.size() != frozen.size()) {
throw conflict("同柜不可拆组的可用入库片段集合已变化");
}
for (InboundCandidateRow row : current) { for (InboundCandidateRow row : current) {
Member member = frozen.get(row.getOrderId()); Member member = frozen.get(row.getInboundId());
if (member == null || member.tenantId() != row.getTenantId() if (member == null || member.tenantId() != row.getTenantId()
|| member.inboundId() != row.getInboundId() || member.orderId() != row.getOrderId()
|| member.fullPackageCount() != row.getFullPackageCount()) { || member.fullPackageCount() != row.getFullPackageCount()) {
throw conflict("同柜不可拆组成员的当前入库单或满箱数已变化"); throw conflict("同柜不可拆组的入库片段或满箱数已变化");
} }
} }
validateSameRoute(current); validateSameRoute(current);
@@ -257,8 +249,7 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
value.getFullPackageCount())) value.getFullPackageCount()))
.toList(); .toList();
return new GroupSnapshot(row.getId(), row.getGroupNo(), row.getGroupStatus(), row.getWarehouseId(), return new GroupSnapshot(row.getId(), row.getGroupNo(), row.getGroupStatus(), row.getWarehouseId(),
row.getChannelCode(), row.getDestinationType(), row.getDestinationWarehouseId(), row.getChannelCode(), row.getLockedPlanId(), row.getVersion(), members,
row.getDestinationCode(), row.getLockedPlanId(), row.getVersion(), members,
members.stream().mapToInt(Member::fullPackageCount).sum(), members.stream().mapToInt(Member::fullPackageCount).sum(),
row.getCreatedAt(), row.getUpdatedAt()); row.getCreatedAt(), row.getUpdatedAt());
} }
@@ -303,14 +294,6 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
operationId, trim(remark), operatorId, trim(operatorName)); 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) { private static List<Long> normalizedOrderIds(List<Long> source, int minimum, int maximum) {
if (source == null) throw badRequest("orderIds不能为空"); if (source == null) throw badRequest("orderIds不能为空");
List<Long> values = source.stream().filter(Objects::nonNull).distinct().sorted().toList(); List<Long> values = source.stream().filter(Objects::nonNull).distinct().sorted().toList();
@@ -6,9 +6,6 @@ CREATE TABLE IF NOT EXISTS fl_coload_group (
group_status VARCHAR(16) NOT NULL COMMENT '组状态:ACTIVE、LOCKED、CONSUMED、CANCELLED', group_status VARCHAR(16) NOT NULL COMMENT '组状态:ACTIVE、LOCKED、CONSUMED、CANCELLED',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '国内收货仓ID快照', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '国内收货仓ID快照',
channel_code VARCHAR(64) NOT NULL COMMENT '物流渠道编码快照', 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', locked_plan_id BIGINT UNSIGNED NULL COMMENT 'LOCKED/CONSUMED绑定装柜计划ID',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观版本', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观版本',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
@@ -30,8 +27,6 @@ CREATE TABLE IF NOT EXISTS fl_coload_group (
KEY idx_coload_group_plan (locked_plan_id,group_status), 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 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_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 ( CONSTRAINT chk_coload_group_lock CHECK (
(group_status IN ('ACTIVE','CANCELLED') AND locked_plan_id IS NULL) (group_status IN ('ACTIVE','CANCELLED') AND locked_plan_id IS NULL)
OR (group_status IN ('LOCKED','CONSUMED') AND locked_plan_id IS NOT NULL)) OR (group_status IN ('LOCKED','CONSUMED') AND locked_plan_id IS NOT NULL))
@@ -2,14 +2,14 @@ openapi: 3.0.3
info: info:
title: Firstleg Coload Group API title: Firstleg Coload Group API
version: 1.0.0 version: 1.0.0
description: 同柜且不可拆组平台管理契约;跨客户成组,但仓库、渠道和目的仓港必须一致。 description: 同柜且不可拆组平台管理契约;允许跨客户及不同目的仓港成组,仅要求国内仓和渠道一致。
paths: paths:
/api/platform/v1/first-leg/coload-groups: /api/platform/v1/first-leg/coload-groups:
post: post:
summary: 创建同柜不可拆组 summary: 创建同柜不可拆组
description: >- description: >-
需要 platform:firstleg:smart-loading:assign。orderIds 至少2项;服务端解析每个订单唯一当前权威入库单, 需要 platform:firstleg:smart-loading:assign。orderIds 至少2项;服务端解析每个订单全部 WAIT_SPLIT、
要求 WAIT_SPLIT、原始MASTER、未拆分、未分配、无子单或拆分批次,并冻结全部成员满箱数。 未分配且未属于其他活动组的可用入库片段,要求国内仓和渠道一致,并冻结全部片段及其满箱数。
parameters: parameters:
- $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/IdempotencyKey'
requestBody: requestBody:
@@ -130,23 +130,19 @@ components:
ColoadGroup: ColoadGroup:
type: object type: object
additionalProperties: false additionalProperties: false
required: [groupId, groupNo, status, warehouseId, channelCode, destinationType, required: [groupId, groupNo, status, warehouseId, channelCode, version, members,
version, members, fullPackageCount, createdAt, updatedAt] fullPackageCount, createdAt, updatedAt]
properties: properties:
groupId: {type: string, pattern: '^[1-9][0-9]*$'} groupId: {type: string, pattern: '^[1-9][0-9]*$'}
groupNo: {type: string, pattern: '^CLG-[1-9][0-9]*$'} groupNo: {type: string, pattern: '^CLG-[1-9][0-9]*$'}
status: {type: string, enum: [ACTIVE, LOCKED, CONSUMED, CANCELLED]} status: {type: string, enum: [ACTIVE, LOCKED, CONSUMED, CANCELLED]}
warehouseId: {type: string, pattern: '^[1-9][0-9]*$'} warehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
channelCode: {type: string} channelCode: {type: string}
destinationType: {type: string}
destinationWarehouseId: {type: string, nullable: true}
destinationCode: {type: string, nullable: true}
lockedPlanId: {type: string, nullable: true} lockedPlanId: {type: string, nullable: true}
version: {type: integer, minimum: 0} version: {type: integer, minimum: 0}
members: members:
type: array type: array
minItems: 2 minItems: 2
maxItems: 100
items: {$ref: '#/components/schemas/ColoadGroupMember'} items: {$ref: '#/components/schemas/ColoadGroupMember'}
fullPackageCount: {type: integer, minimum: 2, description: 全部成员满箱数之和} fullPackageCount: {type: integer, minimum: 2, description: 全部成员满箱数之和}
createdAt: {type: string, format: date-time} createdAt: {type: string, format: date-time}
@@ -154,8 +150,7 @@ components:
OrderColoadGroupInfo: OrderColoadGroupInfo:
type: object type: object
additionalProperties: false additionalProperties: false
required: [orderId, inboundId, group] required: [orderId, group]
properties: properties:
orderId: {type: string, pattern: '^[1-9][0-9]*$'} orderId: {type: string, pattern: '^[1-9][0-9]*$'}
inboundId: {type: string, pattern: '^[1-9][0-9]*$'}
group: {$ref: '#/components/schemas/ColoadGroup'} group: {$ref: '#/components/schemas/ColoadGroup'}
@@ -16,6 +16,10 @@ class ColoadGroupOpenApiContractTest {
String source = Files.readString(Path.of("src/main/resources/openapi/coload-groups-v1.yaml")); 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> yaml = new Yaml().load(source);
Map<String, Object> paths = (Map<String, Object>) yaml.get("paths"); Map<String, Object> paths = (Map<String, Object>) yaml.get("paths");
Map<String, Object> components = (Map<String, Object>) yaml.get("components");
Map<String, Object> schemas = (Map<String, Object>) components.get("schemas");
Map<String, Object> orderInfo = (Map<String, Object>) schemas.get("OrderColoadGroupInfo");
Map<String, Object> orderInfoProperties = (Map<String, Object>) orderInfo.get("properties");
assertThat(paths).containsKeys( assertThat(paths).containsKeys(
"/api/platform/v1/first-leg/coload-groups", "/api/platform/v1/first-leg/coload-groups",
@@ -32,6 +36,12 @@ class ColoadGroupOpenApiContractTest {
.contains("expectedVersion") .contains("expectedVersion")
.contains("fullPackageCount") .contains("fullPackageCount")
.contains("platform:firstleg:smart-loading:assign") .contains("platform:firstleg:smart-loading:assign")
.contains("全部 WAIT_SPLIT")
.contains("国内仓和渠道一致")
.doesNotContain("destinationType")
.doesNotContain("destinationWarehouseId")
.doesNotContain("destinationCode")
.doesNotContain("platform:firstleg:coload"); .doesNotContain("platform:firstleg:coload");
assertThat(orderInfoProperties).containsOnlyKeys("orderId", "group");
} }
} }
@@ -34,11 +34,12 @@ class ColoadGroupServiceTest {
private ColoadGroupService service; private ColoadGroupService service;
@Test @Test
void createsCrossCustomerGroupAndFreezesEveryMembersFullPackageCount() { void createsCrossCustomerGroupAndFreezesAllAvailableFragmentsForEveryOrder() {
when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(List.of( when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(List.of(
candidate(101L, 11L, 1001L, 30), candidate(202L, 12L, 1002L, 50))); candidate(101L, 11L, 1001L, 30), candidate(101L, 11L, 1003L, 20),
when(mapper.insertGroup(anyLong(), anyString(), eq(9L), eq("CH"), eq("FBA"), candidate(202L, 12L, 1002L, 50)));
eq(88L), eq("ONT8"), eq(7L), eq("operator"))).thenReturn(1); when(mapper.insertGroup(anyLong(), anyString(), eq(9L), eq("CH"),
eq(7L), eq("operator"))).thenReturn(1);
when(mapper.insertMember(anyLong(), anyLong(), anyLong(), anyLong(), anyLong(), anyInt())) when(mapper.insertMember(anyLong(), anyLong(), anyLong(), anyLong(), anyLong(), anyInt()))
.thenReturn(1); .thenReturn(1);
when(mapper.insertHistory(anyLong(), anyLong(), anyString(), any(), anyString(), any(), when(mapper.insertHistory(anyLong(), anyLong(), anyString(), any(), anyString(), any(),
@@ -48,17 +49,20 @@ class ColoadGroupServiceTest {
when(mapper.completeCommand(anyLong(), anyLong())).thenReturn(1); when(mapper.completeCommand(anyLong(), anyLong())).thenReturn(1);
when(mapper.group(anyLong())).thenReturn(group(500L, "ACTIVE", null, 0)); when(mapper.group(anyLong())).thenReturn(group(500L, "ACTIVE", null, 0));
when(mapper.members(anyLong())).thenReturn(List.of( when(mapper.members(anyLong())).thenReturn(List.of(
member(101L, 11L, 1001L, 30), member(202L, 12L, 1002L, 50))); member(101L, 11L, 1001L, 30), member(101L, 11L, 1003L, 20),
member(202L, 12L, 1002L, 50)));
var result = service.create(List.of(12L, 11L), "create-key", 7L, "operator"); var result = service.create(List.of(12L, 11L), "create-key", 7L, "operator");
assertThat(result.members()).extracting("tenantId", "orderId", "inboundId", "fullPackageCount") assertThat(result.members()).extracting("tenantId", "orderId", "inboundId", "fullPackageCount")
.containsExactly(tuple(101L, 11L, 1001L, 30), tuple(202L, 12L, 1002L, 50)); .containsExactly(tuple(101L, 11L, 1001L, 30), tuple(101L, 11L, 1003L, 20),
assertThat(result.fullPackageCount()).isEqualTo(80); tuple(202L, 12L, 1002L, 50));
assertThat(result.fullPackageCount()).isEqualTo(100);
ArgumentCaptor<Long> groupId = ArgumentCaptor.forClass(Long.class); ArgumentCaptor<Long> groupId = ArgumentCaptor.forClass(Long.class);
verify(mapper).insertGroup(groupId.capture(), anyString(), eq(9L), eq("CH"), eq("FBA"), verify(mapper).insertGroup(groupId.capture(), anyString(), eq(9L), eq("CH"),
eq(88L), eq("ONT8"), eq(7L), eq("operator")); 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(101L), eq(11L), eq(1001L), eq(30));
verify(mapper).insertMember(anyLong(), eq(groupId.getValue()), eq(101L), eq(11L), eq(1003L), eq(20));
verify(mapper).insertMember(anyLong(), eq(groupId.getValue()), eq(202L), eq(12L), eq(1002L), eq(50)); verify(mapper).insertMember(anyLong(), eq(groupId.getValue()), eq(202L), eq(12L), eq(1002L), eq(50));
} }
@@ -71,11 +75,9 @@ class ColoadGroupServiceTest {
} }
@Test @Test
void rejectsMemberThatIsAllocatedSplitOrAlreadyGrouped() { void rejectsOrderWhenNoAvailableFragmentRemains() {
InboundCandidateRow invalid = candidate(101L, 11L, 1001L, 30); InboundCandidateRow invalid = candidate(101L, 11L, 1001L, 30);
invalid.setActiveAllocationCount(1); invalid.setActiveAllocationCount(1);
invalid.setChildCount(1);
invalid.setSplitBatchCount(1);
invalid.setActiveGroupId(900L); invalid.setActiveGroupId(900L);
when(mapper.insertCommand(anyLong(), eq(7L), eq("CREATE"), eq("key"), anyString())).thenReturn(1); when(mapper.insertCommand(anyLong(), eq(7L), eq("CREATE"), eq("key"), anyString())).thenReturn(1);
when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(List.of( when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(List.of(
@@ -83,16 +85,15 @@ class ColoadGroupServiceTest {
assertThatThrownBy(() -> service.create(List.of(11L, 12L), "key", 7L, "operator")) assertThatThrownBy(() -> service.create(List.of(11L, 12L), "key", 7L, "operator"))
.isInstanceOf(BizException.class) .isInstanceOf(BizException.class)
.hasMessageContaining("唯一可创建"); .hasMessageContaining("不存在可创建");
verify(mapper, never()).insertGroup(anyLong(), anyString(), anyLong(), anyString(), anyString(), verify(mapper, never()).insertGroup(anyLong(), anyString(), anyLong(), anyString(),
any(), any(), anyLong(), any()); anyLong(), any());
} }
@Test @Test
void rejectsDifferentWarehouseChannelOrDestination() { void rejectsDifferentWarehouseOrChannel() {
InboundCandidateRow second = candidate(202L, 12L, 1002L, 50); InboundCandidateRow second = candidate(202L, 12L, 1002L, 50);
second.setDestinationWarehouseId(99L); second.setWarehouseId(10L);
second.setDestinationCode("LAX9");
when(mapper.insertCommand(anyLong(), eq(7L), eq("CREATE"), eq("route-key"), anyString())) when(mapper.insertCommand(anyLong(), eq(7L), eq("CREATE"), eq("route-key"), anyString()))
.thenReturn(1); .thenReturn(1);
when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(List.of( when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(List.of(
@@ -100,7 +101,7 @@ class ColoadGroupServiceTest {
assertThatThrownBy(() -> service.create(List.of(11L, 12L), "route-key", 7L, "operator")) assertThatThrownBy(() -> service.create(List.of(11L, 12L), "route-key", 7L, "operator"))
.isInstanceOf(BizException.class) .isInstanceOf(BizException.class)
.hasMessageContaining("同一国内仓、渠道和目的仓港"); .hasMessageContaining("同一国内仓和渠道");
} }
@Test @Test
@@ -143,10 +144,12 @@ class ColoadGroupServiceTest {
.thenReturn(1); .thenReturn(1);
when(mapper.lockGroup(500L)).thenReturn(group(500L, "ACTIVE", null, 0)); when(mapper.lockGroup(500L)).thenReturn(group(500L, "ACTIVE", null, 0));
when(mapper.members(500L)).thenReturn(List.of( when(mapper.members(500L)).thenReturn(List.of(
member(101L, 11L, 1001L, 30), member(202L, 12L, 1002L, 50))); member(101L, 11L, 1001L, 30), member(101L, 11L, 1003L, 20),
member(202L, 12L, 1002L, 50)));
InboundCandidateRow first = candidate(101L, 11L, 1001L, 30); first.setActiveGroupId(500L); InboundCandidateRow first = candidate(101L, 11L, 1001L, 30); first.setActiveGroupId(500L);
InboundCandidateRow fragment = candidate(101L, 11L, 1003L, 20); fragment.setActiveGroupId(500L);
InboundCandidateRow second = candidate(202L, 12L, 1002L, 50); second.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.lockCandidates(List.of(11L, 12L))).thenReturn(List.of(first, fragment, second));
when(mapper.lockToPlan(500L, 700L, 7L, "operator")).thenReturn(1); when(mapper.lockToPlan(500L, 700L, 7L, "operator")).thenReturn(1);
when(mapper.completeCommand(anyLong(), eq(500L))).thenReturn(1); when(mapper.completeCommand(anyLong(), eq(500L))).thenReturn(1);
when(mapper.group(500L)).thenReturn(group(500L, "LOCKED", 700L, 1)); when(mapper.group(500L)).thenReturn(group(500L, "LOCKED", 700L, 1));
@@ -156,8 +159,8 @@ class ColoadGroupServiceTest {
assertThat(result.status()).isEqualTo("LOCKED"); assertThat(result.status()).isEqualTo("LOCKED");
assertThat(result.lockedPlanId()).isEqualTo(700L); assertThat(result.lockedPlanId()).isEqualTo(700L);
assertThat(result.members()).hasSize(2); assertThat(result.members()).hasSize(3);
assertThat(result.fullPackageCount()).isEqualTo(80); assertThat(result.fullPackageCount()).isEqualTo(100);
} }
@Test @Test
@@ -184,20 +187,15 @@ class ColoadGroupServiceTest {
private static InboundCandidateRow candidate(long tenantId, long orderId, long inboundId, int packages) { private static InboundCandidateRow candidate(long tenantId, long orderId, long inboundId, int packages) {
InboundCandidateRow value = new InboundCandidateRow(); InboundCandidateRow value = new InboundCandidateRow();
value.setTenantId(tenantId); value.setOrderId(orderId); value.setInboundId(inboundId); value.setTenantId(tenantId); value.setOrderId(orderId); value.setInboundId(inboundId);
value.setProcessStatus("WAIT_SPLIT"); value.setInboundRole("MASTER"); value.setProcessStatus("WAIT_SPLIT"); value.setWarehouseId(9L); value.setChannelCode("CH");
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.setFullPackageCount(packages); value.setActiveAllocationCount(0);
value.setChildCount(0); value.setSplitBatchCount(0);
return value; return value;
} }
private static GroupRow group(long id, String status, Long planId, int version) { private static GroupRow group(long id, String status, Long planId, int version) {
GroupRow value = new GroupRow(); GroupRow value = new GroupRow();
value.setId(id); value.setGroupNo("CLG-" + id); value.setGroupStatus(status); value.setId(id); value.setGroupNo("CLG-" + id); value.setGroupStatus(status);
value.setWarehouseId(9L); value.setChannelCode("CH"); value.setDestinationType("FBA"); value.setWarehouseId(9L); value.setChannelCode("CH");
value.setDestinationWarehouseId(88L); value.setDestinationCode("ONT8");
value.setLockedPlanId(planId); value.setVersion(version); value.setLockedPlanId(planId); value.setVersion(version);
value.setCreatedAt(LocalDateTime.of(2026, 8, 29, 9, 0)); value.setCreatedAt(LocalDateTime.of(2026, 8, 29, 9, 0));
value.setUpdatedAt(value.getCreatedAt()); value.setUpdatedAt(value.getCreatedAt());
@@ -27,11 +27,16 @@ class ColoadGroupSqlContractTest {
.contains("full_package_count") .contains("full_package_count")
.contains("uk_coload_command") .contains("uk_coload_command")
.contains("locked_plan_id"); .contains("locked_plan_id");
assertThat(tableDefinition(source, "fl_coload_group"))
.doesNotContain("chk_coload_group_destination")
.doesNotContain("destination_type")
.doesNotContain("destination_warehouse_id")
.doesNotContain("destination_code");
} }
} }
@Test @Test
void mapperLocksAuthoritativeInboundAndRejectsAllocationChildrenAndSplitFacts() throws Exception { void mapperLocksEveryInboundFragmentAndOnlyProjectsCurrentAllocationAndGroupFacts() throws Exception {
assertThat(ColoadGroupMapper.class.getAnnotation(Mapper.class)).isNotNull(); assertThat(ColoadGroupMapper.class.getAnnotation(Mapper.class)).isNotNull();
Method method = ColoadGroupMapper.class.getDeclaredMethod("lockCandidates", java.util.List.class); Method method = ColoadGroupMapper.class.getDeclaredMethod("lockCandidates", java.util.List.class);
String sql = String.join(" ", method.getAnnotation(Select.class).value()); String sql = String.join(" ", method.getAnnotation(Select.class).value());
@@ -39,10 +44,21 @@ class ColoadGroupSqlContractTest {
.contains("FROM fl_inbound_order o") .contains("FROM fl_inbound_order o")
.contains("FROM fl_loading_plan_item item") .contains("FROM fl_loading_plan_item item")
.contains("item.deleted=0") .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("member.active_flag=1")
.contains("FOR UPDATE"); .contains("FOR UPDATE")
.doesNotContain("inbound_role")
.doesNotContain("split_status")
.doesNotContain("split_purpose")
.doesNotContain("parent_inbound_id")
.doesNotContain("fl_inbound_split_batch")
.doesNotContain("destination_type")
.doesNotContain("destination_warehouse_id")
.doesNotContain("destination_code");
}
private static String tableDefinition(String source, String tableName) {
int start = source.indexOf("CREATE TABLE IF NOT EXISTS " + tableName + " (");
int end = source.indexOf(';', start);
return source.substring(start, end + 1);
} }
} }