修正外箱快照清空并提供当前计费量只读预览

This commit is contained in:
2026-10-28 11:03:44 +08:00
parent b2e4f0d246
commit ca61f79b6d
10 changed files with 376 additions and 35 deletions
@@ -7,6 +7,8 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.FbaPricingDtos.CalculateRequest;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeePreviewResponse;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeePreviewRequest;
import com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest;
import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest;
import com.baiyue.firstleg.service.FbaPricingService;
@@ -34,6 +36,16 @@ public class PlatformFbaPricingController {
@Resource
private FbaPricingService service;
/** 预览当前重量、体积对应的计费量和价格,不生成费用版本或改变履约状态。 */
@PostMapping("/preview")
@PreAuthorize("hasAnyAuthority('firstleg:inbound:confirm-send-fee', 'firstleg:fba-pricing:calculate')")
public ApiResponse<FeePreviewResponse> preview(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId, @Valid @RequestBody FeePreviewRequest request) {
long scope = FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.preview(scope, inboundId, request));
}
@PostMapping("/calculate")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:calculate')")
@BusinessAudit(event = "FBA_FEE_CALCULATE", resourceType = "INBOUND", resourceId = "#inboundId",
@@ -76,6 +76,25 @@ public final class FbaPricingDtos {
}
}
/** 当前输入的只读预览,人工单价仅用于预览金额,不写入费用。 */
public record FeePreviewRequest(
LocalDateTime pricingAt,
@DecimalMin(value = "0", inclusive = false) BigDecimal selectedWeightKg,
@DecimalMin(value = "0", inclusive = false) BigDecimal selectedVolumeCbm,
@Size(max = 32) String weightSource,
@Size(max = 32) String volumeSource,
@DecimalMin(value = "0", inclusive = false) BigDecimal selectedUnitPrice
) {}
/** 当前选择值的只读计费预览,不包含费用版本或持久化标识。 */
public record FeePreviewResponse(
BigDecimal actualWeightKg, BigDecimal actualVolumeCbm,
BigDecimal chargeableQuantity, String chargeUnit,
BigDecimal conversionFactor, Integer roundingScale, String roundingMode,
BigDecimal unitPrice, BigDecimal additionalAmount, BigDecimal totalAmount,
String currency, boolean priceMatched
) {}
public record ManualQuoteRequest(
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal selectedWeightKg,
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal selectedVolumeCbm,
@@ -1,6 +1,8 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
@@ -34,6 +36,7 @@ public class InboundMeasurementItemEntity {
private BigDecimal volumeCbm;
private BigDecimal totalVolumeCbm;
private BigDecimal volumeWeightKg;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Long chargeRuleId;
private String coefficientMode;
private String calculationRuleVersion;
@@ -44,8 +47,12 @@ public class InboundMeasurementItemEntity {
private BigDecimal outerLengthCm;
private BigDecimal outerWidthCm;
private BigDecimal outerHeightCm;
// 清空修正表示恢复对应实测边,必须显式写入 NULL,不能被默认的非空更新策略忽略。
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private BigDecimal manualOuterLengthCm;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private BigDecimal manualOuterWidthCm;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private BigDecimal manualOuterHeightCm;
private String remark;
private LocalDateTime createdAt;
@@ -5,6 +5,8 @@ import jakarta.annotation.Resource;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeePreviewResponse;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeePreviewRequest;
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
import com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest;
import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest;
@@ -63,6 +65,41 @@ public class FbaPricingService {
@Resource
private ContainerTierAmountAllocator containerTierAllocator;
/** 按当前选择值只读预览,复用正式计价和柜级阶梯规则,不写费用、幂等或财务记录。 */
public FeePreviewResponse preview(long tenantId, long inboundId, FeePreviewRequest request) {
InboundPricingEntity inbound = requireEligibleInbound(tenantId, inboundId);
requireMeasurementSources(tenantId, inboundId, request.weightSource(), request.volumeSource());
BigDecimal weight = selectedWeight(inbound, request.selectedWeightKg());
BigDecimal volume = selectedVolume(inbound, request.selectedVolumeCbm());
PricingPolicy policy = pricingPolicy(inbound,
request.pricingAt() == null ? LocalDateTime.now(clock) : request.pricingAt());
try {
ActivePriceBookEstimator.Estimate estimate = estimateFee(tenantId, inbound, policy, weight, volume);
BigDecimal additional = estimate.rate().getAdditionalFee() == null ? BigDecimal.ZERO
: estimate.rate().getAdditionalFee().setScale(MONEY_SCALE, RoundingMode.HALF_UP);
BigDecimal subtotal = request.selectedUnitPrice() == null
|| request.selectedUnitPrice().compareTo(estimate.unitPrice()) == 0
? estimate.amount().setScale(MONEY_SCALE, RoundingMode.HALF_UP)
: estimate.chargeableQuantity().multiply(request.selectedUnitPrice()).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
return new FeePreviewResponse(weight, volume, estimate.chargeableQuantity(), estimate.chargeUnit(),
estimate.conversionFactor(), estimate.roundingScale(), estimate.roundingMode(),
estimate.unitPrice(), additional,
subtotal.add(additional),
estimate.currency(), true);
} catch (BizException exception) {
// 仅价格未命中且可人工定价的 FBA 返回计费量;其他业务错误继续明确拒绝。
if (exception.getResultCode() != ResultCode.NOT_FOUND || !"FBA".equals(inbound.getDestinationType())) {
throw exception;
}
BigDecimal factor = new BigDecimal("KG".equals(policy.chargeMode()) ? "167" : "363");
return new FeePreviewResponse(weight, volume, manualChargeableQuantity(policy.chargeMode(), weight, volume),
policy.chargeMode(), factor, 6, "HALF_UP", null,
BigDecimal.ZERO.setScale(MONEY_SCALE), request.selectedUnitPrice() == null ? null
: manualChargeableQuantity(policy.chargeMode(), weight, volume).multiply(request.selectedUnitPrice())
.setScale(MONEY_SCALE, RoundingMode.HALF_UP), "CNY", false);
}
}
@Transactional
public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId,
LocalDateTime requestedAt, String idempotencyKey) {
@@ -77,6 +114,7 @@ public class FbaPricingService {
selectedVolumeCbm, null, null, idempotencyKey);
}
/** 复用预览的计价规则生成费用版本,仅正式确认流程写入计费事实。 */
@Transactional
public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId,
LocalDateTime requestedAt, BigDecimal selectedWeightKg,
@@ -105,38 +143,11 @@ public class FbaPricingService {
throw conflict("客户已确认,不允许再修改或重新发送费用");
}
}
var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId);
boolean actualMeasurementSource = "ACTUAL".equalsIgnoreCase(weightSource)
|| "ACTUAL".equalsIgnoreCase(volumeSource);
boolean outerMeasurementSource = "OUTER".equalsIgnoreCase(weightSource)
|| "OUTER".equalsIgnoreCase(volumeSource);
if ((actualMeasurementSource || outerMeasurementSource) && measurement == null) {
throw conflict(actualMeasurementSource
? "实际测量数据不存在,不能生成对客费用"
: "外箱规测量批次不存在,不能生成对客费用");
}
if (actualMeasurementSource || outerMeasurementSource) {
var measurementItems = measurementRepository.listItems(tenantId, measurement.getId());
if (actualMeasurementSource && measurementItems.isEmpty()) {
throw conflict("实际测量明细不存在,不能生成对客费用");
}
boolean incomplete = outerMeasurementSource && (measurementItems.isEmpty() || measurementItems.stream().anyMatch(item ->
item.getOuterLengthCm() == null || item.getOuterLengthCm().signum() <= 0
|| item.getOuterWidthCm() == null || item.getOuterWidthCm().signum() <= 0
|| item.getOuterHeightCm() == null || item.getOuterHeightCm().signum() <= 0
|| !StringUtils.hasText(item.getCalculationRuleVersion())));
if (incomplete) throw conflict("外箱规数据或绑定的计算规则不完整,不能生成对客费用");
}
var measurement = requireMeasurementSources(tenantId, inboundId, weightSource, volumeSource);
PricingPolicy policy = pricingPolicy(inbound, requestedAt == null ? LocalDateTime.now(clock) : requestedAt);
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(tenantId, new EstimateRequest(
inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(),
destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null,
pricingTaxMode(inbound), policy.chargeMode(),
selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg,
selectedVolumeCbm == null ? inbound.getActualVolumeCbm() : selectedVolumeCbm));
BigDecimal weight = (selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg).setScale(3, RoundingMode.HALF_UP);
BigDecimal volume = (selectedVolumeCbm == null ? inbound.getActualVolumeCbm() : selectedVolumeCbm).setScale(4, RoundingMode.HALF_UP);
estimate = applyContainerCustomerTier(inbound, estimate, weight, volume);
BigDecimal weight = selectedWeight(inbound, selectedWeightKg);
BigDecimal volume = selectedVolume(inbound, selectedVolumeCbm);
ActivePriceBookEstimator.Estimate estimate = estimateFee(tenantId, inbound, policy, weight, volume);
RateRow rate = estimate.rate();
BigDecimal chargeable = estimate.chargeableQuantity();
BigDecimal converted = chargeable;
@@ -179,6 +190,7 @@ public class FbaPricingService {
return response(inbound, fee, rate.getUnitPrice(), nextStatus);
}
/** 价格未命中时按人工单价生成正式费用,计费量与只读预览保持一致。 */
@Transactional
public FeeSnapshotResponse manualQuote(long tenantId, long operatorId, long inboundId,
ManualQuoteRequest request, String idempotencyKey) {
@@ -219,9 +231,7 @@ public class FbaPricingService {
BigDecimal volume = request.selectedVolumeCbm().setScale(4, RoundingMode.HALF_UP);
String unit = policy.chargeMode();
BigDecimal factor = new BigDecimal("KG".equals(unit) ? "167" : "363");
BigDecimal quantity = ("KG".equals(unit) ? weight.max(volume.multiply(factor))
: volume.max(weight.divide(factor, 12, RoundingMode.HALF_UP)))
.setScale(6, RoundingMode.HALF_UP);
BigDecimal quantity = manualChargeableQuantity(unit, weight, volume);
BigDecimal subtotal = quantity.multiply(request.unitPrice()).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
int version = repository.nextFeeVersion(tenantId, inboundId);
InboundFeeEntity fee = new InboundFeeEntity();
@@ -267,6 +277,61 @@ public class FbaPricingService {
return response(inbound, fee, request.unitPrice(), nextStatus);
}
/** 预览和正式计价共用价格匹配与柜级阶梯分摊,避免前端复制计价公式。 */
private ActivePriceBookEstimator.Estimate estimateFee(long tenantId, InboundPricingEntity inbound,
PricingPolicy policy, BigDecimal weight, BigDecimal volume) {
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(tenantId, new EstimateRequest(
inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(),
destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null,
pricingTaxMode(inbound), policy.chargeMode(), weight, volume));
return applyContainerCustomerTier(inbound, estimate, weight, volume);
}
/** 计费重量统一保留三位精度,预览与最终费用使用同一基数。 */
private BigDecimal selectedWeight(InboundPricingEntity inbound, BigDecimal selected) {
BigDecimal weight = selected == null ? inbound.getActualNetWeightKg() : selected;
if (weight == null || weight.signum() <= 0) throw conflict("计费重量必须大于0");
return weight.setScale(3, RoundingMode.HALF_UP);
}
/** 计费体积统一保留四位精度,预览与最终费用使用同一基数。 */
private BigDecimal selectedVolume(InboundPricingEntity inbound, BigDecimal selected) {
BigDecimal volume = selected == null ? inbound.getActualVolumeCbm() : selected;
if (volume == null || volume.signum() <= 0) throw conflict("计费体积必须大于0");
return volume.setScale(4, RoundingMode.HALF_UP);
}
/** 人工定价的重量与体积换算规则由服务端统一提供给预览和正式报价。 */
private BigDecimal manualChargeableQuantity(String unit, BigDecimal weight, BigDecimal volume) {
BigDecimal factor = new BigDecimal("KG".equals(unit) ? "167" : "363");
return ("KG".equals(unit) ? weight.max(volume.multiply(factor))
: volume.max(weight.divide(factor, 12, RoundingMode.HALF_UP)))
.setScale(6, RoundingMode.HALF_UP);
}
/** 仅验证测量来源及保存快照完整性,不写入或重新计算测量数据。 */
private com.baiyue.firstleg.entity.InboundMeasurementEntity requireMeasurementSources(
long tenantId, long inboundId, String weightSource, String volumeSource) {
var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId);
boolean actual = "ACTUAL".equalsIgnoreCase(weightSource) || "ACTUAL".equalsIgnoreCase(volumeSource);
boolean outer = "OUTER".equalsIgnoreCase(weightSource) || "OUTER".equalsIgnoreCase(volumeSource);
if ((actual || outer) && measurement == null) {
throw conflict(actual ? "实际测量数据不存在,不能生成对客费用" : "外箱规测量批次不存在,不能生成对客费用");
}
if (actual || outer) {
var items = measurementRepository.listItems(tenantId, measurement.getId());
if (actual && items.isEmpty()) throw conflict("实际测量明细不存在,不能生成对客费用");
if (outer && (items.isEmpty() || items.stream().anyMatch(item ->
item.getOuterLengthCm() == null || item.getOuterLengthCm().signum() <= 0
|| item.getOuterWidthCm() == null || item.getOuterWidthCm().signum() <= 0
|| item.getOuterHeightCm() == null || item.getOuterHeightCm().signum() <= 0
|| !StringUtils.hasText(item.getCalculationRuleVersion())))) {
throw conflict("外箱规数据或绑定的计算规则不完整,不能生成对客费用");
}
}
return measurement;
}
private ActivePriceBookEstimator.Estimate applyContainerCustomerTier(
InboundPricingEntity inbound, ActivePriceBookEstimator.Estimate individual,
BigDecimal currentWeight, BigDecimal currentVolume) {