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:
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
@@ -33,6 +33,8 @@ baiyue:
|
||||
url: ${IAM_SERVICE_URL:}
|
||||
warehouse:
|
||||
url: ${WAREHOUSE_SERVICE_URL:}
|
||||
file:
|
||||
url: ${FILE_SERVICE_URL:}
|
||||
|
||||
logging:
|
||||
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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+130
-42
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user