feat(firstleg): enforce pricing adjustment bounds

This commit is contained in:
2026-09-08 17:13:43 +08:00
parent 78a73a1b0e
commit 2e60f2cd77
11 changed files with 236 additions and 19 deletions
@@ -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));
}