Merge branch 'test' of http://git.buggoglobal.com/java-project/baiyue-firstleg into dev
This commit is contained in:
@@ -36,12 +36,22 @@ public interface FirstlegInboundClient {
|
||||
ApiResponse<Void> updateDeclaration(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody OrderDeclarationDtos.UpdatedEvent event);
|
||||
|
||||
@PostMapping("/order-channel-changes/validate")
|
||||
ApiResponse<OrderChannelChangeValidationResponse> validateOrderChannelChange(
|
||||
@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody OrderChannelChangeValidationRequest request);
|
||||
|
||||
record InboundReferenceRequest(long tenantId, List<Long> orderIds) {
|
||||
}
|
||||
|
||||
record InboundReference(Long orderId, Long inboundId, String inboundCode, String processStatus) {
|
||||
}
|
||||
|
||||
record OrderChannelChangeValidationRequest(long tenantId, long orderId, long orderVersion,
|
||||
String channelCode) {}
|
||||
record OrderChannelChangeValidationResponse(long orderId, long orderVersion,
|
||||
String channelCode, String mainStatus) {}
|
||||
|
||||
record OrderApprovedInboundCommand(
|
||||
long tenantId, long orderId, String orderNo, String inboundCode, Long customerId, String customerName,
|
||||
Long warehouseId, String warehouseName, Long deliveryWarehouseId, String deliveryWarehouseName,
|
||||
|
||||
@@ -13,7 +13,24 @@ public interface FirstlegPricingClient {
|
||||
ApiResponse<ChannelValidationResponse> validateChannel(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody ChannelValidationRequest request);
|
||||
|
||||
@PostMapping("/order-channel-changes/validate")
|
||||
ApiResponse<OrderChannelChangeResponse> validateOrderChannelChange(
|
||||
@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody OrderChannelChangeRequest request);
|
||||
|
||||
@PostMapping("/order-channel-changes/apply")
|
||||
ApiResponse<OrderChannelChangeResponse> applyOrderChannelChange(
|
||||
@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody OrderChannelChangeRequest request);
|
||||
|
||||
record ChannelValidationRequest(long tenantId, long originWarehouseId, String channelCode,
|
||||
String destinationType, boolean requireInboundCodePrefix) {}
|
||||
record ChannelValidationResponse(String channelCode, String inboundCodePrefix) {}
|
||||
record OrderChannelChangeRequest(long tenantId, long orderId, long orderVersion,
|
||||
long originWarehouseId, String channelCode, String channelName,
|
||||
String destinationType, Long destinationWarehouseId,
|
||||
String destinationCode, String pricingTaxMode) {}
|
||||
record OrderChannelChangeResponse(long inboundId, long orderId, long orderVersion,
|
||||
String channelCode, Long priceBookVersionId, String feeStatus,
|
||||
boolean calculatedFeeWillBeInvalidated, Long invalidatedFeeId) {}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ public class OrderDeclarationUpdateService {
|
||||
|
||||
@Resource private OrderRepository repository;
|
||||
@Resource private OrderPricingChannelGuard pricingChannelGuard;
|
||||
@Resource private UnifiedOrderQueryService unifiedOrders;
|
||||
@Resource private OrderFulfillmentChannelChangeGuard fulfillmentChannelGuard;
|
||||
@Resource private ObjectMapper json;
|
||||
|
||||
@Transactional
|
||||
@@ -70,12 +70,16 @@ public class OrderDeclarationUpdateService {
|
||||
boolean channelChangedNow = !Objects.equals(channelCode, order.getChannelCode())
|
||||
|| !Objects.equals(channelName, order.getChannelName());
|
||||
if (channelChangedNow) {
|
||||
pricingChannelGuard.requireAvailable(tenantId, order.getReceivingWarehouseId(), channelCode,
|
||||
order.getDestinationType());
|
||||
if (order.getStatus() == OrderStatus.APPROVED) {
|
||||
long nextVersion = (long) request.expectedVersion() + 1;
|
||||
fulfillmentChannelGuard.requireEligible(order, nextVersion, channelCode);
|
||||
pricingChannelGuard.requireOrderChannelChangeEligible(order, nextVersion, channelCode, channelName);
|
||||
} else {
|
||||
pricingChannelGuard.requireAvailable(tenantId, order.getReceivingWarehouseId(), channelCode,
|
||||
order.getDestinationType());
|
||||
}
|
||||
}
|
||||
|
||||
if (order.getStatus() == OrderStatus.APPROVED) requireReversibleFulfillment(tenantId, orderId);
|
||||
|
||||
List<OrderDeclarationDtos.FieldChange> changes = new ArrayList<>();
|
||||
change(changes, null, "customerRemark", order.getCustomerRemark(), request.customerRemark());
|
||||
change(changes, null, "expectedArrivalAt", order.getExpectedArrivalAt(), request.expectedArrivalAt());
|
||||
@@ -109,17 +113,6 @@ public class OrderDeclarationUpdateService {
|
||||
request.expectedVersion(), version, changedBefore, channelChanged, List.copyOf(changes));
|
||||
}
|
||||
|
||||
private void requireReversibleFulfillment(long tenantId, long orderId) {
|
||||
var row = unifiedOrders.platformDetail(tenantId, orderId).summary();
|
||||
boolean hasIrreversibleFact = row.measurementCompletedAt() != null
|
||||
|| row.preload() != null || row.loading() != null || row.coLoadGroup() != null
|
||||
|| row.feeReviewStatus() != null && !"UNRECORDED".equals(row.feeReviewStatus());
|
||||
if (hasIrreversibleFact || !"WAIT_MEASURE".equals(row.mainStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT,
|
||||
"订单已产生测量、分柜、装柜或计费事实,不能修改客户申报资料");
|
||||
}
|
||||
}
|
||||
|
||||
private void collect(List<OrderDeclarationDtos.FieldChange> changes, OrderCargoEntity before,
|
||||
OrderDeclarationDtos.Cargo after) {
|
||||
int item = after.itemNo();
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
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.FirstlegInboundClient;
|
||||
import com.baiyue.order.entity.OrderEntity;
|
||||
import feign.FeignException;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** 审核后渠道变更的 Firstleg 同柜、分柜、预装及装柜可逆性门禁。 */
|
||||
@Service
|
||||
public class OrderFulfillmentChannelChangeGuard {
|
||||
@Resource private FirstlegInboundClient client;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
|
||||
public void requireEligible(OrderEntity order, long nextOrderVersion, String channelCode) {
|
||||
try {
|
||||
ApiResponse<FirstlegInboundClient.OrderChannelChangeValidationResponse> response =
|
||||
client.validateOrderChannelChange(internalToken,
|
||||
new FirstlegInboundClient.OrderChannelChangeValidationRequest(
|
||||
order.getTenantId(), order.getId(), nextOrderVersion, 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);
|
||||
}
|
||||
var data = response.data();
|
||||
if (data == null || data.orderId() != order.getId()
|
||||
|| data.orderVersion() != nextOrderVersion || !channelCode.equals(data.channelCode())) {
|
||||
throw unavailable();
|
||||
}
|
||||
} catch (BizException exception) {
|
||||
throw exception;
|
||||
} catch (FeignException exception) {
|
||||
throw unavailable();
|
||||
} catch (RuntimeException exception) {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
private BizException unavailable() {
|
||||
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程履约渠道校验服务暂时不可用");
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import jakarta.annotation.Resource;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.order.client.FirstlegInboundClient;
|
||||
import com.baiyue.order.client.FirstlegPricingClient;
|
||||
import com.baiyue.order.entity.OrderEntity;
|
||||
import com.baiyue.order.entity.OutboxEventEntity;
|
||||
import com.baiyue.order.dto.OrderDeclarationDtos;
|
||||
import com.baiyue.order.repository.OrderRepository;
|
||||
@@ -21,6 +23,7 @@ import java.time.LocalDateTime;
|
||||
public class OrderInboundOutboxPublisher {
|
||||
@Resource private OrderRepository repository;
|
||||
@Resource private FirstlegInboundClient client;
|
||||
@Resource private FirstlegPricingClient pricingClient;
|
||||
@Resource private ObjectMapper json;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
|
||||
@@ -91,6 +94,25 @@ public class OrderInboundOutboxPublisher {
|
||||
failPermanently(event, "declaration event ownership does not match payload");
|
||||
return;
|
||||
}
|
||||
if (containsChannelChange(command)) {
|
||||
OrderEntity order = repository.findOwned(event.getTenantId(), event.getAggregateId());
|
||||
if (order == null) {
|
||||
failPermanently(event, "order snapshot is unavailable for channel change");
|
||||
return;
|
||||
}
|
||||
var pricingCommand = new FirstlegPricingClient.OrderChannelChangeRequest(
|
||||
order.getTenantId(), order.getId(), command.version(), order.getReceivingWarehouseId(),
|
||||
command.channelCode(), command.channelName(), order.getDestinationType(),
|
||||
order.getDestinationWarehouseId(), order.getAmazonWarehouseCode(), order.getPricingTaxMode());
|
||||
ApiResponse<FirstlegPricingClient.OrderChannelChangeResponse> pricingResponse =
|
||||
pricingClient.applyOrderChannelChange(internalToken, pricingCommand);
|
||||
if (pricingResponse == null || !pricingResponse.successful() || pricingResponse.data() == null
|
||||
|| pricingResponse.data().orderId() != order.getId()
|
||||
|| pricingResponse.data().orderVersion() != command.version()
|
||||
|| !command.channelCode().equals(pricingResponse.data().channelCode())) {
|
||||
throw new IllegalStateException("头程渠道重新计价同步失败");
|
||||
}
|
||||
}
|
||||
ApiResponse<Void> response = client.updateDeclaration(internalToken, command);
|
||||
if (response == null || !response.successful()) throw new IllegalStateException("头程申报同步失败");
|
||||
repository.markOutboxPublished(event.getTenantId(), event.getId(), LocalDateTime.now());
|
||||
@@ -101,6 +123,11 @@ public class OrderInboundOutboxPublisher {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean containsChannelChange(OrderDeclarationDtos.UpdatedEvent command) {
|
||||
return command.changes() != null && command.changes().stream()
|
||||
.anyMatch(change -> "channelCode".equals(change.field()) || "channelName".equals(change.field()));
|
||||
}
|
||||
|
||||
private boolean incomplete(FirstlegInboundClient.OrderApprovedInboundCommand command) {
|
||||
return command.orderNo() == null || command.orderNo().isBlank()
|
||||
|| command.inboundCode() == null || command.inboundCode().isBlank()
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.client.FirstlegPricingClient;
|
||||
import com.baiyue.order.entity.OrderEntity;
|
||||
import feign.FeignException;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -24,6 +25,29 @@ public class OrderPricingChannelGuard {
|
||||
validate(tenantId, originWarehouseId, channelCode, destinationType, false);
|
||||
}
|
||||
|
||||
public void requireOrderChannelChangeEligible(OrderEntity order, long nextOrderVersion,
|
||||
String channelCode, String channelName) {
|
||||
try {
|
||||
ApiResponse<FirstlegPricingClient.OrderChannelChangeResponse> response =
|
||||
client.validateOrderChannelChange(internalToken, new FirstlegPricingClient.OrderChannelChangeRequest(
|
||||
order.getTenantId(), order.getId(), nextOrderVersion, order.getReceivingWarehouseId(),
|
||||
channelCode, channelName, order.getDestinationType(), order.getDestinationWarehouseId(),
|
||||
order.getAmazonWarehouseCode(), order.getPricingTaxMode()));
|
||||
requireSuccessful(response);
|
||||
if (response.data() == null || response.data().orderId() != order.getId()
|
||||
|| response.data().orderVersion() != nextOrderVersion
|
||||
|| !channelCode.equals(response.data().channelCode())) {
|
||||
throw unavailable();
|
||||
}
|
||||
} catch (BizException exception) {
|
||||
throw exception;
|
||||
} catch (FeignException exception) {
|
||||
throw unavailable();
|
||||
} catch (RuntimeException exception) {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
private String validate(long tenantId, long originWarehouseId, String channelCode,
|
||||
String destinationType, boolean requireInboundCodePrefix) {
|
||||
try {
|
||||
@@ -33,12 +57,7 @@ public class OrderPricingChannelGuard {
|
||||
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);
|
||||
}
|
||||
requireSuccessful(response);
|
||||
if (response.data() == null || requireInboundCodePrefix
|
||||
&& (response.data().inboundCodePrefix() == null || response.data().inboundCodePrefix().isBlank())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "所选渠道未配置入仓码前缀");
|
||||
@@ -53,6 +72,16 @@ public class OrderPricingChannelGuard {
|
||||
}
|
||||
}
|
||||
|
||||
private void requireSuccessful(ApiResponse<?> response) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private BizException unavailable() {
|
||||
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程价格渠道校验服务暂时不可用");
|
||||
}
|
||||
|
||||
@@ -212,8 +212,14 @@ paths:
|
||||
description: >-
|
||||
需要 platform:order:declaration:edit。货物使用订单内 itemNo 定位,不接受货物数据库 ID;
|
||||
不允许修改客户、国内收货仓、订单号、入仓码、状态、仓库/PDA/设备实测、测量审核或计费事实。
|
||||
审核通过订单仅在尚无测量、同柜组、分柜、装柜或计费事实时受理,并通过
|
||||
ORDER_DECLARATION_UPDATED_V1 Outbox 快照同步 Firstleg;消费者按 eventId 幂等、version 顺序应用。
|
||||
已有测量或计费事实不阻断客户申报快照更新,详情继续以最新申报与既有实测分别展示;
|
||||
只有渠道发生变化时才在写入前校验同柜/分柜/装柜可逆性和重新计价资格,任何校验失败均不写入。
|
||||
正式门禁分别为 /internal/firstleg/v1/inbounds/order-channel-changes/validate 与
|
||||
/internal/firstleg/v1/pricing/order-channel-changes/validate,两个服务均失败关闭。
|
||||
国内收货仓或中转仓如需变化必须重新下单。成功后通过 ORDER_DECLARATION_UPDATED_V1 Outbox
|
||||
同步 Firstleg;消费者按 eventId 幂等、version 顺序刷新申报快照,不覆盖实测、箱码或已确认费用。
|
||||
本次 changes 含渠道字段时,同版本 Outbox 还必须调用
|
||||
/internal/firstleg/v1/pricing/order-channel-changes/apply,使未确认价格预估显式失效;普通申报更新不得调用。
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/UnifiedOrderId'
|
||||
- $ref: '#/components/parameters/UnifiedTenantId'
|
||||
@@ -223,7 +229,7 @@ paths:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/OrderDeclarationUpdateRequest'}
|
||||
responses:
|
||||
'200': {description: 成功返回新版本、渠道历史标记和字段级前后值;版本冲突或存在不可逆履约事实时响应体业务码为 A0409}
|
||||
'200': {description: 成功返回新版本、渠道历史标记和字段级前后值;版本冲突、渠道与不可逆履约或已确认费用不兼容时响应体业务码为 A0409}
|
||||
'403': {description: 无权限或非平台身份}
|
||||
/api/merchant/v1/orders:
|
||||
get:
|
||||
|
||||
+26
-2
@@ -1,10 +1,13 @@
|
||||
package com.baiyue.order.controller;
|
||||
|
||||
import com.baiyue.order.dto.OrderDeclarationDtos;
|
||||
import com.baiyue.order.client.FirstlegInboundClient;
|
||||
import com.baiyue.order.client.FirstlegPricingClient;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -26,7 +29,9 @@ class OrderDeclarationUpdateContractTest {
|
||||
assertThat(OrderDeclarationDtos.UpdateRequest.class.getRecordComponents())
|
||||
.extracting(component -> component.getName())
|
||||
.containsExactly("expectedVersion", "customerRemark", "expectedArrivalAt",
|
||||
"channelCode", "channelName", "cargoes");
|
||||
"channelCode", "channelName", "cargoes")
|
||||
.doesNotContain("receivingWarehouseId", "receivingWarehouseName",
|
||||
"destinationWarehouseId", "destinationWarehouseName", "inboundCode", "orderNo");
|
||||
assertThat(OrderDeclarationDtos.Cargo.class.getRecordComponents())
|
||||
.extracting(component -> component.getName())
|
||||
.contains("itemNo", "cargoName", "cargoNameEn", "skuNo", "model", "packageCount",
|
||||
@@ -46,7 +51,26 @@ class OrderDeclarationUpdateContractTest {
|
||||
Map<String, Object> row = map(map(schemas.get("UnifiedOrderRow")).get("properties"));
|
||||
assertThat(row).containsKeys("expectedVolumeCbm", "actualVolumeCbm", "expectedArrivalAt", "channelChanged");
|
||||
assertThat(map(row.get("actualVolumeCbm")).get("description").toString()).contains("仅 Firstleg 实测");
|
||||
assertThat(source).contains("ORDER_DECLARATION_UPDATED_V1", "platform:order:declaration:edit", "A0409");
|
||||
assertThat(source).contains("ORDER_DECLARATION_UPDATED_V1", "platform:order:declaration:edit", "A0409",
|
||||
"/internal/firstleg/v1/inbounds/order-channel-changes/validate",
|
||||
"/internal/firstleg/v1/pricing/order-channel-changes/validate",
|
||||
"/internal/firstleg/v1/pricing/order-channel-changes/apply");
|
||||
}
|
||||
|
||||
@Test
|
||||
void approvedChannelChangeUsesFormalFulfillmentAndPricingValidationContracts() throws Exception {
|
||||
var fulfillment = FirstlegInboundClient.class.getDeclaredMethod("validateOrderChannelChange",
|
||||
String.class, FirstlegInboundClient.OrderChannelChangeValidationRequest.class);
|
||||
var pricing = FirstlegPricingClient.class.getDeclaredMethod("validateOrderChannelChange",
|
||||
String.class, FirstlegPricingClient.OrderChannelChangeRequest.class);
|
||||
var applyPricing = FirstlegPricingClient.class.getDeclaredMethod("applyOrderChannelChange",
|
||||
String.class, FirstlegPricingClient.OrderChannelChangeRequest.class);
|
||||
assertThat(fulfillment.getAnnotation(PostMapping.class).value())
|
||||
.containsExactly("/order-channel-changes/validate");
|
||||
assertThat(pricing.getAnnotation(PostMapping.class).value())
|
||||
.containsExactly("/order-channel-changes/validate");
|
||||
assertThat(applyPricing.getAnnotation(PostMapping.class).value())
|
||||
.containsExactly("/order-channel-changes/apply");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+42
-29
@@ -2,7 +2,6 @@ package com.baiyue.order.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.dto.OrderDeclarationDtos;
|
||||
import com.baiyue.order.dto.UnifiedOrderResponse;
|
||||
import com.baiyue.order.entity.OrderCargoEntity;
|
||||
import com.baiyue.order.entity.OrderEntity;
|
||||
import com.baiyue.order.entity.OrderStatus;
|
||||
@@ -33,7 +32,7 @@ import static org.mockito.Mockito.when;
|
||||
class OrderDeclarationUpdateServiceTest {
|
||||
@MockBean private OrderRepository repository;
|
||||
@MockBean private OrderPricingChannelGuard pricingChannelGuard;
|
||||
@MockBean private UnifiedOrderQueryService unifiedOrders;
|
||||
@MockBean private OrderFulfillmentChannelChangeGuard fulfillmentChannelGuard;
|
||||
@MockBean private ObjectMapper json;
|
||||
@Resource private OrderDeclarationUpdateService service;
|
||||
|
||||
@@ -76,45 +75,59 @@ class OrderDeclarationUpdateServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void approvedOrderWithMeasurementFactIsAtomicallyRejected() {
|
||||
OrderEntity order = order(OrderStatus.APPROVED, 3);
|
||||
when(repository.findOwned(100L, 200L)).thenReturn(order);
|
||||
when(repository.findCargo(100L, 200L)).thenReturn(List.of(cargo()));
|
||||
UnifiedOrderResponse.Detail detail = org.mockito.Mockito.mock(UnifiedOrderResponse.Detail.class);
|
||||
UnifiedOrderResponse.Row row = org.mockito.Mockito.mock(UnifiedOrderResponse.Row.class);
|
||||
when(detail.summary()).thenReturn(row);
|
||||
when(row.mainStatus()).thenReturn("WAIT_MEASURE");
|
||||
when(row.measurementCompletedAt()).thenReturn(LocalDateTime.of(2026, 9, 1, 12, 0));
|
||||
when(unifiedOrders.platformDetail(100L, 200L)).thenReturn(detail);
|
||||
|
||||
assertThatThrownBy(() -> service.update(100L, 200L, 700L, "平台操作员",
|
||||
request(3, "OLD", "旧渠道", "备注")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("测量、分柜、装柜或计费事实");
|
||||
verify(repository, never()).updateDeclarationHeader(anyLong(), anyLong(), anyInt(), anyLong(),
|
||||
any(), any(), any(), any(), anyBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reversibleApprovedUpdateProducesVersionedOutboxSnapshot() throws Exception {
|
||||
void approvedOrderCanRefreshCustomerDeclarationWithoutTouchingMeasurementFacts() throws Exception {
|
||||
OrderEntity order = order(OrderStatus.APPROVED, 3);
|
||||
when(repository.findOwned(100L, 200L)).thenReturn(order);
|
||||
when(repository.findCargo(100L, 200L)).thenReturn(List.of(cargo()));
|
||||
when(repository.updateDeclarationHeader(anyLong(), anyLong(), anyInt(), anyLong(), any(), any(),
|
||||
any(), any(), anyBoolean())).thenReturn(1);
|
||||
when(repository.updateDeclarationCargo(anyLong(), anyLong(), any())).thenReturn(1);
|
||||
UnifiedOrderResponse.Detail detail = org.mockito.Mockito.mock(UnifiedOrderResponse.Detail.class);
|
||||
UnifiedOrderResponse.Row row = org.mockito.Mockito.mock(UnifiedOrderResponse.Row.class);
|
||||
when(detail.summary()).thenReturn(row);
|
||||
when(row.mainStatus()).thenReturn("WAIT_MEASURE");
|
||||
when(row.feeReviewStatus()).thenReturn("UNRECORDED");
|
||||
when(unifiedOrders.platformDetail(100L, 200L)).thenReturn(detail);
|
||||
when(json.writeValueAsString(any())).thenReturn("{\"schemaVersion\":1}");
|
||||
|
||||
service.update(100L, 200L, 700L, "平台操作员", request(3, "OLD", "旧渠道", "新备注"));
|
||||
var response = service.update(100L, 200L, 700L, "平台操作员",
|
||||
request(3, "OLD", "旧渠道", "测量后更新申报"));
|
||||
|
||||
assertThat(response.version()).isEqualTo(4);
|
||||
verify(pricingChannelGuard, never()).requireAvailable(anyLong(), anyLong(), anyString(), anyString());
|
||||
verify(repository).insertOutbox(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void approvedChannelChangePassesBothGatesBeforeVersionedOutboxSnapshot() throws Exception {
|
||||
OrderEntity order = order(OrderStatus.APPROVED, 3);
|
||||
when(repository.findOwned(100L, 200L)).thenReturn(order);
|
||||
when(repository.findCargo(100L, 200L)).thenReturn(List.of(cargo()));
|
||||
when(repository.updateDeclarationHeader(anyLong(), anyLong(), anyInt(), anyLong(), any(), any(),
|
||||
any(), any(), anyBoolean())).thenReturn(1);
|
||||
when(repository.updateDeclarationCargo(anyLong(), anyLong(), any())).thenReturn(1);
|
||||
when(json.writeValueAsString(any())).thenReturn("{\"schemaVersion\":1}");
|
||||
|
||||
service.update(100L, 200L, 700L, "平台操作员", request(3, "NEW", "新渠道", "新备注"));
|
||||
|
||||
verify(fulfillmentChannelGuard).requireEligible(order, 4L, "NEW");
|
||||
verify(pricingChannelGuard).requireOrderChannelChangeEligible(order, 4L, "NEW", "新渠道");
|
||||
verify(repository).insertOutbox(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void approvedChannelChangeRunsBothRemoteGatesBeforeAnyWrite() {
|
||||
OrderEntity order = order(OrderStatus.APPROVED, 3);
|
||||
when(repository.findOwned(100L, 200L)).thenReturn(order);
|
||||
when(repository.findCargo(100L, 200L)).thenReturn(List.of(cargo()));
|
||||
org.mockito.Mockito.doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
|
||||
"装柜已开始,渠道不可变更"))
|
||||
.when(fulfillmentChannelGuard).requireEligible(order, 4L, "NEW");
|
||||
|
||||
assertThatThrownBy(() -> service.update(100L, 200L, 700L, "平台操作员",
|
||||
request(3, "NEW", "新渠道", "备注")))
|
||||
.isInstanceOf(BizException.class).hasMessage("装柜已开始,渠道不可变更");
|
||||
|
||||
verify(fulfillmentChannelGuard).requireEligible(order, 4L, "NEW");
|
||||
verify(pricingChannelGuard, never()).requireOrderChannelChangeEligible(any(), anyLong(), anyString(), anyString());
|
||||
verify(repository, never()).updateDeclarationHeader(anyLong(), anyLong(), anyInt(), anyLong(),
|
||||
any(), any(), any(), any(), anyBoolean());
|
||||
}
|
||||
|
||||
private OrderEntity order(OrderStatus status, int version) {
|
||||
OrderEntity order = new OrderEntity();
|
||||
order.setId(200L); order.setTenantId(100L); order.setOrderNo("ORD-200");
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
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.FirstlegInboundClient;
|
||||
import com.baiyue.order.entity.OrderEntity;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig(OrderFulfillmentChannelChangeGuard.class)
|
||||
@TestPropertySource(properties = "baiyue.security.internal-service-token=token")
|
||||
class OrderFulfillmentChannelChangeGuardTest {
|
||||
@MockBean private FirstlegInboundClient client;
|
||||
@Resource private OrderFulfillmentChannelChangeGuard guard;
|
||||
|
||||
@Test
|
||||
void validatesNextOrderVersionAgainstFirstlegFulfillmentFacts() {
|
||||
OrderEntity order = order();
|
||||
var request = new FirstlegInboundClient.OrderChannelChangeValidationRequest(11L, 21L, 5L, "MATSON_TRUCK");
|
||||
when(client.validateOrderChannelChange("token", request)).thenReturn(ApiResponse.success(
|
||||
new FirstlegInboundClient.OrderChannelChangeValidationResponse(21L, 5L, "MATSON_TRUCK", "WAIT_SPLIT")));
|
||||
|
||||
guard.requireEligible(order, 5L, "MATSON_TRUCK");
|
||||
|
||||
verify(client).validateOrderChannelChange("token", request);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsIrreversibleLoadingBusinessErrorAndFailsBeforeOrderWrite() {
|
||||
OrderEntity order = order();
|
||||
var request = new FirstlegInboundClient.OrderChannelChangeValidationRequest(11L, 21L, 5L, "MATSON_TRUCK");
|
||||
when(client.validateOrderChannelChange("token", request)).thenReturn(
|
||||
ApiResponse.failure(ResultCode.CONFLICT, "装柜已开始,渠道不可变更", null, null));
|
||||
|
||||
assertThatThrownBy(() -> guard.requireEligible(order, 5L, "MATSON_TRUCK"))
|
||||
.isInstanceOf(BizException.class).hasMessage("装柜已开始,渠道不可变更");
|
||||
}
|
||||
|
||||
private OrderEntity order() {
|
||||
OrderEntity order = new OrderEntity();
|
||||
order.setId(21L); order.setTenantId(11L);
|
||||
return order;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.baiyue.order.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.order.client.FirstlegInboundClient;
|
||||
import com.baiyue.order.client.FirstlegPricingClient;
|
||||
import com.baiyue.order.entity.OutboxEventEntity;
|
||||
import com.baiyue.order.repository.OrderRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -130,7 +131,7 @@ class OrderInboundOutboxPublisherTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void declarationUpdateEventIsDeliveredByVersionedContract() {
|
||||
void ordinaryDeclarationUpdateDoesNotInvalidatePricing() {
|
||||
String payload = "{\"schemaVersion\":1,\"eventId\":\"evt-1\",\"tenantId\":11,"
|
||||
+ "\"orderId\":21,\"orderNo\":\"ORD-21\",\"inboundCode\":\"RK-21\","
|
||||
+ "\"version\":4,\"operatorId\":7,\"channelCode\":\"SEA\","
|
||||
@@ -140,9 +141,53 @@ class OrderInboundOutboxPublisherTest {
|
||||
|
||||
fixture.publisher.publishApprovedOrders();
|
||||
|
||||
verify(fixture.client).updateDeclaration(eq("internal-token"), any());
|
||||
verify(fixture.client).updateDeclaration(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(command ->
|
||||
command.version() == 4 && command.channelChanged()
|
||||
&& "SEA".equals(command.channelCode()) && "RK-21".equals(command.inboundCode())));
|
||||
verify(fixture.repository).markOutboxPublished(eq(11L), eq(41L), any(LocalDateTime.class));
|
||||
verify(fixture.client, never()).create(any(), any());
|
||||
verify(fixture.pricingClient, never()).applyOrderChannelChange(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void channelChangeAppliesPricingInvalidationBySameOrderVersionBeforeDelivery() {
|
||||
String payload = "{\"schemaVersion\":1,\"eventId\":\"evt-2\",\"tenantId\":11,"
|
||||
+ "\"orderId\":21,\"orderNo\":\"ORD-21\",\"inboundCode\":\"RK-21\","
|
||||
+ "\"version\":4,\"operatorId\":7,\"channelCode\":\"SEA\","
|
||||
+ "\"channelName\":\"海运\",\"channelChanged\":true,\"cargoes\":[],"
|
||||
+ "\"changes\":[{\"field\":\"channelCode\",\"beforeValue\":\"OLD\",\"afterValue\":\"SEA\"}]}";
|
||||
Fixture fixture = fixture(event("ORDER_DECLARATION_UPDATED_V1", 11L, 21L, payload));
|
||||
when(fixture.pricingClient.applyOrderChannelChange(eq("internal-token"), any())).thenReturn(ApiResponse.success(
|
||||
new FirstlegPricingClient.OrderChannelChangeResponse(
|
||||
51L, 21L, 4L, "SEA", 61L, "UNPRICED", true, 71L)));
|
||||
when(fixture.client.updateDeclaration(eq("internal-token"), any())).thenReturn(ApiResponse.success(null));
|
||||
|
||||
fixture.publisher.publishApprovedOrders();
|
||||
|
||||
verify(fixture.pricingClient).applyOrderChannelChange(eq("internal-token"),
|
||||
org.mockito.ArgumentMatchers.argThat(command -> command.orderVersion() == 4L
|
||||
&& command.orderId() == 21L && "SEA".equals(command.channelCode())));
|
||||
verify(fixture.client).updateDeclaration(eq("internal-token"), any());
|
||||
verify(fixture.repository).markOutboxPublished(eq(11L), eq(41L), any(LocalDateTime.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void channelPricingApplyFailureKeepsOutboxPendingAndDoesNotAdvanceFulfillmentSnapshot() {
|
||||
String payload = "{\"schemaVersion\":1,\"eventId\":\"evt-3\",\"tenantId\":11,"
|
||||
+ "\"orderId\":21,\"orderNo\":\"ORD-21\",\"inboundCode\":\"RK-21\","
|
||||
+ "\"version\":4,\"operatorId\":7,\"channelCode\":\"SEA\","
|
||||
+ "\"channelName\":\"海运\",\"channelChanged\":true,\"cargoes\":[],"
|
||||
+ "\"changes\":[{\"field\":\"channelName\",\"beforeValue\":\"旧渠道\",\"afterValue\":\"海运\"}]}";
|
||||
Fixture fixture = fixture(event("ORDER_DECLARATION_UPDATED_V1", 11L, 21L, payload));
|
||||
when(fixture.pricingClient.applyOrderChannelChange(eq("internal-token"), any())).thenReturn(
|
||||
ApiResponse.failure(com.baiyue.common.core.api.ResultCode.CONFLICT,
|
||||
"费用已确认或已发布,渠道不可变更", null, null));
|
||||
|
||||
fixture.publisher.publishApprovedOrders();
|
||||
|
||||
verify(fixture.repository).markOutboxRetry(eq(11L), eq(41L), any(LocalDateTime.class));
|
||||
verify(fixture.client, never()).updateDeclaration(any(), any());
|
||||
verify(fixture.repository, never()).markOutboxPublished(anyLong(), anyLong(), any());
|
||||
}
|
||||
|
||||
private Fixture fixture(OutboxEventEntity event) {
|
||||
@@ -152,6 +197,7 @@ class OrderInboundOutboxPublisherTest {
|
||||
private Fixture fixture(OutboxEventEntity event, String authoritativeTaxMode) {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
FirstlegInboundClient client = mock(FirstlegInboundClient.class);
|
||||
FirstlegPricingClient pricingClient = mock(FirstlegPricingClient.class);
|
||||
when(repository.pendingInboundEvents(50)).thenReturn(List.of(event));
|
||||
if (event.getTenantId() != null && event.getAggregateId() != null) {
|
||||
var order = new com.baiyue.order.entity.OrderEntity();
|
||||
@@ -163,6 +209,8 @@ class OrderInboundOutboxPublisherTest {
|
||||
order.setReceivingWarehouseId(31L);
|
||||
order.setReceivingWarehouseName("测试仓");
|
||||
order.setDestinationType("OVERSEAS");
|
||||
order.setDestinationWarehouseId(41L);
|
||||
order.setAmazonWarehouseCode("ONT8");
|
||||
order.setTransportMode("SEA");
|
||||
order.setTaxMode(authoritativeTaxMode);
|
||||
order.setPricingTaxMode("TAX_EXCLUDED");
|
||||
@@ -177,9 +225,10 @@ class OrderInboundOutboxPublisherTest {
|
||||
OrderInboundOutboxPublisher publisher = new OrderInboundOutboxPublisher();
|
||||
ReflectionTestUtils.setField(publisher, "repository", repository);
|
||||
ReflectionTestUtils.setField(publisher, "client", client);
|
||||
ReflectionTestUtils.setField(publisher, "pricingClient", pricingClient);
|
||||
ReflectionTestUtils.setField(publisher, "json", new ObjectMapper());
|
||||
ReflectionTestUtils.setField(publisher, "internalToken", "internal-token");
|
||||
return new Fixture(publisher, repository, client);
|
||||
return new Fixture(publisher, repository, client, pricingClient);
|
||||
}
|
||||
|
||||
private OutboxEventEntity event(String eventType, Long tenantId, Long aggregateId, String payload) {
|
||||
@@ -207,6 +256,6 @@ class OrderInboundOutboxPublisherTest {
|
||||
}
|
||||
|
||||
private record Fixture(OrderInboundOutboxPublisher publisher, OrderRepository repository,
|
||||
FirstlegInboundClient client) {
|
||||
FirstlegInboundClient client, FirstlegPricingClient pricingClient) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.client.FirstlegPricingClient;
|
||||
import com.baiyue.order.entity.OrderEntity;
|
||||
import feign.FeignException;
|
||||
import feign.Request;
|
||||
import feign.Response;
|
||||
@@ -65,6 +66,24 @@ class OrderPricingChannelGuardTest {
|
||||
.isEqualTo(ResultCode.CONFLICT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void approvedChannelChangeUsesPricingAndConfirmedFeeGate() {
|
||||
OrderEntity order = new OrderEntity();
|
||||
order.setId(21L); order.setTenantId(11L); order.setReceivingWarehouseId(31L);
|
||||
order.setDestinationType("FBA"); order.setDestinationWarehouseId(41L);
|
||||
order.setAmazonWarehouseCode("ONT8"); order.setPricingTaxMode("TAX_INCLUDED");
|
||||
var request = new FirstlegPricingClient.OrderChannelChangeRequest(
|
||||
11L, 21L, 5L, 31L, "MATSON_TRUCK", "美森海卡",
|
||||
"FBA", 41L, "ONT8", "TAX_INCLUDED");
|
||||
when(client.validateOrderChannelChange("token", request)).thenReturn(ApiResponse.success(
|
||||
new FirstlegPricingClient.OrderChannelChangeResponse(
|
||||
51L, 21L, 5L, "MATSON_TRUCK", 61L, "CALCULATED", true, null)));
|
||||
|
||||
guard.requireOrderChannelChangeEligible(order, 5L, "MATSON_TRUCK", "美森海卡");
|
||||
|
||||
verify(client).validateOrderChannelChange("token", request);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullResponseMapsToServiceUnavailable() {
|
||||
var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK", "FBA", true);
|
||||
|
||||
Reference in New Issue
Block a user