This commit is contained in:
2026-09-02 10:34:02 +08:00
44 changed files with 1003 additions and 172 deletions
@@ -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: