fix: support manual FBA quote fallback

This commit is contained in:
2026-09-21 10:44:23 +08:00
parent 8f2f485402
commit 9147190d67
9 changed files with 196 additions and 6 deletions
@@ -8,6 +8,7 @@ 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.AdjustFeeRequest;
import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest;
import com.baiyue.firstleg.service.FbaPricingService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.Operation;
@@ -50,6 +51,18 @@ public class PlatformFbaPricingController {
request == null ? null : request.volumeSource(), idempotencyKey));
}
@PostMapping("/manual-quote")
@PreAuthorize("hasAuthority('firstleg:inbound:confirm-send-fee')")
@BusinessAudit(event = "FBA_FEE_MANUAL_QUOTE", resourceType = "INBOUND", resourceId = "#inboundId",
description = "价格未命中时人工确认头程运费单价")
public ApiResponse<FeeSnapshotResponse> manualQuote(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody ManualQuoteRequest request) {
long scope = FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.manualQuote(scope, user.userId(), inboundId, request, idempotencyKey));
}
@PostMapping("/confirm")
@PreAuthorize("hasAnyAuthority('firstleg:inbound:confirm-send-fee',"
+ " 'firstleg:fba-pricing:confirm')")
@@ -76,6 +76,13 @@ public final class FbaPricingDtos {
}
}
public record ManualQuoteRequest(
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal selectedWeightKg,
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal selectedVolumeCbm,
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal unitPrice,
@NotBlank @Size(min = 2, max = 500) String reason
) {}
public record AdjustFeeRequest(
@NotNull @Positive Integer expectedFeeVersion,
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal chargeableQuantity,
@@ -7,6 +7,7 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
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;
@@ -46,6 +47,7 @@ public class FbaPricingService {
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
@@ -177,6 +179,94 @@ public class FbaPricingService {
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 = ("KG".equals(unit) ? weight.max(volume.multiply(factor))
: volume.max(weight.divide(factor, 12, RoundingMode.HALF_UP)))
.setScale(6, RoundingMode.HALF_UP);
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 applyContainerCustomerTier(
InboundPricingEntity inbound, ActivePriceBookEstimator.Estimate individual,
BigDecimal currentWeight, BigDecimal currentVolume) {
@@ -280,6 +370,7 @@ public class FbaPricingService {
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()