Merge branch 'test' into test-02huogui

# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
#	firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java
#	firstleg/src/main/resources/application.yml
#	firstleg/src/main/resources/openapi/fba-pricing-v1.yaml
#	firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java
#	order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java
#	order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java
This commit is contained in:
2026-08-07 19:49:09 +08:00
192 changed files with 10729 additions and 391 deletions
@@ -32,7 +32,7 @@ public interface FirstlegInboundClient {
long tenantId, long orderId, String orderNo, String inboundCode, Long customerId, String customerName,
Long warehouseId, String warehouseName, Long deliveryWarehouseId, String deliveryWarehouseName,
LocalDateTime expectedArrivalAt, String businessType, String channelCode, String channelName,
Long carrierId, String carrierName, String taxMode, String containerMode,
Long carrierId, String carrierName, String taxMode, String pricingTaxMode, String containerMode,
Boolean customsInspection, String destinationType, Long destinationWarehouseId,
String destinationCode, String destinationName, String publicRemark,
String expressCompanyName, String expressTrackingNo
@@ -0,0 +1,17 @@
package com.baiyue.order.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
@FeignClient(name = "firstleg-service", contextId = "orderFirstlegPricingClient",
url = "${baiyue.clients.firstleg.url:}", path = "/internal/firstleg/v1/pricing")
public interface FirstlegPricingClient {
@PostMapping("/channels/validate")
ApiResponse<Void> validateChannel(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ChannelValidationRequest request);
record ChannelValidationRequest(long tenantId, long originWarehouseId, String channelCode) {}
}
@@ -0,0 +1,19 @@
package com.baiyue.order.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
@FeignClient(name = "file-service", contextId = "orderProductImageClient",
url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/product-images")
public interface ProductImageClient {
@GetMapping("/{tenantId}/{fileId}")
ApiResponse<FileMetadata> validate(
@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId,
@PathVariable long fileId);
record FileMetadata(Long fileId, String fileName, String contentType, Long sizeBytes) {}
}
@@ -17,6 +17,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.List;
/** 供头程履约服务校验入仓单所关联订单货物明细。 */
@RestController
@@ -51,6 +52,22 @@ public class InternalOrderCargoController {
cargo.getNetWeightKg(), cargo.getCargoRemark()));
}
@GetMapping("/{tenantId}/{orderId}")
public ApiResponse<List<CargoLabelBinding>> listForCartonLabels(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long tenantId,
@PathVariable long orderId) {
requireToken(token);
if (tenantId <= 0 || orderId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "租户和订单ID必须大于0");
}
return ApiResponse.success(orders.findCargo(tenantId, orderId).stream()
.map(cargo -> new CargoLabelBinding(cargo.getId(), cargo.getOrderId(), cargo.getItemNo(),
cargo.getCargoName(), cargo.getPackageCount(), cargo.getLengthCm(), cargo.getWidthCm(),
cargo.getHeightCm()))
.toList());
}
private void requireToken(String token) {
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
@@ -70,4 +87,9 @@ public class InternalOrderCargoController {
originalGrossWeightKg, originalVolumeCbm, null, null, null, null, null, null);
}
}
public record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer packageCount, java.math.BigDecimal lengthCm,
java.math.BigDecimal widthCm, java.math.BigDecimal heightCm) {
}
}
@@ -149,7 +149,7 @@ public class PlatformFirstLegOrderController {
}
@BusinessAudit(event = "ORDER_APPROVE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Approve order / 审核通过")
@Operation(summary = "Approve / 审核通过", description = "Approves a submitted order without requiring carrier assignment and records its status history. / 审核通过已提交订单,不要求预先指派承运商,并记录状态历史。")
@Operation(summary = "Approve / 审核通过", description = "Approves a submitted order, advances it to measurement, and records its status history. / 审核通过已提交订单,进入待测量阶段并记录状态历史。")
@PostMapping("/{id}/approve")
@PreAuthorize("hasAuthority('platform:order:approve')")
public ApiResponse<Void> approve(@AuthenticationPrincipal AuthenticatedUser user,
@@ -56,11 +56,13 @@ public final class OrderRequests {
@NotBlank @Size(max = 128) String receivingWarehouseName,
@NotBlank @Size(max = 64) String channelCode,
@NotBlank @Size(max = 128) String channelName,
@Schema(description = "Carrier ID snapshot; optional until the order channel resolves a carrier / 承运商 ID 快照,渠道明确承运商后可传")
@Schema(description = "Legacy internal transport supplier snapshot; new order clients should omit it / 历史内部运输供应商快照,新订单调用方应留空")
@Min(1) Long carrierId,
@Schema(description = "Carrier name snapshot; optional until the order channel resolves a carrier / 承运商名称快照,渠道明确承运商后可传", maxLength = 128)
@Schema(description = "Legacy internal transport supplier name snapshot; new order clients should omit it / 历史内部运输供应商名称快照,新订单调用方应留空", maxLength = 128)
@Size(max = 128) String carrierName,
@NotBlank @Pattern(regexp = "BUY_DECLARATION|TAX_REFUND") String taxMode,
@Schema(description = "Estimate pricing tax mode; independent from taxMode / 参考报价包税维度,独立于 taxMode", allowableValues = {"TAX_INCLUDED", "TAX_EXCLUDED"})
@NotBlank @Pattern(regexp = "TAX_INCLUDED|TAX_EXCLUDED") String pricingTaxMode,
@Pattern(regexp = "FULL_CONTAINER|LCL") String containerMode,
@NotNull Boolean customsInspection,
@NotBlank @Pattern(regexp = "FBA|OVERSEAS|PRIVATE") String destinationType,
@@ -74,13 +76,6 @@ public final class OrderRequests {
}
}
@Schema(name = "FirstLegOrderCarrierRequest", description = "Platform carrier assignment / 平台指派承运商")
public record CarrierRequest(
@Schema(description = "Active public carrier ID / 启用的公共承运商 ID", example = "193000000000300001", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull @Min(1) Long carrierId
) {
}
@Schema(name = "FirstLegOrderParty", description = "Sender or receiver contact and address / 发货方或收货方联系人及地址")
public record Party(
@Schema(description = "Company name / 公司名称", example = "Example Logistics LLC", maxLength = 128, requiredMode = Schema.RequiredMode.REQUIRED)
@@ -140,7 +135,13 @@ public final class OrderRequests {
@Size(max = 128) String material,
@Size(max = 128) String usage,
@Size(max = 64) String model,
@Size(max = 64) String brand
@Size(max = 64) String brand,
@DecimalMin("0.01") BigDecimal productLengthCm,
@DecimalMin("0.01") BigDecimal productWidthCm,
@DecimalMin("0.01") BigDecimal productHeightCm,
@Size(max = 64) String shipmentId,
@Size(max = 64) String referenceId,
@Schema(description = "Product image file ID / 产品图片文件ID") Long productImageFileId
) {
}
@@ -21,6 +21,7 @@ public final class UnifiedOrderResponse {
Integer totalPackageCount, BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm,
MeasurementSummary measurement, PreloadSummary preload,
LoadingSummary loading, DeliverySummary delivery, TransportSummary transport,
String customerRemark, String latestInternalNote,
LocalDateTime createdAt, LocalDateTime updatedAt
) {}
@@ -12,7 +12,7 @@ import com.baiyue.common.core.model.BaseEntity; import com.baomidou.mybatisplus.
* 申报价值及危险品标识等货物信息。</p>
*/
@TableName("ord_order_cargo") public class OrderCargoEntity extends BaseEntity {
@TableId(type=IdType.ASSIGN_ID) private Long id; private Long tenantId; private Long orderId; private Integer itemNo; private String cargoName; private String hsCode; private String packageType; private Integer packageCount; private BigDecimal grossWeightKg; private BigDecimal volumeCbm; private BigDecimal lengthCm; private BigDecimal widthCm; private BigDecimal heightCm; private BigDecimal declaredValue; private String currency; private Boolean dangerousGoods; private String cargoRemark; private String skuNo; private String upcNo; private String cargoNameEn; private String customsHsCode; private Integer unitsPerPackage; private Integer totalQuantity; private BigDecimal netWeightKg; private BigDecimal unitDeclaredValue; private String material; private String productUsage; private String model; private String brand;
@TableId(type=IdType.ASSIGN_ID) private Long id; private Long tenantId; private Long orderId; private Integer itemNo; private String cargoName; private String hsCode; private String packageType; private Integer packageCount; private BigDecimal grossWeightKg; private BigDecimal volumeCbm; private BigDecimal lengthCm; private BigDecimal widthCm; private BigDecimal heightCm; private BigDecimal declaredValue; private String currency; private Boolean dangerousGoods; private String cargoRemark; private String skuNo; private String upcNo; private String cargoNameEn; private String customsHsCode; private Integer unitsPerPackage; private Integer totalQuantity; private BigDecimal netWeightKg; private BigDecimal unitDeclaredValue; private String material; private String productUsage; private String model; private String brand; private BigDecimal productLengthCm; private BigDecimal productWidthCm; private BigDecimal productHeightCm; private String shipmentId; private String referenceId; private Long productImageFileId;
}
@@ -42,6 +42,7 @@ public class OrderEntity extends BaseEntity {
private Long carrierId;
private String carrierName;
private String taxMode;
private String pricingTaxMode;
private String containerMode;
private Boolean customsInspection;
private String destinationType;
@@ -219,6 +219,23 @@ public class OrderRepository {
.orderByDesc(OrderStatusHistoryEntity::getOccurredAt));
}
public List<OrderEntity> findByIds(Long tenantId, List<Long> orderIds) {
if (orderIds.isEmpty()) return List.of();
return orderMapper.selectList(new LambdaQueryWrapper<OrderEntity>()
.eq(tenantId != null, OrderEntity::getTenantId, tenantId)
.in(OrderEntity::getId, orderIds));
}
public List<OrderInternalNoteEntity> findNotesByOrderIds(Long tenantId, List<Long> orderIds) {
if (orderIds.isEmpty()) return List.of();
return noteMapper.selectList(new LambdaQueryWrapper<OrderInternalNoteEntity>()
.eq(tenantId != null, OrderInternalNoteEntity::getTenantId, tenantId)
.in(OrderInternalNoteEntity::getOrderId, orderIds)
.orderByAsc(OrderInternalNoteEntity::getOrderId)
.orderByDesc(OrderInternalNoteEntity::getCreatedAt)
.orderByDesc(OrderInternalNoteEntity::getId));
}
public Map<Long, Long> countByTenantIds(List<Long> tenantIds) {
if (tenantIds.isEmpty()) return Map.of();
var rows = orderMapper.selectMaps(new QueryWrapper<OrderEntity>()
@@ -22,6 +22,7 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Collectors;
@@ -31,6 +32,8 @@ public class OrderApplicationService {
@Resource private ObjectMapper json;
@Resource private OrderEligibilityGuard eligibilityGuard;
@Resource private OrderMasterDataGuard masterDataGuard;
@Resource private OrderPricingChannelGuard pricingChannelGuard;
@Resource private ProductImageGuard productImageGuard;
/** 创建草稿时使用数据库唯一键保证请求幂等。 */
@Transactional
@@ -47,7 +50,9 @@ public class OrderApplicationService {
private Long createDraftInternal(long tenantId, long userId, OrderRequests.SaveRequest request) {
validatePrototypeFields(request);
productImageGuard.requireOwned(tenantId, request.cargoes());
var snapshots = masterDataGuard.validate(tenantId, request);
pricingChannelGuard.requireAvailable(tenantId, request.receivingWarehouseId(), request.channelCode());
OrderEntity order = new OrderEntity();
long orderId = IdWorker.getId();
order.setId(orderId); order.setTenantId(tenantId); order.setOrderNo(nextNo(orderId)); order.setStatus(OrderStatus.DRAFT);
@@ -73,29 +78,18 @@ public class OrderApplicationService {
updateOrderData(tenantId, userId, id, request, true);
}
@Transactional
public void assignCarrier(long tenantId, long userId, long id, OrderRequests.CarrierRequest request) {
OrderEntity order = require(tenantId, id);
if (order.getStatus() != OrderStatus.SUBMITTED) {
throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "仅待审核订单可以指派承运商");
}
var carrier = masterDataGuard.validateCarrier(request.carrierId());
order.setCarrierId(carrier.id());
order.setCarrierName(carrier.partnerName());
order.setUpdatedBy(userId);
if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update");
}
private void updateOrderData(long tenantId, long userId, long id, OrderRequests.SaveRequest request,
boolean allowSubmitted) {
OrderEntity order = require(tenantId, id);
validatePrototypeFields(request);
productImageGuard.requireOwned(tenantId, request.cargoes());
var snapshots = masterDataGuard.validate(tenantId, request);
OrderStatus before = order.getStatus();
if (before != OrderStatus.DRAFT && before != OrderStatus.REJECTED
&& !(allowSubmitted && before == OrderStatus.SUBMITTED)) {
throw new BizException("当前订单状态不允许编辑订单资料");
}
pricingChannelGuard.requireAvailable(tenantId, request.receivingWarehouseId(), request.channelCode());
apply(order, request, snapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId);
if (before == OrderStatus.REJECTED) order.setInboundCode(null);
if (before == OrderStatus.SUBMITTED) {
@@ -118,10 +112,7 @@ public class OrderApplicationService {
validatePersistedPrototypeFields(tenantId, order);
masterDataGuard.validatePersisted(tenantId, order.getCustomerId(), order.getReceivingWarehouseId(),
order.getDestinationWarehouseId(), order.getDestinationType());
}
if (target == OrderStatus.APPROVED && order.getCarrierId() != null) {
var carrier = masterDataGuard.validateCarrier(order.getCarrierId());
order.setCarrierName(carrier.partnerName());
pricingChannelGuard.requireAvailable(tenantId, order.getReceivingWarehouseId(), order.getChannelCode());
}
if ((target == OrderStatus.REJECTED || target == OrderStatus.CANCELLED) && !StringUtils.hasText(reason)) {
throw new BizException("Reason is required");
@@ -201,14 +192,19 @@ public class OrderApplicationService {
throw new BizException("FBA orders require Amazon warehouse code, Shipment ID and Reference ID");
}
for (var cargo : request.cargoes()) {
if (!StringUtils.hasText(cargo.skuNo()) || !StringUtils.hasText(cargo.upcNo())) {
throw new BizException("FBA cargo requires SKU NO. and UPC NO.");
if (!StringUtils.hasText(cargo.skuNo()) || !StringUtils.hasText(cargo.upcNo())
|| !StringUtils.hasText(cargo.shipmentId()) || !StringUtils.hasText(cargo.referenceId())) {
throw new BizException("FBA cargo requires SKU NO., UPC NO., Shipment ID and Reference ID.");
}
}
}
}
private void validatePersistedPrototypeFields(long tenantId, OrderEntity order) {
if (!"TAX_INCLUDED".equals(order.getPricingTaxMode())
&& !"TAX_EXCLUDED".equals(order.getPricingTaxMode())) {
throw new BizException("请选择包税或不包税报价");
}
if (!StringUtils.hasText(order.getPickupMethod()) || order.getReceivingWarehouseId() == null
|| !StringUtils.hasText(order.getReceivingWarehouseName())
|| !StringUtils.hasText(order.getChannelCode()) || !StringUtils.hasText(order.getChannelName())
@@ -231,8 +227,10 @@ public class OrderApplicationService {
throw new BizException("FBA orders require Amazon warehouse code, Shipment ID and Reference ID");
}
for (var cargo : cargoes) {
if (!StringUtils.hasText(cargo.getSkuNo()) || !StringUtils.hasText(cargo.getUpcNo())) {
throw new BizException("FBA cargo requires SKU NO. and UPC NO.");
if (!StringUtils.hasText(cargo.getSkuNo()) || !StringUtils.hasText(cargo.getUpcNo())
|| !StringUtils.hasText(cargo.getShipmentId())
|| !StringUtils.hasText(cargo.getReferenceId())) {
throw new BizException("FBA cargo requires SKU NO., UPC NO., Shipment ID and Reference ID.");
}
}
}
@@ -347,12 +345,15 @@ public class OrderApplicationService {
order.setReceivingWarehouseName(snapshots.receiving().warehouse().warehouseName());
order.setReceivingWarehouseAddress(formatAddress(snapshots.receiving()));
order.setReceivingTime(formatReceivingTime(snapshots.receiving()));
order.setChannelCode(request.channelCode());
// The price-book catalog is case-insensitive at the boundary, but orders
// keep one canonical code so later estimation and audit lookups are stable.
order.setChannelCode(request.channelCode().trim().toUpperCase(Locale.ROOT));
order.setChannelName(request.channelName());
order.setCarrierId(request.carrierId());
order.setCarrierName(request.carrierId() == null || !StringUtils.hasText(request.carrierName())
? null : request.carrierName().trim());
order.setTaxMode(request.taxMode());
order.setPricingTaxMode(request.pricingTaxMode());
order.setContainerMode(request.containerMode()); order.setCustomsInspection(request.customsInspection());
order.setDestinationType(request.destinationType());
order.setAmazonWarehouseCode("FBA".equals(request.destinationType()) && snapshots.destination() != null
@@ -389,6 +390,10 @@ public class OrderApplicationService {
item.setTotalQuantity(cargo.totalQuantity()); item.setNetWeightKg(cargo.netWeightKg());
item.setUnitDeclaredValue(cargo.unitDeclaredValue()); item.setMaterial(cargo.material());
item.setProductUsage(cargo.usage()); item.setModel(cargo.model()); item.setBrand(cargo.brand());
item.setProductLengthCm(cargo.productLengthCm()); item.setProductWidthCm(cargo.productWidthCm());
item.setProductHeightCm(cargo.productHeightCm()); item.setShipmentId(cargo.shipmentId());
item.setReferenceId(cargo.referenceId());
item.setProductImageFileId(cargo.productImageFileId());
item.setCreatedBy(userId); item.setUpdatedBy(userId); repository.insertCargo(item);
}
if (request.sender() != null) saveParty(tenantId, userId, orderId, "SENDER", request.sender());
@@ -422,7 +427,7 @@ public class OrderApplicationService {
event.setPublishStatus("PENDING"); event.setRetryCount(0); event.setCreatedAt(LocalDateTime.now());
repository.insertOutbox(event);
}
private FirstlegInboundClient.OrderApprovedInboundCommand inboundCommand(OrderEntity order) {
static FirstlegInboundClient.OrderApprovedInboundCommand inboundCommand(OrderEntity order) {
return new FirstlegInboundClient.OrderApprovedInboundCommand(
order.getTenantId(), order.getId(), order.getOrderNo(), order.getInboundCode(),
order.getCustomerId(), order.getCustomerName(),
@@ -430,7 +435,7 @@ public class OrderApplicationService {
order.getDestinationWarehouseName(), order.getExpectedShipDate() == null ? null : order.getExpectedShipDate().atStartOfDay(),
order.getDestinationType() + "_" + order.getTransportMode(), order.getChannelCode(), order.getChannelName(),
order.getCarrierId(), order.getCarrierName(),
"BUY_DECLARATION".equals(order.getTaxMode()) ? "TAX_INCLUDED" : "TAX_EXCLUDED",
order.getTaxMode(), order.getPricingTaxMode(),
order.getContainerMode(), order.getCustomsInspection(), order.getDestinationType(),
order.getDestinationWarehouseId(), order.getAmazonWarehouseCode(), order.getDestinationWarehouseName(),
order.getCustomerRemark(), order.getExpressCompany(), order.getExpressTrackingNo());
@@ -39,12 +39,28 @@ public class OrderInboundOutboxPublisher {
failPermanently(event, "invalid event payload");
continue;
}
if (command == null) {
failPermanently(event, "empty event payload");
continue;
}
if (event.getTenantId() == null || event.getAggregateId() == null
|| event.getTenantId() != command.tenantId()
|| event.getAggregateId() != command.orderId()) {
failPermanently(event, "event ownership does not match payload");
continue;
}
if (incomplete(command)) {
var order = repository.findOwned(event.getTenantId(), event.getAggregateId());
if (order == null) {
failPermanently(event, "order snapshot is unavailable");
continue;
}
command = OrderApplicationService.inboundCommand(order);
if (incomplete(command)) {
failPermanently(event, "rebuilt order snapshot is incomplete");
continue;
}
}
try {
ApiResponse<Long> response = client.create(internalToken, command);
if (response == null || !response.successful()) {
@@ -57,6 +73,13 @@ public class OrderInboundOutboxPublisher {
}
}
private boolean incomplete(FirstlegInboundClient.OrderApprovedInboundCommand command) {
return command.orderNo() == null || command.orderNo().isBlank()
|| command.inboundCode() == null || command.inboundCode().isBlank()
|| command.customerName() == null || command.customerName().isBlank()
|| command.warehouseId() == null || command.warehouseId() <= 0;
}
private void failPermanently(OutboxEventEntity event, String reason) {
log.warn("Order outbox event rejected: reason={}, eventId={}, tenantId={}, aggregateId={}, eventType={}",
reason, event.getId(), event.getTenantId(), event.getAggregateId(), event.getEventType());
@@ -0,0 +1,42 @@
package com.baiyue.order.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.FirstlegPricingClient;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class OrderPricingChannelGuard {
@Resource private FirstlegPricingClient client;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
public void requireAvailable(long tenantId, long originWarehouseId, String channelCode) {
try {
ApiResponse<Void> response = client.validateChannel(internalToken,
new FirstlegPricingClient.ChannelValidationRequest(tenantId, originWarehouseId, channelCode));
if (response == null) {
throw unavailable();
}
if (!response.successful()) {
ResultCode code = ResultCode.of(response.code());
String message = response.message() == null || response.message().isBlank()
? code.message() : response.message();
throw new BizException(code, message);
}
} catch (BizException exception) {
throw exception;
} catch (FeignException exception) {
throw unavailable();
} catch (RuntimeException exception) {
throw unavailable();
}
}
private BizException unavailable() {
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程价格渠道校验服务暂时不可用");
}
}
@@ -0,0 +1,58 @@
package com.baiyue.order.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.ProductImageClient;
import com.baiyue.order.dto.OrderRequests;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Service
public class ProductImageGuard {
@Resource private ProductImageClient client;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
public void requireOwned(long tenantId, List<OrderRequests.Cargo> cargoes) {
Set<Long> validated = new HashSet<>();
for (OrderRequests.Cargo cargo : cargoes) {
Long fileId = cargo.productImageFileId();
if (fileId == null || !validated.add(fileId)) continue;
if (fileId <= 0) throw new BizException(ResultCode.VALIDATION_FAILED, "产品图片文件ID无效");
validate(tenantId, fileId);
}
}
private void validate(long tenantId, long fileId) {
try {
ApiResponse<ProductImageClient.FileMetadata> response = client.validate(internalToken, tenantId, fileId);
if (response == null) throw unavailable();
if (!response.successful()) {
ResultCode code = ResultCode.of(response.code());
String message = response.message() == null || response.message().isBlank()
? code.message() : response.message();
throw new BizException(code, message);
}
if (response.data() == null || response.data().fileId() == null
|| response.data().fileId() != fileId) {
throw unavailable();
}
} catch (BizException exception) {
throw exception;
} catch (FeignException exception) {
throw unavailable();
} catch (RuntimeException exception) {
throw unavailable();
}
}
private static BizException unavailable() {
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "产品图片校验服务暂时不可用");
}
}
@@ -9,6 +9,7 @@ import com.baiyue.order.dto.OrderDetailResponse;
import com.baiyue.order.dto.UnifiedOrderQuery;
import com.baiyue.order.dto.UnifiedOrderResponse;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderInternalNoteEntity;
import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.repository.OrderRepository;
import feign.FeignException;
@@ -54,8 +55,9 @@ public class UnifiedOrderQueryService {
Map<Long, FirstlegUnifiedOrderClient.Summary> inboundByOrder = "PENDING_REVIEW".equals(query.mainStatus())
? Map.of()
: summaries(tenantId, source.records().stream().map(OrderEntity::getId).toList());
Map<Long, String> latestNotes = latestNotes(tenantId, source.records().stream().map(OrderEntity::getId).toList());
List<UnifiedOrderResponse.Row> rows = source.records().stream()
.map(order -> row(order, inboundByOrder.get(order.getId()))).toList();
.map(order -> row(order, inboundByOrder.get(order.getId()), latestNotes.get(order.getId()))).toList();
return new PageResult<>(rows, source.page(), source.size(), source.total(), source.pages());
}
@@ -67,7 +69,8 @@ public class UnifiedOrderQueryService {
orders.findHistory(tenantId, orderId));
FirstlegUnifiedOrderClient.Detail inbound = order.getStatus() == OrderStatus.SUBMITTED
? null : firstlegDetail(tenantId, orderId);
UnifiedOrderResponse.Row summary = row(order, inbound == null ? null : inbound.summary());
String latestNote = orderDetail.internalNotes().isEmpty() ? null : orderDetail.internalNotes().get(0).getContent();
UnifiedOrderResponse.Row summary = row(order, inbound == null ? null : inbound.summary(), latestNote);
List<UnifiedOrderResponse.TimelineEvent> timeline = new ArrayList<>();
orderDetail.statusHistory().forEach(item -> timeline.add(new UnifiedOrderResponse.TimelineEvent(
"ORDER", "STATUS", orderMainStatus(item.getToStatus()), item.getAction(), item.getReason(),
@@ -97,7 +100,13 @@ public class UnifiedOrderQueryService {
query.page(), query.size());
PageResult<FirstlegUnifiedOrderClient.Summary> source = require(
call(() -> firstleg.page(internalToken, request)), "统一订单履约查询响应无效");
List<UnifiedOrderResponse.Row> records = source.records().stream().map(this::row).toList();
List<Long> orderIds = source.records().stream().map(item -> item.inbound().orderId()).toList();
Map<Long, OrderEntity> orderById = orders.findByIds(tenantId, orderIds).stream()
.collect(Collectors.toMap(OrderEntity::getId, Function.identity(), (left, right) -> left));
Map<Long, String> latestNotes = latestNotes(tenantId, orderIds);
List<UnifiedOrderResponse.Row> records = source.records().stream()
.map(summary -> row(summary, orderById.get(summary.inbound().orderId()), latestNotes.get(summary.inbound().orderId())))
.toList();
return new PageResult<>(records, source.page(), source.size(), source.total(), source.pages());
}
@@ -116,7 +125,12 @@ public class UnifiedOrderQueryService {
new FirstlegUnifiedOrderClient.DetailRequest(tenantId, orderId))), "统一订单履约详情响应无效");
}
private UnifiedOrderResponse.Row row(OrderEntity order, FirstlegUnifiedOrderClient.Summary summary) {
private Map<Long, String> latestNotes(Long tenantId, List<Long> orderIds) {
return orders.findNotesByOrderIds(tenantId, orderIds).stream().collect(Collectors.toMap(
OrderInternalNoteEntity::getOrderId, OrderInternalNoteEntity::getContent, (latest, ignored) -> latest));
}
private UnifiedOrderResponse.Row row(OrderEntity order, FirstlegUnifiedOrderClient.Summary summary, String latestInternalNote) {
if (summary == null) {
String mainStatus = orderMainStatus(order.getStatus());
return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(), mainStatus,
@@ -126,6 +140,7 @@ public class UnifiedOrderQueryService {
order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null,
order.getInboundCode(),
order.getExpectedShipDate(), null, null, null, null, null, null, null, null,
order.getCustomerRemark(), latestInternalNote,
order.getCreatedAt(), order.getUpdatedAt());
}
FirstlegUnifiedOrderClient.Inbound inbound = summary.inbound();
@@ -135,10 +150,12 @@ public class UnifiedOrderQueryService {
order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), inbound.id(), inbound.version(), inbound.inboundCode(),
order.getExpectedShipDate(), inbound.totalPackageCount(), inbound.actualGrossWeightKg(),
inbound.actualVolumeCbm(), measurement(inbound), preload(summary.preload()), loading(summary.loading()),
delivery(summary.delivery()), transport(inbound), order.getCreatedAt(), order.getUpdatedAt());
delivery(summary.delivery()), transport(inbound), order.getCustomerRemark(), latestInternalNote,
order.getCreatedAt(), order.getUpdatedAt());
}
private UnifiedOrderResponse.Row row(FirstlegUnifiedOrderClient.Summary summary) {
private UnifiedOrderResponse.Row row(FirstlegUnifiedOrderClient.Summary summary, OrderEntity order,
String latestInternalNote) {
var inbound = summary.inbound();
return new UnifiedOrderResponse.Row(inbound.orderId(), inbound.tenantId(), inbound.orderNo(),
summary.mainStatus(), summary.nextAction(), parseOrderStatus(inbound.orderStatusSnapshot()),
@@ -146,7 +163,8 @@ public class UnifiedOrderQueryService {
inbound.warehouseName(), inbound.deliveryWarehouseId(), inbound.deliveryWarehouseName(),
inbound.id(), inbound.version(), inbound.inboundCode(), null, inbound.totalPackageCount(), inbound.actualGrossWeightKg(),
inbound.actualVolumeCbm(), measurement(inbound), preload(summary.preload()), loading(summary.loading()),
delivery(summary.delivery()), transport(inbound), inbound.createdAt(), inbound.updatedAt());
delivery(summary.delivery()), transport(inbound), order == null ? null : order.getCustomerRemark(), latestInternalNote,
inbound.createdAt(), inbound.updatedAt());
}
private UnifiedOrderResponse.MeasurementSummary measurement(FirstlegUnifiedOrderClient.Inbound inbound) {
+2
View File
@@ -33,6 +33,8 @@ baiyue:
url: ${IAM_SERVICE_URL:}
warehouse:
url: ${WAREHOUSE_SERVICE_URL:}
file:
url: ${FILE_SERVICE_URL:}
logging:
file:
+7
View File
@@ -30,6 +30,7 @@ CREATE TABLE IF NOT EXISTS ord_order (
carrier_id BIGINT UNSIGNED NULL COMMENT '船司逻辑ID快照',
carrier_name VARCHAR(128) NULL COMMENT '船司名称快照',
tax_mode VARCHAR(24) NULL COMMENT '买单或退税',
pricing_tax_mode VARCHAR(16) NULL COMMENT '报价包税维度:TAX_INCLUDED或TAX_EXCLUDED;独立于tax_mode',
container_mode VARCHAR(24) NULL COMMENT '整柜或散货',
customs_inspection TINYINT(1) NULL COMMENT '是否商检',
destination_type VARCHAR(24) NULL COMMENT 'FBA/OVERSEAS/PRIVATE',
@@ -110,6 +111,12 @@ CREATE TABLE IF NOT EXISTS ord_order_cargo (
product_usage VARCHAR(128) NULL COMMENT '用途',
model VARCHAR(64) NULL COMMENT '型号',
brand VARCHAR(64) NULL COMMENT '品牌',
product_length_cm DECIMAL(12,2) NULL COMMENT '产品长度(厘米)',
product_width_cm DECIMAL(12,2) NULL COMMENT '产品宽度(厘米)',
product_height_cm DECIMAL(12,2) NULL COMMENT '产品高度(厘米)',
shipment_id VARCHAR(64) NULL COMMENT '货物级 Amazon Shipment ID',
reference_id VARCHAR(64) NULL COMMENT '货物级 Amazon Reference ID',
product_image_file_id BIGINT UNSIGNED NULL COMMENT '文件中心产品图片逻辑ID',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
@@ -54,6 +54,21 @@ CALL phase1_add_column('ord_order_cargo', 'material', "VARCHAR(128) NULL COMMENT
CALL phase1_add_column('ord_order_cargo', 'product_usage', "VARCHAR(128) NULL COMMENT 'Usage'");
CALL phase1_add_column('ord_order_cargo', 'model', "VARCHAR(64) NULL COMMENT 'Model'");
CALL phase1_add_column('ord_order_cargo', 'brand', "VARCHAR(64) NULL COMMENT 'Brand'");
CALL phase1_add_column('ord_order_cargo', 'product_length_cm', "DECIMAL(12,2) NULL COMMENT 'Product length in centimeters'");
CALL phase1_add_column('ord_order_cargo', 'product_width_cm', "DECIMAL(12,2) NULL COMMENT 'Product width in centimeters'");
CALL phase1_add_column('ord_order_cargo', 'product_height_cm', "DECIMAL(12,2) NULL COMMENT 'Product height in centimeters'");
CALL phase1_add_column('ord_order_cargo', 'shipment_id', "VARCHAR(64) NULL COMMENT 'Cargo Amazon Shipment ID'");
CALL phase1_add_column('ord_order_cargo', 'reference_id', "VARCHAR(64) NULL COMMENT 'Cargo Amazon Reference ID'");
CALL phase1_add_column('ord_order_cargo', 'product_image_file_id', "BIGINT UNSIGNED NULL COMMENT 'Product image logical file ID'");
-- 货物级字段上线前,FBA 编号只保存在主单;回填后提交校验可统一按每条货物执行。
UPDATE ord_order_cargo cargo
JOIN ord_order orders ON orders.id = cargo.order_id AND orders.tenant_id = cargo.tenant_id
SET cargo.shipment_id = COALESCE(NULLIF(cargo.shipment_id, ''), orders.shipment_id),
cargo.reference_id = COALESCE(NULLIF(cargo.reference_id, ''), orders.reference_id)
WHERE orders.destination_type = 'FBA'
AND ((cargo.shipment_id IS NULL OR cargo.shipment_id = '')
OR (cargo.reference_id IS NULL OR cargo.reference_id = ''));
DROP PROCEDURE phase1_add_column;
+74 -25
View File
@@ -2,7 +2,7 @@ openapi: 3.0.3
info:
title: 柏悦头程订单 API
description: 业务成功和失败均使用 HTTP 200;仅响应体 code 为字符串 "200" 时表示成功,其他 code 表示失败。
version: 1.4.0
version: 1.5.0
security:
- bearerAuth: []
paths:
@@ -16,6 +16,7 @@ paths:
才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。
tenantId 省略时跨全部客户;mainStatus 省略时按订单创建时间分页,指定 PENDING_REVIEW 时由 Order 查询,
指定其他主状态时由 Firstleg 精确分页。旧 /orders/first-leg 接口保持兼容。
列表行同时返回 customerRemark(客户下单备注)和 latestInternalNote(最新一条平台内部备注)。
parameters:
- {name: tenantId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- $ref: '#/components/parameters/UnifiedKeyword'
@@ -112,7 +113,11 @@ paths:
parameters:
- $ref: '#/components/parameters/UnifiedOrderId'
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'200':
description: 成功或业务失败,按响应体 code 判断
content:
application/json:
schema: {$ref: '#/components/schemas/FirstLegOrderSummary'}
'403': {description: 需要 merchant:order:view 权限}
/api/merchant/v1/orders/first-leg:
get:
@@ -138,7 +143,10 @@ paths:
description: 成功或业务失败,响应体 code 为字符串 200 时成功
content:
application/json:
schema: {type: object}
schema:
type: object
properties:
items: {type: array, items: {$ref: '#/components/schemas/FirstLegOrderSummary'}}
post:
summary: 创建订单草稿
parameters:
@@ -147,7 +155,7 @@ paths:
required: true
content:
application/json:
schema: {type: object}
schema: {$ref: '#/components/schemas/FirstLegOrderDraft'}
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
/api/merchant/v1/orders/first-leg/{id}/submit:
@@ -180,7 +188,7 @@ paths:
required: true
content:
application/json:
schema: {type: object}
schema: {$ref: '#/components/schemas/FirstLegOrderDraft'}
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 需要 platform:order:create 权限}
@@ -216,35 +224,76 @@ paths:
required: true
content:
application/json:
schema: {type: object}
schema: {$ref: '#/components/schemas/FirstLegOrderDraft'}
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 需要 platform:order:edit 权限}
/api/platform/v1/orders/first-leg/{id}/carrier:
put:
summary: 为待审核订单指派承运商
description: 可选的独立指派动作;服务端从公共往来单位主数据校验 CARRIER 类型并写入可信名称快照,不作为审核通过的前置条件。
parameters:
- $ref: '#/components/parameters/OrderId'
- $ref: '#/components/parameters/TenantId'
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [carrierId]
properties:
carrierId: {type: string, pattern: '^[1-9][0-9]*$'}
responses:
'200': {description: 指派成功或业务失败,按响应体 code 判断}
'403': {description: 需要 platform:order:approve 权限}
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
schemas:
PricingTaxMode:
type: string
enum: [TAX_INCLUDED, TAX_EXCLUDED]
description: 报价包税维度;独立于订单 taxMode 的买单/退税语义
FirstLegOrderDraft:
type: object
required: [pricingTaxMode, cargoes]
properties:
pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'}
cargoes:
type: array
minItems: 1
items: {$ref: '#/components/schemas/FirstLegOrderCargo'}
description: >-
订单保存请求中的完整字段以服务端 FirstLegOrderSaveRequest 为准;pricingTaxMode
是新建和编辑订单的必填报价维度;历史存量订单的响应字段仍允许为空。
FirstLegOrderCargo:
type: object
required: [cargoName, packageType, packageCount, grossWeightKg, volumeCbm]
properties:
skuNo: {type: string, maxLength: 64}
cargoName: {type: string, maxLength: 128}
cargoNameEn: {type: string, maxLength: 128}
productImageFileId: {type: string, pattern: '^[1-9][0-9]*$'}
hsCode: {type: string, maxLength: 20, description: 报关 HS CODE}
customsHsCode: {type: string, maxLength: 20, description: 清关 HS CODE}
lengthCm: {type: number, minimum: 0, description: 箱规长(厘米)}
widthCm: {type: number, minimum: 0, description: 箱规宽(厘米)}
heightCm: {type: number, minimum: 0, description: 箱规高(厘米)}
unitsPerPackage: {type: integer, minimum: 1, description: 每箱数量}
packageCount: {type: integer, minimum: 1, description: 箱数}
grossWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, description: 总毛重}
volumeCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: 总体积}
totalQuantity: {type: integer, minimum: 1, description: 总数量}
netWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, description: 总净重}
unitDeclaredValue: {type: number, minimum: 0, description: 清关申报单价(USD)}
declaredValue: {type: number, minimum: 0, description: 清关申报金额(USD)}
material: {type: string, maxLength: 128}
usage: {type: string, maxLength: 128}
model: {type: string, maxLength: 64}
brand: {type: string, maxLength: 64}
productLengthCm: {type: number, minimum: 0}
productWidthCm: {type: number, minimum: 0}
productHeightCm: {type: number, minimum: 0}
shipmentId: {type: string, maxLength: 64}
referenceId: {type: string, maxLength: 64}
upcNo: {type: string, maxLength: 64}
cargoRemark: {type: string, maxLength: 500}
packageType: {type: string, maxLength: 32}
dangerousGoods: {type: boolean}
description: >-
原型货物明细字段。FBA 货物的 skuNo、upcNo、shipmentId、referenceId 由服务端按每条货物校验;
productImageFileId 必须属于当前登录客户。
FirstLegOrderSummary:
type: object
properties:
orderId: {type: string, pattern: '^[1-9][0-9]*$'}
orderNo: {type: string}
pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'}
parameters:
UnifiedKeyword:
name: keyword
@@ -16,6 +16,9 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import java.util.List;
@SpringJUnitConfig(InternalOrderCargoController.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
class InternalOrderCargoControllerTest {
@@ -70,4 +73,34 @@ class InternalOrderCargoControllerTest {
verify(orders).findCargo(12L, 21L, 31L);
verifyNoMoreInteractions(orders);
}
@Test
void listsCargoInRepositoryOrderForStableCartonAllocation() {
OrderCargoEntity first = cargo(31L, 1, 3, "60", "40", "30");
OrderCargoEntity second = cargo(32L, 2, 2, "50", "30", "20");
when(orders.findCargo(11L, 21L)).thenReturn(List.of(first, second));
var response = controller.listForCartonLabels("internal-token", 11L, 21L);
assertThat(response.successful()).isTrue();
assertThat(response.data()).extracting(InternalOrderCargoController.CargoLabelBinding::cargoId)
.containsExactly(31L, 32L);
assertThat(response.data().get(0).packageCount()).isEqualTo(3);
assertThat(response.data().get(0).lengthCm()).isEqualByComparingTo("60");
verify(orders).findCargo(11L, 21L);
}
private OrderCargoEntity cargo(long id, int itemNo, int packageCount,
String length, String width, String height) {
OrderCargoEntity cargo = new OrderCargoEntity();
cargo.setId(id);
cargo.setOrderId(21L);
cargo.setItemNo(itemNo);
cargo.setCargoName("货物" + itemNo);
cargo.setPackageCount(packageCount);
cargo.setLengthCm(new BigDecimal(length));
cargo.setWidthCm(new BigDecimal(width));
cargo.setHeightCm(new BigDecimal(height));
return cargo;
}
}
@@ -14,28 +14,38 @@ class OrderRequestsValidationTest {
@Test
void carrierSnapshotIsOptionalWhenCustomerCreatesAnOrderDraft() {
assertThat(validator.validate(request(null, null))).isEmpty();
assertThat(validator.validate(request(null, null, "TAX_INCLUDED"))).isEmpty();
}
@Test
void carrierSnapshotMustBeBothPresentOrBothAbsent() {
assertThat(validator.validate(request(9L, null)))
assertThat(validator.validate(request(9L, null, "TAX_INCLUDED")))
.anyMatch(violation -> violation.getMessage().contains("必须同时填写或同时留空"));
assertThat(validator.validate(request(null, "船司")))
assertThat(validator.validate(request(null, "船司", "TAX_INCLUDED")))
.anyMatch(violation -> violation.getMessage().contains("必须同时填写或同时留空"));
assertThat(validator.validate(request(9L, "船司"))).isEmpty();
assertThat(validator.validate(request(9L, "船司", "TAX_INCLUDED"))).isEmpty();
}
private OrderRequests.SaveRequest request(Long carrierId, String carrierName) {
@Test
void pricingTaxModeIsRequiredAndRejectsOrderTaxValues() {
assertThat(validator.validate(request(null, null, "TAX_INCLUDED"))).isEmpty();
assertThat(validator.validate(request(null, null, null)))
.anyMatch(violation -> violation.getPropertyPath().toString().equals("pricingTaxMode"));
assertThat(validator.validate(request(null, null, "BUY_DECLARATION")))
.anyMatch(violation -> violation.getPropertyPath().toString().equals("pricingTaxMode"));
}
private OrderRequests.SaveRequest request(Long carrierId, String carrierName, String pricingTaxMode) {
var cargo = new OrderRequests.Cargo(
"货物", null, "CARTON", 1, BigDecimal.ONE, BigDecimal.ONE,
null, null, null, null, null, false, null, null, null, null,
null, null, null, null, null, null, null, null, null);
null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null);
var request = new OrderRequests.SaveRequest(
null, "当前企业", "SEA", "FOB", "US", 193000000000200003L, "洛杉矶仓",
null, null, null, null, List.of(cargo), "SELF_EXPRESS", 193000000000200001L,
"义乌仓", "SEA-US-GENERAL", "美线普货专线", carrierId, carrierName, "BUY_DECLARATION",
"FULL_CONTAINER", true, "OVERSEAS", null, null, "123456789");
pricingTaxMode, "FULL_CONTAINER", true, "OVERSEAS", null, null, "123456789");
return request;
}
}
@@ -34,6 +34,8 @@ class OrderApplicationServiceStateTest {
@MockBean private OrderRepository contextRepository;
@MockBean private OrderEligibilityGuard contextEligibility;
@MockBean private OrderMasterDataGuard contextMasterData;
@MockBean private OrderPricingChannelGuard contextPricingChannelGuard;
@MockBean private ProductImageGuard contextProductImageGuard;
@Resource private OrderApplicationService springService;
@Configuration(proxyBeanMethods = false)
@@ -58,6 +60,54 @@ class OrderApplicationServiceStateTest {
&& order.getInboundCode() == null));
}
@Test
void creatingDraftValidatesCurrentPriceBookChannelBeforePersistence() {
OrderRepository repository = mock(OrderRepository.class);
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
OrderPricingChannelGuard pricing = mock(OrderPricingChannelGuard.class);
OrderRequests.SaveRequest request = request();
when(masterData.validate(10001L, request)).thenReturn(snapshots());
when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1);
OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), masterData);
ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing);
service.createDraft(10001L, 2L, request, "validate-channel-on-create");
var ordered = inOrder(pricing, repository);
ordered.verify(pricing).requireAvailable(10001L, 8L, "CH");
ordered.verify(repository).insertOrder(argThat(order ->
"CH".equals(order.getChannelCode())
&& "TAX_INCLUDED".equals(order.getPricingTaxMode())));
}
@Test
void orderStoresCanonicalChannelCodeWithoutChangingPricingTaxMode() {
OrderRepository repository = mock(OrderRepository.class);
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
OrderPricingChannelGuard pricing = mock(OrderPricingChannelGuard.class);
OrderRequests.SaveRequest source = request();
OrderRequests.SaveRequest request = new OrderRequests.SaveRequest(
source.customerId(), source.customerName(), source.transportMode(), source.tradeTerm(),
source.destinationCountryCode(), source.destinationWarehouseId(), source.destinationWarehouseName(),
source.expectedShipDate(), source.customerRemark(), source.sender(), source.receiver(), source.cargoes(),
source.pickupMethod(), source.receivingWarehouseId(), source.receivingWarehouseName(),
" ch ", source.channelName(), source.carrierId(), source.carrierName(), source.taxMode(),
"TAX_EXCLUDED", source.containerMode(), source.customsInspection(), source.destinationType(),
source.amazonWarehouseCode(), source.shipmentId(), source.referenceId());
when(masterData.validate(10001L, request)).thenReturn(snapshots());
when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1);
OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), masterData);
ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing);
service.createDraft(10001L, 2L, request, "canonical-channel-code");
verify(pricing).requireAvailable(10001L, 8L, " ch ");
verify(repository).insertOrder(argThat(order ->
"CH".equals(order.getChannelCode())
&& "BUY_DECLARATION".equals(order.getTaxMode())
&& "TAX_EXCLUDED".equals(order.getPricingTaxMode())));
}
@Test
void unapprovedTenantCanUpdateDraft() {
OrderRepository repository = mock(OrderRepository.class);
@@ -79,6 +129,26 @@ class OrderApplicationServiceStateTest {
verify(repository).saveOrder(eq(10001L), argThat(saved -> saved.getStatus() == OrderStatus.DRAFT));
}
@Test
void updatingDraftValidatesCurrentPriceBookChannelBeforePersistence() {
OrderRepository repository = mock(OrderRepository.class);
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
OrderPricingChannelGuard pricing = mock(OrderPricingChannelGuard.class);
OrderRequests.SaveRequest request = request();
OrderEntity order = persistedOrder(OrderStatus.DRAFT);
when(repository.findOwned(10001L, 1L)).thenReturn(order);
when(masterData.validate(10001L, request)).thenReturn(snapshots());
when(repository.saveOrder(10001L, order)).thenReturn(1);
OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), masterData);
ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing);
service.updateDraft(10001L, 2L, 1L, request);
var ordered = inOrder(pricing, repository);
ordered.verify(pricing).requireAvailable(10001L, 8L, "CH");
ordered.verify(repository).saveOrder(10001L, order);
}
@Test
void platformCanEditSubmittedOrderWithoutRollingBackReviewStatus() {
OrderRepository repository = mock(OrderRepository.class);
@@ -102,24 +172,6 @@ class OrderApplicationServiceStateTest {
&& "UPDATE".equals(history.getAction())));
}
@Test
void platformAssignsTrustedCarrierSnapshotToSubmittedOrder() {
OrderRepository repository = mock(OrderRepository.class);
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
OrderEntity order = persistedOrder(OrderStatus.SUBMITTED);
when(repository.findOwned(10001L, 1L)).thenReturn(order);
when(repository.saveOrder(10001L, order)).thenReturn(1);
when(masterData.validateCarrier(9L)).thenReturn(
new PartnerClient.Partner(9L, 0L, "服务端船司名", "ACTIVE", "CARRIER", List.of("CARRIER")));
service(repository, mock(OrderEligibilityGuard.class), masterData)
.assignCarrier(10001L, 2L, 1L, new OrderRequests.CarrierRequest(9L));
assertThat(order.getCarrierId()).isEqualTo(9L);
assertThat(order.getCarrierName()).isEqualTo("服务端船司名");
verify(repository).saveOrder(10001L, order);
}
@Test
void approvalDoesNotRequireCarrierAssignmentAndPublishesInboundCode() {
OrderRepository repository = mock(OrderRepository.class);
@@ -137,26 +189,6 @@ class OrderApplicationServiceStateTest {
&& event.getPayloadJson().contains("\"carrierId\":null")));
}
@Test
void approvalRevalidatesCarrierAndRefreshesItsName() {
OrderRepository repository = mock(OrderRepository.class);
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
OrderEntity order = persistedOrder(OrderStatus.SUBMITTED);
order.setCarrierId(9L);
order.setCarrierName("旧名称");
when(repository.findOwned(10001L, 1L)).thenReturn(order);
when(repository.saveOrder(10001L, order)).thenReturn(1);
when(masterData.validateCarrier(9L)).thenReturn(
new PartnerClient.Partner(9L, 0L, "最新船司名", "ACTIVE", "CARRIER", List.of("CARRIER")));
service(repository, mock(OrderEligibilityGuard.class), masterData)
.transit(10001L, 2L, "u", 1L, OrderStatus.APPROVED, null);
assertThat(order.getStatus()).isEqualTo(OrderStatus.APPROVED);
assertThat(order.getCarrierName()).isEqualTo("最新船司名");
verify(masterData).validateCarrier(9L);
}
@Test
void submittingDraftGeneratesAndPersistsInboundCode() {
OrderRepository repository = mock(OrderRepository.class);
@@ -176,6 +208,57 @@ class OrderApplicationServiceStateTest {
verify(repository).saveOrder(20002L, order);
}
@Test
void submittingLegacyDraftRequiresPricingTaxMode() {
OrderRepository repository = mock(OrderRepository.class);
OrderEntity order = persistedDraft("SELF_EXPRESS", "OVERSEAS");
order.setPricingTaxMode(null);
when(repository.findOwned(20002L, 1L)).thenReturn(order);
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class),
mock(OrderMasterDataGuard.class)).transit(
20002L, 2L, "u", 1L, OrderStatus.SUBMITTED, null))
.isInstanceOf(BizException.class)
.hasMessage("请选择包税或不包税报价");
verify(repository, never()).saveOrder(anyLong(), any());
}
@Test
void priceBookVersionSwitchBlocksSubmissionWhenDraftChannelWasRemoved() {
OrderRepository repository = mock(OrderRepository.class);
OrderPricingChannelGuard pricing = mock(OrderPricingChannelGuard.class);
OrderEntity order = persistedDraft("SELF_EXPRESS", "OVERSEAS");
when(repository.findOwned(20002L, 1L)).thenReturn(order);
when(repository.findCargo(20002L, 1L)).thenReturn(List.of(new OrderCargoEntity()));
when(repository.findParties(20002L, 1L)).thenReturn(List.of());
doThrow(new BizException(ResultCode.CONFLICT, "所选渠道已下线,请重新选择"))
.when(pricing).requireAvailable(20002L, 8L, "CH");
OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class),
mock(OrderMasterDataGuard.class));
ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing);
assertThatThrownBy(() -> service.transit(20002L, 2L, "u", 1L, OrderStatus.SUBMITTED, null))
.isInstanceOf(BizException.class)
.hasMessage("所选渠道已下线,请重新选择");
assertThat(order.getStatus()).isEqualTo(OrderStatus.DRAFT);
verify(repository, never()).saveOrder(anyLong(), any());
}
@Test
void inboundCommandKeepsDeclarationAndPricingTaxModesIndependent() {
OrderEntity order = persistedDraft("SELF_EXPRESS", "OVERSEAS");
order.setOrderNo("FL1");
order.setTaxMode("BUY_DECLARATION");
order.setPricingTaxMode("TAX_EXCLUDED");
var command = OrderApplicationService.inboundCommand(order);
assertThat(command.taxMode()).isEqualTo("BUY_DECLARATION");
assertThat(command.pricingTaxMode()).isEqualTo("TAX_EXCLUDED");
}
@Test
void merchantDraftEditorStillCannotEditSubmittedOrder() {
OrderRepository repository = mock(OrderRepository.class);
@@ -315,6 +398,8 @@ class OrderApplicationServiceStateTest {
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "eligibilityGuard", eligibility);
ReflectionTestUtils.setField(service, "masterDataGuard", masterData);
ReflectionTestUtils.setField(service, "pricingChannelGuard", mock(OrderPricingChannelGuard.class));
ReflectionTestUtils.setField(service, "productImageGuard", mock(ProductImageGuard.class));
ReflectionTestUtils.setField(service, "json", new ObjectMapper());
return service;
}
@@ -325,11 +410,12 @@ class OrderApplicationServiceStateTest {
var cargo = new OrderRequests.Cargo(
"货物", null, "CARTON", 1, BigDecimal.ONE, BigDecimal.ONE,
null, null, null, null, null, false, null, null, null, null,
null, null, null, null, null, null, null, null, null);
null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null);
return new OrderRequests.SaveRequest(
7L, "客户", "SEA", null, "US", null, "目的仓", null, null,
party, party, List.of(cargo), "PICKUP", 8L, "收货仓", "CH", "渠道", 9L, "船司",
"BUY_DECLARATION", "LCL", false, "OVERSEAS", null, null, null);
"BUY_DECLARATION", "TAX_INCLUDED", "LCL", false, "OVERSEAS", null, null, null);
}
private OrderRequests.SaveRequest request(Long customerId, String pickupMethod,
@@ -338,11 +424,12 @@ class OrderApplicationServiceStateTest {
var cargo = new OrderRequests.Cargo(
"货物", null, "CARTON", 1, BigDecimal.ONE, BigDecimal.ONE,
null, null, null, null, null, false, null, null, null, null,
null, null, null, null, null, null, null, null, null);
null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null);
return new OrderRequests.SaveRequest(
customerId, "当前企业", "SEA", null, "US", null, "目的仓", null, null,
sender, receiver, List.of(cargo), pickupMethod, 8L, "收货仓", "CH", "渠道", 9L, "船司",
"BUY_DECLARATION", "LCL", false, destinationType, null, null, null);
"BUY_DECLARATION", "TAX_INCLUDED", "LCL", false, destinationType, null, null, null);
}
private OrderMasterDataGuard.Snapshots snapshots() {
@@ -368,6 +455,7 @@ class OrderApplicationServiceStateTest {
order.setChannelCode("CH");
order.setChannelName("渠道");
order.setTaxMode("BUY_DECLARATION");
order.setPricingTaxMode("TAX_INCLUDED");
order.setCustomsInspection(false);
order.setDestinationType(destinationType);
return order;
@@ -39,6 +39,8 @@ class OrderIdempotencyServiceTest {
@MockBean private OrderRepository contextRepository;
@MockBean private OrderEligibilityGuard contextEligibility;
@MockBean private OrderMasterDataGuard contextMasterData;
@MockBean private OrderPricingChannelGuard contextPricingChannelGuard;
@MockBean private ProductImageGuard contextProductImageGuard;
@Resource private OrderApplicationService springService;
@Configuration(proxyBeanMethods = false)
@@ -151,6 +153,10 @@ class OrderIdempotencyServiceTest {
.contains("platform:order:edit")
.contains("platform:order:submit")
.contains("name: Idempotency-Key")
.contains("pricingTaxMode")
.contains("PricingTaxMode")
.contains("FirstLegOrderDraft")
.contains("FirstLegOrderSummary")
.contains("required: true")
.contains("仅响应体 code 为字符串 \"200\" 时表示成功")
.doesNotContain("'409':")
@@ -158,7 +164,7 @@ class OrderIdempotencyServiceTest {
}
@Test
void newAndExistingDatabaseScriptsContainActualOrderWriteFields() throws Exception {
void baselineContainsCurrentOrderWriteFieldsWithoutAddingPreLaunchPricingMigration() throws Exception {
String baseline = new ClassPathResource("db/order.sql").getContentAsString(StandardCharsets.UTF_8);
String migration = new ClassPathResource("db/phase1-order-extension.sql")
.getContentAsString(StandardCharsets.UTF_8);
@@ -169,6 +175,8 @@ class OrderIdempotencyServiceTest {
assertThat(baseline).contains(column);
assertThat(migration).contains(column);
}
assertThat(baseline).contains("pricing_tax_mode");
assertThat(migration).doesNotContain("phase1_add_column('ord_order', 'pricing_tax_mode'");
}
private OrderApplicationService service(OrderRepository repository) {
@@ -6,6 +6,7 @@ import com.baiyue.order.entity.OutboxEventEntity;
import com.baiyue.order.repository.OrderRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
@@ -28,7 +29,13 @@ class OrderInboundOutboxPublisherTest {
fixture.publisher.publishApprovedOrders();
verify(fixture.client).create(eq("internal-token"), any());
ArgumentCaptor<FirstlegInboundClient.OrderApprovedInboundCommand> command =
ArgumentCaptor.forClass(FirstlegInboundClient.OrderApprovedInboundCommand.class);
verify(fixture.client).create(eq("internal-token"), command.capture());
org.assertj.core.api.Assertions.assertThat(command.getValue().inboundCode()).isEqualTo("RK-21");
org.assertj.core.api.Assertions.assertThat(command.getValue().warehouseId()).isEqualTo(31L);
org.assertj.core.api.Assertions.assertThat(command.getValue().taxMode()).isEqualTo("BUY_DECLARATION");
org.assertj.core.api.Assertions.assertThat(command.getValue().pricingTaxMode()).isEqualTo("TAX_EXCLUDED");
verify(fixture.repository).markOutboxPublished(eq(11L), eq(41L), any(LocalDateTime.class));
verify(fixture.repository, never()).markOutboxRetry(anyLong(), anyLong(), any());
verify(fixture.repository, never()).markOutboxFailed(any(), anyLong());
@@ -93,6 +100,21 @@ class OrderInboundOutboxPublisherTest {
OrderRepository repository = mock(OrderRepository.class);
FirstlegInboundClient client = mock(FirstlegInboundClient.class);
when(repository.pendingInboundEvents(50)).thenReturn(List.of(event));
if (event.getTenantId() != null && event.getAggregateId() != null) {
var order = new com.baiyue.order.entity.OrderEntity();
order.setId(event.getAggregateId());
order.setTenantId(event.getTenantId());
order.setOrderNo("FL-" + event.getAggregateId());
order.setInboundCode("RK-" + event.getAggregateId());
order.setCustomerName("测试客户");
order.setReceivingWarehouseId(31L);
order.setReceivingWarehouseName("测试仓");
order.setDestinationType("OVERSEAS");
order.setTransportMode("SEA");
order.setTaxMode("BUY_DECLARATION");
order.setPricingTaxMode("TAX_EXCLUDED");
when(repository.findOwned(event.getTenantId(), event.getAggregateId())).thenReturn(order);
}
OrderInboundOutboxPublisher publisher = new OrderInboundOutboxPublisher();
ReflectionTestUtils.setField(publisher, "repository", repository);
ReflectionTestUtils.setField(publisher, "client", client);
@@ -253,8 +253,9 @@ class OrderMasterDataGuardTest {
private OrderRequests.SaveRequest request(Long partner,Long warehouse,String destinationType,Long destinationWarehouseId) {
var party=new OrderRequests.Party("公司","联系人","13800138000",null,"CN",null,"深圳",null,"地址",null);
var cargo=new OrderRequests.Cargo("货物",null,"CARTON",1,BigDecimal.ONE,BigDecimal.ONE,
null,null,null,null,null,false,null,null,null,null,null,null,null,null,null,null,null,null,null);
return new OrderRequests.SaveRequest(partner,"不可信客户名","SEA",null,"CN",destinationWarehouseId,"私人地址",null,null,party,party,List.of(cargo),"PICKUP",warehouse,"不可信收货仓","CH","渠道",9L,"船司","BUY_DECLARATION","LCL",false,destinationType,null,null,null);
null,null,null,null,null,false,null,null,null,null,null,null,null,null,null,null,null,null,null,
null,null,null,null,null,null);
return new OrderRequests.SaveRequest(partner,"不可信客户名","SEA",null,"CN",destinationWarehouseId,"私人地址",null,null,party,party,List.of(cargo),"PICKUP",warehouse,"不可信收货仓","CH","渠道",9L,"船司","BUY_DECLARATION",null,"LCL",false,destinationType,null,null,null);
}
private WarehouseClient.Detail warehouse(long id,String type,boolean receiving) {
var value=new WarehouseClient.Warehouse(id,PUBLIC_TENANT_ID,"WH-"+id,"仓库"+id,type,"CN","ACTIVE");
@@ -0,0 +1,85 @@
package com.baiyue.order.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.FirstlegPricingClient;
import feign.FeignException;
import feign.Request;
import feign.Response;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@DisplayName("订单价格渠道校验")
@SpringJUnitConfig(OrderPricingChannelGuard.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=token")
class OrderPricingChannelGuardTest {
@MockBean private FirstlegPricingClient client;
@Resource private OrderPricingChannelGuard guard;
@Test
void forwardsCustomerOriginAndChannelToFirstleg() {
var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK");
when(client.validateChannel("token", request)).thenReturn(ApiResponse.success(null));
guard.requireAvailable(20002L, 8L, "MATSON_TRUCK");
verify(client).validateChannel("token", request);
}
@Test
void removedOrForgedChannelKeepsFirstlegBusinessError() {
var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "FAKE");
when(client.validateChannel("token", request)).thenReturn(
ApiResponse.failure(ResultCode.CONFLICT, "所选渠道已下线,请重新选择", null, null));
assertThatThrownBy(() -> guard.requireAvailable(20002L, 8L, "FAKE"))
.isInstanceOf(BizException.class)
.hasMessage("所选渠道已下线,请重新选择")
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
}
@Test
void nullResponseMapsToServiceUnavailable() {
var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK");
when(client.validateChannel("token", request)).thenReturn(null);
assertUnavailable(() -> guard.requireAvailable(20002L, 8L, "MATSON_TRUCK"));
}
@Test
void downstreamHttpFailureMapsToServiceUnavailable() {
var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK");
when(client.validateChannel("token", request)).thenThrow(feign(403));
assertUnavailable(() -> guard.requireAvailable(20002L, 8L, "MATSON_TRUCK"));
}
private void assertUnavailable(org.assertj.core.api.ThrowableAssert.ThrowingCallable invocation) {
assertThatThrownBy(invocation)
.isInstanceOf(BizException.class)
.hasMessage("头程价格渠道校验服务暂时不可用")
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
}
private FeignException feign(int status) {
Request request = Request.create(Request.HttpMethod.POST, "http://firstleg-service/internal/test",
Map.of(), null, StandardCharsets.UTF_8, null);
Response response = Response.builder().status(status).reason("test").request(request)
.headers(Map.of()).build();
return FeignException.errorStatus("pricingChannel", response);
}
}
@@ -18,6 +18,8 @@ class PlatformOrderPageServiceTest {
@MockBean private OrderRepository repository;
@MockBean private OrderEligibilityGuard eligibilityGuard;
@MockBean private OrderMasterDataGuard masterDataGuard;
@MockBean private OrderPricingChannelGuard pricingChannelGuard;
@MockBean private ProductImageGuard productImageGuard;
@Resource private OrderApplicationService service;
@Configuration(proxyBeanMethods = false)
static class TestConfig {
@@ -0,0 +1,75 @@
package com.baiyue.order.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.ProductImageClient;
import com.baiyue.order.dto.OrderRequests;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(ProductImageGuard.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=token")
class ProductImageGuardTest {
@MockBean private ProductImageClient client;
@Resource private ProductImageGuard guard;
@Test
void validatesEachDistinctImageAgainstTheCurrentCustomer() {
var metadata = new ProductImageClient.FileMetadata(31L, "product.png", "image/png", 100L);
when(client.validate("token", 10001L, 31L)).thenReturn(ApiResponse.success(metadata));
guard.requireOwned(10001L, List.of(cargo(31L), cargo(31L)));
verify(client, times(1)).validate("token", 10001L, 31L);
}
@Test
void preservesCrossCustomerOrMissingImageRejection() {
when(client.validate("token", 10001L, 31L)).thenReturn(
ApiResponse.failure(ResultCode.NOT_FOUND, "产品图片不存在", null, null));
assertThatThrownBy(() -> guard.requireOwned(10001L, List.of(cargo(31L))))
.isInstanceOf(BizException.class).hasMessage("产品图片不存在");
}
@Test
void mapsUnavailableOrMalformedResponsesToServiceUnavailable() {
when(client.validate("token", 10001L, 31L)).thenReturn(null);
assertUnavailable(() -> guard.requireOwned(10001L, List.of(cargo(31L))));
when(client.validate("token", 10001L, 31L)).thenReturn(
ApiResponse.success(new ProductImageClient.FileMetadata(32L, "wrong.png", "image/png", 10L)));
assertUnavailable(() -> guard.requireOwned(10001L, List.of(cargo(31L))));
}
@Test
void rejectsInvalidLogicalIdBeforeCallingFileService() {
assertThatThrownBy(() -> guard.requireOwned(10001L, List.of(cargo(0L))))
.isInstanceOf(BizException.class).hasMessageContaining("文件ID无效");
}
private void assertUnavailable(org.assertj.core.api.ThrowableAssert.ThrowingCallable invocation) {
assertThatThrownBy(invocation).isInstanceOf(BizException.class)
.hasMessage("产品图片校验服务暂时不可用")
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
}
private static OrderRequests.Cargo cargo(Long fileId) {
return new OrderRequests.Cargo("货物", null, "CARTON", 1, BigDecimal.ONE, BigDecimal.ONE,
null, null, null, null, null, false, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, fileId);
}
}
@@ -21,6 +21,8 @@ class TenantOrderCountServiceTest {
@MockBean private OrderRepository repository;
@MockBean private OrderEligibilityGuard eligibilityGuard;
@MockBean private OrderMasterDataGuard masterDataGuard;
@MockBean private OrderPricingChannelGuard pricingChannelGuard;
@MockBean private ProductImageGuard productImageGuard;
@Resource private OrderApplicationService service;
@Configuration(proxyBeanMethods = false)
static class TestConfig {
@@ -6,10 +6,12 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.FirstlegUnifiedOrderClient;
import com.baiyue.order.dto.UnifiedOrderQuery;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderInternalNoteEntity;
import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.repository.OrderRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.TestPropertySource;
@@ -34,15 +36,26 @@ class UnifiedOrderQueryServiceTest {
@MockBean private FirstlegUnifiedOrderClient firstleg;
@Resource private UnifiedOrderQueryService service;
@BeforeEach
void stubListEnrichmentQueries() {
when(orders.findNotesByOrderIds(any(), any())).thenReturn(List.of());
when(orders.findByIds(any(), any())).thenReturn(List.of());
}
@Test
void pendingReviewIsPagedFromOrderAndMappedToApproveAction() {
UnifiedOrderQuery query = query("PENDING_REVIEW");
OrderEntity order = order(OrderStatus.SUBMITTED);
order.setInboundCode("RK-77");
order.setCustomerRemark("客户要求提货前联系");
OrderInternalNoteEntity note = new OrderInternalNoteEntity();
note.setOrderId(77L);
note.setContent("客服已确认提货时间");
when(orders.unifiedPage(eq(10001L), eq(query), eq(List.of(OrderStatus.SUBMITTED))))
.thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1));
when(firstleg.summaries(eq("internal-token"), any()))
.thenReturn(ApiResponse.success(List.of()));
when(orders.findNotesByOrderIds(10001L, List.of(77L))).thenReturn(List.of(note));
var result = service.page(10001L, query);
@@ -51,6 +64,8 @@ class UnifiedOrderQueryServiceTest {
assertThat(row.nextAction()).isEqualTo("APPROVE_ORDER");
assertThat(row.inboundCode()).isEqualTo("RK-77");
assertThat(row.inboundId()).isNull();
assertThat(row.customerRemark()).isEqualTo("客户要求提货前联系");
assertThat(row.latestInternalNote()).isEqualTo("客服已确认提货时间");
});
verify(firstleg, never()).summaries(any(), any());
}