优化
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) {}
|
||||
}
|
||||
+1
-14
@@ -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) {
|
||||
|
||||
@@ -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'");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+124
-40
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user