From de8acc419f6e42a76d27627ead3535b6bd1722a5 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Tue, 4 Aug 2026 20:49:46 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BA=8C=E6=9C=9F=E5=86=85=E5=AE=B9=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=EF=BC=9A=E5=AE=A2=E6=88=B7=E7=AB=AF=E8=AE=A2=E5=8D=95?= =?UTF-8?q?=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InternalInboundOrderController.java | 19 +++ .../firstleg/dto/InboundOrderReference.java | 20 +++ .../service/InboundOrderCreationService.java | 14 ++ ...alInboundOrderReferenceControllerTest.java | 40 ++++++ .../order/client/FirstlegInboundClient.java | 12 ++ .../MerchantFirstLegOrderController.java | 13 +- .../order/dto/MerchantInboundOrderQuery.java | 31 ++++ .../order/dto/MerchantInboundOrderRow.java | 47 ++++++ .../order/repository/OrderRepository.java | 57 ++++++++ .../MerchantInboundOrderListService.java | 136 ++++++++++++++++++ .../src/main/resources/openapi/order-v1.yaml | 24 ++++ .../OrderControllerPermissionTest.java | 3 +- ...rchantInboundOrderQueryRepositoryTest.java | 55 +++++++ .../MerchantInboundOrderListServiceTest.java | 97 +++++++++++++ 14 files changed, 563 insertions(+), 5 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderReference.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java create mode 100644 order/src/main/java/com/baiyue/order/dto/MerchantInboundOrderQuery.java create mode 100644 order/src/main/java/com/baiyue/order/dto/MerchantInboundOrderRow.java create mode 100644 order/src/main/java/com/baiyue/order/service/MerchantInboundOrderListService.java create mode 100644 order/src/test/java/com/baiyue/order/repository/MerchantInboundOrderQueryRepositoryTest.java create mode 100644 order/src/test/java/com/baiyue/order/service/MerchantInboundOrderListServiceTest.java diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java index 66eae62a4..640bfb417 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.OrderApprovedInboundCommand; +import com.baiyue.firstleg.dto.InboundOrderReference; import com.baiyue.firstleg.service.InboundOrderCreationService; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.PostMapping; @@ -11,6 +12,9 @@ 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 jakarta.validation.Valid; + +import java.util.List; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -41,4 +45,19 @@ public class InternalInboundOrderController { } return ApiResponse.success(service.createFromApprovedOrder(command)); } + + @PostMapping("/order-references") + public ApiResponse> references( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody InboundOrderReference.BatchRequest request) { + requireToken(token); + return ApiResponse.success(service.findReferences( + request.tenantId(), request.orderIds().stream().distinct().toList())); + } + + private void requireToken(String token) { + if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) { + throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效"); + } + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderReference.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderReference.java new file mode 100644 index 000000000..9ddb299f1 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderReference.java @@ -0,0 +1,20 @@ +package com.baiyue.firstleg.dto; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +import java.util.List; + +/** 订单服务批量读取头程入库引用的内部契约。 */ +public final class InboundOrderReference { + private InboundOrderReference() { + } + + public record BatchRequest(@Positive long tenantId, + @NotEmpty @Size(max = 200) List<@Positive Long> orderIds) { + } + + public record Item(Long orderId, Long inboundId, String inboundCode, String processStatus) { + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java index b4f6528d9..88f6367db 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java @@ -2,6 +2,7 @@ package com.baiyue.firstleg.service; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.firstleg.dto.OrderApprovedInboundCommand; +import com.baiyue.firstleg.dto.InboundOrderReference; import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; import com.baiyue.firstleg.mapper.InboundOrderMapper; @@ -10,6 +11,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.List; /** 将已审核的订单快照幂等转换为待测量入库单。 */ @Service @@ -86,4 +88,16 @@ public class InboundOrderCreationService { histories.insert(history); return inbound.getId(); } + + public List findReferences(long tenantId, List orderIds) { + if (orderIds.isEmpty()) return List.of(); + return orders.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(InboundOrderEntity::getTenantId, tenantId) + .in(InboundOrderEntity::getOrderId, orderIds) + .orderByDesc(InboundOrderEntity::getCreatedAt)) + .stream() + .map(inbound -> new InboundOrderReference.Item( + inbound.getOrderId(), inbound.getId(), inbound.getInboundCode(), inbound.getProcessStatus())) + .toList(); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java new file mode 100644 index 000000000..37f5ad83e --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java @@ -0,0 +1,40 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.InboundOrderReference; +import com.baiyue.firstleg.service.InboundOrderCreationService; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class InternalInboundOrderReferenceControllerTest { + private final InboundOrderCreationService service = mock(InboundOrderCreationService.class); + private final InternalInboundOrderController controller = + new InternalInboundOrderController(service, "internal-token"); + + @Test + void 使用内部令牌按租户批量查询订单入仓编码() { + var request = new InboundOrderReference.BatchRequest(10001L, List.of(77L, 77L, 78L)); + when(service.findReferences(10001L, List.of(77L, 78L))).thenReturn(List.of( + new InboundOrderReference.Item(77L, 88L, "RK-77", "WAIT_MEASURE"))); + + var response = controller.references("internal-token", request); + + assertThat(response.data()).extracting(InboundOrderReference.Item::inboundCode) + .containsExactly("RK-77"); + verify(service).findReferences(10001L, List.of(77L, 78L)); + } + + @Test + void 错误内部令牌不能查询任意租户的入仓编码() { + var request = new InboundOrderReference.BatchRequest(10001L, List.of(77L)); + assertThatThrownBy(() -> controller.references("bad-token", request)) + .isInstanceOf(BizException.class); + } +} diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java index abbb9b831..6ba498b8f 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java @@ -7,6 +7,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import java.time.LocalDateTime; +import java.util.List; /** 订单审核事件投递到头程入库域的内部客户端。 */ @FeignClient(name = "firstleg-service", contextId = "orderFirstlegInboundClient", @@ -16,6 +17,17 @@ public interface FirstlegInboundClient { ApiResponse create(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody OrderApprovedInboundCommand command); + @PostMapping("/order-references") + ApiResponse> references( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestBody InboundReferenceRequest request); + + record InboundReferenceRequest(long tenantId, List orderIds) { + } + + record InboundReference(Long orderId, Long inboundId, String inboundCode, String processStatus) { + } + record OrderApprovedInboundCommand( long tenantId, long orderId, String orderNo, Long customerId, String customerName, Long warehouseId, String warehouseName, Long deliveryWarehouseId, String deliveryWarehouseName, diff --git a/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java index b3d3ea93e..ddd63de6d 100644 --- a/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java @@ -6,10 +6,12 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.order.dto.OrderDetailResponse; import com.baiyue.order.dto.OrderRequests; -import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.dto.MerchantInboundOrderQuery; +import com.baiyue.order.dto.MerchantInboundOrderRow; import com.baiyue.order.entity.OrderStatus; import com.baiyue.order.service.OrderApplicationService; import com.baiyue.order.service.OrderScopeGuard; +import com.baiyue.order.service.MerchantInboundOrderListService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponses; @@ -39,6 +41,8 @@ import org.springframework.web.bind.annotation.RestController; public class MerchantFirstLegOrderController { @Resource private OrderApplicationService service; + @Resource + private MerchantInboundOrderListService inboundOrderListService; @BusinessAudit(event = "ORDER_CREATE", resourceType = "FIRST_LEG_ORDER", description = "Create draft / 新建草稿") @Operation(summary = "Create draft / 新建草稿", description = "Creates a first-leg draft order in the authenticated merchant tenant. / 在当前认证商户租户下创建头程订单草稿。") @@ -80,9 +84,10 @@ public class MerchantFirstLegOrderController { }) @GetMapping @PreAuthorize("hasAuthority('merchant:order:list')") - public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, - @Valid OrderRequests.QueryRequest query) { - return ApiResponse.success(service.page(OrderScopeGuard.merchant(user), query)); + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid MerchantInboundOrderQuery query) { + return ApiResponse.success(inboundOrderListService.page(OrderScopeGuard.merchant(user), query)); } @Operation(summary = "My order detail / 我的订单详情", description = "Returns the order aggregate only when it belongs to the authenticated merchant tenant. / 仅当订单属于当前认证商户租户时返回订单聚合详情。") diff --git a/order/src/main/java/com/baiyue/order/dto/MerchantInboundOrderQuery.java b/order/src/main/java/com/baiyue/order/dto/MerchantInboundOrderQuery.java new file mode 100644 index 000000000..873691b9e --- /dev/null +++ b/order/src/main/java/com/baiyue/order/dto/MerchantInboundOrderQuery.java @@ -0,0 +1,31 @@ +package com.baiyue.order.dto; + +import com.baiyue.order.entity.OrderStatus; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; + +/** 客户端入库订单列表查询条件。 */ +public record MerchantInboundOrderQuery( + @Size(max = 64) String keyword, + @Size(max = 64) String orderNo, + Long receivingWarehouseId, + @Size(max = 128) String channelName, + @Size(max = 128) String customerContact, + Long customerId, + OrderStatus status, + OrderStatus operationStatus, + @Size(max = 32) String destinationType, + Long destinationWarehouseId, + LocalDateTime createdFrom, + LocalDateTime createdTo, + @Min(1) long page, + @Min(1) @Max(200) long size +) { + public MerchantInboundOrderQuery { + if (page == 0) page = 1; + if (size == 0) size = 20; + } +} diff --git a/order/src/main/java/com/baiyue/order/dto/MerchantInboundOrderRow.java b/order/src/main/java/com/baiyue/order/dto/MerchantInboundOrderRow.java new file mode 100644 index 000000000..cadfc3307 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/dto/MerchantInboundOrderRow.java @@ -0,0 +1,47 @@ +package com.baiyue.order.dto; + +import com.baiyue.order.entity.OrderStatus; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +/** 客户端入库订单列表行,聚合订单、货物、收货方和头程入库编码。 */ +public record MerchantInboundOrderRow( + Long orderId, + String orderNo, + Integer packageCount, + BigDecimal weightKg, + String inboundCode, + String channelName, + String destinationType, + String customerName, + String receivingCompanyName, + String specificationModel, + OrderStatus status, + Integer totalQuantity, + BigDecimal totalGrossWeightKg, + BigDecimal totalNetWeightKg, + BigDecimal totalVolumeCbm, + BigDecimal customsDeclaredUnitPrice, + BigDecimal customsDeclaredAmount, + String currency, + String material, + String productUsage, + String brand, + LocalDateTime createdAt, + String trackingNo, + String internationalOrderNo, + String referenceId, + Long operatorId, + String operatorName, + String remark, + List cargoes +) { + public record CargoColumn( + Long cargoId, Integer itemNo, String cargoName, Integer packageCount, + Integer totalQuantity, BigDecimal grossWeightKg, BigDecimal netWeightKg, + BigDecimal volumeCbm, BigDecimal unitDeclaredValue, BigDecimal declaredValue, + String currency, String model, String material, String productUsage, String brand) { + } +} diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index d420896bc..eaf95844b 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -2,6 +2,7 @@ package com.baiyue.order.repository; import com.baiyue.common.core.api.PageResult; import com.baiyue.order.dto.OrderRequests; +import com.baiyue.order.dto.MerchantInboundOrderQuery; import com.baiyue.order.entity.*; import com.baiyue.order.mapper.*; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; @@ -113,6 +114,62 @@ public class OrderRepository { return PageResult.from(orderMapper.selectPage(new Page<>(query.page(), query.size()), wrapper)); } + public PageResult merchantInboundPage(long tenantId, MerchantInboundOrderQuery query) { + com.baiyue.order.entity.OrderStatus effectiveStatus = query.operationStatus() == null + ? query.status() : query.operationStatus(); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(OrderEntity::getTenantId, tenantId) + .like(StringUtils.hasText(query.orderNo()), OrderEntity::getOrderNo, query.orderNo()) + .eq(query.receivingWarehouseId() != null, OrderEntity::getReceivingWarehouseId, + query.receivingWarehouseId()) + .eq(StringUtils.hasText(query.channelName()), OrderEntity::getChannelName, query.channelName()) + .eq(query.customerId() != null, OrderEntity::getCustomerId, query.customerId()) + .eq(effectiveStatus != null, OrderEntity::getStatus, effectiveStatus) + .eq(StringUtils.hasText(query.destinationType()), OrderEntity::getDestinationType, + query.destinationType()) + .eq(query.destinationWarehouseId() != null, OrderEntity::getDestinationWarehouseId, + query.destinationWarehouseId()) + .ge(query.createdFrom() != null, OrderEntity::getCreatedAt, query.createdFrom()) + .le(query.createdTo() != null, OrderEntity::getCreatedAt, query.createdTo()) + .and(StringUtils.hasText(query.keyword()), nested -> nested + .like(OrderEntity::getOrderNo, query.keyword()) + .or().like(OrderEntity::getCustomerName, query.keyword())) + .and(StringUtils.hasText(query.customerContact()), nested -> nested + .like(OrderEntity::getCustomerName, query.customerContact()) + .or().apply("EXISTS (SELECT 1 FROM ord_order_party p " + + "WHERE p.tenant_id = {0} AND p.order_id = ord_order.id AND p.deleted = 0 " + + "AND (p.contact_name LIKE CONCAT('%', {1}, '%') " + + "OR p.phone LIKE CONCAT('%', {1}, '%')))", + tenantId, query.customerContact())) + .orderByDesc(OrderEntity::getCreatedAt) + .orderByDesc(OrderEntity::getId); + return PageResult.from(orderMapper.selectPage(new Page<>(query.page(), query.size()), wrapper)); + } + + public List findCargoByOrderIds(long tenantId, List orderIds) { + if (orderIds.isEmpty()) return List.of(); + return cargoMapper.selectList(new LambdaQueryWrapper() + .eq(OrderCargoEntity::getTenantId, tenantId) + .in(OrderCargoEntity::getOrderId, orderIds) + .orderByAsc(OrderCargoEntity::getOrderId) + .orderByAsc(OrderCargoEntity::getItemNo)); + } + + public List findPartiesByOrderIds(long tenantId, List orderIds) { + if (orderIds.isEmpty()) return List.of(); + return partyMapper.selectList(new LambdaQueryWrapper() + .eq(OrderPartyEntity::getTenantId, tenantId) + .in(OrderPartyEntity::getOrderId, orderIds)); + } + + public List findHistoryByOrderIds(long tenantId, List orderIds) { + if (orderIds.isEmpty()) return List.of(); + return historyMapper.selectList(new LambdaQueryWrapper() + .eq(OrderStatusHistoryEntity::getTenantId, tenantId) + .in(OrderStatusHistoryEntity::getOrderId, orderIds) + .orderByDesc(OrderStatusHistoryEntity::getOccurredAt)); + } + public Map countByTenantIds(List tenantIds) { if (tenantIds.isEmpty()) return Map.of(); var rows = orderMapper.selectMaps(new QueryWrapper() diff --git a/order/src/main/java/com/baiyue/order/service/MerchantInboundOrderListService.java b/order/src/main/java/com/baiyue/order/service/MerchantInboundOrderListService.java new file mode 100644 index 000000000..11cc7c711 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/service/MerchantInboundOrderListService.java @@ -0,0 +1,136 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.client.FirstlegInboundClient; +import com.baiyue.order.dto.MerchantInboundOrderQuery; +import com.baiyue.order.dto.MerchantInboundOrderRow; +import com.baiyue.order.entity.OrderCargoEntity; +import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.entity.OrderPartyEntity; +import com.baiyue.order.entity.OrderStatusHistoryEntity; +import com.baiyue.order.repository.OrderRepository; +import feign.FeignException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +public class MerchantInboundOrderListService { + private final OrderRepository repository; + private final FirstlegInboundClient firstleg; + private final String internalToken; + + public MerchantInboundOrderListService( + OrderRepository repository, + FirstlegInboundClient firstleg, + @Value("${baiyue.security.internal-service-token}") String internalToken) { + this.repository = repository; + this.firstleg = firstleg; + this.internalToken = internalToken; + } + + public PageResult page(long tenantId, MerchantInboundOrderQuery query) { + PageResult source = repository.merchantInboundPage(tenantId, query); + List orderIds = source.records().stream().map(OrderEntity::getId).toList(); + if (orderIds.isEmpty()) { + return new PageResult<>(List.of(), source.page(), source.size(), source.total(), source.pages()); + } + + Map> cargoes = repository.findCargoByOrderIds(tenantId, orderIds) + .stream().collect(Collectors.groupingBy(OrderCargoEntity::getOrderId)); + Map consignees = repository.findPartiesByOrderIds(tenantId, orderIds) + .stream().filter(party -> "RECEIVER".equals(party.getPartyType())) + .collect(Collectors.toMap(OrderPartyEntity::getOrderId, Function.identity(), (left, right) -> left)); + Map latestHistory = repository.findHistoryByOrderIds(tenantId, orderIds) + .stream().collect(Collectors.toMap( + OrderStatusHistoryEntity::getOrderId, Function.identity(), (latest, ignored) -> latest)); + Map inboundReferences = references(tenantId, orderIds); + + List records = source.records().stream() + .map(order -> row(order, cargoes.getOrDefault(order.getId(), List.of()), + consignees.get(order.getId()), latestHistory.get(order.getId()), + inboundReferences.get(order.getId()))) + .toList(); + return new PageResult<>(records, source.page(), source.size(), source.total(), source.pages()); + } + + private Map references(long tenantId, List orderIds) { + try { + var response = firstleg.references(internalToken, + new FirstlegInboundClient.InboundReferenceRequest(tenantId, orderIds)); + if (response == null || !response.successful() || response.data() == null) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程入库引用查询响应无效"); + } + return response.data().stream().collect(Collectors.toMap( + FirstlegInboundClient.InboundReference::orderId, Function.identity(), (left, right) -> left)); + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程服务暂时不可用"); + } + } + + private MerchantInboundOrderRow row( + OrderEntity order, + List cargoes, + OrderPartyEntity consignee, + OrderStatusHistoryEntity latestHistory, + FirstlegInboundClient.InboundReference inbound) { + int packageCount = cargoes.stream().map(OrderCargoEntity::getPackageCount) + .filter(Objects::nonNull).mapToInt(Integer::intValue).sum(); + int totalQuantity = cargoes.stream().map(OrderCargoEntity::getTotalQuantity) + .filter(Objects::nonNull).mapToInt(Integer::intValue).sum(); + BigDecimal grossWeight = sum(cargoes, OrderCargoEntity::getGrossWeightKg); + BigDecimal netWeight = sum(cargoes, OrderCargoEntity::getNetWeightKg); + BigDecimal volume = sum(cargoes, OrderCargoEntity::getVolumeCbm); + BigDecimal declaredAmount = sum(cargoes, OrderCargoEntity::getDeclaredValue); + + List cargoColumns = cargoes.stream() + .map(cargo -> new MerchantInboundOrderRow.CargoColumn( + cargo.getId(), cargo.getItemNo(), cargo.getCargoName(), cargo.getPackageCount(), + cargo.getTotalQuantity(), cargo.getGrossWeightKg(), cargo.getNetWeightKg(), + cargo.getVolumeCbm(), cargo.getUnitDeclaredValue(), cargo.getDeclaredValue(), + cargo.getCurrency(), cargo.getModel(), cargo.getMaterial(), cargo.getProductUsage(), + cargo.getBrand())) + .toList(); + + return new MerchantInboundOrderRow( + order.getId(), order.getOrderNo(), packageCount, grossWeight, + inbound == null ? null : inbound.inboundCode(), order.getChannelName(), + order.getDestinationType(), order.getCustomerName(), + consignee == null ? null : consignee.getCompanyName(), joined(cargoes, OrderCargoEntity::getModel), + order.getStatus(), totalQuantity, grossWeight, netWeight, volume, + commonUnitPrice(cargoes), declaredAmount, joined(cargoes, OrderCargoEntity::getCurrency), + joined(cargoes, OrderCargoEntity::getMaterial), + joined(cargoes, OrderCargoEntity::getProductUsage), + joined(cargoes, OrderCargoEntity::getBrand), order.getCreatedAt(), + order.getExpressTrackingNo(), order.getShipmentId(), order.getReferenceId(), + latestHistory == null ? order.getCreatedBy() : latestHistory.getOperatorId(), + latestHistory == null ? null : latestHistory.getOperatorName(), order.getCustomerRemark(), + cargoColumns); + } + + private BigDecimal sum(List cargoes, + Function getter) { + return cargoes.stream().map(getter).filter(Objects::nonNull) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + private String joined(List cargoes, Function getter) { + String value = cargoes.stream().map(getter).filter(text -> text != null && !text.isBlank()) + .distinct().collect(Collectors.joining("、")); + return value.isEmpty() ? null : value; + } + + private BigDecimal commonUnitPrice(List cargoes) { + List values = cargoes.stream().map(OrderCargoEntity::getUnitDeclaredValue) + .filter(Objects::nonNull).distinct().toList(); + return values.size() == 1 ? values.get(0) : null; + } +} diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index 3284b34dd..5e068eef5 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -7,6 +7,30 @@ security: - bearerAuth: [] paths: /api/merchant/v1/orders/first-leg: + get: + summary: 客户端入库订单聚合列表 + description: 按当前登录租户分页返回订单、货物汇总、收货方快照及头程入仓编码,需要 merchant:order:list 权限。 + parameters: + - {name: keyword, in: query, required: false, schema: {type: string, maxLength: 64}} + - {name: orderNo, in: query, required: false, schema: {type: string, maxLength: 64}} + - {name: receivingWarehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: channelName, in: query, required: false, schema: {type: string, maxLength: 128}} + - {name: customerContact, in: query, required: false, description: 客户名称、联系人或手机号, schema: {type: string, maxLength: 128}} + - {name: customerId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: status, in: query, required: false, schema: {type: string}} + - {name: operationStatus, in: query, required: false, description: 操作状态,优先于兼容参数 status, schema: {type: string}} + - {name: destinationType, in: query, required: false, schema: {type: string, maxLength: 32}} + - {name: destinationWarehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: createdFrom, in: query, required: false, schema: {type: string, format: date-time}} + - {name: createdTo, in: query, required: false, schema: {type: string, format: date-time}} + - {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} + responses: + '200': + description: 成功或业务失败,响应体 code 为字符串 200 时成功 + content: + application/json: + schema: {type: object} post: summary: 创建订单草稿 parameters: diff --git a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java index 24939b033..5b6addeff 100644 --- a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java +++ b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java @@ -2,6 +2,7 @@ package com.baiyue.order.controller; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.order.dto.OrderRequests; +import com.baiyue.order.dto.MerchantInboundOrderQuery; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; @@ -16,7 +17,7 @@ class OrderControllerPermissionTest { @Test void merchantOrderListAndDetailRequireCorrespondingPermissions() throws Exception { assertAuthority(MerchantFirstLegOrderController.class, "page", "hasAuthority('merchant:order:list')", - AuthenticatedUser.class, OrderRequests.QueryRequest.class); + AuthenticatedUser.class, MerchantInboundOrderQuery.class); assertAuthority(MerchantFirstLegOrderController.class, "detail", "hasAuthority('merchant:order:view')", AuthenticatedUser.class, long.class); } diff --git a/order/src/test/java/com/baiyue/order/repository/MerchantInboundOrderQueryRepositoryTest.java b/order/src/test/java/com/baiyue/order/repository/MerchantInboundOrderQueryRepositoryTest.java new file mode 100644 index 000000000..eaa5a7f3e --- /dev/null +++ b/order/src/test/java/com/baiyue/order/repository/MerchantInboundOrderQueryRepositoryTest.java @@ -0,0 +1,55 @@ +package com.baiyue.order.repository; + +import com.baiyue.order.dto.MerchantInboundOrderQuery; +import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.entity.OrderStatus; +import com.baiyue.order.mapper.OrderCargoMapper; +import com.baiyue.order.mapper.OrderChildPurgeMapper; +import com.baiyue.order.mapper.OrderIdempotencyRecordMapper; +import com.baiyue.order.mapper.OrderInternalNoteMapper; +import com.baiyue.order.mapper.OrderMapper; +import com.baiyue.order.mapper.OrderPartyMapper; +import com.baiyue.order.mapper.OrderStatusHistoryMapper; +import com.baiyue.order.mapper.OutboxEventMapper; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class MerchantInboundOrderQueryRepositoryTest { + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void 查询始终带租户并覆盖页面筛选条件() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), "merchant-inbound-query-test"), + OrderEntity.class); + OrderMapper mapper = mock(OrderMapper.class); + when(mapper.selectPage(any(Page.class), any(Wrapper.class))).thenReturn(new Page<>()); + OrderRepository repository = new OrderRepository( + mapper, mock(OrderCargoMapper.class), mock(OrderPartyMapper.class), + mock(OrderInternalNoteMapper.class), mock(OrderStatusHistoryMapper.class), + mock(OutboxEventMapper.class), mock(OrderChildPurgeMapper.class), + mock(OrderIdempotencyRecordMapper.class)); + MerchantInboundOrderQuery query = new MerchantInboundOrderQuery( + "客户", "FL-", 11L, "美森", "13800000000", 30001L, null, OrderStatus.APPROVED, + "FBA", 22L, null, null, 1, 20); + + repository.merchantInboundPage(10001L, query); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(mapper).selectPage(any(Page.class), captor.capture()); + String sql = captor.getValue().getSqlSegment(); + assertThat(sql).contains("tenant_id", "order_no", "receiving_warehouse_id", "channel_name", + "customer_id", "customer_name", "contact_name", "phone", "status", + "destination_type", "destination_warehouse_id"); + } +} diff --git a/order/src/test/java/com/baiyue/order/service/MerchantInboundOrderListServiceTest.java b/order/src/test/java/com/baiyue/order/service/MerchantInboundOrderListServiceTest.java new file mode 100644 index 000000000..941e2506f --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/MerchantInboundOrderListServiceTest.java @@ -0,0 +1,97 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.order.client.FirstlegInboundClient; +import com.baiyue.order.dto.MerchantInboundOrderQuery; +import com.baiyue.order.entity.OrderCargoEntity; +import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.entity.OrderPartyEntity; +import com.baiyue.order.entity.OrderStatus; +import com.baiyue.order.entity.OrderStatusHistoryEntity; +import com.baiyue.order.repository.OrderRepository; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class MerchantInboundOrderListServiceTest { + private final OrderRepository repository = mock(OrderRepository.class); + private final FirstlegInboundClient firstleg = mock(FirstlegInboundClient.class); + private final MerchantInboundOrderListService service = + new MerchantInboundOrderListService(repository, firstleg, "internal-token"); + + @Test + void 聚合客户端列表所需的订单货物收货方和入仓编码() { + MerchantInboundOrderQuery query = new MerchantInboundOrderQuery( + null, null, null, null, null, null, null, null, null, null, null, null, 1, 20); + OrderEntity order = new OrderEntity(); + order.setId(77L); + order.setOrderNo("FL-77"); + order.setStatus(OrderStatus.APPROVED); + order.setChannelName("美森"); + order.setDestinationType("FBA"); + order.setCustomerName("测试客户"); + order.setShipmentId("FBA-SHIPMENT-77"); + order.setExpressTrackingNo("TRACK-77"); + order.setCustomerRemark("客户备注"); + when(repository.merchantInboundPage(10001L, query)) + .thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1)); + + OrderCargoEntity first = cargo(1L, 1, 2, 20, "10.000", "9.000", "0.1200", "10.00", "20.00", "A"); + OrderCargoEntity second = cargo(2L, 2, 3, 30, "15.000", "14.000", "0.1800", "10.00", "30.00", "B"); + when(repository.findCargoByOrderIds(10001L, List.of(77L))).thenReturn(List.of(first, second)); + OrderPartyEntity consignee = new OrderPartyEntity(); + consignee.setOrderId(77L); + consignee.setPartyType("RECEIVER"); + consignee.setCompanyName("收货公司"); + when(repository.findPartiesByOrderIds(10001L, List.of(77L))).thenReturn(List.of(consignee)); + OrderStatusHistoryEntity history = new OrderStatusHistoryEntity(); + history.setOrderId(77L); + history.setOperatorId(9L); + history.setOperatorName("操作员"); + when(repository.findHistoryByOrderIds(10001L, List.of(77L))).thenReturn(List.of(history)); + when(firstleg.references(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(List.of( + new FirstlegInboundClient.InboundReference(77L, 88L, "RK-77", "WAIT_MEASURE")))); + + var row = service.page(10001L, query).records().get(0); + + assertThat(row.inboundCode()).isEqualTo("RK-77"); + assertThat(row.packageCount()).isEqualTo(5); + assertThat(row.totalQuantity()).isEqualTo(50); + assertThat(row.totalGrossWeightKg()).isEqualByComparingTo("25.000"); + assertThat(row.totalNetWeightKg()).isEqualByComparingTo("23.000"); + assertThat(row.totalVolumeCbm()).isEqualByComparingTo("0.3000"); + assertThat(row.customsDeclaredAmount()).isEqualByComparingTo("50.00"); + assertThat(row.customsDeclaredUnitPrice()).isEqualByComparingTo("10.00"); + assertThat(row.specificationModel()).isEqualTo("A、B"); + assertThat(row.receivingCompanyName()).isEqualTo("收货公司"); + assertThat(row.operatorName()).isEqualTo("操作员"); + assertThat(row.cargoes()).hasSize(2); + verify(repository).merchantInboundPage(10001L, query); + } + + private static OrderCargoEntity cargo(Long id, int itemNo, int packages, int quantity, + String gross, String net, String volume, + String unitPrice, String amount, String model) { + OrderCargoEntity cargo = new OrderCargoEntity(); + cargo.setId(id); + cargo.setOrderId(77L); + cargo.setItemNo(itemNo); + cargo.setPackageCount(packages); + cargo.setTotalQuantity(quantity); + cargo.setGrossWeightKg(new BigDecimal(gross)); + cargo.setNetWeightKg(new BigDecimal(net)); + cargo.setVolumeCbm(new BigDecimal(volume)); + cargo.setUnitDeclaredValue(new BigDecimal(unitPrice)); + cargo.setDeclaredValue(new BigDecimal(amount)); + cargo.setModel(model); + return cargo; + } +}