优化
This commit is contained in:
+13
@@ -7,6 +7,7 @@ import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderDetailResponse;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderRequests;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderSummary;
|
||||
import com.baiyue.lastleg.dto.PendingContainerSummary;
|
||||
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import com.baiyue.lastleg.service.LastlegOrderService;
|
||||
@@ -71,6 +72,18 @@ public class LastlegOrderController {
|
||||
return ApiResponse.success(orderService.page(platformScope(user, customerId), query));
|
||||
}
|
||||
|
||||
@GetMapping("/api/platform/v1/last-leg/orders/container-summaries")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
|
||||
@Operation(summary = "分页查询待分配柜级摘要",
|
||||
description = "按稳定 sourceContainerBookingId 聚合待分配尾程订单;平台跨客户查询。Required authority: "
|
||||
+ OverseasPermissions.LAST_MILE_ORDER_LIST)
|
||||
public ApiResponse<PageResult<PendingContainerSummary>> containerSummaries(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid LastlegOrderRequests.PendingContainerQuery query) {
|
||||
platformScope(user, null);
|
||||
return ApiResponse.success(orderService.pendingContainerPage(query));
|
||||
}
|
||||
|
||||
@GetMapping("/api/platform/v1/last-leg/orders/{id}")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
|
||||
@Operation(summary = "Get one inbound-code-level tail-leg order", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST)
|
||||
|
||||
@@ -36,6 +36,19 @@ public final class LastlegOrderRequests {
|
||||
}
|
||||
}
|
||||
|
||||
public record PendingContainerQuery(
|
||||
@Min(1) long page,
|
||||
@Min(1) @Max(200) long size,
|
||||
String containerNo,
|
||||
LocalDateTime createdFrom,
|
||||
LocalDateTime createdTo
|
||||
) {
|
||||
public PendingContainerQuery {
|
||||
if (page == 0) page = 1;
|
||||
if (size == 0) size = 20;
|
||||
}
|
||||
}
|
||||
|
||||
public record TransferDestination(
|
||||
@NotEmpty @Size(max = 200) List<Long> orderIds,
|
||||
@NotBlank @Size(max = 128) String warehouseId,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.baiyue.lastleg.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/** 平台待分配尾程订单按来源货柜聚合的只读摘要。 */
|
||||
public record PendingContainerSummary(
|
||||
String sourceContainerBookingId,
|
||||
String containerNo,
|
||||
long orderCount,
|
||||
long inboundCodeCount,
|
||||
long customerCount,
|
||||
long totalPackageCount,
|
||||
long totalPalletCount,
|
||||
BigDecimal totalVolumeCbm,
|
||||
boolean eligible,
|
||||
String ineligibleReason
|
||||
) {
|
||||
}
|
||||
+46
-10
@@ -31,6 +31,7 @@ import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.ArrayList;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@@ -73,7 +74,16 @@ public class InboundPlanService {
|
||||
return detail(customerId, existing);
|
||||
}
|
||||
validateOrders(orders);
|
||||
return createPlan(customerId, selectedOrderId, orders);
|
||||
}
|
||||
|
||||
private InboundPlanResponse createPlan(String customerId, Long selectedOrderId,
|
||||
List<SourceOrderSnapshot> orders) {
|
||||
String containerNo = trimToNull(orders.get(0).containerNo());
|
||||
if (containerNo == null) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"Inbound order container number is empty");
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
InboundPlan plan = new InboundPlan();
|
||||
plan.setId(IdWorker.getId());
|
||||
@@ -128,21 +138,47 @@ public class InboundPlanService {
|
||||
if (bookingId == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订 ID 不能为空");
|
||||
List<InboundPlanOrderMapper.ContainerOrderRef> refs = orderMapper.lockContainerOrders(bookingId);
|
||||
if (refs.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜没有尾程订单");
|
||||
boolean hasEligible = refs.stream().anyMatch(ref -> "PENDING_ASSIGNMENT".equals(ref.currentStatus())
|
||||
|| "DESTINATION_ASSIGNED".equals(ref.currentStatus()));
|
||||
if (!hasEligible) {
|
||||
List<InboundPlanResponse> replay = refs.stream().map(ref -> {
|
||||
InboundPlan existing = planMapper.findBySourceOrder(ref.customerId(), ref.orderId());
|
||||
if (existing == null) throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜转换状态不一致");
|
||||
return detail(ref.customerId(), existing);
|
||||
}).toList();
|
||||
return replay;
|
||||
List<InboundPlan> existingPlans = refs.stream()
|
||||
.map(ref -> planMapper.findBySourceOrder(ref.customerId(), ref.orderId()))
|
||||
.toList();
|
||||
long existingCount = existingPlans.stream().filter(Objects::nonNull).count();
|
||||
if (existingCount == refs.size()) {
|
||||
List<InboundPlanResponse> replay = new ArrayList<>(refs.size());
|
||||
for (int index = 0; index < refs.size(); index++) {
|
||||
replay.add(detail(refs.get(index).customerId(), existingPlans.get(index)));
|
||||
}
|
||||
return List.copyOf(replay);
|
||||
}
|
||||
if (existingCount != 0) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜仅部分尾程订单已有入库计划");
|
||||
}
|
||||
if (refs.stream().anyMatch(ref -> !("PENDING_ASSIGNMENT".equals(ref.currentStatus())
|
||||
|| "DESTINATION_ASSIGNED".equals(ref.currentStatus())))) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜存在不能转换的尾程订单");
|
||||
}
|
||||
return refs.stream().map(ref -> convert(ref.customerId(), ref.orderId())).toList();
|
||||
|
||||
List<List<SourceOrderSnapshot>> snapshots = new ArrayList<>(refs.size());
|
||||
String containerNo = null;
|
||||
for (InboundPlanOrderMapper.ContainerOrderRef ref : refs) {
|
||||
List<SourceOrderSnapshot> orders = orderMapper.lockOrder(ref.customerId(), ref.orderId());
|
||||
if (orders == null || orders.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜订单在锁定后不存在");
|
||||
}
|
||||
validateOrders(orders);
|
||||
String currentContainerNo = trimToNull(orders.get(0).containerNo());
|
||||
if (containerNo == null) containerNo = currentContainerNo;
|
||||
if (!Objects.equals(containerNo, currentContainerNo)) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜内尾程订单柜号不一致");
|
||||
}
|
||||
snapshots.add(orders);
|
||||
}
|
||||
|
||||
List<InboundPlanResponse> created = new ArrayList<>(refs.size());
|
||||
for (int index = 0; index < refs.size(); index++) {
|
||||
InboundPlanOrderMapper.ContainerOrderRef ref = refs.get(index);
|
||||
created.add(createPlan(ref.customerId(), ref.orderId(), snapshots.get(index)));
|
||||
}
|
||||
return List.copyOf(created);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,13 +2,47 @@ package com.baiyue.lastleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderRequests;
|
||||
import com.baiyue.lastleg.dto.PendingContainerSummary;
|
||||
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;
|
||||
|
||||
@Mapper
|
||||
public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
|
||||
String PENDING_CONTAINER_FILTER = " FROM ll_order o " +
|
||||
"WHERE o.source_container_booking_id IS NOT NULL AND o.source_container_booking_id<>'' " +
|
||||
"<if test='q.containerNo != null and q.containerNo != \"\"'>AND o.container_no LIKE CONCAT('%',#{q.containerNo},'%') </if>" +
|
||||
"<if test='q.createdFrom != null'>AND o.created_at >=#{q.createdFrom} </if>" +
|
||||
"<if test='q.createdTo != null'>AND o.created_at <=#{q.createdTo} </if>" +
|
||||
"GROUP BY o.source_container_booking_id " +
|
||||
"HAVING SUM(CASE WHEN o.current_status IN ('PENDING_ASSIGNMENT','DESTINATION_ASSIGNED') THEN 1 ELSE 0 END)>0 ";
|
||||
|
||||
@Select("<script>SELECT o.source_container_booking_id AS sourceContainerBookingId," +
|
||||
"CASE WHEN COUNT(DISTINCT NULLIF(o.container_no,''))=1 THEN MAX(NULLIF(o.container_no,'')) ELSE NULL END AS containerNo," +
|
||||
"COUNT(*) AS orderCount,COUNT(DISTINCT o.inbound_code) AS inboundCodeCount," +
|
||||
"COUNT(DISTINCT o.customer_id) AS customerCount,COALESCE(SUM(o.package_count),0) AS totalPackageCount," +
|
||||
"COALESCE(SUM(o.pallet_count),0) AS totalPalletCount,COALESCE(SUM(o.volume_cbm),0) AS totalVolumeCbm," +
|
||||
"CASE WHEN SUM(CASE WHEN o.current_status IN ('PENDING_ASSIGNMENT','DESTINATION_ASSIGNED') THEN 0 ELSE 1 END)=0 " +
|
||||
"AND COUNT(NULLIF(o.container_no,''))=COUNT(*) AND COUNT(DISTINCT NULLIF(o.container_no,''))=1 " +
|
||||
"THEN TRUE ELSE FALSE END AS eligible," +
|
||||
"CASE WHEN COUNT(NULLIF(o.container_no,''))<>COUNT(*) " +
|
||||
"OR COUNT(DISTINCT NULLIF(o.container_no,''))<>1 THEN '柜号缺失或不一致' " +
|
||||
"WHEN SUM(CASE WHEN o.current_status IN ('PENDING_ASSIGNMENT','DESTINATION_ASSIGNED') THEN 0 ELSE 1 END)>0 " +
|
||||
"THEN '柜内存在已生成计划或非待分配订单' ELSE NULL END AS ineligibleReason" +
|
||||
PENDING_CONTAINER_FILTER +
|
||||
"ORDER BY MAX(o.created_at) DESC,o.source_container_booking_id DESC LIMIT #{limit} OFFSET #{offset}</script>")
|
||||
List<PendingContainerSummary> pagePendingContainers(@Param("q") LastlegOrderRequests.PendingContainerQuery query,
|
||||
@Param("offset") long offset,
|
||||
@Param("limit") long limit);
|
||||
|
||||
@Select("<script>SELECT COUNT(*) FROM (SELECT o.source_container_booking_id" +
|
||||
PENDING_CONTAINER_FILTER + ") pending_containers</script>")
|
||||
long countPendingContainers(@Param("q") LastlegOrderRequests.PendingContainerQuery query);
|
||||
|
||||
@Select("SELECT * FROM ll_order WHERE customer_id=#{customerId} AND source_region=#{sourceRegion} " +
|
||||
"AND source_inbound_id=#{sourceInboundId} LIMIT 1")
|
||||
LastlegOrder findSource(@Param("customerId") String customerId,
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderDetailResponse;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderRequests;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderSummary;
|
||||
import com.baiyue.lastleg.dto.PendingContainerSummary;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderDetail;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
|
||||
@@ -46,6 +47,21 @@ public class LastlegOrderService {
|
||||
page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResult<PendingContainerSummary> pendingContainerPage(
|
||||
LastlegOrderRequests.PendingContainerQuery query) {
|
||||
if (query.createdFrom() != null && query.createdTo() != null
|
||||
&& query.createdFrom().isAfter(query.createdTo())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"createdFrom must not be after createdTo");
|
||||
}
|
||||
long total = orderMapper.countPendingContainers(query);
|
||||
List<PendingContainerSummary> records = total == 0 ? List.of()
|
||||
: orderMapper.pagePendingContainers(query, (query.page() - 1) * query.size(), query.size());
|
||||
long pages = total == 0 ? 0 : (total + query.size() - 1) / query.size();
|
||||
return new PageResult<>(records, query.page(), query.size(), total, pages);
|
||||
}
|
||||
|
||||
public LastlegOrderDetailResponse detail(String customerId, long orderId) {
|
||||
LastlegOrder order = findOwned(customerId, orderId);
|
||||
List<LastlegOrderDetail> cargo = detailMapper.selectList(new LambdaQueryWrapper<LastlegOrderDetail>()
|
||||
|
||||
@@ -100,7 +100,8 @@ CREATE TABLE IF NOT EXISTS ll_order (
|
||||
UNIQUE KEY uk_ll_order_source (customer_id,source_region,source_inbound_id),
|
||||
KEY idx_ll_order_group(customer_id,order_no),
|
||||
KEY idx_ll_order_container(customer_id,container_no),
|
||||
KEY idx_ll_order_list(customer_id,current_status,created_at)
|
||||
KEY idx_ll_order_list(customer_id,current_status,created_at),
|
||||
KEY idx_ll_order_pending_container(source_container_booking_id,current_status,created_at)
|
||||
) ENGINE=InnoDB COMMENT='尾程订单主表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_order_detail (
|
||||
@@ -198,6 +199,8 @@ ALTER TABLE ll_loading_event_inbox COMMENT='头程装柜完成事件接收幂等
|
||||
ALTER TABLE ll_order COMMENT='尾程订单主表';
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_container_booking_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id'));
|
||||
PREPARE lastleg_order_container_booking_stmt FROM @ddl; EXECUTE lastleg_order_container_booking_stmt; DEALLOCATE PREPARE lastleg_order_container_booking_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_pending_container'), 'SELECT 1', 'ALTER TABLE ll_order ADD INDEX idx_ll_order_pending_container(source_container_booking_id,current_status,created_at)'));
|
||||
PREPARE lastleg_order_pending_container_idx_stmt FROM @ddl; EXECUTE lastleg_order_pending_container_idx_stmt; DEALLOCATE PREPARE lastleg_order_pending_container_idx_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER order_type'));
|
||||
PREPARE lastleg_order_destination_type_stmt FROM @ddl; EXECUTE lastleg_order_destination_type_stmt; DEALLOCATE PREPARE lastleg_order_destination_type_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER destination_warehouse_name'));
|
||||
|
||||
@@ -156,6 +156,36 @@ paths:
|
||||
- { name: createdFrom, in: query, schema: { type: string, format: date-time } }
|
||||
- { name: createdTo, in: query, schema: { type: string, format: date-time } }
|
||||
responses: { '200': { description: 统一 ApiResponse 包装的尾程订单分页;列表行 destinationType 为 FBA 或 OVERSEAS } }
|
||||
/api/platform/v1/last-leg/orders/container-summaries:
|
||||
get:
|
||||
summary: 分页查询待分配柜级摘要
|
||||
description: 权限 platform:overseas-warehouse:order:list;平台跨客户按稳定 sourceContainerBookingId 聚合。仅返回至少有一票待分配的柜;eligible=false 表示柜内状态或柜号不一致,禁止转换。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
|
||||
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
|
||||
- { name: containerNo, in: query, schema: { type: string } }
|
||||
- { name: createdFrom, in: query, schema: { type: string, format: date-time } }
|
||||
- { name: createdTo, in: query, schema: { type: string, format: date-time } }
|
||||
responses:
|
||||
'200':
|
||||
description: 统一 ApiResponse 包装的柜级摘要分页
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: { type: string, enum: ['200'] }
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
page: { type: integer, format: int64 }
|
||||
size: { type: integer, format: int64 }
|
||||
total: { type: integer, format: int64 }
|
||||
pages: { type: integer, format: int64 }
|
||||
records:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/PendingContainerSummary' }
|
||||
/api/platform/v1/last-leg/inbound-plans/convert:
|
||||
post:
|
||||
summary: 平台生成入库计划
|
||||
@@ -562,6 +592,20 @@ paths:
|
||||
responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } }
|
||||
components:
|
||||
schemas:
|
||||
PendingContainerSummary:
|
||||
type: object
|
||||
required: [sourceContainerBookingId, orderCount, inboundCodeCount, customerCount, totalPackageCount, totalPalletCount, totalVolumeCbm, eligible]
|
||||
properties:
|
||||
sourceContainerBookingId: { type: string }
|
||||
containerNo: { type: string, nullable: true }
|
||||
orderCount: { type: integer, format: int64 }
|
||||
inboundCodeCount: { type: integer, format: int64 }
|
||||
customerCount: { type: integer, format: int64 }
|
||||
totalPackageCount: { type: integer, format: int64 }
|
||||
totalPalletCount: { type: integer, format: int64 }
|
||||
totalVolumeCbm: { type: number }
|
||||
eligible: { type: boolean }
|
||||
ineligibleReason: { type: string, nullable: true }
|
||||
PdaWarehouseScan:
|
||||
type: object
|
||||
required: [code]
|
||||
|
||||
+18
@@ -50,6 +50,24 @@ class LastlegOrderControllerTest {
|
||||
verify(service).page(eq("200"), eq(query));
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformOperatorCanQueryCrossCustomerContainerSummaries() {
|
||||
var summaryQuery = new LastlegOrderRequests.PendingContainerQuery(1, 20, "CONT-", null, null);
|
||||
|
||||
controller.containerSummaries(user(0L, "PLATFORM"), summaryQuery);
|
||||
|
||||
verify(service).pendingContainerPage(summaryQuery);
|
||||
}
|
||||
|
||||
@Test
|
||||
void containerSummariesRejectMerchantIdentity() {
|
||||
var summaryQuery = new LastlegOrderRequests.PendingContainerQuery(1, 20, null, null, null);
|
||||
|
||||
assertThrows(ResponseStatusException.class,
|
||||
() -> controller.containerSummaries(user(100L, "MERCHANT"), summaryQuery));
|
||||
verify(service, never()).pendingContainerPage(summaryQuery);
|
||||
}
|
||||
|
||||
@Test
|
||||
void iamSecurityEventsAreRejectedBeforeLoadingCompletedConsumer() {
|
||||
EventEnvelope event = mock(EventEnvelope.class);
|
||||
|
||||
+18
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
|
||||
import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest;
|
||||
import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest;
|
||||
import com.baiyue.lastleg.inboundplan.dto.UpdateFbaAppointmentRequest;
|
||||
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
|
||||
import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
|
||||
@@ -87,6 +88,23 @@ class InboundPlanControllerTest {
|
||||
verify(service).convert("100", 2L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformContainerConversionUsesStableBookingIdentity() {
|
||||
controller.convertContainer(platform(), new ConvertContainerInboundPlanRequest(" BOOKING-1 "));
|
||||
|
||||
verify(service).convertContainer(" BOOKING-1 ");
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantCannotConvertAWholeContainer() {
|
||||
AuthenticatedUser customer = new AuthenticatedUser(8L, 100L, 100L, 9L, "customer", "MERCHANT", "WEB", Set.of());
|
||||
|
||||
assertThatThrownBy(() -> controller.convertContainer(customer,
|
||||
new ConvertContainerInboundPlanRequest("BOOKING-1")))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
verify(service, never()).convertContainer(org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
private static AuthenticatedUser platform() {
|
||||
return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB",
|
||||
Set.of("platform:overseas-warehouse:inbound-order:list"));
|
||||
|
||||
+87
@@ -191,6 +191,93 @@ class InboundPlanServiceTest {
|
||||
verify(planMapper, never()).insert(any(InboundPlan.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void containerConversionPrevalidatesEveryOrderBeforeWritingIndependentPlans() throws Exception {
|
||||
assertNotNull(InboundPlanService.class.getMethod("convertContainer", String.class)
|
||||
.getAnnotation(Transactional.class));
|
||||
when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of(
|
||||
new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "PENDING_ASSIGNMENT"),
|
||||
new InboundPlanOrderMapper.ContainerOrderRef("200", 2L, "DESTINATION_ASSIGNED")));
|
||||
when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(order("100", 1L, "RK-1", "WH-A")));
|
||||
when(orderMapper.lockOrder("200", 2L)).thenReturn(List.of(order("200", 2L, "RK-2", "WH-B")));
|
||||
when(orderMapper.markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()))
|
||||
.thenReturn(1);
|
||||
|
||||
var result = service.convertContainer(" BOOKING-1 ");
|
||||
|
||||
assertEquals(2, result.size());
|
||||
assertEquals(1L, result.get(0).sourceOrderId());
|
||||
assertEquals(2L, result.get(1).sourceOrderId());
|
||||
verify(planMapper, times(2)).insert(any(InboundPlan.class));
|
||||
verify(orderMapper).lockContainerOrders("BOOKING-1");
|
||||
verify(orderMapper).lockOrder("100", 1L);
|
||||
verify(orderMapper).lockOrder("200", 2L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidLaterOrderCausesNoWritesBecauseWholeContainerIsPrevalidated() {
|
||||
when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of(
|
||||
new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "PENDING_ASSIGNMENT"),
|
||||
new InboundPlanOrderMapper.ContainerOrderRef("100", 2L, "PENDING_ASSIGNMENT")));
|
||||
when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(order(1L, "RK-1", "1", "WH-A")));
|
||||
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(new SourceOrderSnapshot(2L, "100", "CONT-1", "RK-2",
|
||||
"PENDING_ASSIGNMENT", null, BigDecimal.ONE, 1, null, null, null, 1, 0)));
|
||||
|
||||
assertThrows(ResponseStatusException.class, () -> service.convertContainer("BOOKING-1"));
|
||||
|
||||
verify(planMapper, never()).insert(any(InboundPlan.class));
|
||||
verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any());
|
||||
verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completeContainerReplayReturnsStableExistingPlansWithoutWriting() {
|
||||
var refs = List.of(
|
||||
new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "INBOUND_PLANNED"),
|
||||
new InboundPlanOrderMapper.ContainerOrderRef("200", 2L, "INBOUND_COMPLETED"));
|
||||
when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(refs);
|
||||
when(planMapper.findBySourceOrder("100", 1L)).thenReturn(existingPlan("100", 1L, "LIP-1"));
|
||||
when(planMapper.findBySourceOrder("200", 2L)).thenReturn(existingPlan("200", 2L, "LIP-2"));
|
||||
|
||||
var result = service.convertContainer("BOOKING-1");
|
||||
|
||||
assertEquals(List.of("LIP-1", "LIP-2"), result.stream().map(value -> value.planNo()).toList());
|
||||
verify(orderMapper, never()).lockOrder(any(), any());
|
||||
verify(planMapper, never()).insert(any(InboundPlan.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void partialExistingPlansAreRejectedBeforeAnyWrite() {
|
||||
when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of(
|
||||
new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "INBOUND_PLANNED"),
|
||||
new InboundPlanOrderMapper.ContainerOrderRef("100", 2L, "PENDING_ASSIGNMENT")));
|
||||
when(planMapper.findBySourceOrder("100", 1L)).thenReturn(existingPlan("100", 1L, "LIP-1"));
|
||||
|
||||
assertThrows(ResponseStatusException.class, () -> service.convertContainer("BOOKING-1"));
|
||||
|
||||
verify(orderMapper, never()).lockOrder(any(), any());
|
||||
verify(planMapper, never()).insert(any(InboundPlan.class));
|
||||
}
|
||||
|
||||
private InboundPlan existingPlan(String customerId, long orderId, String planNo) {
|
||||
InboundPlan plan = new InboundPlan();
|
||||
plan.setId(orderId + 100);
|
||||
plan.setPlanNo(planNo);
|
||||
plan.setCustomerId(customerId);
|
||||
plan.setSourceOrderId(orderId);
|
||||
plan.setContainerNo("CONT-1");
|
||||
plan.setStatus("PENDING_ASSIGNMENT");
|
||||
plan.setLineCount(1);
|
||||
plan.setPalletCount(1);
|
||||
plan.setTotalVolumeCbm(BigDecimal.ONE);
|
||||
return plan;
|
||||
}
|
||||
|
||||
private SourceOrderSnapshot order(String customerId, Long id, String inboundCode, String warehouse) {
|
||||
return new SourceOrderSnapshot(id, customerId, "CONT-1", inboundCode, "PENDING_ASSIGNMENT", null,
|
||||
BigDecimal.ONE, 10, warehouse, warehouse, warehouse, 1, 0);
|
||||
}
|
||||
|
||||
private SourceOrderSnapshot order(Long id, String inboundCode, String volume, String warehouse) {
|
||||
return new SourceOrderSnapshot(id, "100", "CONT-1", inboundCode, "PENDING_ASSIGNMENT", null,
|
||||
new BigDecimal(volume), new BigDecimal(volume).multiply(BigDecimal.TEN).intValue(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.lastleg.service;
|
||||
|
||||
import com.baiyue.lastleg.dto.LastlegOrderRequests;
|
||||
import com.baiyue.lastleg.dto.PendingContainerSummary;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderDetail;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
|
||||
@@ -19,6 +20,8 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.List;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
@@ -133,6 +136,33 @@ class LastlegOrderServiceTest {
|
||||
assertEquals(25, page.getValue().getSize());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pendingContainerPageUsesServerSideAggregatePagination() {
|
||||
var query = new LastlegOrderRequests.PendingContainerQuery(2, 10, "CONT-", null, null);
|
||||
var row = new PendingContainerSummary("BOOKING-1", "CONT-1", 15, 15, 3,
|
||||
150, 0, new BigDecimal("12.5000"), true, null);
|
||||
when(orders.countPendingContainers(query)).thenReturn(11L);
|
||||
when(orders.pagePendingContainers(query, 10, 10)).thenReturn(List.of(row));
|
||||
|
||||
var result = service.pendingContainerPage(query);
|
||||
|
||||
assertEquals(2, result.page());
|
||||
assertEquals(10, result.size());
|
||||
assertEquals(11, result.total());
|
||||
assertEquals(2, result.pages());
|
||||
assertEquals(List.of(row), result.records());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pendingContainerPageRejectsReversedDateRangeBeforeQuerying() {
|
||||
var query = new LastlegOrderRequests.PendingContainerQuery(1, 20, null,
|
||||
LocalDateTime.of(2026, 8, 20, 0, 0), LocalDateTime.of(2026, 8, 19, 0, 0));
|
||||
|
||||
assertThrows(ResponseStatusException.class, () -> service.pendingContainerPage(query));
|
||||
|
||||
verify(orders, never()).countPendingContainers(any());
|
||||
}
|
||||
|
||||
private static LastlegOrder order(long id, String customerId, String status, String inboundOrderNo) {
|
||||
LastlegOrder order = new LastlegOrder();
|
||||
order.setId(id);
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.baiyue.lastleg.service;
|
||||
|
||||
import com.baiyue.lastleg.dto.PendingContainerSummary;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class PendingContainerSummaryContractTest {
|
||||
@Test
|
||||
void responseFieldsAndServerAggregateSqlStayFrozen() throws Exception {
|
||||
assertEquals(List.of("sourceContainerBookingId", "containerNo", "orderCount", "inboundCodeCount",
|
||||
"customerCount", "totalPackageCount", "totalPalletCount", "totalVolumeCbm", "eligible",
|
||||
"ineligibleReason"),
|
||||
Arrays.stream(PendingContainerSummary.class.getRecordComponents()).map(value -> value.getName()).toList());
|
||||
|
||||
Select rows = LastlegOrderMapper.class.getMethod("pagePendingContainers",
|
||||
com.baiyue.lastleg.dto.LastlegOrderRequests.PendingContainerQuery.class, long.class, long.class)
|
||||
.getAnnotation(Select.class);
|
||||
String sql = String.join("", rows.value()).toLowerCase();
|
||||
assertTrue(sql.contains("group by o.source_container_booking_id"));
|
||||
assertTrue(sql.contains("pending_assignment"));
|
||||
assertTrue(sql.contains("destination_assigned"));
|
||||
assertTrue(sql.contains("limit #{limit} offset #{offset}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void openApiAndSchemaExposeStableContainerIdentity() throws Exception {
|
||||
String openApi = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"),
|
||||
StandardCharsets.UTF_8);
|
||||
String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8);
|
||||
|
||||
assertTrue(openApi.contains("/api/platform/v1/last-leg/orders/container-summaries:"));
|
||||
assertTrue(openApi.contains("PendingContainerSummary:"));
|
||||
assertTrue(openApi.contains("sourceContainerBookingId:"));
|
||||
assertTrue(schema.contains("idx_ll_order_pending_container(source_container_booking_id,current_status,created_at)"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user