feat(firstleg): enforce pricing adjustment bounds
This commit is contained in:
@@ -7,6 +7,7 @@ import jakarta.validation.constraints.NotBlank;
|
|||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import jakarta.validation.constraints.Pattern;
|
import jakarta.validation.constraints.Pattern;
|
||||||
import jakarta.validation.constraints.Positive;
|
import jakarta.validation.constraints.Positive;
|
||||||
|
import jakarta.validation.constraints.AssertTrue;
|
||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
@@ -80,7 +81,8 @@ public final class FbaPricingDtos {
|
|||||||
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal chargeableQuantity,
|
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal chargeableQuantity,
|
||||||
@NotNull @DecimalMin("0.00") BigDecimal unitPrice,
|
@NotNull @DecimalMin("0.00") BigDecimal unitPrice,
|
||||||
@NotNull @DecimalMin("0.00") BigDecimal additionalAmount,
|
@NotNull @DecimalMin("0.00") BigDecimal additionalAmount,
|
||||||
@NotBlank @Size(max = 500) String reason
|
@NotBlank @Size(max = 500) String reason,
|
||||||
|
@NotNull @AssertTrue Boolean toleranceConfirmed
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public record FeeSnapshotResponse(
|
public record FeeSnapshotResponse(
|
||||||
@@ -96,7 +98,9 @@ public final class FbaPricingDtos {
|
|||||||
@NotNull BigDecimal conversionFactor, @NotNull Integer roundingScale,
|
@NotNull BigDecimal conversionFactor, @NotNull Integer roundingScale,
|
||||||
@NotBlank String roundingMode,
|
@NotBlank String roundingMode,
|
||||||
BigDecimal actualWeightKg, BigDecimal actualVolumeCbm,
|
BigDecimal actualWeightKg, BigDecimal actualVolumeCbm,
|
||||||
BigDecimal convertedQuantity, BigDecimal chargeableQuantity, String chargeUnit,
|
BigDecimal convertedQuantity, BigDecimal baselineChargeableQuantity,
|
||||||
|
BigDecimal lowerChargeableQuantity, BigDecimal upperChargeableQuantity,
|
||||||
|
BigDecimal tolerancePercent, BigDecimal chargeableQuantity, String chargeUnit,
|
||||||
BigDecimal unitPrice, BigDecimal subtotalAmount, BigDecimal additionalAmount,
|
BigDecimal unitPrice, BigDecimal subtotalAmount, BigDecimal additionalAmount,
|
||||||
BigDecimal totalAmount, String currency,
|
BigDecimal totalAmount, String currency,
|
||||||
LocalDateTime rateEffectiveFrom, LocalDateTime rateEffectiveTo,
|
LocalDateTime rateEffectiveFrom, LocalDateTime rateEffectiveTo,
|
||||||
|
|||||||
@@ -119,9 +119,20 @@ public final class PriceBookDtos {
|
|||||||
@Size(max = 128) String destinationName,
|
@Size(max = 128) String destinationName,
|
||||||
PriceRegion destinationRegion,
|
PriceRegion destinationRegion,
|
||||||
PricingTaxMode pricingTaxMode,
|
PricingTaxMode pricingTaxMode,
|
||||||
|
@Pattern(regexp = "KG|CBM") String chargeMode,
|
||||||
@DecimalMin(value = "0", inclusive = true) BigDecimal actualWeightKg,
|
@DecimalMin(value = "0", inclusive = true) BigDecimal actualWeightKg,
|
||||||
@DecimalMin(value = "0", inclusive = true) BigDecimal actualVolumeCbm
|
@DecimalMin(value = "0", inclusive = true) BigDecimal actualVolumeCbm
|
||||||
) {}
|
) {
|
||||||
|
public EstimateRequest(Long originWarehouseId, String channelCode, String destinationCode,
|
||||||
|
PricingDestinationType destinationType, Long destinationWarehouseId,
|
||||||
|
String destinationName, PriceRegion destinationRegion,
|
||||||
|
PricingTaxMode pricingTaxMode, BigDecimal actualWeightKg,
|
||||||
|
BigDecimal actualVolumeCbm) {
|
||||||
|
this(originWarehouseId, channelCode, destinationCode, destinationType,
|
||||||
|
destinationWarehouseId, destinationName, destinationRegion, pricingTaxMode,
|
||||||
|
null, actualWeightKg, actualVolumeCbm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public record EstimateResponse(
|
public record EstimateResponse(
|
||||||
Long priceBookVersionId,
|
Long priceBookVersionId,
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ public class InboundFeeEntity {
|
|||||||
private BigDecimal actualWeightKg;
|
private BigDecimal actualWeightKg;
|
||||||
private BigDecimal actualVolumeCbm;
|
private BigDecimal actualVolumeCbm;
|
||||||
private BigDecimal convertedQuantity;
|
private BigDecimal convertedQuantity;
|
||||||
|
/** 系统按权威测量与生效报价计算的原始计费量;人工调整不得改变。 */
|
||||||
|
private BigDecimal baselineChargeableQuantity;
|
||||||
|
/** 人工计费量相对系统基准允许的最大偏差百分比。 */
|
||||||
|
private BigDecimal tolerancePercent;
|
||||||
private BigDecimal chargeableQuantity;
|
private BigDecimal chargeableQuantity;
|
||||||
private String chargeUnit;
|
private String chargeUnit;
|
||||||
private String currency;
|
private String currency;
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ public class ActivePriceBookEstimator {
|
|||||||
} else {
|
} else {
|
||||||
throw validation("私人地址暂不支持参考估价");
|
throw validation("私人地址暂不支持参考估价");
|
||||||
}
|
}
|
||||||
|
String chargeMode = requiredChargeMode(request);
|
||||||
|
rates = rates.stream().filter(value -> chargeMode.equals(value.getChargeUnit())).toList();
|
||||||
RateRow rate = uniqueRate(rates);
|
RateRow rate = uniqueRate(rates);
|
||||||
requireModel(rate, PriceModel.FBA_WAREHOUSE_RATE);
|
requireModel(rate, PriceModel.FBA_WAREHOUSE_RATE);
|
||||||
var estimate = fbaEstimator.estimate(rate, request.actualWeightKg(), request.actualVolumeCbm());
|
var estimate = fbaEstimator.estimate(rate, request.actualWeightKg(), request.actualVolumeCbm());
|
||||||
@@ -81,6 +83,9 @@ public class ActivePriceBookEstimator {
|
|||||||
if (request.pricingTaxMode() == null) {
|
if (request.pricingTaxMode() == null) {
|
||||||
throw validation("请选择包税或不包税报价");
|
throw validation("请选择包税或不包税报价");
|
||||||
}
|
}
|
||||||
|
if (StringUtils.hasText(request.chargeMode()) && !"KG".equals(request.chargeMode())) {
|
||||||
|
throw validation("区域重量阶梯仅支持KG计费");
|
||||||
|
}
|
||||||
PriceRegion region = request.destinationRegion() == null
|
PriceRegion region = request.destinationRegion() == null
|
||||||
? resolveWarehouseRegion(request.destinationWarehouseId()) : request.destinationRegion();
|
? resolveWarehouseRegion(request.destinationWarehouseId()) : request.destinationRegion();
|
||||||
List<RateRow> tiers = repository.regionTiers(versionId, request.originWarehouseId(), channelCode,
|
List<RateRow> tiers = repository.regionTiers(versionId, request.originWarehouseId(), channelCode,
|
||||||
@@ -113,6 +118,19 @@ public class ActivePriceBookEstimator {
|
|||||||
return rates.get(0);
|
return rates.get(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String requiredChargeMode(EstimateRequest request) {
|
||||||
|
if (request.pricingTaxMode() == PricingTaxMode.TAX_INCLUDED) {
|
||||||
|
if (StringUtils.hasText(request.chargeMode()) && !"KG".equals(request.chargeMode())) {
|
||||||
|
throw validation("包税报价必须按KG计费");
|
||||||
|
}
|
||||||
|
return "KG";
|
||||||
|
}
|
||||||
|
if (!StringUtils.hasText(request.chargeMode())) {
|
||||||
|
throw validation("不包税报价请选择客户计费方式");
|
||||||
|
}
|
||||||
|
return request.chargeMode();
|
||||||
|
}
|
||||||
|
|
||||||
private static void requireModel(RateRow rate, PriceModel expected) {
|
private static void requireModel(RateRow rate, PriceModel expected) {
|
||||||
if (rate == null || !expected.name().equals(rate.getPriceModel())) {
|
if (rate == null || !expected.name().equals(rate.getPriceModel())) {
|
||||||
throw new BizException(ResultCode.CONFLICT, "价格模型与渠道配置不一致");
|
throw new BizException(ResultCode.CONFLICT, "价格模型与渠道配置不一致");
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ public class FbaPricingRepository {
|
|||||||
.eq(CustomerChargeRuleEntity::getTenantId, inbound.getTenantId())
|
.eq(CustomerChargeRuleEntity::getTenantId, inbound.getTenantId())
|
||||||
.eq(CustomerChargeRuleEntity::getCustomerId, inbound.getTenantId())
|
.eq(CustomerChargeRuleEntity::getCustomerId, inbound.getTenantId())
|
||||||
.eq(CustomerChargeRuleEntity::getBusinessType, inbound.getBusinessType())
|
.eq(CustomerChargeRuleEntity::getBusinessType, inbound.getBusinessType())
|
||||||
.eq(CustomerChargeRuleEntity::getTaxMode, inbound.getTaxMode())
|
.eq(CustomerChargeRuleEntity::getTaxMode, inbound.getPricingTaxMode())
|
||||||
.eq(CustomerChargeRuleEntity::getStatus, "ACTIVE")
|
.eq(CustomerChargeRuleEntity::getStatus, "ACTIVE")
|
||||||
.le(CustomerChargeRuleEntity::getEffectiveFrom, at)
|
.le(CustomerChargeRuleEntity::getEffectiveFrom, at)
|
||||||
.and(w -> w.isNull(CustomerChargeRuleEntity::getEffectiveTo)
|
.and(w -> w.isNull(CustomerChargeRuleEntity::getEffectiveTo)
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ import java.util.List;
|
|||||||
@Service
|
@Service
|
||||||
public class FbaPricingService {
|
public class FbaPricingService {
|
||||||
private static final int MONEY_SCALE = 2;
|
private static final int MONEY_SCALE = 2;
|
||||||
|
private static final BigDecimal ADJUST_TOLERANCE_PERCENT = new BigDecimal("10.00");
|
||||||
|
private static final BigDecimal ONE_HUNDRED = new BigDecimal("100");
|
||||||
private static final long AUTOMATIC_CONFIRM_OPERATOR_ID = 0L;
|
private static final long AUTOMATIC_CONFIRM_OPERATOR_ID = 0L;
|
||||||
private static final String AUTOMATIC_CONFIRM_OPERATOR_NAME = "装柜完成自动确认";
|
private static final String AUTOMATIC_CONFIRM_OPERATOR_NAME = "装柜完成自动确认";
|
||||||
@Resource
|
@Resource
|
||||||
@@ -114,10 +116,12 @@ public class FbaPricingService {
|
|||||||
|| !StringUtils.hasText(item.getCalculationRuleVersion())));
|
|| !StringUtils.hasText(item.getCalculationRuleVersion())));
|
||||||
if (incomplete) throw conflict("外箱规数据或绑定的计算规则不完整,不能生成对客费用");
|
if (incomplete) throw conflict("外箱规数据或绑定的计算规则不完整,不能生成对客费用");
|
||||||
}
|
}
|
||||||
|
PricingPolicy policy = pricingPolicy(inbound, requestedAt == null ? LocalDateTime.now(clock) : requestedAt);
|
||||||
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(new EstimateRequest(
|
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(new EstimateRequest(
|
||||||
inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(),
|
inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(),
|
||||||
destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null,
|
destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null,
|
||||||
pricingTaxMode(inbound), selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg,
|
pricingTaxMode(inbound), policy.chargeMode(),
|
||||||
|
selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg,
|
||||||
selectedVolumeCbm == null ? inbound.getActualVolumeCbm() : selectedVolumeCbm));
|
selectedVolumeCbm == null ? inbound.getActualVolumeCbm() : selectedVolumeCbm));
|
||||||
RateRow rate = estimate.rate();
|
RateRow rate = estimate.rate();
|
||||||
BigDecimal weight = (selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg).setScale(3, RoundingMode.HALF_UP);
|
BigDecimal weight = (selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg).setScale(3, RoundingMode.HALF_UP);
|
||||||
@@ -133,6 +137,13 @@ public class FbaPricingService {
|
|||||||
|
|
||||||
InboundFeeEntity fee = snapshot(tenantId, operatorId, inbound, estimate, rate, version,
|
InboundFeeEntity fee = snapshot(tenantId, operatorId, inbound, estimate, rate, version,
|
||||||
weight, volume, converted, chargeable, subtotal, additional, total, now);
|
weight, volume, converted, chargeable, subtotal, additional, total, now);
|
||||||
|
if (policy.rule() != null) {
|
||||||
|
fee.setChargeRuleId(policy.rule().getId());
|
||||||
|
fee.setChargeRuleVersion(policy.rule().getRuleVersion());
|
||||||
|
fee.setRuleCode(policy.rule().getRuleCode());
|
||||||
|
fee.setRuleEffectiveFrom(policy.rule().getEffectiveFrom());
|
||||||
|
fee.setRuleEffectiveTo(policy.rule().getEffectiveTo());
|
||||||
|
}
|
||||||
fee.setMeasurementId(measurement == null ? null : measurement.getId());
|
fee.setMeasurementId(measurement == null ? null : measurement.getId());
|
||||||
fee.setMeasurementSource("OUTER".equalsIgnoreCase(volumeSource) ? "OUTER" : volumeSource);
|
fee.setMeasurementSource("OUTER".equalsIgnoreCase(volumeSource) ? "OUTER" : volumeSource);
|
||||||
if (published != null) {
|
if (published != null) {
|
||||||
@@ -283,7 +294,8 @@ public class FbaPricingService {
|
|||||||
}
|
}
|
||||||
String discriminator = current.getId() + ":" + request.expectedFeeVersion() + ":"
|
String discriminator = current.getId() + ":" + request.expectedFeeVersion() + ":"
|
||||||
+ request.chargeableQuantity() + ":" + request.unitPrice() + ":"
|
+ request.chargeableQuantity() + ":" + request.unitPrice() + ":"
|
||||||
+ request.additionalAmount() + ":" + request.reason().trim();
|
+ request.additionalAmount() + ":" + request.reason().trim() + ":"
|
||||||
|
+ request.toleranceConfirmed();
|
||||||
InboundPricingIdempotencyEntity token = beginIdempotency(tenantId, operatorId, inboundId,
|
InboundPricingIdempotencyEntity token = beginIdempotency(tenantId, operatorId, inboundId,
|
||||||
"FEE_ADJUST", idempotencyKey, hash("ADJUST", inboundId, discriminator));
|
"FEE_ADJUST", idempotencyKey, hash("ADJUST", inboundId, discriminator));
|
||||||
if ("COMPLETED".equals(token.getStatus())) {
|
if ("COMPLETED".equals(token.getStatus())) {
|
||||||
@@ -297,6 +309,17 @@ public class FbaPricingService {
|
|||||||
adjusted.setId(null);
|
adjusted.setId(null);
|
||||||
adjusted.setFeeNo("FBAF-" + inboundId + "-" + nextVersion);
|
adjusted.setFeeNo("FBAF-" + inboundId + "-" + nextVersion);
|
||||||
adjusted.setFeeVersion(nextVersion);
|
adjusted.setFeeVersion(nextVersion);
|
||||||
|
BigDecimal baseline = baseline(current);
|
||||||
|
ToleranceRange range = toleranceRange(baseline);
|
||||||
|
if (request.chargeableQuantity().compareTo(range.lower()) < 0
|
||||||
|
|| request.chargeableQuantity().compareTo(range.upper()) > 0) {
|
||||||
|
throw conflict("调整计费量必须在系统原计费量±10%范围内(含边界)");
|
||||||
|
}
|
||||||
|
if (!Boolean.TRUE.equals(request.toleranceConfirmed())) {
|
||||||
|
throw conflict("请二次确认计费量调整范围后再提交");
|
||||||
|
}
|
||||||
|
adjusted.setBaselineChargeableQuantity(baseline);
|
||||||
|
adjusted.setTolerancePercent(ADJUST_TOLERANCE_PERCENT);
|
||||||
adjusted.setChargeableQuantity(request.chargeableQuantity().setScale(4, RoundingMode.HALF_UP));
|
adjusted.setChargeableQuantity(request.chargeableQuantity().setScale(4, RoundingMode.HALF_UP));
|
||||||
adjusted.setConvertedQuantity(adjusted.getChargeableQuantity());
|
adjusted.setConvertedQuantity(adjusted.getChargeableQuantity());
|
||||||
adjusted.setSubtotalAmount(adjusted.getChargeableQuantity().multiply(request.unitPrice())
|
adjusted.setSubtotalAmount(adjusted.getChargeableQuantity().multiply(request.unitPrice())
|
||||||
@@ -401,6 +424,27 @@ public class FbaPricingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private PricingPolicy pricingPolicy(InboundPricingEntity inbound, LocalDateTime at) {
|
||||||
|
PricingTaxMode taxMode = pricingTaxMode(inbound);
|
||||||
|
if (taxMode == PricingTaxMode.TAX_INCLUDED) return new PricingPolicy("KG", null);
|
||||||
|
List<com.baiyue.firstleg.entity.CustomerChargeRuleEntity> rules = repository.matchRules(inbound, at);
|
||||||
|
if (rules == null || rules.isEmpty()) {
|
||||||
|
throw conflict("客户未配置当前不包税报价的计费方式");
|
||||||
|
}
|
||||||
|
List<com.baiyue.firstleg.entity.CustomerChargeRuleEntity> defaults = rules.stream()
|
||||||
|
.filter(rule -> Boolean.TRUE.equals(rule.getDefaultRule())).toList();
|
||||||
|
com.baiyue.firstleg.entity.CustomerChargeRuleEntity selected = defaults.size() == 1
|
||||||
|
? defaults.get(0) : rules.size() == 1 ? rules.get(0) : null;
|
||||||
|
if (selected == null) throw conflict("客户当前不包税计费规则匹配不唯一");
|
||||||
|
if (!List.of("KG", "CBM").contains(selected.getChargeMode())) {
|
||||||
|
throw conflict("客户计费方式配置无效");
|
||||||
|
}
|
||||||
|
return new PricingPolicy(selected.getChargeMode(), selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record PricingPolicy(String chargeMode,
|
||||||
|
com.baiyue.firstleg.entity.CustomerChargeRuleEntity rule) { }
|
||||||
|
|
||||||
private InboundPricingIdempotencyEntity beginIdempotency(long tenantId, long operatorId,
|
private InboundPricingIdempotencyEntity beginIdempotency(long tenantId, long operatorId,
|
||||||
long inboundId, String operation,
|
long inboundId, String operation,
|
||||||
String key, String hash) {
|
String key, String hash) {
|
||||||
@@ -456,6 +500,8 @@ public class FbaPricingService {
|
|||||||
fee.setRateEffectiveFrom(rate.getEffectiveFrom()); fee.setRateEffectiveTo(rate.getEffectiveTo());
|
fee.setRateEffectiveFrom(rate.getEffectiveFrom()); fee.setRateEffectiveTo(rate.getEffectiveTo());
|
||||||
fee.setChargeMode(estimate.chargeUnit()); fee.setConversionFactor(estimate.conversionFactor());
|
fee.setChargeMode(estimate.chargeUnit()); fee.setConversionFactor(estimate.conversionFactor());
|
||||||
fee.setActualWeightKg(weight); fee.setActualVolumeCbm(volume); fee.setConvertedQuantity(converted);
|
fee.setActualWeightKg(weight); fee.setActualVolumeCbm(volume); fee.setConvertedQuantity(converted);
|
||||||
|
fee.setBaselineChargeableQuantity(chargeable);
|
||||||
|
fee.setTolerancePercent(ADJUST_TOLERANCE_PERCENT);
|
||||||
fee.setChargeableQuantity(chargeable); fee.setChargeUnit(estimate.chargeUnit()); fee.setCurrency(rate.getCurrency());
|
fee.setChargeableQuantity(chargeable); fee.setChargeUnit(estimate.chargeUnit()); fee.setCurrency(rate.getCurrency());
|
||||||
fee.setSubtotalAmount(subtotal); fee.setAdditionalAmount(additional); fee.setTotalAmount(total);
|
fee.setSubtotalAmount(subtotal); fee.setAdditionalAmount(additional); fee.setTotalAmount(total);
|
||||||
fee.setFeeStatus("CALCULATED"); fee.setReviewStatus("NOT_SUBMITTED"); fee.setExceptionRelease(false);
|
fee.setFeeStatus("CALCULATED"); fee.setReviewStatus("NOT_SUBMITTED"); fee.setExceptionRelease(false);
|
||||||
@@ -497,6 +543,8 @@ public class FbaPricingService {
|
|||||||
|
|
||||||
private FeeSnapshotResponse response(InboundPricingEntity inbound, InboundFeeEntity fee,
|
private FeeSnapshotResponse response(InboundPricingEntity inbound, InboundFeeEntity fee,
|
||||||
BigDecimal unitPrice, String nextStatus) {
|
BigDecimal unitPrice, String nextStatus) {
|
||||||
|
BigDecimal baseline = baseline(fee);
|
||||||
|
ToleranceRange range = toleranceRange(baseline);
|
||||||
return new FeeSnapshotResponse(fee.getId(), fee.getFeeNo(), fee.getFeeVersion(), fee.getFeeStatus(),
|
return new FeeSnapshotResponse(fee.getId(), fee.getFeeNo(), fee.getFeeVersion(), fee.getFeeStatus(),
|
||||||
fee.getInboundId(), inbound == null ? null : inbound.getInboundCode(),
|
fee.getInboundId(), inbound == null ? null : inbound.getInboundCode(),
|
||||||
fee.getMeasurementId(), fee.getMeasurementSource(),
|
fee.getMeasurementId(), fee.getMeasurementSource(),
|
||||||
@@ -508,7 +556,9 @@ public class FbaPricingService {
|
|||||||
fee.getTaxMode(), fee.getPricingTaxMode(), fee.getChargeMode(),
|
fee.getTaxMode(), fee.getPricingTaxMode(), fee.getChargeMode(),
|
||||||
fee.getConversionFactor(),
|
fee.getConversionFactor(),
|
||||||
fee.getRoundingScale(), fee.getRoundingMode(), fee.getActualWeightKg(), fee.getActualVolumeCbm(),
|
fee.getRoundingScale(), fee.getRoundingMode(), fee.getActualWeightKg(), fee.getActualVolumeCbm(),
|
||||||
fee.getConvertedQuantity(), fee.getChargeableQuantity(), fee.getChargeUnit(), unitPrice,
|
fee.getConvertedQuantity(), baseline, range.lower(), range.upper(),
|
||||||
|
fee.getTolerancePercent() == null ? ADJUST_TOLERANCE_PERCENT : fee.getTolerancePercent(),
|
||||||
|
fee.getChargeableQuantity(), fee.getChargeUnit(), unitPrice,
|
||||||
fee.getSubtotalAmount(), fee.getAdditionalAmount(), fee.getTotalAmount(), fee.getCurrency(),
|
fee.getSubtotalAmount(), fee.getAdditionalAmount(), fee.getTotalAmount(), fee.getCurrency(),
|
||||||
fee.getRateEffectiveFrom(), fee.getRateEffectiveTo(), fee.getCalculatedAt(), fee.getConfirmedAt(),
|
fee.getRateEffectiveFrom(), fee.getRateEffectiveTo(), fee.getCalculatedAt(), fee.getConfirmedAt(),
|
||||||
fee.getSentAt(), nextStatus,
|
fee.getSentAt(), nextStatus,
|
||||||
@@ -516,6 +566,22 @@ public class FbaPricingService {
|
|||||||
fee.getCustomerConfirmedBy(), fee.getCustomerConfirmedByName(), fee.getCustomerConfirmedAt());
|
fee.getCustomerConfirmedBy(), fee.getCustomerConfirmedByName(), fee.getCustomerConfirmedAt());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static BigDecimal baseline(InboundFeeEntity fee) {
|
||||||
|
BigDecimal value = fee.getBaselineChargeableQuantity();
|
||||||
|
if (value == null) value = fee.getChargeableQuantity();
|
||||||
|
if (value == null || value.signum() <= 0) throw conflict("系统原计费量必须大于零");
|
||||||
|
return value.setScale(6, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ToleranceRange toleranceRange(BigDecimal baseline) {
|
||||||
|
BigDecimal delta = baseline.multiply(ADJUST_TOLERANCE_PERCENT)
|
||||||
|
.divide(ONE_HUNDRED, 6, RoundingMode.HALF_UP);
|
||||||
|
return new ToleranceRange(baseline.subtract(delta).setScale(6, RoundingMode.HALF_UP),
|
||||||
|
baseline.add(delta).setScale(6, RoundingMode.HALF_UP));
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ToleranceRange(BigDecimal lower, BigDecimal upper) { }
|
||||||
|
|
||||||
private static String hash(String operation, long inboundId, String discriminator) {
|
private static String hash(String operation, long inboundId, String discriminator) {
|
||||||
try {
|
try {
|
||||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ public class MerchantPriceBookService {
|
|||||||
|
|
||||||
public EstimateResponse estimate(long merchantTenantId, EstimateRequest request) {
|
public EstimateResponse estimate(long merchantTenantId, EstimateRequest request) {
|
||||||
requireMerchant(merchantTenantId);
|
requireMerchant(merchantTenantId);
|
||||||
|
if (request.pricingTaxMode() == PricingTaxMode.TAX_INCLUDED
|
||||||
|
&& request.chargeMode() != null && !"KG".equals(request.chargeMode())) {
|
||||||
|
throw validation("包税报价必须按KG计费");
|
||||||
|
}
|
||||||
ActivePriceBookEstimator.Estimate estimate = estimator.estimate(request);
|
ActivePriceBookEstimator.Estimate estimate = estimator.estimate(request);
|
||||||
return new EstimateResponse(estimate.priceBookVersionId(), estimate.channelCode(), estimate.priceModel(),
|
return new EstimateResponse(estimate.priceBookVersionId(), estimate.channelCode(), estimate.priceModel(),
|
||||||
estimate.pricingTaxMode(), estimate.chargeableQuantity(), estimate.chargeUnit(), estimate.unitPrice(),
|
estimate.pricingTaxMode(), estimate.chargeableQuantity(), estimate.chargeUnit(), estimate.unitPrice(),
|
||||||
|
|||||||
@@ -672,6 +672,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee (
|
|||||||
actual_weight_kg DECIMAL(18,3) NOT NULL COMMENT '试算时实际重量kg',
|
actual_weight_kg DECIMAL(18,3) NOT NULL COMMENT '试算时实际重量kg',
|
||||||
actual_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '试算时实际体积m3',
|
actual_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '试算时实际体积m3',
|
||||||
converted_quantity DECIMAL(18,4) NOT NULL COMMENT '换算后的重量或体积',
|
converted_quantity DECIMAL(18,4) NOT NULL COMMENT '换算后的重量或体积',
|
||||||
|
baseline_chargeable_quantity DECIMAL(18,6) NOT NULL COMMENT '系统按权威测量与生效报价计算的原始计费量',
|
||||||
|
tolerance_percent DECIMAL(5,2) NOT NULL DEFAULT 10.00 COMMENT '人工调整允许的最大偏差百分比,边界包含',
|
||||||
chargeable_quantity DECIMAL(18,4) NOT NULL COMMENT '最终计费量',
|
chargeable_quantity DECIMAL(18,4) NOT NULL COMMENT '最终计费量',
|
||||||
charge_unit VARCHAR(16) NOT NULL COMMENT '计费单位',
|
charge_unit VARCHAR(16) NOT NULL COMMENT '计费单位',
|
||||||
currency CHAR(3) NOT NULL COMMENT '币种',
|
currency CHAR(3) NOT NULL COMMENT '币种',
|
||||||
@@ -701,6 +703,32 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee (
|
|||||||
CONSTRAINT fk_fee_carrier_rate FOREIGN KEY (carrier_rate_id) REFERENCES fl_carrier_fba_rate (id)
|
CONSTRAINT fk_fee_carrier_rate FOREIGN KEY (carrier_rate_id) REFERENCES fl_carrier_fba_rate (id)
|
||||||
) ENGINE=InnoDB COMMENT='入库头程费用计算和确认快照';
|
) ENGINE=InnoDB COMMENT='入库头程费用计算和确认快照';
|
||||||
|
|
||||||
|
SET @fee_baseline_quantity_sql = IF(
|
||||||
|
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||||
|
AND table_name='fl_inbound_fee' AND column_name='baseline_chargeable_quantity'),
|
||||||
|
'SELECT 1',
|
||||||
|
'ALTER TABLE fl_inbound_fee ADD COLUMN baseline_chargeable_quantity DECIMAL(18,6) NULL COMMENT ''系统按权威测量与生效报价计算的原始计费量'' AFTER converted_quantity'
|
||||||
|
);
|
||||||
|
PREPARE fee_baseline_quantity_stmt FROM @fee_baseline_quantity_sql;
|
||||||
|
EXECUTE fee_baseline_quantity_stmt;
|
||||||
|
DEALLOCATE PREPARE fee_baseline_quantity_stmt;
|
||||||
|
UPDATE fl_inbound_fee
|
||||||
|
SET baseline_chargeable_quantity = chargeable_quantity
|
||||||
|
WHERE baseline_chargeable_quantity IS NULL;
|
||||||
|
ALTER TABLE fl_inbound_fee
|
||||||
|
MODIFY COLUMN baseline_chargeable_quantity DECIMAL(18,6) NOT NULL
|
||||||
|
COMMENT '系统按权威测量与生效报价计算的原始计费量';
|
||||||
|
|
||||||
|
SET @fee_tolerance_percent_sql = IF(
|
||||||
|
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||||
|
AND table_name='fl_inbound_fee' AND column_name='tolerance_percent'),
|
||||||
|
'SELECT 1',
|
||||||
|
'ALTER TABLE fl_inbound_fee ADD COLUMN tolerance_percent DECIMAL(5,2) NOT NULL DEFAULT 10.00 COMMENT ''人工调整允许的最大偏差百分比,边界包含'' AFTER baseline_chargeable_quantity'
|
||||||
|
);
|
||||||
|
PREPARE fee_tolerance_percent_stmt FROM @fee_tolerance_percent_sql;
|
||||||
|
EXECUTE fee_tolerance_percent_stmt;
|
||||||
|
DEALLOCATE PREPARE fee_tolerance_percent_stmt;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS fl_inbound_fee_item (
|
CREATE TABLE IF NOT EXISTS fl_inbound_fee_item (
|
||||||
id BIGINT UNSIGNED NOT NULL COMMENT '费用明细ID',
|
id BIGINT UNSIGNED NOT NULL COMMENT '费用明细ID',
|
||||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
|
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ paths:
|
|||||||
/api/platform/v1/inbounds/{inboundId}/fees/published:
|
/api/platform/v1/inbounds/{inboundId}/fees/published:
|
||||||
put:
|
put:
|
||||||
summary: 调整初算或已发布费用
|
summary: 调整初算或已发布费用
|
||||||
description: 需要 firstleg:fba-pricing:adjust 权限;人工修改计费量、单价和附加费,小计与总额由服务端重算并生成新版本,不回退 WAIT_SPLIT 状态。调整 CALCULATED/CONFIRMED 时生成新的 CALCULATED 版本;调整 SENT 时生成新的 SENT 版本并发布调整事件。
|
description: 需要 firstleg:fba-pricing:adjust 权限;计费量只能在系统原计费量上下10%范围内(含边界),请求必须携带当前费用版本、必填原因及二次确认标志,越界或并发冲突返回A0409。小计与总额由服务端重算并生成新版本,不回退 WAIT_SPLIT 状态。调整 CALCULATED/CONFIRMED 时生成新的 CALCULATED 版本;调整 SENT 时生成新的 SENT 版本并发布调整事件。
|
||||||
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -330,6 +330,10 @@ components:
|
|||||||
actualWeightKg: {type: number, nullable: true}
|
actualWeightKg: {type: number, nullable: true}
|
||||||
actualVolumeCbm: {type: number, nullable: true}
|
actualVolumeCbm: {type: number, nullable: true}
|
||||||
convertedQuantity: {type: number, nullable: true}
|
convertedQuantity: {type: number, nullable: true}
|
||||||
|
baselineChargeableQuantity: {type: number, description: 系统按权威测量与生效报价计算的原计费量,人工调整后保持不变}
|
||||||
|
lowerChargeableQuantity: {type: number, description: 允许人工调整的计费量下界,包含边界}
|
||||||
|
upperChargeableQuantity: {type: number, description: 允许人工调整的计费量上界,包含边界}
|
||||||
|
tolerancePercent: {type: number, enum: [10.00], description: 相对系统原计费量允许的上下偏差百分比}
|
||||||
chargeableQuantity: {type: number, nullable: true}
|
chargeableQuantity: {type: number, nullable: true}
|
||||||
chargeUnit: {type: string, nullable: true}
|
chargeUnit: {type: string, nullable: true}
|
||||||
unitPrice: {type: number, nullable: true}
|
unitPrice: {type: number, nullable: true}
|
||||||
@@ -466,16 +470,22 @@ components:
|
|||||||
timestamp: {type: string, format: date-time, nullable: true}
|
timestamp: {type: string, format: date-time, nullable: true}
|
||||||
AdjustFeeRequest:
|
AdjustFeeRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [expectedFeeVersion, chargeableQuantity, unitPrice, additionalAmount, reason]
|
required: [expectedFeeVersion, chargeableQuantity, unitPrice, additionalAmount, reason, toleranceConfirmed]
|
||||||
properties:
|
properties:
|
||||||
expectedFeeVersion: {type: integer, minimum: 1}
|
expectedFeeVersion: {type: integer, minimum: 1}
|
||||||
chargeableQuantity: {type: number, minimum: 0, exclusiveMinimum: true, description: 计费量,单位沿用当前费用快照的 chargeUnit}
|
chargeableQuantity: {type: number, minimum: 0, exclusiveMinimum: true, description: 计费量,单位沿用当前费用快照的 chargeUnit}
|
||||||
unitPrice: {type: number, minimum: 0, description: 人工确认单价}
|
unitPrice: {type: number, minimum: 0, description: 人工确认单价}
|
||||||
additionalAmount: {type: number, minimum: 0, multipleOf: 0.01}
|
additionalAmount: {type: number, minimum: 0, multipleOf: 0.01}
|
||||||
reason: {type: string, minLength: 1, maxLength: 500}
|
reason: {type: string, minLength: 1, maxLength: 500}
|
||||||
|
toleranceConfirmed: {type: boolean, enum: [true], description: 前端展示上下界并由操作员二次确认后固定传true;服务端仍会重新校验范围与版本}
|
||||||
CalculateRequest:
|
CalculateRequest:
|
||||||
type: object
|
type: object
|
||||||
properties: {pricingAt: {type: string, format: date-time, description: 缺省为服务端当前时间}}
|
properties:
|
||||||
|
pricingAt: {type: string, format: date-time, description: 缺省为服务端当前时间}
|
||||||
|
selectedWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true, description: 选定的权威重量快照}
|
||||||
|
selectedVolumeCbm: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true, description: 选定的权威体积快照}
|
||||||
|
weightSource: {type: string, maxLength: 32, nullable: true}
|
||||||
|
volumeSource: {type: string, maxLength: 32, nullable: true}
|
||||||
CustomerRuleVersionRequest:
|
CustomerRuleVersionRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [ruleCode, customerId, customerName, businessType, chargeMode, taxMode, conversionFactor, roundingScale, roundingMode, currency, effectiveFrom]
|
required: [ruleCode, customerId, customerName, businessType, chargeMode, taxMode, conversionFactor, roundingScale, roundingMode, currency, effectiveFrom]
|
||||||
@@ -668,5 +678,6 @@ components:
|
|||||||
destinationName: {type: string, maxLength: 128, nullable: true}
|
destinationName: {type: string, maxLength: 128, nullable: true}
|
||||||
destinationRegion: {type: string, enum: [WEST, CENTRAL, EAST]}
|
destinationRegion: {type: string, enum: [WEST, CENTRAL, EAST]}
|
||||||
pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'}
|
pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'}
|
||||||
|
chargeMode: {type: string, enum: [KG, CBM], description: 包税报价固定为KG;不包税报价必须与客户当前计费规则一致}
|
||||||
actualWeightKg: {type: number, minimum: 0}
|
actualWeightKg: {type: number, minimum: 0}
|
||||||
actualVolumeCbm: {type: number, minimum: 0}
|
actualVolumeCbm: {type: number, minimum: 0}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import com.baiyue.firstleg.entity.InboundPricingEntity;
|
|||||||
import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity;
|
import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity;
|
||||||
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
|
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
|
||||||
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
|
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
|
||||||
|
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
|
||||||
import com.baiyue.firstleg.pricing.ActivePriceBookEstimator;
|
import com.baiyue.firstleg.pricing.ActivePriceBookEstimator;
|
||||||
import com.baiyue.firstleg.repository.FbaPricingRepository;
|
import com.baiyue.firstleg.repository.FbaPricingRepository;
|
||||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow;
|
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow;
|
||||||
@@ -37,6 +38,7 @@ import static org.mockito.ArgumentMatchers.any;
|
|||||||
import static org.mockito.ArgumentMatchers.anyInt;
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
import static org.mockito.ArgumentMatchers.anyLong;
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.argThat;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.doAnswer;
|
import static org.mockito.Mockito.doAnswer;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
@@ -217,6 +219,10 @@ class FbaPricingServiceTest {
|
|||||||
InboundPricingEntity inbound = inbound("363.001", "0.5000");
|
InboundPricingEntity inbound = inbound("363.001", "0.5000");
|
||||||
inbound.setPricingTaxMode("TAX_EXCLUDED");
|
inbound.setPricingTaxMode("TAX_EXCLUDED");
|
||||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||||
|
CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity();
|
||||||
|
rule.setId(601L); rule.setRuleCode("CBM-RULE"); rule.setRuleVersion(1);
|
||||||
|
rule.setChargeMode("CBM"); rule.setDefaultRule(true);
|
||||||
|
when(repository.matchRules(eq(inbound), any())).thenReturn(List.of(rule));
|
||||||
when(priceBookEstimator.estimate(any())).thenReturn(
|
when(priceBookEstimator.estimate(any())).thenReturn(
|
||||||
estimate("CBM", "1.0001", "100.000000", "100.01", "0.00"));
|
estimate("CBM", "1.0001", "100.000000", "100.01", "0.00"));
|
||||||
|
|
||||||
@@ -228,6 +234,8 @@ class FbaPricingServiceTest {
|
|||||||
assertThat(response.conversionFactor()).isEqualByComparingTo("363");
|
assertThat(response.conversionFactor()).isEqualByComparingTo("363");
|
||||||
assertThat(response.roundingScale()).isEqualTo(6);
|
assertThat(response.roundingScale()).isEqualTo(6);
|
||||||
assertThat(response.roundingMode()).isEqualTo("HALF_UP");
|
assertThat(response.roundingMode()).isEqualTo("HALF_UP");
|
||||||
|
assertThat(response.chargeRuleId()).isEqualTo(601L);
|
||||||
|
assertThat(response.ruleCode()).isEqualTo("CBM-RULE");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -483,15 +491,19 @@ class FbaPricingServiceTest {
|
|||||||
|
|
||||||
FeeSnapshotResponse response = service.adjustPublished(TENANT, OPERATOR, INBOUND,
|
FeeSnapshotResponse response = service.adjustPublished(TENANT, OPERATOR, INBOUND,
|
||||||
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
|
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
|
||||||
1, new BigDecimal("12.5"), new BigDecimal("8.00"),
|
1, new BigDecimal("160"), new BigDecimal("8.00"),
|
||||||
new BigDecimal("20.00"), "客服修正"), "adjust-1");
|
new BigDecimal("20.00"), "客服修正", true), "adjust-1");
|
||||||
|
|
||||||
assertThat(response.feeVersion()).isEqualTo(2);
|
assertThat(response.feeVersion()).isEqualTo(2);
|
||||||
assertThat(response.feeStatus()).isEqualTo("SENT");
|
assertThat(response.feeStatus()).isEqualTo("SENT");
|
||||||
assertThat(response.totalAmount()).isEqualByComparingTo("120.00");
|
assertThat(response.totalAmount()).isEqualByComparingTo("1300.00");
|
||||||
|
assertThat(response.baselineChargeableQuantity()).isEqualByComparingTo("167.000000");
|
||||||
|
assertThat(response.lowerChargeableQuantity()).isEqualByComparingTo("150.300000");
|
||||||
|
assertThat(response.upperChargeableQuantity()).isEqualByComparingTo("183.700000");
|
||||||
|
assertThat(response.tolerancePercent()).isEqualByComparingTo("10.00");
|
||||||
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
|
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
|
||||||
assertThat(inbound.getProcessStatus()).isEqualTo("WAIT_SPLIT");
|
assertThat(inbound.getProcessStatus()).isEqualTo("WAIT_SPLIT");
|
||||||
assertThat(inserted.get().getChargeableQuantity()).isEqualByComparingTo("12.5000");
|
assertThat(inserted.get().getChargeableQuantity()).isEqualByComparingTo("160.0000");
|
||||||
verify(repository).supersedeCurrent(TENANT, 501L, "SENT");
|
verify(repository).supersedeCurrent(TENANT, 501L, "SENT");
|
||||||
verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.ADJUSTED),
|
verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.ADJUSTED),
|
||||||
eq(TENANT), eq(INBOUND), eq(502L), eq(2), eq(NOW));
|
eq(TENANT), eq(INBOUND), eq(502L), eq(2), eq(NOW));
|
||||||
@@ -538,7 +550,7 @@ class FbaPricingServiceTest {
|
|||||||
assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND,
|
assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND,
|
||||||
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
|
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
|
||||||
1, new BigDecimal("12.5"), new BigDecimal("8.00"),
|
1, new BigDecimal("12.5"), new BigDecimal("8.00"),
|
||||||
BigDecimal.ZERO, "客服修正"), "locked-adjust"))
|
BigDecimal.ZERO, "客服修正", true), "locked-adjust"))
|
||||||
.isInstanceOf(BizException.class)
|
.isInstanceOf(BizException.class)
|
||||||
.hasMessageContaining("客户已确认");
|
.hasMessageContaining("客户已确认");
|
||||||
|
|
||||||
@@ -574,11 +586,11 @@ class FbaPricingServiceTest {
|
|||||||
|
|
||||||
FeeSnapshotResponse response = service.adjustPublished(TENANT, OPERATOR, INBOUND,
|
FeeSnapshotResponse response = service.adjustPublished(TENANT, OPERATOR, INBOUND,
|
||||||
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
|
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
|
||||||
1, new BigDecimal("12.5"), new BigDecimal("8.00"),
|
1, new BigDecimal("160"), new BigDecimal("8.00"),
|
||||||
new BigDecimal("20.00"), "客服修正"), "adjust-calculated");
|
new BigDecimal("20.00"), "客服修正", true), "adjust-calculated");
|
||||||
|
|
||||||
assertThat(response.feeStatus()).isEqualTo("CALCULATED");
|
assertThat(response.feeStatus()).isEqualTo("CALCULATED");
|
||||||
assertThat(response.totalAmount()).isEqualByComparingTo("120.00");
|
assertThat(response.totalAmount()).isEqualByComparingTo("1300.00");
|
||||||
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
|
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
|
||||||
verify(repository).supersedeCurrent(TENANT, 501L, "CALCULATED");
|
verify(repository).supersedeCurrent(TENANT, 501L, "CALCULATED");
|
||||||
verify(feeDetails).materializeCalculatedFreight(TENANT, INBOUND, 503L, OPERATOR, NOW);
|
verify(feeDetails).materializeCalculatedFreight(TENANT, INBOUND, 503L, OPERATOR, NOW);
|
||||||
@@ -598,7 +610,7 @@ class FbaPricingServiceTest {
|
|||||||
assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND,
|
assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND,
|
||||||
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
|
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
|
||||||
1, new BigDecimal("12.5"), new BigDecimal("8.00"),
|
1, new BigDecimal("12.5"), new BigDecimal("8.00"),
|
||||||
BigDecimal.ZERO, "客服修正"), "stale-adjust"))
|
BigDecimal.ZERO, "客服修正", true), "stale-adjust"))
|
||||||
.isInstanceOf(BizException.class)
|
.isInstanceOf(BizException.class)
|
||||||
.extracting(error -> ((BizException) error).getResultCode())
|
.extracting(error -> ((BizException) error).getResultCode())
|
||||||
.isEqualTo(ResultCode.CONFLICT);
|
.isEqualTo(ResultCode.CONFLICT);
|
||||||
@@ -607,6 +619,64 @@ class FbaPricingServiceTest {
|
|||||||
verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), any());
|
verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("人工计费量允许上下10%含边界")
|
||||||
|
void acceptsInclusiveToleranceBoundaries() {
|
||||||
|
InboundPricingEntity inbound = inbound("10", "1");
|
||||||
|
inbound.setProcessStatus("WAIT_SPLIT");
|
||||||
|
InboundFeeEntity current = savedFee("CALCULATED");
|
||||||
|
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||||
|
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current);
|
||||||
|
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_ADJUST", "lower-bound")).thenReturn(null);
|
||||||
|
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(903L); return null; })
|
||||||
|
.when(repository).insertIdempotency(any());
|
||||||
|
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2);
|
||||||
|
when(repository.supersedeCurrent(TENANT, 501L, "CALCULATED")).thenReturn(1);
|
||||||
|
when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("CALCULATED"), eq("KG"), any()))
|
||||||
|
.thenReturn(1);
|
||||||
|
doAnswer(invocation -> { ((InboundFeeEntity) invocation.getArgument(0)).setId(504L); return null; })
|
||||||
|
.when(repository).insertFee(any());
|
||||||
|
when(repository.completeIdempotency(any(), eq(504L), eq(NOW))).thenReturn(1);
|
||||||
|
when(repository.findFee(TENANT, 504L)).thenAnswer(invocation -> current);
|
||||||
|
|
||||||
|
service.adjustPublished(TENANT, OPERATOR, INBOUND,
|
||||||
|
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
|
||||||
|
1, new BigDecimal("150.3"), new BigDecimal("2"), BigDecimal.ZERO,
|
||||||
|
"下界确认", true), "lower-bound");
|
||||||
|
|
||||||
|
verify(repository).insertFee(argThat(fee ->
|
||||||
|
fee.getChargeableQuantity().compareTo(new BigDecimal("150.3000")) == 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("人工计费量越过10%或未二次确认返回冲突")
|
||||||
|
void rejectsOutOfToleranceOrUnconfirmedAdjustment() {
|
||||||
|
InboundPricingEntity inbound = inbound("10", "1");
|
||||||
|
inbound.setProcessStatus("WAIT_SPLIT");
|
||||||
|
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||||
|
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(savedFee("CALCULATED"));
|
||||||
|
when(repository.findIdempotency(anyLong(), anyLong(), any(), any())).thenReturn(null);
|
||||||
|
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(904L); return null; })
|
||||||
|
.when(repository).insertIdempotency(any());
|
||||||
|
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND,
|
||||||
|
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
|
||||||
|
1, new BigDecimal("183.7001"), new BigDecimal("2"), BigDecimal.ZERO,
|
||||||
|
"越界", true), "outside"))
|
||||||
|
.isInstanceOf(BizException.class)
|
||||||
|
.extracting(error -> ((BizException) error).getResultCode())
|
||||||
|
.isEqualTo(ResultCode.CONFLICT);
|
||||||
|
assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND,
|
||||||
|
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
|
||||||
|
1, new BigDecimal("167"), new BigDecimal("2"), BigDecimal.ZERO,
|
||||||
|
"未确认", false), "unconfirmed"))
|
||||||
|
.isInstanceOf(BizException.class)
|
||||||
|
.extracting(error -> ((BizException) error).getResultCode())
|
||||||
|
.isEqualTo(ResultCode.CONFLICT);
|
||||||
|
verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), any());
|
||||||
|
}
|
||||||
|
|
||||||
private void readyRepository() {
|
private void readyRepository() {
|
||||||
when(repository.findIdempotency(anyLong(), anyLong(), any(), any())).thenReturn(null);
|
when(repository.findIdempotency(anyLong(), anyLong(), any(), any())).thenReturn(null);
|
||||||
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; })
|
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; })
|
||||||
|
|||||||
@@ -292,6 +292,7 @@ class MerchantPriceBookServiceTest {
|
|||||||
String weight, String volume) {
|
String weight, String volume) {
|
||||||
return new EstimateRequest(10L, channelCode, destinationCode, destinationType,
|
return new EstimateRequest(10L, channelCode, destinationCode, destinationType,
|
||||||
destinationWarehouseId, null, destinationRegion, pricingTaxMode,
|
destinationWarehouseId, null, destinationRegion, pricingTaxMode,
|
||||||
|
pricingTaxMode == PricingTaxMode.TAX_INCLUDED ? "KG" : "CBM",
|
||||||
decimal(weight), decimal(volume));
|
decimal(weight), decimal(volume));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user