This commit is contained in:
2026-08-07 14:46:55 +08:00
parent 830575cc92
commit b0ea1a0c5c
109 changed files with 5774 additions and 339 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) {}
}
@@ -146,7 +146,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,
@@ -155,19 +155,6 @@ public class PlatformFirstLegOrderController {
return transit(user, tenantId, id, OrderStatus.APPROVED, null);
}
@BusinessAudit(event = "ORDER_ASSIGN_CARRIER", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "指派订单承运商")
@Operation(summary = "指派承运商", description = "可选地为待审核订单选择启用的公共承运商,名称快照由服务端主数据回填;该动作不是审核前置条件。")
@PutMapping("/{id}/carrier")
@PreAuthorize("hasAuthority('platform:order:approve')")
public ApiResponse<Void> assignCarrier(@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "目标租户 ID", required = true) @RequestParam long tenantId,
@Parameter(description = "订单 ID", required = true) @PathVariable long id,
@Valid @RequestBody OrderRequests.CarrierRequest request) {
long scopedTenant = OrderScopeGuard.platform(user, tenantId);
service.assignCarrier(scopedTenant, user.userId(), id, request);
return ApiResponse.success(null);
}
@BusinessAudit(event = "ORDER_REJECT", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Reject order / 驳回订单")
@Operation(summary = "Reject / 驳回", description = "Rejects a submitted order with a required reason. / 使用必填原因驳回已提交订单。")
@PostMapping("/{id}/reject")
@@ -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)
@@ -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
) {}
@@ -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,7 @@ public class OrderApplicationService {
@Resource private ObjectMapper json;
@Resource private OrderEligibilityGuard eligibilityGuard;
@Resource private OrderMasterDataGuard masterDataGuard;
@Resource private OrderPricingChannelGuard pricingChannelGuard;
/** 创建草稿时使用数据库唯一键保证请求幂等。 */
@Transactional
@@ -48,6 +50,7 @@ public class OrderApplicationService {
private Long createDraftInternal(long tenantId, long userId, OrderRequests.SaveRequest request) {
validatePrototypeFields(request);
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,19 +76,6 @@ 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);
@@ -96,6 +86,7 @@ public class OrderApplicationService {
&& !(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 +109,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");
@@ -209,6 +197,10 @@ public class OrderApplicationService {
}
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())
@@ -347,12 +339,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
@@ -422,7 +417,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 +425,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, "头程价格渠道校验服务暂时不可用");
}
}
@@ -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) {
+1
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',
@@ -33,6 +33,7 @@ CALL phase1_add_column('ord_order', 'receiving_time', "VARCHAR(128) NULL COMMENT
CALL phase1_add_column('ord_order', 'channel_code', "VARCHAR(64) NULL COMMENT 'Shipping channel code'");
CALL phase1_add_column('ord_order', 'channel_name', "VARCHAR(128) NULL COMMENT 'Shipping channel snapshot'");
CALL phase1_add_column('ord_order', 'tax_mode', "VARCHAR(24) NULL COMMENT 'BUY_DECLARATION or TAX_REFUND'");
CALL phase1_add_column('ord_order', 'pricing_tax_mode', "VARCHAR(16) NULL COMMENT 'TAX_INCLUDED or TAX_EXCLUDED; independent from tax_mode'");
CALL phase1_add_column('ord_order', 'container_mode', "VARCHAR(24) NULL COMMENT 'FULL_CONTAINER or LCL'");
CALL phase1_add_column('ord_order', 'customs_inspection', "TINYINT(1) NULL COMMENT 'Whether customs inspection is required'");
CALL phase1_add_column('ord_order', 'destination_type', "VARCHAR(24) NULL COMMENT 'FBA, OVERSEAS or PRIVATE'");
+32 -24
View File
@@ -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,35 @@ 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]
properties:
pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'}
description: >-
订单保存请求中的完整字段以服务端 FirstLegOrderSaveRequest 为准;pricingTaxMode
是新建和编辑订单的必填报价维度;历史存量订单的响应字段仍允许为空。
FirstLegOrderSummary:
type: object
properties:
orderId: {type: string, pattern: '^[1-9][0-9]*$'}
orderNo: {type: string}
pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'}
parameters:
UnifiedKeyword:
name: keyword
@@ -41,8 +41,6 @@ class OrderControllerPermissionTest {
AuthenticatedUser.class, long.class, long.class, OrderRequests.SaveRequest.class);
assertAuthority(PlatformFirstLegOrderController.class, "submit", "hasAuthority('platform:order:submit')",
AuthenticatedUser.class, long.class, long.class, String.class);
assertAuthority(PlatformFirstLegOrderController.class, "assignCarrier", "hasAuthority('platform:order:approve')",
AuthenticatedUser.class, long.class, long.class, OrderRequests.CarrierRequest.class);
}
@Test
@@ -14,19 +14,28 @@ 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,
@@ -35,7 +44,7 @@ class OrderRequestsValidationTest {
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,7 @@ class OrderApplicationServiceStateTest {
@MockBean private OrderRepository contextRepository;
@MockBean private OrderEligibilityGuard contextEligibility;
@MockBean private OrderMasterDataGuard contextMasterData;
@MockBean private OrderPricingChannelGuard contextPricingChannelGuard;
@Resource private OrderApplicationService springService;
@Configuration(proxyBeanMethods = false)
@@ -58,6 +59,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 +128,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 +171,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 +188,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 +207,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 +397,7 @@ 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, "json", new ObjectMapper());
return service;
}
@@ -329,7 +412,7 @@ class OrderApplicationServiceStateTest {
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,
@@ -342,7 +425,7 @@ class OrderApplicationServiceStateTest {
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 +451,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,7 @@ class OrderIdempotencyServiceTest {
@MockBean private OrderRepository contextRepository;
@MockBean private OrderEligibilityGuard contextEligibility;
@MockBean private OrderMasterDataGuard contextMasterData;
@MockBean private OrderPricingChannelGuard contextPricingChannelGuard;
@Resource private OrderApplicationService springService;
@Configuration(proxyBeanMethods = false)
@@ -151,6 +152,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':")
@@ -164,7 +169,7 @@ class OrderIdempotencyServiceTest {
.getContentAsString(StandardCharsets.UTF_8);
for (String column : new String[] {
"receiving_warehouse_address", "receiving_time", "express_company", "express_tracking_no"
"receiving_warehouse_address", "receiving_time", "express_company", "express_tracking_no", "pricing_tax_mode"
}) {
assertThat(baseline).contains(column);
assertThat(migration).contains(column);
@@ -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);
@@ -254,7 +254,7 @@ class OrderMasterDataGuardTest {
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);
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,7 @@ class PlatformOrderPageServiceTest {
@MockBean private OrderRepository repository;
@MockBean private OrderEligibilityGuard eligibilityGuard;
@MockBean private OrderMasterDataGuard masterDataGuard;
@MockBean private OrderPricingChannelGuard pricingChannelGuard;
@Resource private OrderApplicationService service;
@Configuration(proxyBeanMethods = false)
static class TestConfig {
@@ -21,6 +21,7 @@ class TenantOrderCountServiceTest {
@MockBean private OrderRepository repository;
@MockBean private OrderEligibilityGuard eligibilityGuard;
@MockBean private OrderMasterDataGuard masterDataGuard;
@MockBean private OrderPricingChannelGuard pricingChannelGuard;
@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());
}