优化
This commit is contained in:
+19
@@ -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())
|
||||
|
||||
+173
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,39 @@ paths:
|
||||
requireInboundCodePrefix: {type: boolean}
|
||||
responses:
|
||||
'200': {description: 成功时 data 返回 channelCode;requireInboundCodePrefix=true 时同时返回 inboundCodePrefix}
|
||||
/internal/firstleg/v1/pricing/order-channel-changes/validate:
|
||||
post:
|
||||
summary: Order 提交渠道变更前校验 Firstleg 计价资格与费用可逆性
|
||||
description: >-
|
||||
按客户、订单、国内收货仓、目的仓类型/标识、报价包税方式和新渠道校验当前生效价格。
|
||||
若已有 CONFIRMED/SENT 费用或 PUBLISHED 运费明细,返回冲突并明确要求先走费用作废流程;
|
||||
CALCULATED 仅在响应中标记将失效,本接口不写数据。
|
||||
security:
|
||||
- internalServiceToken: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/OrderChannelChangePricingRequest'}
|
||||
responses:
|
||||
'200': {description: 成功时 data 返回价格版本、费用状态及是否需失效未确认预估}
|
||||
/internal/firstleg/v1/pricing/order-channel-changes/apply:
|
||||
post:
|
||||
summary: 按订单版本幂等应用渠道变更并失效未确认价格预估
|
||||
description: >-
|
||||
仅供 Order 在渠道变更成功后调用。tenantId + orderId + orderVersion 构成幂等边界;
|
||||
旧版本重放不会覆盖新版本。只将 CALCULATED 费用及草稿运费标记为 SUPERSEDED,
|
||||
入库单恢复 UNPRICED 并可重新计价;实际净重、实际体积和测量审核事实保持不变。
|
||||
已确认、已发送或已发布费用一律拒绝,不能由本命令覆盖。
|
||||
security:
|
||||
- internalServiceToken: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/OrderChannelChangePricingRequest'}
|
||||
responses:
|
||||
'200': {description: 成功或同版本同请求幂等重放,data 返回应用版本和失效费用 ID}
|
||||
/internal/firstleg/v1/inbounds/{inboundId}/fees/{feeId}/source-snapshot:
|
||||
get:
|
||||
summary: Finance 按入库费用来源版本精确回查不可变快照
|
||||
@@ -1009,6 +1042,33 @@ components:
|
||||
required: true
|
||||
schema: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
schemas:
|
||||
OrderChannelChangePricingRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [tenantId, orderId, orderVersion, originWarehouseId, channelCode, channelName, destinationType, pricingTaxMode]
|
||||
properties:
|
||||
tenantId: {type: integer, format: int64, minimum: 1}
|
||||
orderId: {type: integer, format: int64, minimum: 1}
|
||||
orderVersion: {type: integer, format: int64, minimum: 0, description: Order 渠道变更完成后的聚合版本;用于幂等与拒绝旧事件}
|
||||
originWarehouseId: {type: integer, format: int64, minimum: 1}
|
||||
channelCode: {type: string, minLength: 1, maxLength: 64}
|
||||
channelName: {type: string, minLength: 1, maxLength: 128}
|
||||
destinationType: {type: string, enum: [FBA, OVERSEAS]}
|
||||
destinationWarehouseId: {type: integer, format: int64, minimum: 1, nullable: true}
|
||||
destinationCode: {type: string, maxLength: 64, nullable: true}
|
||||
pricingTaxMode: {type: string, enum: [TAX_INCLUDED, TAX_EXCLUDED]}
|
||||
OrderChannelChangePricingResponse:
|
||||
type: object
|
||||
required: [inboundId, orderId, orderVersion, channelCode, feeStatus, calculatedFeeWillBeInvalidated]
|
||||
properties:
|
||||
inboundId: {type: integer, format: int64}
|
||||
orderId: {type: integer, format: int64}
|
||||
orderVersion: {type: integer, format: int64}
|
||||
channelCode: {type: string}
|
||||
priceBookVersionId: {type: integer, format: int64, nullable: true}
|
||||
feeStatus: {type: string, enum: [UNPRICED, CALCULATED]}
|
||||
calculatedFeeWillBeInvalidated: {type: boolean}
|
||||
invalidatedFeeId: {type: integer, format: int64, nullable: true}
|
||||
UnifiedMainStatus:
|
||||
type: string
|
||||
enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING, LOADED, DELIVERED]
|
||||
|
||||
@@ -120,6 +120,10 @@ class PriceBookContractTest {
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(firstlegOpenapi)
|
||||
.contains("/internal/firstleg/v1/pricing/channels/validate:")
|
||||
.contains("/internal/firstleg/v1/pricing/order-channel-changes/validate:")
|
||||
.contains("/internal/firstleg/v1/pricing/order-channel-changes/apply:")
|
||||
.contains("tenantId + orderId + orderVersion 构成幂等边界")
|
||||
.contains("实际净重、实际体积和测量审核事实保持不变")
|
||||
.contains("required: [tenantId, originWarehouseId, channelCode, destinationType, requireInboundCodePrefix]")
|
||||
.contains("LEGACY_MANUAL 模式仅校验渠道可用性");
|
||||
}
|
||||
|
||||
@@ -41,7 +41,6 @@ class LoadingListRepositoryTest {
|
||||
row.setVersion(1);
|
||||
row.setContainerNo("MSCU-001");
|
||||
row.setCabinetNo("CAB-001");
|
||||
row.setPreloadPushedAt(java.time.LocalDateTime.now());
|
||||
row.setPreloadLinkCount(2L);
|
||||
row.setConfirmedPreloadCount(1L);
|
||||
row.setOceanBookingId(81L);
|
||||
@@ -81,7 +80,6 @@ class LoadingListRepositoryTest {
|
||||
row.setPlanNo("LP-41");
|
||||
row.setPlanStatus("EDITING");
|
||||
row.setVersion(1);
|
||||
row.setPreloadPushedAt(java.time.LocalDateTime.now());
|
||||
row.setPreloadLinkCount(2L);
|
||||
row.setConfirmedPreloadCount(0L);
|
||||
row.setOceanBookingId(81L);
|
||||
|
||||
+1
-1
@@ -155,7 +155,7 @@ class Phase3FinalUnifiedSqlContractTest {
|
||||
.contains("存在缺失中文注释的业务字段")
|
||||
.doesNotContain("CREATE PROCEDURE upgrade_column_comment");
|
||||
assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(144);
|
||||
assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2378);
|
||||
assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2374);
|
||||
assertThat(sql)
|
||||
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_id'")
|
||||
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_name'")
|
||||
|
||||
@@ -129,7 +129,6 @@ class LoadingPickServiceTest {
|
||||
booking.setId(40L);
|
||||
var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity();
|
||||
plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING");
|
||||
plan.setPreloadPushedAt(java.time.LocalDateTime.now());
|
||||
var allocation = new OceanBookingContainerAllocationEntity(); allocation.setOceanBookingId(90L);
|
||||
var ocean = new OceanBookingEntity(); ocean.setId(90L); ocean.setStatus("RELEASED");
|
||||
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
|
||||
@@ -157,7 +156,6 @@ class LoadingPickServiceTest {
|
||||
booking.setId(40L); booking.setContainerNo("MSCU-001");
|
||||
var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity();
|
||||
plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING");
|
||||
plan.setPreloadPushedAt(java.time.LocalDateTime.now());
|
||||
var allocation = new OceanBookingContainerAllocationEntity(); allocation.setOceanBookingId(90L);
|
||||
var ocean = new OceanBookingEntity(); ocean.setId(90L); ocean.setStatus("CONFIRMED");
|
||||
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
|
||||
@@ -178,7 +176,6 @@ class LoadingPickServiceTest {
|
||||
booking.setId(40L); booking.setContainerNo("MSCU-001");
|
||||
var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity();
|
||||
plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING");
|
||||
plan.setPreloadPushedAt(java.time.LocalDateTime.now());
|
||||
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
|
||||
when(smartLoadingRepository.planByBooking(40L)).thenReturn(plan);
|
||||
when(loadingModelBridge.byLoadingPlan(10L)).thenReturn(List.of(
|
||||
@@ -244,7 +241,6 @@ class LoadingPickServiceTest {
|
||||
booking.setId(40L); booking.setContainerNo("MSCU-001");
|
||||
var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity();
|
||||
plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING");
|
||||
plan.setPreloadPushedAt(java.time.LocalDateTime.now());
|
||||
var allocation = new OceanBookingContainerAllocationEntity(); allocation.setOceanBookingId(90L);
|
||||
var ocean = new OceanBookingEntity(); ocean.setId(90L); ocean.setStatus("RELEASED");
|
||||
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
|
||||
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.OrderChannelChangePricing.Request;
|
||||
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.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig(OrderChannelChangePricingService.class)
|
||||
class OrderChannelChangePricingServiceTest {
|
||||
@MockBean private FbaPricingRepository repository;
|
||||
@MockBean private FeeItemRepository feeItems;
|
||||
@MockBean private MerchantPriceBookService priceBooks;
|
||||
@MockBean private ActivePriceBookEstimator estimator;
|
||||
@Resource private OrderChannelChangePricingService service;
|
||||
|
||||
@Test
|
||||
void rejectsConfirmedFeeBeforeChannelCanChange() {
|
||||
InboundPricingEntity inbound = inbound("CONFIRMED");
|
||||
InboundFeeEntity fee = fee(91L, "CONFIRMED");
|
||||
when(repository.findInboundByOrder(10001L, 77L)).thenReturn(inbound);
|
||||
when(repository.findLatestFee(10001L, 88L)).thenReturn(fee);
|
||||
|
||||
assertThatThrownBy(() -> service.validate(request(4)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("费用已确认或已发布");
|
||||
verify(estimator, never()).estimate(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatesExactPriceAndMarksCalculatedEstimateForInvalidation() {
|
||||
InboundPricingEntity inbound = inbound("CALCULATED");
|
||||
when(repository.findInboundByOrder(10001L, 77L)).thenReturn(inbound);
|
||||
when(repository.findLatestFee(10001L, 88L)).thenReturn(fee(91L, "CALCULATED"));
|
||||
when(estimator.estimate(any())).thenReturn(estimate());
|
||||
|
||||
var response = service.validate(request(4));
|
||||
|
||||
assertThat(response.priceBookVersionId()).isEqualTo(12L);
|
||||
assertThat(response.calculatedFeeWillBeInvalidated()).isTrue();
|
||||
assertThat(response.feeStatus()).isEqualTo("CALCULATED");
|
||||
verify(priceBooks).requireAvailableChannel(10001L, 9L, "MATSON_NEW", "FBA", false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliesByOrderVersionAndOnlyInvalidatesCalculatedFacts() {
|
||||
InboundPricingEntity inbound = inbound("CALCULATED");
|
||||
InboundFeeEntity fee = fee(91L, "CALCULATED");
|
||||
when(repository.findInboundByOrderForUpdate(10001L, 77L)).thenReturn(inbound);
|
||||
when(repository.findLatestFeeForUpdate(10001L, 88L)).thenReturn(fee);
|
||||
when(estimator.estimate(any())).thenReturn(estimate());
|
||||
when(repository.supersedeCurrent(10001L, 91L, "CALCULATED")).thenReturn(1);
|
||||
when(repository.applyOrderChannelChange(inbound, "MATSON_NEW", "新渠道")).thenReturn(1);
|
||||
when(repository.completeIdempotency(any(), org.mockito.ArgumentMatchers.eq(4L), any())).thenReturn(1);
|
||||
|
||||
var response = service.apply(request(4));
|
||||
|
||||
assertThat(response.feeStatus()).isEqualTo("UNPRICED");
|
||||
assertThat(response.invalidatedFeeId()).isEqualTo(91L);
|
||||
assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("37");
|
||||
assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("0.102");
|
||||
verify(feeItems).supersedeFreight(
|
||||
org.mockito.ArgumentMatchers.eq(10001L), org.mockito.ArgumentMatchers.eq(88L),
|
||||
org.mockito.ArgumentMatchers.eq(0L), org.mockito.ArgumentMatchers.eq(0L), any());
|
||||
verify(repository).insertIdempotency(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameOrderVersionReplaysWithoutInvalidatingAgain() {
|
||||
InboundPricingEntity inbound = inbound("UNPRICED");
|
||||
InboundPricingIdempotencyEntity token = token(4L, "COMPLETED");
|
||||
token.setRequestHash(requestHash(request(4)));
|
||||
when(repository.findInboundByOrderForUpdate(10001L, 77L)).thenReturn(inbound);
|
||||
when(repository.findIdempotency(10001L, 0L, "ORDER_CHANNEL_CHANGE", "77:4")).thenReturn(token);
|
||||
when(estimator.estimate(any())).thenReturn(estimate());
|
||||
|
||||
assertThat(service.apply(request(4)).orderVersion()).isEqualTo(4L);
|
||||
|
||||
verify(repository, never()).supersedeCurrent(any(Long.class), any(Long.class), any());
|
||||
verify(repository, never()).applyOrderChannelChange(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOlderOrderVersionAfterNewerVersionWasApplied() {
|
||||
InboundPricingEntity inbound = inbound("UNPRICED");
|
||||
when(repository.findInboundByOrderForUpdate(10001L, 77L)).thenReturn(inbound);
|
||||
when(repository.findLatestCompletedOrderChannelChange(10001L, 88L))
|
||||
.thenReturn(token(5L, "COMPLETED"));
|
||||
|
||||
assertThatThrownBy(() -> service.apply(request(4)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("订单渠道版本已推进");
|
||||
verify(estimator, never()).estimate(any());
|
||||
}
|
||||
|
||||
private static Request request(long orderVersion) {
|
||||
return new Request(10001L, 77L, orderVersion, 9L, "MATSON_NEW", "新渠道",
|
||||
"FBA", 501L, "ONT8", "TAX_INCLUDED");
|
||||
}
|
||||
|
||||
private static InboundPricingEntity inbound(String feeStatus) {
|
||||
InboundPricingEntity value = new InboundPricingEntity();
|
||||
value.setId(88L);
|
||||
value.setTenantId(10001L);
|
||||
value.setOrderId(77L);
|
||||
value.setWarehouseId(9L);
|
||||
value.setDestinationType("FBA");
|
||||
value.setDestinationWarehouseId(501L);
|
||||
value.setDestinationCode("ONT8");
|
||||
value.setDestinationName("ONT8");
|
||||
value.setPricingTaxMode("TAX_INCLUDED");
|
||||
value.setProcessStatus("WAIT_SPLIT");
|
||||
value.setFeeStatus(feeStatus);
|
||||
value.setActualNetWeightKg(new BigDecimal("37"));
|
||||
value.setActualVolumeCbm(new BigDecimal("0.102"));
|
||||
return value;
|
||||
}
|
||||
|
||||
private static InboundFeeEntity fee(long id, String status) {
|
||||
InboundFeeEntity value = new InboundFeeEntity();
|
||||
value.setId(id);
|
||||
value.setInboundId(88L);
|
||||
value.setFeeStatus(status);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static ActivePriceBookEstimator.Estimate estimate() {
|
||||
return new ActivePriceBookEstimator.Estimate(12L, "MATSON_NEW", null, null, null,
|
||||
BigDecimal.ONE, "KG", BigDecimal.ONE, BigDecimal.ONE, "CNY", BigDecimal.ONE, 6, "HALF_UP");
|
||||
}
|
||||
|
||||
private static InboundPricingIdempotencyEntity token(long version, String status) {
|
||||
InboundPricingIdempotencyEntity value = new InboundPricingIdempotencyEntity();
|
||||
value.setResultResourceId(version);
|
||||
value.setStatus(status);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String requestHash(Request request) {
|
||||
try {
|
||||
var method = OrderChannelChangePricingService.class.getDeclaredMethod("hash", Request.class);
|
||||
method.setAccessible(true);
|
||||
return (String) method.invoke(null, request);
|
||||
} catch (ReflectiveOperationException exception) {
|
||||
throw new AssertionError(exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user