This commit is contained in:
2026-09-02 10:26:37 +08:00
parent 37c37c75c8
commit 5f8b7baa06
11 changed files with 507 additions and 7 deletions
@@ -4,7 +4,9 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.PriceBookChannelValidation;
import com.baiyue.firstleg.dto.OrderChannelChangePricing;
import com.baiyue.firstleg.service.MerchantPriceBookService;
import com.baiyue.firstleg.service.OrderChannelChangePricingService;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
@@ -22,6 +24,7 @@ import java.security.MessageDigest;
@RequestMapping("/internal/firstleg/v1/pricing")
public class InternalPriceBookController {
@Resource private MerchantPriceBookService service;
@Resource private OrderChannelChangePricingService channelChanges;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
private byte[] expectedToken;
@@ -30,6 +33,22 @@ public class InternalPriceBookController {
expectedToken = internalToken.getBytes(StandardCharsets.UTF_8);
}
@PostMapping("/order-channel-changes/validate")
public ApiResponse<OrderChannelChangePricing.Response> validateOrderChannelChange(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody OrderChannelChangePricing.Request request) {
requireToken(token);
return ApiResponse.success(channelChanges.validate(request));
}
@PostMapping("/order-channel-changes/apply")
public ApiResponse<OrderChannelChangePricing.Response> applyOrderChannelChange(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody OrderChannelChangePricing.Request request) {
requireToken(token);
return ApiResponse.success(channelChanges.apply(request));
}
@PostMapping("/channels/validate")
public ApiResponse<PriceBookChannelValidation.Response> validateChannel(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@@ -0,0 +1,36 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
/** Order 审核后变更渠道时使用的 Firstleg 计价门禁与同步契约。 */
public final class OrderChannelChangePricing {
private OrderChannelChangePricing() {}
public record Request(
@Min(1) long tenantId,
@Min(1) long orderId,
@Min(0) long orderVersion,
@Min(1) long originWarehouseId,
@NotBlank @Size(max = 64) String channelCode,
@NotBlank @Size(max = 128) String channelName,
@NotNull @Pattern(regexp = "FBA|OVERSEAS") String destinationType,
Long destinationWarehouseId,
@Size(max = 64) String destinationCode,
@NotNull @Pattern(regexp = "TAX_INCLUDED|TAX_EXCLUDED") String pricingTaxMode
) {}
public record Response(
long inboundId,
long orderId,
long orderVersion,
String channelCode,
Long priceBookVersionId,
String feeStatus,
boolean calculatedFeeWillBeInvalidated,
Long invalidatedFeeId
) {}
}
@@ -17,6 +17,7 @@ public class InboundPricingEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long orderId;
private String inboundCode;
private String processStatus;
private String measureReviewStatus;
@@ -27,6 +28,7 @@ public class InboundPricingEntity extends BaseEntity {
private String warehouseName;
private String businessType;
private String channelCode;
private String channelName;
private Long carrierId;
private String carrierName;
private String taxMode;
@@ -119,6 +119,19 @@ public class FbaPricingRepository {
.eq(InboundPricingEntity::getId, inboundId));
}
public InboundPricingEntity findInboundByOrderForUpdate(long tenantId, long orderId) {
return inbounds.selectOne(new LambdaQueryWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId)
.eq(InboundPricingEntity::getOrderId, orderId)
.last("FOR UPDATE"));
}
public InboundPricingEntity findInboundByOrder(long tenantId, long orderId) {
return inbounds.selectOne(new LambdaQueryWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId)
.eq(InboundPricingEntity::getOrderId, orderId));
}
public List<CustomerChargeRuleEntity> matchRules(InboundPricingEntity inbound, LocalDateTime at) {
return rules.selectList(new LambdaQueryWrapper<CustomerChargeRuleEntity>()
.eq(CustomerChargeRuleEntity::getTenantId, inbound.getTenantId())
@@ -178,6 +191,31 @@ public class FbaPricingRepository {
.orderByDesc(InboundFeeEntity::getFeeVersion).last("LIMIT 1"));
}
public InboundFeeEntity findLatestFeeForUpdate(long tenantId, long inboundId) {
return fees.selectOne(new LambdaQueryWrapper<InboundFeeEntity>()
.eq(InboundFeeEntity::getTenantId, tenantId)
.eq(InboundFeeEntity::getInboundId, inboundId)
.orderByDesc(InboundFeeEntity::getFeeVersion).last("LIMIT 1 FOR UPDATE"));
}
public int applyOrderChannelChange(InboundPricingEntity inbound, String channelCode, String channelName) {
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, inbound.getTenantId())
.eq(InboundPricingEntity::getId, inbound.getId())
.in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED"))
.set(InboundPricingEntity::getChannelCode, channelCode)
.set(InboundPricingEntity::getChannelName, channelName)
.set(InboundPricingEntity::getFeeStatus, "UNPRICED")
.set(InboundPricingEntity::getChargeableWeightKg, null)
.set(InboundPricingEntity::getChargeableVolumeCbm, null)
.set(InboundPricingEntity::getUpdatedAt, LocalDateTime.now())
.setSql("version = version + 1");
if ("FEE_CONFIRMING".equals(inbound.getProcessStatus())) {
update.set(InboundPricingEntity::getProcessStatus, "WAIT_PRICING");
}
return inbounds.update(null, update);
}
public InboundFeeItemEntity findBaseItem(long tenantId, long feeId) {
return items.selectOne(new LambdaQueryWrapper<InboundFeeItemEntity>()
.eq(InboundFeeItemEntity::getTenantId, tenantId)
@@ -302,6 +340,16 @@ public class FbaPricingRepository {
public void insertIdempotency(InboundPricingIdempotencyEntity value) { idempotencies.insert(value); }
public InboundPricingIdempotencyEntity findLatestCompletedOrderChannelChange(long tenantId, long inboundId) {
return idempotencies.selectOne(new LambdaQueryWrapper<InboundPricingIdempotencyEntity>()
.eq(InboundPricingIdempotencyEntity::getTenantId, tenantId)
.eq(InboundPricingIdempotencyEntity::getInboundId, inboundId)
.eq(InboundPricingIdempotencyEntity::getOperation, "ORDER_CHANNEL_CHANGE")
.eq(InboundPricingIdempotencyEntity::getStatus, "COMPLETED")
.orderByDesc(InboundPricingIdempotencyEntity::getResultResourceId)
.last("LIMIT 1"));
}
public int completeIdempotency(InboundPricingIdempotencyEntity value, long resourceId, LocalDateTime now) {
return idempotencies.update(null, new LambdaUpdateWrapper<InboundPricingIdempotencyEntity>()
.eq(InboundPricingIdempotencyEntity::getTenantId, value.getTenantId())
@@ -0,0 +1,173 @@
package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.domain.PricingDestinationType;
import com.baiyue.firstleg.domain.PricingTaxMode;
import com.baiyue.firstleg.dto.OrderChannelChangePricing.Request;
import com.baiyue.firstleg.dto.OrderChannelChangePricing.Response;
import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest;
import com.baiyue.firstleg.entity.InboundFeeEntity;
import com.baiyue.firstleg.entity.InboundPricingEntity;
import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity;
import com.baiyue.firstleg.pricing.ActivePriceBookEstimator;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import com.baiyue.firstleg.repository.FeeItemRepository;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.Objects;
@Service
public class OrderChannelChangePricingService {
private static final BigDecimal ELIGIBILITY_WEIGHT = new BigDecimal("1000000");
private static final BigDecimal ELIGIBILITY_VOLUME = BigDecimal.ONE;
@Resource private FbaPricingRepository repository;
@Resource private FeeItemRepository feeItems;
@Resource private MerchantPriceBookService priceBooks;
@Resource private ActivePriceBookEstimator estimator;
@Transactional(readOnly = true)
public Response validate(Request request) {
InboundPricingEntity inbound = requiredInbound(request, false);
InboundFeeEntity fee = repository.findLatestFee(request.tenantId(), inbound.getId());
requireReversible(inbound, fee);
var estimate = requireEligiblePrice(request, inbound);
return response(request, inbound, estimate.priceBookVersionId(), fee, null);
}
@Transactional
public Response apply(Request request) {
InboundPricingEntity inbound = requiredInbound(request, true);
String key = request.orderId() + ":" + request.orderVersion();
String requestHash = hash(request);
InboundPricingIdempotencyEntity replay = repository.findIdempotency(
request.tenantId(), 0L, "ORDER_CHANNEL_CHANGE", key);
if (replay != null) {
if (!requestHash.equals(replay.getRequestHash())) throw conflict("同一订单版本已用于不同渠道变更");
if (!"COMPLETED".equals(replay.getStatus())) throw conflict("相同渠道变更正在处理中");
var estimate = requireEligiblePrice(request, inbound);
return new Response(inbound.getId(), request.orderId(), request.orderVersion(), estimate.channelCode(),
estimate.priceBookVersionId(), inbound.getFeeStatus(), false, null);
}
InboundPricingIdempotencyEntity latest = repository.findLatestCompletedOrderChannelChange(
request.tenantId(), inbound.getId());
if (latest != null && latest.getResultResourceId() > request.orderVersion()) {
throw conflict("订单渠道版本已推进,请勿重放旧版本");
}
InboundFeeEntity fee = repository.findLatestFeeForUpdate(request.tenantId(), inbound.getId());
requireReversible(inbound, fee);
var estimate = requireEligiblePrice(request, inbound);
Long invalidatedFeeId = fee != null && "CALCULATED".equals(fee.getFeeStatus()) ? fee.getId() : null;
if (invalidatedFeeId != null) {
if (repository.supersedeCurrent(request.tenantId(), invalidatedFeeId, "CALCULATED") != 1) {
throw conflict("费用预估状态已变化,渠道变更未应用");
}
feeItems.supersedeFreight(request.tenantId(), inbound.getId(), 0L, 0L, LocalDateTime.now());
}
if (repository.applyOrderChannelChange(inbound, estimate.channelCode(), request.channelName()) != 1) {
throw conflict("入库单费用状态已变化,渠道变更未应用");
}
InboundPricingIdempotencyEntity token = new InboundPricingIdempotencyEntity();
token.setId(IdWorker.getId());
token.setTenantId(request.tenantId());
token.setOperation("ORDER_CHANNEL_CHANGE");
token.setOperatorId(0L);
token.setInboundId(inbound.getId());
token.setIdempotencyKey(key);
token.setRequestHash(requestHash);
token.setStatus("PROCESSING");
token.setCreatedAt(LocalDateTime.now());
token.setUpdatedAt(LocalDateTime.now());
repository.insertIdempotency(token);
if (repository.completeIdempotency(token, request.orderVersion(), LocalDateTime.now()) != 1) {
throw conflict("渠道变更幂等状态已变化");
}
return new Response(inbound.getId(), request.orderId(), request.orderVersion(), estimate.channelCode(),
estimate.priceBookVersionId(), "UNPRICED", invalidatedFeeId != null, invalidatedFeeId);
}
private InboundPricingEntity requiredInbound(Request request, boolean lock) {
InboundPricingEntity inbound = lock
? repository.findInboundByOrderForUpdate(request.tenantId(), request.orderId())
: repository.findInboundByOrder(request.tenantId(), request.orderId());
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "订单对应入库单不存在,无法校验费用门禁");
if (!Objects.equals(inbound.getWarehouseId(), request.originWarehouseId())
|| !Objects.equals(inbound.getDestinationType(), request.destinationType())
|| !Objects.equals(inbound.getDestinationWarehouseId(), request.destinationWarehouseId())
|| !sameText(inbound.getDestinationCode(), request.destinationCode())
|| !Objects.equals(inbound.getPricingTaxMode(), request.pricingTaxMode())) {
throw conflict("订单计价维度与 Firstleg 入库快照不一致");
}
return inbound;
}
private ActivePriceBookEstimator.Estimate requireEligiblePrice(Request request, InboundPricingEntity inbound) {
priceBooks.requireAvailableChannel(request.tenantId(), request.originWarehouseId(),
request.channelCode(), request.destinationType(), false);
BigDecimal weight = positive(inbound.getActualNetWeightKg(), ELIGIBILITY_WEIGHT);
BigDecimal volume = positive(inbound.getActualVolumeCbm(), ELIGIBILITY_VOLUME);
return estimator.estimate(new EstimateRequest(request.originWarehouseId(), request.channelCode(),
request.destinationCode(), PricingDestinationType.valueOf(request.destinationType()),
request.destinationWarehouseId(), inbound.getDestinationName(), null,
PricingTaxMode.valueOf(request.pricingTaxMode()), weight, volume));
}
private void requireReversible(InboundPricingEntity inbound, InboundFeeEntity fee) {
String feeStatus = fee == null ? inbound.getFeeStatus() : fee.getFeeStatus();
if ("CONFIRMED".equals(feeStatus) || "SENT".equals(feeStatus)
|| feeItems.findPublishedFreight(inbound.getTenantId(), inbound.getId()) != null) {
throw conflict("费用已确认或已发布,渠道不可变更;请先按费用作废流程处理");
}
if (!"UNPRICED".equals(inbound.getFeeStatus()) && !"CALCULATED".equals(inbound.getFeeStatus())) {
throw conflict("当前费用状态不可逆,渠道不可变更");
}
}
private static Response response(Request request, InboundPricingEntity inbound, Long versionId,
InboundFeeEntity fee, Long invalidatedFeeId) {
String status = fee != null && ("CALCULATED".equals(fee.getFeeStatus())
|| "CONFIRMED".equals(fee.getFeeStatus()) || "SENT".equals(fee.getFeeStatus()))
? fee.getFeeStatus() : inbound.getFeeStatus();
boolean calculated = "CALCULATED".equals(status);
return new Response(inbound.getId(), request.orderId(), request.orderVersion(), request.channelCode().trim().toUpperCase(),
versionId, status, calculated, invalidatedFeeId);
}
private static BigDecimal positive(BigDecimal value, BigDecimal fallback) {
return value != null && value.signum() > 0 ? value : fallback;
}
private static boolean sameText(String left, String right) {
String a = left == null ? null : left.trim().toUpperCase();
String b = right == null ? null : right.trim().toUpperCase();
return Objects.equals(a, b);
}
private static String hash(Request request) {
try {
String value = request.tenantId() + ":" + request.orderId() + ":" + request.orderVersion() + ":"
+ request.originWarehouseId() + ":" + request.channelCode().trim().toUpperCase() + ":"
+ request.destinationType() + ":" + request.destinationWarehouseId() + ":"
+ request.destinationCode() + ":" + request.pricingTaxMode();
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("JVM不支持SHA-256", exception);
}
}
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
}