feat(order): add container cross-order fulfillment aggregation

This commit is contained in:
2026-08-10 13:16:40 +08:00
parent 3c44f3bd51
commit 88390f9401
8 changed files with 115 additions and 1 deletions
@@ -19,6 +19,7 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** Merchant product-level order query restricted to the authenticated customer. */
@Tag(name = "Merchant unified orders / 客户统一订单")
@@ -51,4 +52,12 @@ public class MerchantUnifiedOrderController {
@Operation(summary = "我的父子订单与柜次数量摘要", description = "不返回装柜照片、锁码或内部审计字段。")
public ApiResponse<OrderSplitDtos.Tree> fulfillmentTree(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long orderId){return ApiResponse.success(splitService.tree(OrderScopeGuard.merchant(user),orderId));}
@GetMapping("/container-bookings/{containerBookingId}/fulfillment-slices")
@PreAuthorize("hasAuthority('merchant:order:view')")
@Operation(summary = "按柜次查询我的订单分片")
public ApiResponse<List<OrderSplitDtos.ContainerSlice>> containerSlices(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long containerBookingId) {
return ApiResponse.success(splitService.containerSlices(containerBookingId, OrderScopeGuard.merchant(user)));
}
}
@@ -25,6 +25,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** Platform product-level order query. Legacy first-leg paths remain available. */
@Tag(name = "Platform unified orders / 平台统一订单")
@@ -65,6 +66,16 @@ public class PlatformUnifiedOrderController {
return ApiResponse.success(splitService.tree(OrderScopeGuard.platform(user,tenantId),orderId));
}
@GetMapping("/container-bookings/{containerBookingId}/fulfillment-slices")
@PreAuthorize("hasAuthority('platform:order:list')")
@Operation(summary = "按柜次查询订单分片")
public ApiResponse<List<OrderSplitDtos.ContainerSlice>> containerSlices(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long containerBookingId, @RequestParam(required = false) Long tenantId) {
OrderScopeGuard.platform(user);
if (tenantId != null) OrderScopeGuard.platform(user, tenantId);
return ApiResponse.success(splitService.containerSlices(containerBookingId, tenantId));
}
@PostMapping("/{orderId}/internal-notes")
@BusinessAudit(event = "ORDER_INTERNAL_NOTE", resourceType = "FIRST_LEG_ORDER", resourceId = "#orderId", description = "Add internal note / 添加内部备注")
@PreAuthorize("hasAuthority('platform:order:note')")
@@ -49,4 +49,11 @@ public final class OrderSplitDtos {
public record Tree(Long parentOrderId, String parentOrderNo, Integer parentVersion,
List<Child> children, Integer allocatedPackageCount,
Integer remainingPackageCount) {}
public record ContainerSlice(Long containerBookingId, Long tenantId,
Long parentOrderId, String parentOrderNo,
Long childOrderId, String childOrderNo,
Long sourceOrderId, String sourceOrderNo,
String splitRole, Integer packageCount,
Long loadingPlanId) {}
}
@@ -8,6 +8,7 @@ import org.apache.ibatis.annotations.Select;
import com.baiyue.order.entity.OrderEntity;
import java.util.List;
import com.baiyue.order.dto.OrderSplitDtos;
import org.apache.ibatis.annotations.Update;
@Mapper
@@ -41,4 +42,10 @@ public interface OrderFulfillmentSplitMapper extends BaseMapper<OrderFulfillment
@Select("SELECT COALESCE(MAX(split_sequence),0) FROM ord_order WHERE tenant_id=#{tenantId} AND (id=#{parentOrderId} OR parent_order_id=#{parentOrderId}) AND deleted=0")
Integer maxSplitSequence(@Param("tenantId") long tenantId, @Param("parentOrderId") long parentOrderId);
@Select("SELECT s.container_booking_id AS containerBookingId,s.tenant_id AS tenantId,s.parent_order_id AS parentOrderId,p.order_no AS parentOrderNo,s.child_order_id AS childOrderId,c.order_no AS childOrderNo,s.source_order_id AS sourceOrderId,so.order_no AS sourceOrderNo,s.split_role AS splitRole,CASE WHEN s.split_role='LOADED' THEN s.loaded_package_count ELSE s.remaining_package_count END AS packageCount,s.loading_plan_id AS loadingPlanId FROM ord_order_fulfillment_split s JOIN ord_order p ON p.id=s.parent_order_id AND p.tenant_id=s.tenant_id JOIN ord_order c ON c.id=s.child_order_id AND c.tenant_id=s.tenant_id LEFT JOIN ord_order so ON so.id=s.source_order_id AND so.tenant_id=s.tenant_id WHERE s.container_booking_id=#{containerBookingId} ORDER BY s.tenant_id,s.id")
List<OrderSplitDtos.ContainerSlice> findByContainer(@Param("containerBookingId") long containerBookingId);
@Select("SELECT s.container_booking_id AS containerBookingId,s.tenant_id AS tenantId,s.parent_order_id AS parentOrderId,p.order_no AS parentOrderNo,s.child_order_id AS childOrderId,c.order_no AS childOrderNo,s.source_order_id AS sourceOrderId,so.order_no AS sourceOrderNo,s.split_role AS splitRole,CASE WHEN s.split_role='LOADED' THEN s.loaded_package_count ELSE s.remaining_package_count END AS packageCount,s.loading_plan_id AS loadingPlanId FROM ord_order_fulfillment_split s JOIN ord_order p ON p.id=s.parent_order_id AND p.tenant_id=s.tenant_id JOIN ord_order c ON c.id=s.child_order_id AND c.tenant_id=s.tenant_id LEFT JOIN ord_order so ON so.id=s.source_order_id AND so.tenant_id=s.tenant_id WHERE s.container_booking_id=#{containerBookingId} AND s.tenant_id=#{tenantId} ORDER BY s.id")
List<OrderSplitDtos.ContainerSlice> findByContainerForTenant(@Param("tenantId") long tenantId, @Param("containerBookingId") long containerBookingId);
}
@@ -25,6 +25,12 @@ public class OrderFulfillmentSplitService {
@Resource private OrderMapper orders;
@Resource private ObjectMapper json;
public List<OrderSplitDtos.ContainerSlice> containerSlices(long containerBookingId, Long tenantId) {
if (containerBookingId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "柜次ID必须大于0");
return tenantId == null ? splits.findByContainer(containerBookingId)
: splits.findByContainerForTenant(tenantId, containerBookingId);
}
@Transactional
public OrderSplitDtos.Response split(OrderSplitDtos.CreateRequest req, long operatorId) {
validate(req);
@@ -267,6 +267,26 @@ paths:
- { name: orderId, in: path, required: true, schema: { type: integer, format: int64 } }
responses: { '200': { description: 成功 }, '403': { description: 无权限 } }
/api/platform/v1/orders/container-bookings/{containerBookingId}/fulfillment-slices:
get:
summary: 平台按柜次查询全部订单分片
description: tenantId 可选;省略时跨客户汇总。响应不包含照片、锁码或内部审计字段。
parameters:
- {name: containerBookingId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: tenantId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses:
'200': {description: 成功;无分片时返回空数组}
'403': {description: 需要平台身份和 platform:order:list 权限}
/api/merchant/v1/orders/container-bookings/{containerBookingId}/fulfillment-slices:
get:
summary: 客户按柜次查询本客户订单分片
description: 客户范围来自登录态,不接受目标客户参数。
parameters:
- {name: containerBookingId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses:
'200': {description: 成功;无分片时返回空数组}
'403': {description: 需要 merchant:order:view 权限}
components:
securitySchemes:
bearerAuth:
@@ -385,6 +405,20 @@ components:
orderId: {type: string, pattern: '^[1-9][0-9]*$'}
orderNo: {type: string}
pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'}
ContainerSlice:
type: object
properties:
containerBookingId: {type: string}
tenantId: {type: string}
parentOrderId: {type: string}
parentOrderNo: {type: string}
childOrderId: {type: string}
childOrderNo: {type: string}
sourceOrderId: {type: string}
sourceOrderNo: {type: string}
splitRole: {type: string, enum: [LOADED, REMAINDER]}
packageCount: {type: integer}
loadingPlanId: {type: string}
parameters:
InternalToken:
name: X-Internal-Service-Token
@@ -0,0 +1,37 @@
package com.baiyue.order.service;
import com.baiyue.order.dto.OrderSplitDtos;
import com.baiyue.order.mapper.OrderFulfillmentSplitMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class OrderContainerAggregationTest {
@Mock OrderFulfillmentSplitMapper splits;
@InjectMocks OrderFulfillmentSplitService service;
@Test void platformAggregatesMultipleCustomersInOneContainer() {
var a = slice(1L, 11L, 101L);
var b = slice(2L, 22L, 202L);
when(splits.findByContainer(900L)).thenReturn(List.of(a, b));
assertThat(service.containerSlices(900L, null)).containsExactly(a, b);
verify(splits, never()).findByContainerForTenant(anyLong(), anyLong());
}
@Test void merchantIsAlwaysTenantScopedAndEmptyIsStable() {
when(splits.findByContainerForTenant(7L, 900L)).thenReturn(List.of());
assertThat(service.containerSlices(900L, 7L)).isEmpty();
verify(splits, never()).findByContainer(anyLong());
}
private OrderSplitDtos.ContainerSlice slice(long tenant, long parent, long child) {
return new OrderSplitDtos.ContainerSlice(900L, tenant, parent, "P"+parent, child, "C"+child,
parent, "P"+parent, "LOADED", 10, 800L);
}
}
@@ -38,6 +38,9 @@ class OrderFulfillmentSplitContractTest {
.contains("containerBookingId:")
.contains("expectedParentVersion:")
.contains("FulfillmentSplitResponse:")
.contains("InternalToken:");
.contains("InternalToken:")
.contains("/api/platform/v1/orders/container-bookings/{containerBookingId}/fulfillment-slices:")
.contains("/api/merchant/v1/orders/container-bookings/{containerBookingId}/fulfillment-slices:")
.contains("ContainerSlice");
}
}