This commit is contained in:
2026-08-31 10:09:14 +08:00
parent ff6a8498a6
commit aa583efa4a
14 changed files with 320 additions and 92 deletions
@@ -5,11 +5,10 @@ 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.BusinessSummary;
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;
@@ -17,9 +16,11 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Size;
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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -27,8 +28,6 @@ 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
@@ -69,15 +68,15 @@ public class PlatformColoadGroupController {
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(
@GetMapping("/by-inbound-code/{inboundCode}")
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
@Operation(summary = "按入仓码查询同柜组业务摘要",
description = "仅返回业务状态、有效成员数及去重后的全部成员入仓码;内部组ID和组号不作为页面识别键。")
public ApiResponse<BusinessSummary> byInboundCode(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody BatchQueryRequest request) {
@PathVariable @Size(min = 1, max = 40) String inboundCode) {
requirePlatform(user);
return ApiResponse.success(service.queryByOrderIds(request.orderIds()));
return ApiResponse.success(service.queryByInboundCode(inboundCode));
}
private static void requirePlatform(AuthenticatedUser user) {
@@ -24,11 +24,6 @@ public final class ColoadGroupModels {
) {
}
public record BatchQueryRequest(
@NotEmpty @Size(max = 200) List<@NotNull @Min(1) Long> orderIds
) {
}
public record Member(
long tenantId,
long orderId,
@@ -65,9 +60,18 @@ public final class ColoadGroupModels {
}
}
public record OrderGroupInfo(
long orderId,
GroupSnapshot group
public record BusinessMember(String inboundCode) {
}
/** 页面只读业务摘要;内部组标识不作为用户识别键。 */
public record BusinessSummary(
String status,
int memberCount,
List<BusinessMember> members
) {
public BusinessSummary {
members = members == null ? List.of() : List.copyOf(members);
memberCount = members.size();
}
}
}
@@ -85,15 +85,28 @@ public interface ColoadGroupMapper {
GroupRow activeGroupByInbound(@Param("tenantId") long tenantId,
@Param("inboundId") long inboundId);
@Select({"<script>", """
SELECT member.order_id orderId,member.group_id groupId
FROM fl_coload_group_member member
@Select("""
SELECT DISTINCT group_row.id groupId,group_row.group_status groupStatus
FROM fl_inbound_order requested
JOIN fl_coload_group_member member
ON member.tenant_id=requested.tenant_id AND member.inbound_id=requested.id
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);
WHERE requested.deleted=0 AND requested.inbound_code=#{inboundCode}
AND group_row.group_status IN ('ACTIVE','LOCKED','CONSUMED')
ORDER BY group_row.id
""")
List<InboundGroupLookupRow> groupsByInboundCode(@Param("inboundCode") String inboundCode);
@Select("""
SELECT DISTINCT inbound.inbound_code inboundCode
FROM fl_coload_group_member member
JOIN fl_inbound_order inbound
ON inbound.tenant_id=member.tenant_id AND inbound.id=member.inbound_id
WHERE member.group_id=#{groupId} AND inbound.deleted=0
AND inbound.inbound_code IS NOT NULL AND TRIM(inbound.inbound_code)<>''
ORDER BY inbound.inbound_code
""")
List<MemberCodeRow> effectiveMemberCodes(@Param("groupId") long groupId);
@Update("""
UPDATE fl_coload_group
@@ -49,9 +49,15 @@ public final class ColoadPersistenceRows {
@Getter
@Setter
public static class OrderGroupLinkRow {
private Long orderId;
public static class InboundGroupLookupRow {
private Long groupId;
private String groupStatus;
}
@Getter
@Setter
public static class MemberCodeRow {
private String inboundCode;
}
@Getter
@@ -5,7 +5,8 @@ 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.model.ColoadGroupModels.BusinessMember;
import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.BusinessSummary;
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;
@@ -89,17 +90,28 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
}
@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(), group));
public BusinessSummary queryByInboundCode(String inboundCode) {
String code = trim(inboundCode);
if (code == null || code.length() > 40) throw badRequest("inboundCode不能为空且不能超过40字符");
var groups = mapper.groupsByInboundCode(code).stream()
.filter(row -> row.getGroupId() != null && CURRENT_GROUP_STATUSES.contains(row.getGroupStatus()))
.toList();
long distinctGroups = groups.stream().map(row -> row.getGroupId()).distinct().limit(2).count();
if (distinctGroups == 0) {
throw new BizException(ResultCode.NOT_FOUND, "入仓码未关联有效同柜不可拆组");
}
return orderIds.stream().map(latest::get).filter(Objects::nonNull).toList();
if (distinctGroups > 1) throw conflict("入仓码关联多个有效同柜不可拆组");
long groupId = groups.get(0).getGroupId();
String status = groups.get(0).getGroupStatus();
List<BusinessMember> members = mapper.effectiveMemberCodes(groupId).stream()
.map(row -> trim(row.getInboundCode()))
.filter(Objects::nonNull)
.distinct()
.sorted()
.map(BusinessMember::new)
.toList();
if (members.isEmpty()) throw conflict("同柜不可拆组不存在有效入仓片段");
return new BusinessSummary(status, members.size(), members);
}
@Override
@@ -35,17 +35,17 @@ paths:
responses:
'200': {$ref: '#/components/responses/ColoadGroupResponse'}
'403': {description: 缺少智能排柜分配权限}
/api/platform/v1/first-leg/coload-groups/batch-query:
post:
summary: 按订单ID批量查询同柜不可拆组
/api/platform/v1/first-leg/coload-groups/by-inbound-code/{inboundCode}:
get:
summary: 按入仓码查询同柜组业务摘要
description: >-
需要 platform:firstleg:smart-loading:assign。最多200个订单;返回当前非取消组的组号、状态、
全部成员和整组满箱数,供平台订单列表批量补齐,不按单成员修改。
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/BatchQueryColoadGroupRequest'}
需要 platform:firstleg:smart-loading:list。使用任一有效成员的全局唯一 inboundCode 查询;
仅返回组业务状态、有效成员数和去重后的全部成员入仓码。页面不持有或展示 groupId、groupNo、orderId。
parameters:
- name: inboundCode
in: path
required: true
schema: {type: string, minLength: 1, maxLength: 40}
responses:
'200':
description: 业务响应
@@ -56,11 +56,8 @@ paths:
- {$ref: '#/components/schemas/ApiEnvelope'}
- type: object
properties:
data:
type: array
maxItems: 200
items: {$ref: '#/components/schemas/OrderColoadGroupInfo'}
'403': {description: 缺少智能排柜分配权限}
data: {$ref: '#/components/schemas/ColoadGroupBusinessSummary'}
'403': {description: 缺少智能排柜列表权限}
components:
parameters:
IdempotencyKey:
@@ -107,17 +104,6 @@ components:
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
@@ -147,10 +133,21 @@ components:
fullPackageCount: {type: integer, minimum: 2, description: 全部成员满箱数之和}
createdAt: {type: string, format: date-time}
updatedAt: {type: string, format: date-time}
OrderColoadGroupInfo:
ColoadGroupBusinessMember:
type: object
additionalProperties: false
required: [orderId, group]
required: [inboundCode]
properties:
orderId: {type: string, pattern: '^[1-9][0-9]*$'}
group: {$ref: '#/components/schemas/ColoadGroup'}
inboundCode: {type: string, minLength: 1, maxLength: 40}
ColoadGroupBusinessSummary:
type: object
additionalProperties: false
required: [status, memberCount, members]
properties:
status: {type: string, enum: [ACTIVE, LOCKED, CONSUMED]}
memberCount: {type: integer, minimum: 1}
members:
type: array
minItems: 1
uniqueItems: true
items: {$ref: '#/components/schemas/ColoadGroupBusinessMember'}
@@ -2,13 +2,13 @@ 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.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.lang.reflect.Method;
@@ -25,9 +25,15 @@ class ColoadGroupContractTest {
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);
Method read = PlatformColoadGroupController.class.getDeclaredMethod(
"byInboundCode", AuthenticatedUser.class, String.class);
assertThat(read.getAnnotation(GetMapping.class).value())
.containsExactly("/by-inbound-code/{inboundCode}");
assertThat(read.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:firstleg:smart-loading:list')");
assertThat(Arrays.stream(PlatformColoadGroupController.class.getDeclaredMethods())
.map(Method::getName)).doesNotContain("addMember", "removeMember", "updateMember");
.map(Method::getName)).doesNotContain(
"batchQuery", "addMember", "removeMember", "updateMember");
}
@Test
@@ -12,23 +12,25 @@ import static org.assertj.core.api.Assertions.assertThat;
class ColoadGroupOpenApiContractTest {
@SuppressWarnings("unchecked")
@Test
void publishesBoundedLongIdGroupContractWithoutNewPermission() throws Exception {
void publishesInboundCodeBusinessSummaryWithoutExposingInternalGroupIdentity() 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");
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");
Map<String, Object> summary = (Map<String, Object>) schemas.get("ColoadGroupBusinessSummary");
Map<String, Object> summaryProperties = (Map<String, Object>) summary.get("properties");
Map<String, Object> member = (Map<String, Object>) schemas.get("ColoadGroupBusinessMember");
Map<String, Object> memberProperties = (Map<String, Object>) member.get("properties");
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");
"/api/platform/v1/first-leg/coload-groups/by-inbound-code/{inboundCode}");
assertThat(paths).doesNotContainKey("/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]")
@@ -36,12 +38,14 @@ class ColoadGroupOpenApiContractTest {
.contains("expectedVersion")
.contains("fullPackageCount")
.contains("platform:firstleg:smart-loading:assign")
.contains("platform:firstleg:smart-loading:list")
.contains("全部 WAIT_SPLIT")
.contains("国内仓和渠道一致")
.doesNotContain("destinationType")
.doesNotContain("destinationWarehouseId")
.doesNotContain("destinationCode")
.doesNotContain("platform:firstleg:coload");
assertThat(orderInfoProperties).containsOnlyKeys("orderId", "group");
assertThat(summaryProperties).containsOnlyKeys("status", "memberCount", "members");
assertThat(memberProperties).containsOnlyKeys("inboundCode");
}
}
@@ -5,6 +5,8 @@ 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.persistence.ColoadPersistenceRows.InboundGroupLookupRow;
import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.MemberCodeRow;
import com.baiyue.firstleg.loading.coload.service.ColoadGroupService;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
@@ -33,6 +35,54 @@ class ColoadGroupServiceTest {
@Resource
private ColoadGroupService service;
@Test
void readsCrossCustomerTwoMemberGroupByEitherInboundCodeWithoutInternalIdentifiers() {
when(mapper.groupsByInboundCode("IN-A")).thenReturn(List.of(lookup(500L, "ACTIVE")));
when(mapper.effectiveMemberCodes(500L)).thenReturn(List.of(code("IN-A"), code("IN-B")));
var result = service.queryByInboundCode(" IN-A ");
assertThat(result.status()).isEqualTo("ACTIVE");
assertThat(result.memberCount()).isEqualTo(2);
assertThat(result.members()).extracting("inboundCode").containsExactly("IN-A", "IN-B");
verify(mapper).groupsByInboundCode("IN-A");
}
@Test
void returnsEveryEffectiveSplitFragmentAndEliminatesDuplicateInboundCodes() {
when(mapper.groupsByInboundCode("MASTER-A-2")).thenReturn(List.of(lookup(500L, "LOCKED")));
when(mapper.effectiveMemberCodes(500L)).thenReturn(List.of(
code("MASTER-A-3"), code("MASTER-A-2"), code("MASTER-A-2"), code("ORDER-B")));
var result = service.queryByInboundCode("MASTER-A-2");
assertThat(result.status()).isEqualTo("LOCKED");
assertThat(result.memberCount()).isEqualTo(3);
assertThat(result.members()).extracting("inboundCode")
.containsExactly("MASTER-A-2", "MASTER-A-3", "ORDER-B");
}
@Test
void preservesConsumedStatusWhileReadingClosedFrozenMemberships() {
when(mapper.groupsByInboundCode("IN-A")).thenReturn(List.of(lookup(500L, "CONSUMED")));
when(mapper.effectiveMemberCodes(500L)).thenReturn(List.of(code("IN-A"), code("IN-B")));
assertThat(service.queryByInboundCode("IN-A").status()).isEqualTo("CONSUMED");
}
@Test
void failsClosedWhenGroupHasNoEffectiveMemberOrLookupIsAmbiguous() {
when(mapper.groupsByInboundCode("EMPTY")).thenReturn(List.of(lookup(500L, "ACTIVE")));
when(mapper.effectiveMemberCodes(500L)).thenReturn(List.of());
when(mapper.groupsByInboundCode("AMBIGUOUS")).thenReturn(List.of(
lookup(500L, "ACTIVE"), lookup(501L, "LOCKED")));
assertThatThrownBy(() -> service.queryByInboundCode("EMPTY"))
.isInstanceOf(BizException.class).hasMessageContaining("不存在有效入仓片段");
assertThatThrownBy(() -> service.queryByInboundCode("AMBIGUOUS"))
.isInstanceOf(BizException.class).hasMessageContaining("多个有效");
}
@Test
void createsCrossCustomerGroupAndFreezesAllAvailableFragmentsForEveryOrder() {
when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(List.of(
@@ -209,6 +259,19 @@ class ColoadGroupServiceTest {
return value;
}
private static InboundGroupLookupRow lookup(long groupId, String status) {
InboundGroupLookupRow value = new InboundGroupLookupRow();
value.setGroupId(groupId);
value.setGroupStatus(status);
return value;
}
private static MemberCodeRow code(String inboundCode) {
MemberCodeRow value = new MemberCodeRow();
value.setInboundCode(inboundCode);
return value;
}
private static org.assertj.core.groups.Tuple tuple(Object... values) {
return org.assertj.core.groups.Tuple.tuple(values);
}
@@ -56,6 +56,25 @@ class ColoadGroupSqlContractTest {
.doesNotContain("destination_code");
}
@Test
void inboundCodeReadKeepsConsumedGroupsAndAllFrozenEffectiveFragments() throws Exception {
Method lookup = ColoadGroupMapper.class.getDeclaredMethod("groupsByInboundCode", String.class);
String lookupSql = String.join(" ", lookup.getAnnotation(Select.class).value());
Method members = ColoadGroupMapper.class.getDeclaredMethod("effectiveMemberCodes", long.class);
String memberSql = String.join(" ", members.getAnnotation(Select.class).value());
assertThat(lookupSql)
.contains("requested.inbound_code=#{inboundCode}")
.contains("requested.deleted=0")
.contains("'ACTIVE','LOCKED','CONSUMED'")
.doesNotContain("group_no", "order_id=#{");
assertThat(memberSql)
.contains("SELECT DISTINCT inbound.inbound_code inboundCode")
.contains("member.group_id=#{groupId}")
.contains("inbound.deleted=0")
.doesNotContain("member.active_flag=1", "inbound_role", "split_status", "split_purpose");
}
private static String tableDefinition(String source, String tableName) {
int start = source.indexOf("CREATE TABLE IF NOT EXISTS " + tableName + " (");
int end = source.indexOf(';', start);