diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java index 9e1a1b462..140947486 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java @@ -36,12 +36,22 @@ public interface FirstlegInboundClient { ApiResponse updateDeclaration(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody OrderDeclarationDtos.UpdatedEvent event); + @PostMapping("/order-channel-changes/validate") + ApiResponse validateOrderChannelChange( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestBody OrderChannelChangeValidationRequest request); + record InboundReferenceRequest(long tenantId, List 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, diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java index 3a14fbe13..de9933e8f 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java @@ -13,7 +13,19 @@ public interface FirstlegPricingClient { ApiResponse validateChannel(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody ChannelValidationRequest request); + @PostMapping("/order-channel-changes/validate") + ApiResponse validateOrderChannelChange( + @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) {} } diff --git a/order/src/main/java/com/baiyue/order/service/OrderDeclarationUpdateService.java b/order/src/main/java/com/baiyue/order/service/OrderDeclarationUpdateService.java index 5e048ae89..ef2cb4e37 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderDeclarationUpdateService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderDeclarationUpdateService.java @@ -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 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 changes, OrderCargoEntity before, OrderDeclarationDtos.Cargo after) { int item = after.itemNo(); diff --git a/order/src/main/java/com/baiyue/order/service/OrderFulfillmentChannelChangeGuard.java b/order/src/main/java/com/baiyue/order/service/OrderFulfillmentChannelChangeGuard.java new file mode 100644 index 000000000..19368ff3e --- /dev/null +++ b/order/src/main/java/com/baiyue/order/service/OrderFulfillmentChannelChangeGuard.java @@ -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 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, "头程履约渠道校验服务暂时不可用"); + } +} diff --git a/order/src/main/java/com/baiyue/order/service/OrderPricingChannelGuard.java b/order/src/main/java/com/baiyue/order/service/OrderPricingChannelGuard.java index 8a901f1b3..d86d17b68 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderPricingChannelGuard.java +++ b/order/src/main/java/com/baiyue/order/service/OrderPricingChannelGuard.java @@ -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 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, "头程价格渠道校验服务暂时不可用"); } diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index c51731040..e5c213751 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -212,8 +212,12 @@ 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 顺序刷新申报快照,不覆盖实测、箱码或已确认费用。 parameters: - $ref: '#/components/parameters/UnifiedOrderId' - $ref: '#/components/parameters/UnifiedTenantId' @@ -223,7 +227,7 @@ paths: application/json: schema: {$ref: '#/components/schemas/OrderDeclarationUpdateRequest'} responses: - '200': {description: 成功返回新版本、渠道历史标记和字段级前后值;版本冲突或存在不可逆履约事实时响应体业务码为 A0409} + '200': {description: 成功返回新版本、渠道历史标记和字段级前后值;版本冲突、渠道与不可逆履约或已确认费用不兼容时响应体业务码为 A0409} '403': {description: 无权限或非平台身份} /api/merchant/v1/orders: get: diff --git a/order/src/test/java/com/baiyue/order/controller/OrderDeclarationUpdateContractTest.java b/order/src/test/java/com/baiyue/order/controller/OrderDeclarationUpdateContractTest.java index 49094568d..b1157847f 100644 --- a/order/src/test/java/com/baiyue/order/controller/OrderDeclarationUpdateContractTest.java +++ b/order/src/test/java/com/baiyue/order/controller/OrderDeclarationUpdateContractTest.java @@ -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; @@ -24,7 +27,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", @@ -44,7 +49,21 @@ class OrderDeclarationUpdateContractTest { Map 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"); + } + + @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") diff --git a/order/src/test/java/com/baiyue/order/service/OrderDeclarationUpdateServiceTest.java b/order/src/test/java/com/baiyue/order/service/OrderDeclarationUpdateServiceTest.java index bfb7fc338..d688ee976 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderDeclarationUpdateServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderDeclarationUpdateServiceTest.java @@ -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"); diff --git a/order/src/test/java/com/baiyue/order/service/OrderFulfillmentChannelChangeGuardTest.java b/order/src/test/java/com/baiyue/order/service/OrderFulfillmentChannelChangeGuardTest.java new file mode 100644 index 000000000..8907ba5bd --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/OrderFulfillmentChannelChangeGuardTest.java @@ -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; + } +} diff --git a/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java b/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java index ca93490e5..7eaf38524 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java @@ -140,7 +140,9 @@ 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()); } diff --git a/order/src/test/java/com/baiyue/order/service/OrderPricingChannelGuardTest.java b/order/src/test/java/com/baiyue/order/service/OrderPricingChannelGuardTest.java index d26d9b3e6..b17369765 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderPricingChannelGuardTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderPricingChannelGuardTest.java @@ -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);