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.Pattern;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -80,7 +81,8 @@ public final class FbaPricingDtos {
|
||||
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal chargeableQuantity,
|
||||
@NotNull @DecimalMin("0.00") BigDecimal unitPrice,
|
||||
@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(
|
||||
@@ -96,7 +98,9 @@ public final class FbaPricingDtos {
|
||||
@NotNull BigDecimal conversionFactor, @NotNull Integer roundingScale,
|
||||
@NotBlank String roundingMode,
|
||||
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 totalAmount, String currency,
|
||||
LocalDateTime rateEffectiveFrom, LocalDateTime rateEffectiveTo,
|
||||
|
||||
@@ -119,9 +119,20 @@ public final class PriceBookDtos {
|
||||
@Size(max = 128) String destinationName,
|
||||
PriceRegion destinationRegion,
|
||||
PricingTaxMode pricingTaxMode,
|
||||
@Pattern(regexp = "KG|CBM") String chargeMode,
|
||||
@DecimalMin(value = "0", inclusive = true) BigDecimal actualWeightKg,
|
||||
@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(
|
||||
Long priceBookVersionId,
|
||||
|
||||
@@ -54,6 +54,10 @@ public class InboundFeeEntity {
|
||||
private BigDecimal actualWeightKg;
|
||||
private BigDecimal actualVolumeCbm;
|
||||
private BigDecimal convertedQuantity;
|
||||
/** 系统按权威测量与生效报价计算的原始计费量;人工调整不得改变。 */
|
||||
private BigDecimal baselineChargeableQuantity;
|
||||
/** 人工计费量相对系统基准允许的最大偏差百分比。 */
|
||||
private BigDecimal tolerancePercent;
|
||||
private BigDecimal chargeableQuantity;
|
||||
private String chargeUnit;
|
||||
private String currency;
|
||||
|
||||
@@ -65,6 +65,8 @@ public class ActivePriceBookEstimator {
|
||||
} else {
|
||||
throw validation("私人地址暂不支持参考估价");
|
||||
}
|
||||
String chargeMode = requiredChargeMode(request);
|
||||
rates = rates.stream().filter(value -> chargeMode.equals(value.getChargeUnit())).toList();
|
||||
RateRow rate = uniqueRate(rates);
|
||||
requireModel(rate, PriceModel.FBA_WAREHOUSE_RATE);
|
||||
var estimate = fbaEstimator.estimate(rate, request.actualWeightKg(), request.actualVolumeCbm());
|
||||
@@ -81,6 +83,9 @@ public class ActivePriceBookEstimator {
|
||||
if (request.pricingTaxMode() == null) {
|
||||
throw validation("请选择包税或不包税报价");
|
||||
}
|
||||
if (StringUtils.hasText(request.chargeMode()) && !"KG".equals(request.chargeMode())) {
|
||||
throw validation("区域重量阶梯仅支持KG计费");
|
||||
}
|
||||
PriceRegion region = request.destinationRegion() == null
|
||||
? resolveWarehouseRegion(request.destinationWarehouseId()) : request.destinationRegion();
|
||||
List<RateRow> tiers = repository.regionTiers(versionId, request.originWarehouseId(), channelCode,
|
||||
@@ -113,6 +118,19 @@ public class ActivePriceBookEstimator {
|
||||
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) {
|
||||
if (rate == null || !expected.name().equals(rate.getPriceModel())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "价格模型与渠道配置不一致");
|
||||
|
||||
@@ -171,7 +171,7 @@ public class FbaPricingRepository {
|
||||
.eq(CustomerChargeRuleEntity::getTenantId, inbound.getTenantId())
|
||||
.eq(CustomerChargeRuleEntity::getCustomerId, inbound.getTenantId())
|
||||
.eq(CustomerChargeRuleEntity::getBusinessType, inbound.getBusinessType())
|
||||
.eq(CustomerChargeRuleEntity::getTaxMode, inbound.getTaxMode())
|
||||
.eq(CustomerChargeRuleEntity::getTaxMode, inbound.getPricingTaxMode())
|
||||
.eq(CustomerChargeRuleEntity::getStatus, "ACTIVE")
|
||||
.le(CustomerChargeRuleEntity::getEffectiveFrom, at)
|
||||
.and(w -> w.isNull(CustomerChargeRuleEntity::getEffectiveTo)
|
||||
|
||||
@@ -35,6 +35,8 @@ import java.util.List;
|
||||
@Service
|
||||
public class FbaPricingService {
|
||||
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 String AUTOMATIC_CONFIRM_OPERATOR_NAME = "装柜完成自动确认";
|
||||
@Resource
|
||||
@@ -114,10 +116,12 @@ public class FbaPricingService {
|
||||
|| !StringUtils.hasText(item.getCalculationRuleVersion())));
|
||||
if (incomplete) throw conflict("外箱规数据或绑定的计算规则不完整,不能生成对客费用");
|
||||
}
|
||||
PricingPolicy policy = pricingPolicy(inbound, requestedAt == null ? LocalDateTime.now(clock) : requestedAt);
|
||||
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(new EstimateRequest(
|
||||
inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(),
|
||||
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));
|
||||
RateRow rate = estimate.rate();
|
||||
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,
|
||||
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.setMeasurementSource("OUTER".equalsIgnoreCase(volumeSource) ? "OUTER" : volumeSource);
|
||||
if (published != null) {
|
||||
@@ -283,7 +294,8 @@ public class FbaPricingService {
|
||||
}
|
||||
String discriminator = current.getId() + ":" + request.expectedFeeVersion() + ":"
|
||||
+ request.chargeableQuantity() + ":" + request.unitPrice() + ":"
|
||||
+ request.additionalAmount() + ":" + request.reason().trim();
|
||||
+ request.additionalAmount() + ":" + request.reason().trim() + ":"
|
||||
+ request.toleranceConfirmed();
|
||||
InboundPricingIdempotencyEntity token = beginIdempotency(tenantId, operatorId, inboundId,
|
||||
"FEE_ADJUST", idempotencyKey, hash("ADJUST", inboundId, discriminator));
|
||||
if ("COMPLETED".equals(token.getStatus())) {
|
||||
@@ -297,6 +309,17 @@ public class FbaPricingService {
|
||||
adjusted.setId(null);
|
||||
adjusted.setFeeNo("FBAF-" + inboundId + "-" + 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.setConvertedQuantity(adjusted.getChargeableQuantity());
|
||||
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,
|
||||
long inboundId, String operation,
|
||||
String key, String hash) {
|
||||
@@ -456,6 +500,8 @@ public class FbaPricingService {
|
||||
fee.setRateEffectiveFrom(rate.getEffectiveFrom()); fee.setRateEffectiveTo(rate.getEffectiveTo());
|
||||
fee.setChargeMode(estimate.chargeUnit()); fee.setConversionFactor(estimate.conversionFactor());
|
||||
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.setSubtotalAmount(subtotal); fee.setAdditionalAmount(additional); fee.setTotalAmount(total);
|
||||
fee.setFeeStatus("CALCULATED"); fee.setReviewStatus("NOT_SUBMITTED"); fee.setExceptionRelease(false);
|
||||
@@ -497,6 +543,8 @@ public class FbaPricingService {
|
||||
|
||||
private FeeSnapshotResponse response(InboundPricingEntity inbound, InboundFeeEntity fee,
|
||||
BigDecimal unitPrice, String nextStatus) {
|
||||
BigDecimal baseline = baseline(fee);
|
||||
ToleranceRange range = toleranceRange(baseline);
|
||||
return new FeeSnapshotResponse(fee.getId(), fee.getFeeNo(), fee.getFeeVersion(), fee.getFeeStatus(),
|
||||
fee.getInboundId(), inbound == null ? null : inbound.getInboundCode(),
|
||||
fee.getMeasurementId(), fee.getMeasurementSource(),
|
||||
@@ -508,7 +556,9 @@ public class FbaPricingService {
|
||||
fee.getTaxMode(), fee.getPricingTaxMode(), fee.getChargeMode(),
|
||||
fee.getConversionFactor(),
|
||||
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.getRateEffectiveFrom(), fee.getRateEffectiveTo(), fee.getCalculatedAt(), fee.getConfirmedAt(),
|
||||
fee.getSentAt(), nextStatus,
|
||||
@@ -516,6 +566,22 @@ public class FbaPricingService {
|
||||
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) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
|
||||
@@ -42,6 +42,10 @@ public class MerchantPriceBookService {
|
||||
|
||||
public EstimateResponse estimate(long merchantTenantId, EstimateRequest request) {
|
||||
requireMerchant(merchantTenantId);
|
||||
if (request.pricingTaxMode() == PricingTaxMode.TAX_INCLUDED
|
||||
&& request.chargeMode() != null && !"KG".equals(request.chargeMode())) {
|
||||
throw validation("包税报价必须按KG计费");
|
||||
}
|
||||
ActivePriceBookEstimator.Estimate estimate = estimator.estimate(request);
|
||||
return new EstimateResponse(estimate.priceBookVersionId(), estimate.channelCode(), estimate.priceModel(),
|
||||
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_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '试算时实际体积m3',
|
||||
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 '最终计费量',
|
||||
charge_unit VARCHAR(16) 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)
|
||||
) 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 (
|
||||
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:
|
||||
put:
|
||||
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'}]
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -330,6 +330,10 @@ components:
|
||||
actualWeightKg: {type: number, nullable: true}
|
||||
actualVolumeCbm: {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}
|
||||
chargeUnit: {type: string, nullable: true}
|
||||
unitPrice: {type: number, nullable: true}
|
||||
@@ -466,16 +470,22 @@ components:
|
||||
timestamp: {type: string, format: date-time, nullable: true}
|
||||
AdjustFeeRequest:
|
||||
type: object
|
||||
required: [expectedFeeVersion, chargeableQuantity, unitPrice, additionalAmount, reason]
|
||||
required: [expectedFeeVersion, chargeableQuantity, unitPrice, additionalAmount, reason, toleranceConfirmed]
|
||||
properties:
|
||||
expectedFeeVersion: {type: integer, minimum: 1}
|
||||
chargeableQuantity: {type: number, minimum: 0, exclusiveMinimum: true, description: 计费量,单位沿用当前费用快照的 chargeUnit}
|
||||
unitPrice: {type: number, minimum: 0, description: 人工确认单价}
|
||||
additionalAmount: {type: number, minimum: 0, multipleOf: 0.01}
|
||||
reason: {type: string, minLength: 1, maxLength: 500}
|
||||
toleranceConfirmed: {type: boolean, enum: [true], description: 前端展示上下界并由操作员二次确认后固定传true;服务端仍会重新校验范围与版本}
|
||||
CalculateRequest:
|
||||
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:
|
||||
type: object
|
||||
required: [ruleCode, customerId, customerName, businessType, chargeMode, taxMode, conversionFactor, roundingScale, roundingMode, currency, effectiveFrom]
|
||||
@@ -668,5 +678,6 @@ components:
|
||||
destinationName: {type: string, maxLength: 128, nullable: true}
|
||||
destinationRegion: {type: string, enum: [WEST, CENTRAL, EAST]}
|
||||
pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'}
|
||||
chargeMode: {type: string, enum: [KG, CBM], description: 包税报价固定为KG;不包税报价必须与客户当前计费规则一致}
|
||||
actualWeightKg: {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.InboundMeasurementEntity;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
|
||||
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
|
||||
import com.baiyue.firstleg.pricing.ActivePriceBookEstimator;
|
||||
import com.baiyue.firstleg.repository.FbaPricingRepository;
|
||||
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.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.never;
|
||||
@@ -217,6 +219,10 @@ class FbaPricingServiceTest {
|
||||
InboundPricingEntity inbound = inbound("363.001", "0.5000");
|
||||
inbound.setPricingTaxMode("TAX_EXCLUDED");
|
||||
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(
|
||||
estimate("CBM", "1.0001", "100.000000", "100.01", "0.00"));
|
||||
|
||||
@@ -228,6 +234,8 @@ class FbaPricingServiceTest {
|
||||
assertThat(response.conversionFactor()).isEqualByComparingTo("363");
|
||||
assertThat(response.roundingScale()).isEqualTo(6);
|
||||
assertThat(response.roundingMode()).isEqualTo("HALF_UP");
|
||||
assertThat(response.chargeRuleId()).isEqualTo(601L);
|
||||
assertThat(response.ruleCode()).isEqualTo("CBM-RULE");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -483,15 +491,19 @@ class FbaPricingServiceTest {
|
||||
|
||||
FeeSnapshotResponse response = service.adjustPublished(TENANT, OPERATOR, INBOUND,
|
||||
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
|
||||
1, new BigDecimal("12.5"), new BigDecimal("8.00"),
|
||||
new BigDecimal("20.00"), "客服修正"), "adjust-1");
|
||||
1, new BigDecimal("160"), new BigDecimal("8.00"),
|
||||
new BigDecimal("20.00"), "客服修正", true), "adjust-1");
|
||||
|
||||
assertThat(response.feeVersion()).isEqualTo(2);
|
||||
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(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(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.ADJUSTED),
|
||||
eq(TENANT), eq(INBOUND), eq(502L), eq(2), eq(NOW));
|
||||
@@ -538,7 +550,7 @@ class FbaPricingServiceTest {
|
||||
assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND,
|
||||
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
|
||||
1, new BigDecimal("12.5"), new BigDecimal("8.00"),
|
||||
BigDecimal.ZERO, "客服修正"), "locked-adjust"))
|
||||
BigDecimal.ZERO, "客服修正", true), "locked-adjust"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("客户已确认");
|
||||
|
||||
@@ -574,11 +586,11 @@ class FbaPricingServiceTest {
|
||||
|
||||
FeeSnapshotResponse response = service.adjustPublished(TENANT, OPERATOR, INBOUND,
|
||||
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
|
||||
1, new BigDecimal("12.5"), new BigDecimal("8.00"),
|
||||
new BigDecimal("20.00"), "客服修正"), "adjust-calculated");
|
||||
1, new BigDecimal("160"), new BigDecimal("8.00"),
|
||||
new BigDecimal("20.00"), "客服修正", true), "adjust-calculated");
|
||||
|
||||
assertThat(response.feeStatus()).isEqualTo("CALCULATED");
|
||||
assertThat(response.totalAmount()).isEqualByComparingTo("120.00");
|
||||
assertThat(response.totalAmount()).isEqualByComparingTo("1300.00");
|
||||
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
|
||||
verify(repository).supersedeCurrent(TENANT, 501L, "CALCULATED");
|
||||
verify(feeDetails).materializeCalculatedFreight(TENANT, INBOUND, 503L, OPERATOR, NOW);
|
||||
@@ -598,7 +610,7 @@ class FbaPricingServiceTest {
|
||||
assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND,
|
||||
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
|
||||
1, new BigDecimal("12.5"), new BigDecimal("8.00"),
|
||||
BigDecimal.ZERO, "客服修正"), "stale-adjust"))
|
||||
BigDecimal.ZERO, "客服修正", true), "stale-adjust"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(error -> ((BizException) error).getResultCode())
|
||||
.isEqualTo(ResultCode.CONFLICT);
|
||||
@@ -607,6 +619,64 @@ class FbaPricingServiceTest {
|
||||
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() {
|
||||
when(repository.findIdempotency(anyLong(), anyLong(), any(), any())).thenReturn(null);
|
||||
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; })
|
||||
|
||||
@@ -292,6 +292,7 @@ class MerchantPriceBookServiceTest {
|
||||
String weight, String volume) {
|
||||
return new EstimateRequest(10L, channelCode, destinationCode, destinationType,
|
||||
destinationWarehouseId, null, destinationRegion, pricingTaxMode,
|
||||
pricingTaxMode == PricingTaxMode.TAX_INCLUDED ? "KG" : "CBM",
|
||||
decimal(weight), decimal(volume));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user