This commit is contained in:
2026-09-02 10:26:34 +08:00
parent 70bbdd5b7b
commit 37c37c75c8
11 changed files with 259 additions and 57 deletions
@@ -36,12 +36,22 @@ public interface FirstlegInboundClient {
ApiResponse<Void> updateDeclaration(@RequestHeader("X-Internal-Service-Token") String token, ApiResponse<Void> updateDeclaration(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody OrderDeclarationDtos.UpdatedEvent event); @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 InboundReferenceRequest(long tenantId, List<Long> orderIds) {
} }
record InboundReference(Long orderId, Long inboundId, String inboundCode, String processStatus) { 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( record OrderApprovedInboundCommand(
long tenantId, long orderId, String orderNo, String inboundCode, Long customerId, String customerName, long tenantId, long orderId, String orderNo, String inboundCode, Long customerId, String customerName,
Long warehouseId, String warehouseName, Long deliveryWarehouseId, String deliveryWarehouseName, Long warehouseId, String warehouseName, Long deliveryWarehouseId, String deliveryWarehouseName,
@@ -13,7 +13,19 @@ public interface FirstlegPricingClient {
ApiResponse<ChannelValidationResponse> validateChannel(@RequestHeader("X-Internal-Service-Token") String token, ApiResponse<ChannelValidationResponse> validateChannel(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ChannelValidationRequest request); @RequestBody ChannelValidationRequest request);
@PostMapping("/order-channel-changes/validate")
ApiResponse<OrderChannelChangeResponse> validateOrderChannelChange(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody OrderChannelChangeRequest request);
record ChannelValidationRequest(long tenantId, long originWarehouseId, String channelCode, record ChannelValidationRequest(long tenantId, long originWarehouseId, String channelCode,
String destinationType, boolean requireInboundCodePrefix) {} String destinationType, boolean requireInboundCodePrefix) {}
record ChannelValidationResponse(String channelCode, String inboundCodePrefix) {} 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 OrderRepository repository;
@Resource private OrderPricingChannelGuard pricingChannelGuard; @Resource private OrderPricingChannelGuard pricingChannelGuard;
@Resource private UnifiedOrderQueryService unifiedOrders; @Resource private OrderFulfillmentChannelChangeGuard fulfillmentChannelGuard;
@Resource private ObjectMapper json; @Resource private ObjectMapper json;
@Transactional @Transactional
@@ -70,12 +70,16 @@ public class OrderDeclarationUpdateService {
boolean channelChangedNow = !Objects.equals(channelCode, order.getChannelCode()) boolean channelChangedNow = !Objects.equals(channelCode, order.getChannelCode())
|| !Objects.equals(channelName, order.getChannelName()); || !Objects.equals(channelName, order.getChannelName());
if (channelChangedNow) { if (channelChangedNow) {
pricingChannelGuard.requireAvailable(tenantId, order.getReceivingWarehouseId(), channelCode, if (order.getStatus() == OrderStatus.APPROVED) {
order.getDestinationType()); 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<>(); List<OrderDeclarationDtos.FieldChange> changes = new ArrayList<>();
change(changes, null, "customerRemark", order.getCustomerRemark(), request.customerRemark()); change(changes, null, "customerRemark", order.getCustomerRemark(), request.customerRemark());
change(changes, null, "expectedArrivalAt", order.getExpectedArrivalAt(), request.expectedArrivalAt()); change(changes, null, "expectedArrivalAt", order.getExpectedArrivalAt(), request.expectedArrivalAt());
@@ -109,17 +113,6 @@ public class OrderDeclarationUpdateService {
request.expectedVersion(), version, changedBefore, channelChanged, List.copyOf(changes)); 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, private void collect(List<OrderDeclarationDtos.FieldChange> changes, OrderCargoEntity before,
OrderDeclarationDtos.Cargo after) { OrderDeclarationDtos.Cargo after) {
int item = after.itemNo(); 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,7 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.FirstlegPricingClient; import com.baiyue.order.client.FirstlegPricingClient;
import com.baiyue.order.entity.OrderEntity;
import feign.FeignException; import feign.FeignException;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -24,6 +25,29 @@ public class OrderPricingChannelGuard {
validate(tenantId, originWarehouseId, channelCode, destinationType, false); 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, private String validate(long tenantId, long originWarehouseId, String channelCode,
String destinationType, boolean requireInboundCodePrefix) { String destinationType, boolean requireInboundCodePrefix) {
try { try {
@@ -33,12 +57,7 @@ public class OrderPricingChannelGuard {
if (response == null) { if (response == null) {
throw unavailable(); throw unavailable();
} }
if (!response.successful()) { requireSuccessful(response);
ResultCode code = ResultCode.of(response.code());
String message = response.message() == null || response.message().isBlank()
? code.message() : response.message();
throw new BizException(code, message);
}
if (response.data() == null || requireInboundCodePrefix if (response.data() == null || requireInboundCodePrefix
&& (response.data().inboundCodePrefix() == null || response.data().inboundCodePrefix().isBlank())) { && (response.data().inboundCodePrefix() == null || response.data().inboundCodePrefix().isBlank())) {
throw new BizException(ResultCode.CONFLICT, "所选渠道未配置入仓码前缀"); 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() { private BizException unavailable() {
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程价格渠道校验服务暂时不可用"); return new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程价格渠道校验服务暂时不可用");
} }
@@ -212,8 +212,12 @@ paths:
description: >- description: >-
需要 platform:order:declaration:edit。货物使用订单内 itemNo 定位,不接受货物数据库 ID; 需要 platform:order:declaration:edit。货物使用订单内 itemNo 定位,不接受货物数据库 ID;
不允许修改客户、国内收货仓、订单号、入仓码、状态、仓库/PDA/设备实测、测量审核或计费事实。 不允许修改客户、国内收货仓、订单号、入仓码、状态、仓库/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 顺序刷新申报快照,不覆盖实测、箱码或已确认费用。
parameters: parameters:
- $ref: '#/components/parameters/UnifiedOrderId' - $ref: '#/components/parameters/UnifiedOrderId'
- $ref: '#/components/parameters/UnifiedTenantId' - $ref: '#/components/parameters/UnifiedTenantId'
@@ -223,7 +227,7 @@ paths:
application/json: application/json:
schema: {$ref: '#/components/schemas/OrderDeclarationUpdateRequest'} schema: {$ref: '#/components/schemas/OrderDeclarationUpdateRequest'}
responses: responses:
'200': {description: 成功返回新版本、渠道历史标记和字段级前后值;版本冲突或存在不可逆履约事实时响应体业务码为 A0409} '200': {description: 成功返回新版本、渠道历史标记和字段级前后值;版本冲突、渠道与不可逆履约或已确认费用不兼容时响应体业务码为 A0409}
'403': {description: 无权限或非平台身份} '403': {description: 无权限或非平台身份}
/api/merchant/v1/orders: /api/merchant/v1/orders:
get: get:
@@ -1,10 +1,13 @@
package com.baiyue.order.controller; package com.baiyue.order.controller;
import com.baiyue.order.dto.OrderDeclarationDtos; 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.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.ClassPathResource;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.Yaml;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@@ -24,7 +27,9 @@ class OrderDeclarationUpdateContractTest {
assertThat(OrderDeclarationDtos.UpdateRequest.class.getRecordComponents()) assertThat(OrderDeclarationDtos.UpdateRequest.class.getRecordComponents())
.extracting(component -> component.getName()) .extracting(component -> component.getName())
.containsExactly("expectedVersion", "customerRemark", "expectedArrivalAt", .containsExactly("expectedVersion", "customerRemark", "expectedArrivalAt",
"channelCode", "channelName", "cargoes"); "channelCode", "channelName", "cargoes")
.doesNotContain("receivingWarehouseId", "receivingWarehouseName",
"destinationWarehouseId", "destinationWarehouseName", "inboundCode", "orderNo");
assertThat(OrderDeclarationDtos.Cargo.class.getRecordComponents()) assertThat(OrderDeclarationDtos.Cargo.class.getRecordComponents())
.extracting(component -> component.getName()) .extracting(component -> component.getName())
.contains("itemNo", "cargoName", "cargoNameEn", "skuNo", "model", "packageCount", .contains("itemNo", "cargoName", "cargoNameEn", "skuNo", "model", "packageCount",
@@ -44,7 +49,21 @@ class OrderDeclarationUpdateContractTest {
Map<String, Object> row = map(map(schemas.get("UnifiedOrderRow")).get("properties")); Map<String, Object> row = map(map(schemas.get("UnifiedOrderRow")).get("properties"));
assertThat(row).containsKeys("expectedVolumeCbm", "actualVolumeCbm", "expectedArrivalAt", "channelChanged"); assertThat(row).containsKeys("expectedVolumeCbm", "actualVolumeCbm", "expectedArrivalAt", "channelChanged");
assertThat(map(row.get("actualVolumeCbm")).get("description").toString()).contains("仅 Firstleg 实测"); 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");
}
@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);
assertThat(fulfillment.getAnnotation(PostMapping.class).value())
.containsExactly("/order-channel-changes/validate");
assertThat(pricing.getAnnotation(PostMapping.class).value())
.containsExactly("/order-channel-changes/validate");
} }
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
@@ -2,7 +2,6 @@ package com.baiyue.order.service;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.dto.OrderDeclarationDtos; import com.baiyue.order.dto.OrderDeclarationDtos;
import com.baiyue.order.dto.UnifiedOrderResponse;
import com.baiyue.order.entity.OrderCargoEntity; import com.baiyue.order.entity.OrderCargoEntity;
import com.baiyue.order.entity.OrderEntity; import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderStatus; import com.baiyue.order.entity.OrderStatus;
@@ -33,7 +32,7 @@ import static org.mockito.Mockito.when;
class OrderDeclarationUpdateServiceTest { class OrderDeclarationUpdateServiceTest {
@MockBean private OrderRepository repository; @MockBean private OrderRepository repository;
@MockBean private OrderPricingChannelGuard pricingChannelGuard; @MockBean private OrderPricingChannelGuard pricingChannelGuard;
@MockBean private UnifiedOrderQueryService unifiedOrders; @MockBean private OrderFulfillmentChannelChangeGuard fulfillmentChannelGuard;
@MockBean private ObjectMapper json; @MockBean private ObjectMapper json;
@Resource private OrderDeclarationUpdateService service; @Resource private OrderDeclarationUpdateService service;
@@ -76,45 +75,59 @@ class OrderDeclarationUpdateServiceTest {
} }
@Test @Test
void approvedOrderWithMeasurementFactIsAtomicallyRejected() { 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()));
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 {
OrderEntity order = order(OrderStatus.APPROVED, 3); OrderEntity order = order(OrderStatus.APPROVED, 3);
when(repository.findOwned(100L, 200L)).thenReturn(order); when(repository.findOwned(100L, 200L)).thenReturn(order);
when(repository.findCargo(100L, 200L)).thenReturn(List.of(cargo())); when(repository.findCargo(100L, 200L)).thenReturn(List.of(cargo()));
when(repository.updateDeclarationHeader(anyLong(), anyLong(), anyInt(), anyLong(), any(), any(), when(repository.updateDeclarationHeader(anyLong(), anyLong(), anyInt(), anyLong(), any(), any(),
any(), any(), anyBoolean())).thenReturn(1); any(), any(), anyBoolean())).thenReturn(1);
when(repository.updateDeclarationCargo(anyLong(), anyLong(), any())).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}"); 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()); 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) { private OrderEntity order(OrderStatus status, int version) {
OrderEntity order = new OrderEntity(); OrderEntity order = new OrderEntity();
order.setId(200L); order.setTenantId(100L); order.setOrderNo("ORD-200"); order.setId(200L); order.setTenantId(100L); order.setOrderNo("ORD-200");
@@ -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;
}
}
@@ -140,7 +140,9 @@ class OrderInboundOutboxPublisherTest {
fixture.publisher.publishApprovedOrders(); 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.repository).markOutboxPublished(eq(11L), eq(41L), any(LocalDateTime.class));
verify(fixture.client, never()).create(any(), any()); verify(fixture.client, never()).create(any(), any());
} }
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.FirstlegPricingClient; import com.baiyue.order.client.FirstlegPricingClient;
import com.baiyue.order.entity.OrderEntity;
import feign.FeignException; import feign.FeignException;
import feign.Request; import feign.Request;
import feign.Response; import feign.Response;
@@ -65,6 +66,24 @@ class OrderPricingChannelGuardTest {
.isEqualTo(ResultCode.CONFLICT); .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 @Test
void nullResponseMapsToServiceUnavailable() { void nullResponseMapsToServiceUnavailable() {
var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK", "FBA", true); var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK", "FBA", true);