1001 lines
62 KiB
Java
1001 lines
62 KiB
Java
package com.baiyue.firstleg.service;
|
|
|
|
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;
|
|
import com.baiyue.firstleg.domain.PricingDestinationType;
|
|
import com.baiyue.firstleg.domain.PricingTaxMode;
|
|
import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest;
|
|
import com.baiyue.firstleg.entity.InboundFeeEntity;
|
|
import com.baiyue.firstleg.entity.InboundFeeItemEntity;
|
|
import com.baiyue.firstleg.entity.InboundPricingEntity;
|
|
import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity;
|
|
import com.baiyue.firstleg.mapper.ContainerTierPricingMapper;
|
|
import com.baiyue.firstleg.pricing.ActivePriceBookEstimator;
|
|
import com.baiyue.firstleg.pricing.ContainerTierAmountAllocator;
|
|
import com.baiyue.firstleg.domain.PriceModel;
|
|
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow;
|
|
import com.baiyue.firstleg.repository.FbaPricingRepository;
|
|
import org.springframework.stereotype.Service;
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
import org.springframework.util.StringUtils;
|
|
import org.springframework.beans.BeanUtils;
|
|
|
|
import java.math.BigDecimal;
|
|
import java.math.RoundingMode;
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.security.MessageDigest;
|
|
import java.security.NoSuchAlgorithmException;
|
|
import java.time.Clock;
|
|
import java.time.LocalDateTime;
|
|
import java.util.ArrayList;
|
|
import java.util.HexFormat;
|
|
import java.util.List;
|
|
import java.util.LinkedHashMap;
|
|
import java.util.Map;
|
|
|
|
/** FBA 费用试算、确认和发送状态机。 */
|
|
@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 CONTAINER_RECALCULATE_OPERATION = "CONTAINER_FEE_RECALCULATE";
|
|
private static final String MANUAL_RATE_CODE = "MANUAL_QUOTE";
|
|
@Resource
|
|
private FbaPricingRepository repository;
|
|
@Resource
|
|
private com.baiyue.firstleg.repository.InboundMeasurementRepository measurementRepository;
|
|
@Resource
|
|
private ActivePriceBookEstimator priceBookEstimator;
|
|
@Resource
|
|
private Clock clock;
|
|
@Resource
|
|
private InboundFeeDetailService feeDetails;
|
|
@Resource
|
|
private FinanceFeeSourceOutboxService financeOutbox;
|
|
@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) {
|
|
return calculate(tenantId, operatorId, inboundId, requestedAt, null, null, idempotencyKey);
|
|
}
|
|
|
|
@Transactional
|
|
public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId,
|
|
LocalDateTime requestedAt, BigDecimal selectedWeightKg,
|
|
BigDecimal selectedVolumeCbm, String idempotencyKey) {
|
|
return calculate(tenantId, operatorId, inboundId, requestedAt, selectedWeightKg,
|
|
selectedVolumeCbm, null, null, idempotencyKey);
|
|
}
|
|
|
|
/** 复用预览的计价规则生成费用版本,仅正式确认流程写入计费事实。 */
|
|
@Transactional
|
|
public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId,
|
|
LocalDateTime requestedAt, BigDecimal selectedWeightKg,
|
|
BigDecimal selectedVolumeCbm, String weightSource,
|
|
String volumeSource, String idempotencyKey) {
|
|
String discriminator = (requestedAt == null ? "CURRENT" : requestedAt.toString()) + ":"
|
|
+ (selectedWeightKg == null ? "ACTUAL_WEIGHT" : selectedWeightKg) + ":"
|
|
+ (selectedVolumeCbm == null ? "ACTUAL_VOLUME" : selectedVolumeCbm);
|
|
if (weightSource != null || volumeSource != null) {
|
|
discriminator += ":" + (weightSource == null ? "UNSPECIFIED" : weightSource)
|
|
+ ":" + (volumeSource == null ? "UNSPECIFIED" : volumeSource);
|
|
}
|
|
String hash = hash("CALCULATE", inboundId, discriminator);
|
|
InboundPricingIdempotencyEntity token = beginIdempotency(
|
|
tenantId, operatorId, inboundId, "FEE_CALCULATE", idempotencyKey, hash);
|
|
if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), null);
|
|
|
|
InboundPricingEntity inbound = requireEligibleInbound(tenantId, inboundId);
|
|
InboundFeeEntity published = null;
|
|
if ("SENT".equals(inbound.getFeeStatus())) {
|
|
published = repository.findLatestFeeForUpdate(tenantId, inboundId);
|
|
if (published == null || !"SENT".equals(published.getFeeStatus())) {
|
|
throw conflict("费用状态已变化,请刷新后重试");
|
|
}
|
|
if (feeRevisionLocked(published)) {
|
|
throw conflict("客户已确认,不允许再修改或重新发送费用");
|
|
}
|
|
}
|
|
var measurement = requireMeasurementSources(tenantId, inboundId, weightSource, volumeSource);
|
|
PricingPolicy policy = pricingPolicy(inbound, requestedAt == null ? LocalDateTime.now(clock) : requestedAt);
|
|
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;
|
|
BigDecimal subtotal = estimate.amount().setScale(MONEY_SCALE, RoundingMode.HALF_UP);
|
|
BigDecimal additional = rate.getAdditionalFee() == null ? BigDecimal.ZERO
|
|
: rate.getAdditionalFee().setScale(MONEY_SCALE, RoundingMode.HALF_UP);
|
|
BigDecimal total = subtotal.add(additional).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
|
|
LocalDateTime now = LocalDateTime.now(clock);
|
|
int version = repository.nextFeeVersion(tenantId, inboundId);
|
|
|
|
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) {
|
|
if (repository.supersedeCurrent(tenantId, published.getId(), "SENT") != 1) {
|
|
throw conflict("费用已被客户确认或其他操作员修改,请刷新后重试");
|
|
}
|
|
} else {
|
|
repository.supersedeCalculated(tenantId, inboundId);
|
|
}
|
|
repository.insertFee(fee);
|
|
repository.insertItem(baseItem(tenantId, fee, rate, chargeable, subtotal, now));
|
|
if (additional.signum() > 0) repository.insertItem(additionalItem(tenantId, fee, additional, now));
|
|
if (repository.markInboundCalculated(tenantId, inboundId, inbound.getProcessStatus(),
|
|
estimate.chargeUnit(), weight, volume, chargeable) != 1) {
|
|
throw conflict("入库单状态已变化,费用试算未保存");
|
|
}
|
|
feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now);
|
|
complete(token, fee.getId(), now);
|
|
String nextStatus = preserveProcessStatusDuringPricing(inbound.getProcessStatus())
|
|
? inbound.getProcessStatus() : "FEE_CONFIRMING";
|
|
return response(inbound, fee, rate.getUnitPrice(), nextStatus);
|
|
}
|
|
|
|
/** 价格未命中时按人工单价生成正式费用,计费量与只读预览保持一致。 */
|
|
@Transactional
|
|
public FeeSnapshotResponse manualQuote(long tenantId, long operatorId, long inboundId,
|
|
ManualQuoteRequest request, String idempotencyKey) {
|
|
String reason = request.reason().trim();
|
|
if (reason.length() < 2 || request.unitPrice().signum() <= 0
|
|
|| request.selectedWeightKg().signum() <= 0 || request.selectedVolumeCbm().signum() <= 0) {
|
|
throw new BizException(ResultCode.VALIDATION_FAILED, "人工定价的单价、计费重量、体积和原因必须有效");
|
|
}
|
|
String discriminator = request.selectedWeightKg() + ":" + request.selectedVolumeCbm()
|
|
+ ":" + request.unitPrice() + ":" + reason;
|
|
InboundPricingIdempotencyEntity token = beginIdempotency(tenantId, operatorId, inboundId,
|
|
"FEE_MANUAL_QUOTE", idempotencyKey, hash("MANUAL_QUOTE", inboundId, discriminator));
|
|
if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), null);
|
|
|
|
InboundPricingEntity inbound = requireEligibleInbound(tenantId, inboundId);
|
|
if (!"FBA".equals(inbound.getDestinationType())) throw conflict("仅FBA头程运费支持未命中人工定价");
|
|
var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId);
|
|
if (measurement == null || measurementRepository.listItems(tenantId, measurement.getId()).isEmpty()) {
|
|
throw conflict("实际测量明细不存在,不能生成人工头程费用");
|
|
}
|
|
InboundFeeEntity current = repository.findLatestFeeForUpdate(tenantId, inboundId);
|
|
if (feeRevisionLocked(current)) throw conflict("客户已确认,不允许再修改费用");
|
|
if (current != null && !List.of("CALCULATED", "CONFIRMED", "SENT").contains(current.getFeeStatus())) {
|
|
throw conflict("当前费用状态不允许重新定价");
|
|
}
|
|
LocalDateTime now = LocalDateTime.now(clock);
|
|
PricingPolicy policy = pricingPolicy(inbound, now);
|
|
try {
|
|
priceBookEstimator.estimate(tenantId, new EstimateRequest(
|
|
inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(),
|
|
destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null,
|
|
pricingTaxMode(inbound), policy.chargeMode(), request.selectedWeightKg(), request.selectedVolumeCbm()));
|
|
throw conflict("当前条件已有匹配价格,请使用正常试算并调整单价");
|
|
} catch (BizException exception) {
|
|
if (exception.getResultCode() != ResultCode.NOT_FOUND) throw exception;
|
|
}
|
|
BigDecimal weight = request.selectedWeightKg().setScale(3, RoundingMode.HALF_UP);
|
|
BigDecimal volume = request.selectedVolumeCbm().setScale(4, RoundingMode.HALF_UP);
|
|
String unit = policy.chargeMode();
|
|
BigDecimal factor = new BigDecimal("KG".equals(unit) ? "167" : "363");
|
|
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();
|
|
fee.setTenantId(tenantId); fee.setInboundId(inboundId);
|
|
fee.setFeeNo("FBAF-" + inboundId + "-" + version); fee.setFeeVersion(version);
|
|
fee.setMeasurementId(measurement.getId()); fee.setMeasurementSource("ACTUAL");
|
|
fee.setCarrierRateId(null); fee.setCarrierRateVersion(null); fee.setPriceBookVersionId(null);
|
|
fee.setRateCode(MANUAL_RATE_CODE); fee.setRateEffectiveFrom(null); fee.setRateEffectiveTo(null);
|
|
fee.setCustomerId(tenantId); fee.setCustomerName(inbound.getCustomerName());
|
|
fee.setBusinessType(inbound.getBusinessType()); fee.setTaxMode(inbound.getTaxMode());
|
|
fee.setPricingTaxMode(inbound.getPricingTaxMode()); fee.setOriginWarehouseId(inbound.getWarehouseId());
|
|
fee.setOriginWarehouseName(inbound.getWarehouseName()); fee.setChannelCode(inbound.getChannelCode());
|
|
fee.setDestinationType(inbound.getDestinationType()); fee.setDestinationWarehouseId(inbound.getDestinationWarehouseId());
|
|
fee.setDestinationName(inbound.getDestinationName()); fee.setDestinationCode(inbound.getDestinationCode());
|
|
fee.setChargeMode(unit); fee.setChargeUnit(unit); fee.setConversionFactor(factor);
|
|
fee.setRoundingScale(6); fee.setRoundingMode(RoundingMode.HALF_UP.name());
|
|
fee.setActualWeightKg(weight); fee.setActualVolumeCbm(volume); fee.setConvertedQuantity(quantity);
|
|
fee.setBaselineChargeableQuantity(quantity); fee.setTolerancePercent(ADJUST_TOLERANCE_PERCENT);
|
|
fee.setChargeableQuantity(quantity); fee.setCurrency("CNY");
|
|
fee.setSubtotalAmount(subtotal); fee.setAdditionalAmount(BigDecimal.ZERO.setScale(MONEY_SCALE));
|
|
fee.setTotalAmount(subtotal); fee.setFeeStatus("CALCULATED"); fee.setReviewStatus("NOT_SUBMITTED");
|
|
fee.setExceptionRelease(false); fee.setCalculatedBy(operatorId); fee.setCalculatedAt(now);
|
|
fee.setCreatedAt(now); fee.setUpdatedAt(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());
|
|
}
|
|
if (current != null && repository.supersedeCurrent(tenantId, current.getId(), current.getFeeStatus()) != 1) {
|
|
throw conflict("费用已被其他操作员修改,请刷新后重试");
|
|
}
|
|
repository.insertFee(fee);
|
|
InboundFeeItemEntity item = item(tenantId, fee, 1, "FBA_BASE", "FBA基础运费", subtotal, now);
|
|
item.setQuantity(quantity); item.setUnit(unit); item.setUnitPrice(request.unitPrice());
|
|
item.setAutomaticFlag(false); item.setRemark(reason);
|
|
repository.insertItem(item);
|
|
if (repository.markInboundCalculated(tenantId, inboundId, inbound.getProcessStatus(), unit,
|
|
weight, volume, quantity) != 1) throw conflict("入库单状态已变化,费用未保存");
|
|
feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now);
|
|
complete(token, fee.getId(), now);
|
|
String nextStatus = preserveProcessStatusDuringPricing(inbound.getProcessStatus())
|
|
? inbound.getProcessStatus() : "FEE_CONFIRMING";
|
|
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) {
|
|
if (individual.priceModel() != PriceModel.REGION_WEIGHT_TIER) return individual;
|
|
var rows = repository.containerTierMembers(inbound.getTenantId(), inbound.getId());
|
|
if (rows == null || rows.isEmpty()) return individual;
|
|
long containerId = rows.get(0).containerBookingId();
|
|
if (rows.stream().anyMatch(row -> row.containerBookingId() != containerId)) {
|
|
throw conflict("同一入仓单已分配到多个柜子,不能生成柜级阶梯费用");
|
|
}
|
|
Map<Long, ContainerTierAmountAllocator.Member> unique = new LinkedHashMap<>();
|
|
for (var row : rows) {
|
|
BigDecimal weight = row.inboundId() == inbound.getId() ? currentWeight : row.actualWeightKg();
|
|
BigDecimal volume = row.inboundId() == inbound.getId() ? currentVolume : row.actualVolumeCbm();
|
|
BigDecimal quantity = chargeableWeight(weight, volume);
|
|
unique.putIfAbsent(row.inboundId(), new ContainerTierAmountAllocator.Member(row.inboundId(), quantity));
|
|
}
|
|
if (!unique.containsKey(inbound.getId()) || unique.size() < 2) return individual;
|
|
BigDecimal aggregate = unique.values().stream().map(ContainerTierAmountAllocator.Member::chargeableQuantity)
|
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
ActivePriceBookEstimator.Estimate group = priceBookEstimator.estimate(inbound.getTenantId(), new EstimateRequest(
|
|
inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(),
|
|
destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null,
|
|
pricingTaxMode(inbound), "KG", aggregate, BigDecimal.ZERO));
|
|
var allocation = containerTierAllocator.allocate(List.copyOf(unique.values()), group.unitPrice());
|
|
BigDecimal memberQuantity = unique.get(inbound.getId()).chargeableQuantity();
|
|
return new ActivePriceBookEstimator.Estimate(group.priceBookVersionId(), group.channelCode(),
|
|
group.priceModel(), group.pricingTaxMode(), group.rate(), memberQuantity, group.chargeUnit(),
|
|
group.unitPrice(), allocation.memberAmounts().get(inbound.getId()), group.currency(),
|
|
group.conversionFactor(), group.roundingScale(), group.roundingMode());
|
|
}
|
|
|
|
private static BigDecimal chargeableWeight(BigDecimal weight, BigDecimal volume) {
|
|
BigDecimal safeWeight = weight == null ? BigDecimal.ZERO : weight;
|
|
BigDecimal safeVolume = volume == null ? BigDecimal.ZERO : volume;
|
|
return safeWeight.max(safeVolume.multiply(BigDecimal.valueOf(167)))
|
|
.setScale(6, RoundingMode.HALF_UP);
|
|
}
|
|
|
|
/**
|
|
* 柜内明细变更后,按最新有效柜内成员重新命中区域重量阶梯。
|
|
* 仅使用当前费用快照冻结的选定重量、体积和测量来源,绝不回退到入库单实测值。
|
|
* 已发送或客户已确认的费用继续参与柜级计费量聚合,但自身快照保持不变。
|
|
*/
|
|
@Transactional
|
|
public ContainerFeeRecalculationResult recalculateUnlockedContainerCustomerFees(
|
|
long containerBookingId, long tenantId, String trigger) {
|
|
if (containerBookingId <= 0 || tenantId <= 0 || !StringUtils.hasText(trigger)) {
|
|
throw new BizException(ResultCode.BAD_REQUEST, "柜次、客户和重算触发标识必填");
|
|
}
|
|
String normalizedTrigger = trigger.trim();
|
|
String key = "CONTAINER:" + containerBookingId + ":" + normalizedTrigger;
|
|
if (key.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "重算触发标识不能超过96字符");
|
|
String requestHash = hash(CONTAINER_RECALCULATE_OPERATION, containerBookingId, normalizedTrigger);
|
|
InboundPricingIdempotencyEntity existing = repository.findIdempotency(tenantId,
|
|
AUTOMATIC_CONFIRM_OPERATOR_ID, CONTAINER_RECALCULATE_OPERATION, key);
|
|
if (existing != null) {
|
|
if (!requestHash.equals(existing.getRequestHash())) throw conflict("重算触发标识已用于不同柜次");
|
|
if (!"COMPLETED".equals(existing.getStatus())) throw conflict("相同柜次费用重算正在处理中");
|
|
return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.REPLAYED, 0);
|
|
}
|
|
|
|
Map<Long, ContainerTierPricingMapper.MemberRow> rows = new LinkedHashMap<>();
|
|
for (var row : repository.containerTierMembersForUpdate(containerBookingId, tenantId)) {
|
|
rows.putIfAbsent(row.inboundId(), row);
|
|
}
|
|
LocalDateTime now = LocalDateTime.now(clock);
|
|
if (rows.isEmpty()) {
|
|
return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.NOT_READY, 0);
|
|
}
|
|
|
|
List<ContainerFeeMember> members = new ArrayList<>();
|
|
boolean notReady = false;
|
|
for (var row : rows.values()) {
|
|
InboundPricingEntity inbound = repository.findInbound(tenantId, row.inboundId());
|
|
if (inbound == null) throw conflict("柜内入库单已变化,请刷新后重试");
|
|
InboundFeeEntity current = repository.findLatestFeeForUpdate(tenantId, row.inboundId());
|
|
if (current == null) {
|
|
notReady = true;
|
|
continue;
|
|
}
|
|
boolean locked = List.of("CONFIRMED", "SENT").contains(current.getFeeStatus())
|
|
|| current.getCustomerConfirmedAt() != null;
|
|
if (!locked && !"CALCULATED".equals(current.getFeeStatus())) {
|
|
throw conflict("柜内费用状态已变化,不能重算阶梯费用");
|
|
}
|
|
BigDecimal weight = requiredFrozenQuantity(current.getActualWeightKg(), "重量");
|
|
BigDecimal volume = requiredFrozenQuantity(current.getActualVolumeCbm(), "体积");
|
|
members.add(new ContainerFeeMember(inbound, current,
|
|
weight.setScale(3, RoundingMode.HALF_UP), volume.setScale(4, RoundingMode.HALF_UP),
|
|
chargeableWeight(weight, volume), locked));
|
|
}
|
|
if (notReady) return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.NOT_READY, 0);
|
|
List<ContainerFeeMember> unlockedMembers = members.stream().filter(member -> !member.locked()).toList();
|
|
if (unlockedMembers.isEmpty()) {
|
|
return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.NOT_APPLICABLE, 0);
|
|
}
|
|
InboundPricingIdempotencyEntity token = beginContainerRecalculation(
|
|
tenantId, containerBookingId, key, normalizedTrigger);
|
|
if ("COMPLETED".equals(token.getStatus())) {
|
|
return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.REPLAYED, 0);
|
|
}
|
|
List<ContainerFeeMember> regionalMembers = members.stream()
|
|
.filter(member -> member.current().getCarrierRateId() != null)
|
|
.filter(member -> priceModel(member.current()) == PriceModel.REGION_WEIGHT_TIER)
|
|
.toList();
|
|
List<ContainerFeeMember> unlockedRegionalMembers = regionalMembers.stream()
|
|
.filter(member -> !member.locked()).toList();
|
|
if (unlockedRegionalMembers.isEmpty()) {
|
|
complete(token, containerBookingId, now);
|
|
return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.NOT_APPLICABLE, 0);
|
|
}
|
|
BigDecimal aggregate = regionalMembers.stream().map(ContainerFeeMember::chargeableQuantity)
|
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
List<ContainerTierAmountAllocator.Member> allocationMembers = regionalMembers.stream()
|
|
.map(member -> new ContainerTierAmountAllocator.Member(member.inbound().getId(), member.chargeableQuantity()))
|
|
.toList();
|
|
List<ContainerFeeQuote> quotes = new ArrayList<>();
|
|
for (ContainerFeeMember member : unlockedRegionalMembers) {
|
|
PricingPolicy policy = pricingPolicy(member.inbound(), now);
|
|
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(tenantId, new EstimateRequest(
|
|
member.inbound().getWarehouseId(), member.inbound().getChannelCode(), member.inbound().getDestinationCode(),
|
|
destinationType(member.inbound()), member.inbound().getDestinationWarehouseId(),
|
|
member.inbound().getDestinationName(), null, pricingTaxMode(member.inbound()), policy.chargeMode(),
|
|
aggregate, BigDecimal.ZERO));
|
|
if (estimate.priceModel() != PriceModel.REGION_WEIGHT_TIER) {
|
|
throw conflict("柜内区域阶梯报价模型已变化,请重新试算费用后重试");
|
|
}
|
|
quotes.add(new ContainerFeeQuote(member, policy, estimate));
|
|
}
|
|
for (ContainerFeeQuote quote : quotes) {
|
|
ContainerTierAmountAllocator.Allocation allocation = containerTierAllocator.allocate(
|
|
allocationMembers, quote.estimate().unitPrice());
|
|
BigDecimal subtotal = allocation.memberAmounts().get(quote.member().inbound().getId());
|
|
if (subtotal == null) throw conflict("柜级阶梯分摊结果不完整");
|
|
persistContainerRecalculation(tenantId, quote, subtotal, now);
|
|
}
|
|
complete(token, containerBookingId, now);
|
|
return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.RECALCULATED, quotes.size());
|
|
}
|
|
|
|
private PriceModel priceModel(InboundFeeEntity fee) {
|
|
CarrierFbaRateEntity rate = repository.findRate(fee.getCarrierRateId());
|
|
if (rate == null || !StringUtils.hasText(rate.getPriceModel())) {
|
|
throw conflict("费用快照引用的报价模型不存在,不能重算阶梯费用");
|
|
}
|
|
try {
|
|
return PriceModel.valueOf(rate.getPriceModel());
|
|
} catch (IllegalArgumentException exception) {
|
|
throw conflict("费用快照引用的报价模型无效,不能重算阶梯费用");
|
|
}
|
|
}
|
|
|
|
private void persistContainerRecalculation(long tenantId, ContainerFeeQuote quote,
|
|
BigDecimal subtotal, LocalDateTime now) {
|
|
ContainerFeeMember member = quote.member();
|
|
ActivePriceBookEstimator.Estimate estimate = quote.estimate();
|
|
RateRow rate = estimate.rate();
|
|
int version = repository.nextFeeVersion(tenantId, member.inbound().getId());
|
|
BigDecimal additional = rate.getAdditionalFee() == null ? BigDecimal.ZERO
|
|
: rate.getAdditionalFee().setScale(MONEY_SCALE, RoundingMode.HALF_UP);
|
|
InboundFeeEntity recalculated = snapshot(tenantId, AUTOMATIC_CONFIRM_OPERATOR_ID, member.inbound(), estimate,
|
|
rate, version, member.weight(), member.volume(), member.chargeableQuantity(),
|
|
member.chargeableQuantity(), subtotal.setScale(MONEY_SCALE, RoundingMode.HALF_UP), additional,
|
|
subtotal.add(additional).setScale(MONEY_SCALE, RoundingMode.HALF_UP), now);
|
|
PricingPolicy policy = quote.policy();
|
|
if (policy.rule() != null) {
|
|
recalculated.setChargeRuleId(policy.rule().getId());
|
|
recalculated.setChargeRuleVersion(policy.rule().getRuleVersion());
|
|
recalculated.setRuleCode(policy.rule().getRuleCode());
|
|
recalculated.setRuleEffectiveFrom(policy.rule().getEffectiveFrom());
|
|
recalculated.setRuleEffectiveTo(policy.rule().getEffectiveTo());
|
|
}
|
|
recalculated.setMeasurementId(member.current().getMeasurementId());
|
|
recalculated.setMeasurementSource(member.current().getMeasurementSource());
|
|
if (repository.supersedeCurrent(tenantId, member.current().getId(), member.current().getFeeStatus()) != 1) {
|
|
throw conflict("柜内费用已变化,不能重算阶梯费用");
|
|
}
|
|
repository.insertFee(recalculated);
|
|
repository.insertItem(baseItem(tenantId, recalculated, rate, member.chargeableQuantity(),
|
|
recalculated.getSubtotalAmount(), now));
|
|
if (additional.signum() > 0) repository.insertItem(additionalItem(tenantId, recalculated, additional, now));
|
|
if (repository.markInboundContainerFeeRecalculated(tenantId, member.inbound().getId(),
|
|
member.inbound().getProcessStatus(), member.current().getActualWeightKg(),
|
|
member.current().getActualVolumeCbm(), estimate.chargeUnit(), member.chargeableQuantity()) != 1) {
|
|
throw conflict("柜内入库单状态已变化,不能重算阶梯费用");
|
|
}
|
|
feeDetails.materializeCalculatedFreight(tenantId, member.inbound().getId(), recalculated.getId(),
|
|
AUTOMATIC_CONFIRM_OPERATOR_ID, now);
|
|
}
|
|
|
|
private InboundPricingIdempotencyEntity beginContainerRecalculation(long tenantId, long containerBookingId,
|
|
String key, String trigger) {
|
|
String requestHash = hash(CONTAINER_RECALCULATE_OPERATION, containerBookingId, trigger);
|
|
InboundPricingIdempotencyEntity existing = repository.findIdempotency(tenantId,
|
|
AUTOMATIC_CONFIRM_OPERATOR_ID, CONTAINER_RECALCULATE_OPERATION, key);
|
|
if (existing != null) {
|
|
if (!requestHash.equals(existing.getRequestHash())) throw conflict("重算触发标识已用于不同柜次");
|
|
if (!"COMPLETED".equals(existing.getStatus())) throw conflict("相同柜次费用重算正在处理中");
|
|
return existing;
|
|
}
|
|
InboundPricingIdempotencyEntity token = new InboundPricingIdempotencyEntity();
|
|
token.setTenantId(tenantId); token.setOperation(CONTAINER_RECALCULATE_OPERATION);
|
|
token.setOperatorId(AUTOMATIC_CONFIRM_OPERATOR_ID); token.setInboundId(0L);
|
|
token.setIdempotencyKey(key); token.setRequestHash(requestHash); token.setStatus("PROCESSING");
|
|
token.setCreatedAt(LocalDateTime.now(clock)); token.setUpdatedAt(LocalDateTime.now(clock));
|
|
repository.insertIdempotency(token);
|
|
return token;
|
|
}
|
|
|
|
private static BigDecimal requiredFrozenQuantity(BigDecimal value, String field) {
|
|
if (value == null || value.signum() < 0) throw conflict("费用快照缺少冻结" + field + ",不能重算阶梯费用");
|
|
return value;
|
|
}
|
|
|
|
private record ContainerFeeMember(InboundPricingEntity inbound, InboundFeeEntity current,
|
|
BigDecimal weight, BigDecimal volume, BigDecimal chargeableQuantity,
|
|
boolean locked) { }
|
|
|
|
private record ContainerFeeQuote(ContainerFeeMember member, PricingPolicy policy,
|
|
ActivePriceBookEstimator.Estimate estimate) { }
|
|
|
|
public enum ContainerFeeRecalculationStatus { RECALCULATED, NOT_READY, NOT_APPLICABLE, REPLAYED }
|
|
|
|
public record ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus status,
|
|
int recalculatedCount) { }
|
|
|
|
@Transactional
|
|
public FeeSnapshotResponse confirm(long tenantId, long operatorId, long inboundId,
|
|
String idempotencyKey) {
|
|
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
|
|
if (!feeRevisionStatusSupported(inbound.getProcessStatus())) {
|
|
throw conflict("当前入库单状态不允许确认费用");
|
|
}
|
|
InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId);
|
|
if (current == null) throw conflict("入库单尚无可确认的费用试算");
|
|
String hash = hash("CONFIRM", inboundId, current.getId().toString());
|
|
InboundPricingIdempotencyEntity token = beginIdempotency(
|
|
tenantId, operatorId, inboundId, "FEE_CONFIRM", idempotencyKey, hash);
|
|
if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), null);
|
|
LocalDateTime now = LocalDateTime.now(clock);
|
|
if (repository.confirmFee(tenantId, current.getId(), operatorId, now) != 1
|
|
|| repository.markInboundConfirmed(tenantId, inboundId) != 1) {
|
|
throw conflict("费用或入库单状态已变化,不能重复确认");
|
|
}
|
|
complete(token, current.getId(), now);
|
|
return response(tenantId, current.getId(), null);
|
|
}
|
|
|
|
@Transactional
|
|
public FeeSnapshotResponse send(long tenantId, long operatorId, long inboundId,
|
|
String idempotencyKey) {
|
|
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
|
|
if (!feeRevisionStatusSupported(inbound.getProcessStatus())) {
|
|
throw conflict("当前入库单状态不允许发送费用");
|
|
}
|
|
InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId);
|
|
if (current == null) throw conflict("入库单尚无费用快照");
|
|
String hash = hash("SEND", inboundId, current.getId().toString());
|
|
InboundPricingIdempotencyEntity token = beginIdempotency(
|
|
tenantId, operatorId, inboundId, "FEE_SEND", idempotencyKey, hash);
|
|
String nextProcessStatus = preserveProcessStatusDuringPricing(inbound.getProcessStatus())
|
|
? inbound.getProcessStatus() : "WAIT_PALLET";
|
|
if ("COMPLETED".equals(token.getStatus())) {
|
|
feeDetails.publishFreight(tenantId, inboundId, token.getResultResourceId(),
|
|
operatorId, null, LocalDateTime.now(clock));
|
|
return response(tenantId, token.getResultResourceId(), nextProcessStatus);
|
|
}
|
|
LocalDateTime now = LocalDateTime.now(clock);
|
|
if (repository.sendFee(tenantId, current.getId(), operatorId, now) != 1
|
|
|| repository.markInboundFeeSent(tenantId, inboundId, inbound.getProcessStatus()) != 1) {
|
|
throw conflict("费用未确认或入库单状态已变化,不能发送");
|
|
}
|
|
feeDetails.publishFreight(tenantId, inboundId, current.getId(), operatorId, null, now);
|
|
financeOutbox.publishInbound(FinanceFeeSourceOutboxService.PUBLISHED,
|
|
tenantId, inboundId, current.getId(), current.getFeeVersion(), now);
|
|
complete(token, current.getId(), now);
|
|
return response(tenantId, current.getId(), nextProcessStatus);
|
|
}
|
|
|
|
/**
|
|
* 装柜完成是客户费用的最后自动推送节点。人工已发送时直接幂等返回;
|
|
* 仍为初算时先自动确认,再复用正式发送状态机,确保费用明细和 Finance outbox 只生成一次。
|
|
*/
|
|
@Transactional
|
|
public void autoSendAfterLoadingCompleted(long tenantId, long inboundId) {
|
|
InboundPricingEntity inbound = repository.findInbound(tenantId, inboundId);
|
|
if (inbound == null) return;
|
|
InboundFeeEntity current = repository.findLatestFeeForUpdate(tenantId, inboundId);
|
|
if (current == null || "SENT".equals(current.getFeeStatus())) return;
|
|
String prefix = "loading-completed-" + inboundId;
|
|
if ("CALCULATED".equals(current.getFeeStatus())) {
|
|
confirm(tenantId, AUTOMATIC_CONFIRM_OPERATOR_ID, inboundId, prefix + "-confirm");
|
|
} else if (!"CONFIRMED".equals(current.getFeeStatus())) {
|
|
return;
|
|
}
|
|
send(tenantId, AUTOMATIC_CONFIRM_OPERATOR_ID, inboundId, prefix + "-send");
|
|
}
|
|
|
|
public FeeSnapshotResponse latest(long tenantId, long inboundId) {
|
|
InboundFeeEntity fee = repository.findLatestFee(tenantId, inboundId);
|
|
if (fee == null) throw new BizException(ResultCode.NOT_FOUND, "费用快照不存在");
|
|
return response(tenantId, fee.getId(), null);
|
|
}
|
|
|
|
@Transactional
|
|
public FeeSnapshotResponse confirmByCustomer(long tenantId, long operatorId, String operatorName,
|
|
long inboundId, int expectedFeeVersion,
|
|
String idempotencyKey) {
|
|
InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId);
|
|
if (current == null || !"SENT".equals(current.getFeeStatus())) {
|
|
throw conflict("费用尚未发送给客户,不能确认");
|
|
}
|
|
if (!Integer.valueOf(expectedFeeVersion).equals(current.getFeeVersion())) {
|
|
throw conflict("费用版本已变化,请刷新后重新确认");
|
|
}
|
|
String hash = hash("CUSTOMER_CONFIRM", inboundId,
|
|
current.getId() + ":" + expectedFeeVersion);
|
|
InboundPricingIdempotencyEntity token = beginIdempotency(tenantId, operatorId, inboundId,
|
|
"CUSTOMER_FEE_CONFIRM", idempotencyKey, hash);
|
|
if ("COMPLETED".equals(token.getStatus())) {
|
|
return response(tenantId, token.getResultResourceId(), null);
|
|
}
|
|
if (current.getCustomerConfirmedAt() != null) {
|
|
throw conflict("客户已确认该费用,不允许重复确认");
|
|
}
|
|
String confirmedName = StringUtils.hasText(operatorName)
|
|
? operatorName.trim() : String.valueOf(operatorId);
|
|
if (confirmedName.length() > 64) confirmedName = confirmedName.substring(0, 64);
|
|
LocalDateTime now = LocalDateTime.now(clock);
|
|
if (repository.confirmFeeByCustomer(tenantId, current.getId(), expectedFeeVersion,
|
|
operatorId, confirmedName, now) != 1) {
|
|
throw conflict("客户已确认或费用版本已变化,请刷新后重试");
|
|
}
|
|
complete(token, current.getId(), now);
|
|
return response(tenantId, current.getId(), null);
|
|
}
|
|
|
|
@Transactional
|
|
public FeeSnapshotResponse adjustPublished(long tenantId, long operatorId, long inboundId,
|
|
AdjustFeeRequest request, String idempotencyKey) {
|
|
InboundPricingEntity inbound = repository.findInbound(tenantId, inboundId);
|
|
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
|
|
if (!feeRevisionStatusSupported(inbound.getProcessStatus())) {
|
|
throw conflict("当前入库单状态不允许修改初算或已发布费用");
|
|
}
|
|
InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId);
|
|
if (current == null || !List.of("CALCULATED", "CONFIRMED", "SENT").contains(current.getFeeStatus())) {
|
|
throw conflict("入库单没有可修改的初算或已发布费用");
|
|
}
|
|
if (feeRevisionLocked(current)) {
|
|
throw conflict("客户已确认,不允许再修改费用");
|
|
}
|
|
if (!request.expectedFeeVersion().equals(current.getFeeVersion())) {
|
|
throw conflict("费用版本已变化,请刷新后重试");
|
|
}
|
|
String discriminator = current.getId() + ":" + request.expectedFeeVersion() + ":"
|
|
+ request.chargeableQuantity() + ":" + request.unitPrice() + ":"
|
|
+ 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())) {
|
|
return response(tenantId, token.getResultResourceId(), inbound.getProcessStatus());
|
|
}
|
|
|
|
LocalDateTime now = LocalDateTime.now(clock);
|
|
int nextVersion = repository.nextFeeVersion(tenantId, inboundId);
|
|
InboundFeeEntity adjusted = new InboundFeeEntity();
|
|
BeanUtils.copyProperties(current, adjusted);
|
|
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())
|
|
.setScale(MONEY_SCALE, RoundingMode.HALF_UP));
|
|
adjusted.setAdditionalAmount(request.additionalAmount().setScale(MONEY_SCALE, RoundingMode.HALF_UP));
|
|
adjusted.setTotalAmount(adjusted.getSubtotalAmount().add(adjusted.getAdditionalAmount()));
|
|
boolean published = "SENT".equals(current.getFeeStatus());
|
|
adjusted.setFeeStatus(published ? "SENT" : "CALCULATED");
|
|
adjusted.setReviewStatus(published ? "NOT_REQUIRED" : "NOT_SUBMITTED");
|
|
adjusted.setCalculatedBy(operatorId);
|
|
adjusted.setCalculatedAt(now);
|
|
adjusted.setConfirmedBy(published ? operatorId : null);
|
|
adjusted.setConfirmedAt(published ? now : null);
|
|
adjusted.setSentBy(published ? operatorId : null);
|
|
adjusted.setSentAt(published ? now : null);
|
|
adjusted.setCustomerConfirmedBy(null);
|
|
adjusted.setCustomerConfirmedByName(null);
|
|
adjusted.setCustomerConfirmedAt(null);
|
|
adjusted.setCreatedAt(now);
|
|
adjusted.setUpdatedAt(now);
|
|
if (repository.supersedeCurrent(tenantId, current.getId(), current.getFeeStatus()) != 1) {
|
|
throw conflict("费用已被其他操作员修改,请刷新后重试");
|
|
}
|
|
repository.insertFee(adjusted);
|
|
BigDecimal quantity = adjusted.getChargeableQuantity();
|
|
InboundFeeItemEntity base = item(tenantId, adjusted, 1, "FBA_BASE", "FBA基础运费",
|
|
adjusted.getSubtotalAmount(), now);
|
|
base.setQuantity(quantity);
|
|
base.setUnit(current.getChargeUnit());
|
|
base.setUnitPrice(request.unitPrice().setScale(6, RoundingMode.HALF_UP));
|
|
base.setAutomaticFlag(false);
|
|
base.setRemark(request.reason().trim());
|
|
repository.insertItem(base);
|
|
if (adjusted.getAdditionalAmount().signum() > 0) {
|
|
InboundFeeItemEntity extra = additionalItem(tenantId, adjusted,
|
|
adjusted.getAdditionalAmount(), now);
|
|
extra.setAutomaticFlag(false);
|
|
extra.setRemark(request.reason().trim());
|
|
repository.insertItem(extra);
|
|
}
|
|
if (repository.markInboundAdjusted(tenantId, inboundId, inbound.getProcessStatus(), adjusted.getFeeStatus(),
|
|
adjusted.getActualWeightKg(), adjusted.getActualVolumeCbm(), adjusted.getChargeUnit(), quantity) != 1) {
|
|
throw conflict("入库单状态已变化,费用调整未保存");
|
|
}
|
|
if (published) {
|
|
feeDetails.publishFreight(tenantId, inboundId, adjusted.getId(), operatorId, null, now);
|
|
financeOutbox.publishInbound(FinanceFeeSourceOutboxService.ADJUSTED,
|
|
tenantId, inboundId, adjusted.getId(), nextVersion, now);
|
|
} else {
|
|
feeDetails.materializeCalculatedFreight(
|
|
tenantId, inboundId, adjusted.getId(), operatorId, now);
|
|
}
|
|
complete(token, adjusted.getId(), now);
|
|
return response(tenantId, adjusted.getId(), inbound.getProcessStatus());
|
|
}
|
|
|
|
private InboundPricingEntity requireEligibleInbound(long tenantId, long inboundId) {
|
|
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
|
|
if (!feeRevisionStatusSupported(inbound.getProcessStatus())
|
|
|| !List.of("UNPRICED", "CALCULATED", "SENT").contains(inbound.getFeeStatus())) {
|
|
throw conflict("当前入库单状态不允许费用试算");
|
|
}
|
|
return inbound;
|
|
}
|
|
|
|
private InboundPricingEntity requireApprovedInbound(long tenantId, long inboundId) {
|
|
InboundPricingEntity inbound = repository.findInbound(tenantId, inboundId);
|
|
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
|
|
if (!"FBA".equals(inbound.getDestinationType()) && !"OVERSEAS".equals(inbound.getDestinationType())) {
|
|
throw conflict("当前目的地类型不支持自动计费");
|
|
}
|
|
if (inbound.getWarehouseId() == null || !StringUtils.hasText(inbound.getChannelCode())) {
|
|
throw conflict("入库单缺少起运仓或渠道匹配维度");
|
|
}
|
|
return inbound;
|
|
}
|
|
|
|
private static boolean preserveProcessStatusDuringPricing(String processStatus) {
|
|
return List.of("WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED")
|
|
.contains(processStatus);
|
|
}
|
|
|
|
private static boolean feeRevisionStatusSupported(String processStatus) {
|
|
return List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
|
|
"WAIT_PRICING", "FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING",
|
|
"LOADED", "DELIVERED")
|
|
.contains(processStatus);
|
|
}
|
|
|
|
private static boolean feeRevisionLocked(InboundFeeEntity fee) {
|
|
return fee != null && fee.getCustomerConfirmedAt() != null;
|
|
}
|
|
|
|
private static PricingDestinationType destinationType(InboundPricingEntity inbound) {
|
|
try {
|
|
return PricingDestinationType.valueOf(inbound.getDestinationType());
|
|
} catch (RuntimeException exception) {
|
|
throw conflict("入库单目的地类型配置无效");
|
|
}
|
|
}
|
|
|
|
private static PricingTaxMode pricingTaxMode(InboundPricingEntity inbound) {
|
|
if (!StringUtils.hasText(inbound.getPricingTaxMode())) return null;
|
|
try {
|
|
return PricingTaxMode.valueOf(inbound.getPricingTaxMode());
|
|
} catch (RuntimeException exception) {
|
|
throw conflict("入库单报价包税维度配置无效");
|
|
}
|
|
}
|
|
|
|
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) {
|
|
if (!StringUtils.hasText(key) || key.length() > 128) {
|
|
throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key 必填且不能超过128字符");
|
|
}
|
|
InboundPricingIdempotencyEntity existing = repository.findIdempotency(tenantId, operatorId, operation, key);
|
|
if (existing != null) {
|
|
if (!hash.equals(existing.getRequestHash())) throw conflict("幂等键已用于不同请求");
|
|
if (!"COMPLETED".equals(existing.getStatus())) throw conflict("相同请求正在处理中");
|
|
return existing;
|
|
}
|
|
InboundPricingIdempotencyEntity value = new InboundPricingIdempotencyEntity();
|
|
value.setTenantId(tenantId);
|
|
value.setOperation(operation);
|
|
value.setOperatorId(operatorId);
|
|
value.setInboundId(inboundId);
|
|
value.setIdempotencyKey(key);
|
|
value.setRequestHash(hash);
|
|
value.setStatus("PROCESSING");
|
|
value.setCreatedAt(LocalDateTime.now(clock));
|
|
value.setUpdatedAt(LocalDateTime.now(clock));
|
|
repository.insertIdempotency(value);
|
|
return value;
|
|
}
|
|
|
|
private void complete(InboundPricingIdempotencyEntity token, long resourceId, LocalDateTime now) {
|
|
if (repository.completeIdempotency(token, resourceId, now) != 1) throw conflict("幂等记录状态已变化");
|
|
}
|
|
|
|
private InboundFeeEntity snapshot(long tenantId, long operatorId, InboundPricingEntity inbound,
|
|
ActivePriceBookEstimator.Estimate estimate, RateRow rate, int version,
|
|
BigDecimal weight, BigDecimal volume, BigDecimal converted,
|
|
BigDecimal chargeable, BigDecimal subtotal, BigDecimal additional,
|
|
BigDecimal total, LocalDateTime now) {
|
|
InboundFeeEntity fee = new InboundFeeEntity();
|
|
fee.setTenantId(tenantId); fee.setInboundId(inbound.getId());
|
|
fee.setFeeNo("FBAF-" + inbound.getId() + "-" + version); fee.setFeeVersion(version);
|
|
fee.setChargeRuleId(null); fee.setChargeRuleVersion(null);
|
|
fee.setCarrierRateId(rate.getId()); fee.setCarrierRateVersion(rate.getRateVersion());
|
|
fee.setPriceBookVersionId(estimate.priceBookVersionId());
|
|
fee.setRuleCode(null); fee.setCustomerId(tenantId);
|
|
fee.setCustomerName(inbound.getCustomerName()); fee.setBusinessType(inbound.getBusinessType());
|
|
fee.setTaxMode(inbound.getTaxMode()); fee.setPricingTaxMode(inbound.getPricingTaxMode());
|
|
fee.setRoundingScale(estimate.roundingScale()); fee.setRoundingMode(estimate.roundingMode());
|
|
fee.setRuleEffectiveFrom(null);
|
|
fee.setRuleEffectiveTo(null); fee.setRateCode(rate.getRateCode());
|
|
fee.setOriginWarehouseId(rate.getOriginWarehouseId()); fee.setOriginWarehouseName(rate.getOriginWarehouseName());
|
|
fee.setChannelCode(rate.getChannelCode()); fee.setCarrierId(null);
|
|
fee.setCarrierName(null); fee.setDestinationType(inbound.getDestinationType());
|
|
fee.setDestinationWarehouseId(inbound.getDestinationWarehouseId()); fee.setDestinationName(inbound.getDestinationName());
|
|
fee.setDestinationCode(inbound.getDestinationCode());
|
|
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);
|
|
fee.setCalculatedBy(operatorId); fee.setCalculatedAt(now); fee.setCreatedAt(now); fee.setUpdatedAt(now);
|
|
return fee;
|
|
}
|
|
|
|
private InboundFeeItemEntity baseItem(long tenantId, InboundFeeEntity fee, RateRow rate,
|
|
BigDecimal quantity, BigDecimal amount, LocalDateTime now) {
|
|
InboundFeeItemEntity item = item(tenantId, fee, 1, "FBA_BASE", "FBA基础运费", amount, now);
|
|
item.setQuantity(quantity); item.setUnit(fee.getChargeUnit()); item.setUnitPrice(rate.getUnitPrice());
|
|
return item;
|
|
}
|
|
|
|
private InboundFeeItemEntity additionalItem(long tenantId, InboundFeeEntity fee,
|
|
BigDecimal amount, LocalDateTime now) {
|
|
InboundFeeItemEntity item = item(tenantId, fee, 2, "FBA_ADDITIONAL", "FBA固定附加费", amount, now);
|
|
item.setQuantity(BigDecimal.ONE.setScale(4)); item.setUnit("ORDER"); item.setUnitPrice(amount);
|
|
return item;
|
|
}
|
|
|
|
private InboundFeeItemEntity item(long tenantId, InboundFeeEntity fee, int no, String code,
|
|
String name, BigDecimal amount, LocalDateTime now) {
|
|
InboundFeeItemEntity item = new InboundFeeItemEntity();
|
|
item.setTenantId(tenantId); item.setFeeId(fee.getId()); item.setItemNo(no);
|
|
item.setFeeCode(code); item.setFeeName(name); item.setAmount(amount); item.setCurrency(fee.getCurrency());
|
|
item.setAutomaticFlag(true); item.setCreatedAt(now); return item;
|
|
}
|
|
|
|
private FeeSnapshotResponse response(long tenantId, long feeId, String nextStatus) {
|
|
InboundFeeEntity fee = repository.findFee(tenantId, feeId);
|
|
if (fee == null) throw new BizException(ResultCode.NOT_FOUND, "费用快照不存在");
|
|
InboundPricingEntity inbound = repository.findInbound(tenantId, fee.getInboundId());
|
|
InboundFeeItemEntity base = repository.findBaseItem(tenantId, feeId);
|
|
String actualStatus = nextStatus == null && inbound != null
|
|
? inbound.getProcessStatus() : nextStatus;
|
|
return response(inbound, fee, base == null ? null : base.getUnitPrice(), actualStatus);
|
|
}
|
|
|
|
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(),
|
|
fee.getChargeRuleId(), fee.getRuleCode(), fee.getChargeRuleVersion(),
|
|
fee.getCarrierRateId(), fee.getRateCode(), fee.getCarrierRateVersion(), fee.getPriceBookVersionId(),
|
|
fee.getCustomerId(), fee.getCustomerName(), fee.getOriginWarehouseId(), fee.getOriginWarehouseName(),
|
|
fee.getBusinessType(), fee.getChannelCode(), fee.getCarrierId(), fee.getCarrierName(),
|
|
fee.getDestinationType(), fee.getDestinationWarehouseId(), fee.getDestinationName(), fee.getDestinationCode(),
|
|
fee.getTaxMode(), fee.getPricingTaxMode(), fee.getChargeMode(),
|
|
fee.getConversionFactor(),
|
|
fee.getRoundingScale(), fee.getRoundingMode(), fee.getActualWeightKg(), fee.getActualVolumeCbm(),
|
|
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,
|
|
fee.getCustomerConfirmedAt() == null ? "PENDING_CONFIRMATION" : "CONFIRMED",
|
|
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");
|
|
return HexFormat.of().formatHex(digest.digest(
|
|
(operation + ":" + inboundId + ":" + discriminator).getBytes(StandardCharsets.UTF_8)));
|
|
} catch (NoSuchAlgorithmException exception) {
|
|
throw new IllegalStateException("JVM不支持SHA-256", exception);
|
|
}
|
|
}
|
|
|
|
private static BizException conflict(String message) {
|
|
return new BizException(ResultCode.CONFLICT, message);
|
|
}
|
|
}
|