feat(firstleg): add indivisible coload groups
This commit is contained in:
+89
@@ -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, "必须指定有效平台操作人");
|
||||
}
|
||||
}
|
||||
}
|
||||
+79
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
+193
@@ -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<>'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);
|
||||
}
|
||||
+78
@@ -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;
|
||||
}
|
||||
}
|
||||
+23
@@ -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);
|
||||
}
|
||||
+366
@@ -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) {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user