修改提交

This commit is contained in:
2026-09-03 21:09:22 +08:00
parent 171f450e59
commit baabfdabba
38 changed files with 646 additions and 206 deletions
@@ -13,11 +13,16 @@ import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import jakarta.validation.Valid;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
@Tag(name = "客户端入库结果与费用")
@RestController
@@ -31,12 +36,27 @@ public class MerchantInboundBillingController {
@Operation(summary = "查询订单入库结果和最新费用",
description = "客户按登录态客户范围只读查看;入库流程达到 WAIT_SPLIT 后,"
+ "在 LOADING、LOADED、DELIVERED 仍持续可见,且始终要求 APPROVED + SENT "
+ "并存在已发布入库运费明细,不需要客户确认费用。")
+ "并存在已发布入库运费明细;同时返回当前费用版本的客户确认状态。")
public ApiResponse<MerchantInboundBillingResponse> find(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long orderId) {
return ApiResponse.success(service.find(FirstlegScopeGuard.merchant(user), orderId));
}
@PostMapping("/{orderId}/inbound-billing/confirm")
@PreAuthorize("hasAuthority('merchant:order:edit')")
@Operation(summary = "客户确认已发布的入库费用",
description = "仅允许当前客户确认 SENT 状态的最新费用版本;确认后该版本永久锁定,平台不得再修改。")
public ApiResponse<FeeSnapshotResponse> confirm(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long orderId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody MerchantInboundBillingResponse.ConfirmFeeRequest request) {
long tenantId = FirstlegScopeGuard.merchant(user);
String operatorName = user.displayName() == null ? user.username() : user.displayName();
return ApiResponse.success(service.confirmFee(tenantId, user.userId(), operatorName,
orderId, request.expectedFeeVersion(), idempotencyKey));
}
@GetMapping("/{orderId}/inbound-billing/images/{fileId}")
@PreAuthorize("hasAuthority('merchant:order:view')")
@Operation(summary = "查看已发布计费明细中的测量或称重图片")
@@ -45,7 +45,9 @@ public class PlatformFbaPricingController {
return ApiResponse.success(service.calculate(scope, user.userId(), inboundId,
request == null ? null : request.pricingAt(),
request == null ? null : request.selectedWeightKg(),
request == null ? null : request.selectedVolumeCbm(), idempotencyKey));
request == null ? null : request.selectedVolumeCbm(),
request == null ? null : request.weightSource(),
request == null ? null : request.volumeSource(), idempotencyKey));
}
@PostMapping("/confirm")
@@ -23,7 +23,7 @@ public final class CustomerDimensionConfigDtos {
) { }
public record DefaultCalculationModeUpdateRequest(
@NotBlank @Pattern(regexp = "ADD|MULTIPLY") String defaultCalculationMode
@NotBlank @Pattern(regexp = "ACTUAL|ADD|MULTIPLY") String defaultCalculationMode
) { }
public record RuleUpdateRequest(
@@ -86,6 +86,7 @@ public final class FbaPricingDtos {
public record FeeSnapshotResponse(
Long feeId, String feeNo, Integer feeVersion, String feeStatus,
Long inboundId, String inboundCode,
Long measurementId, String measurementSource,
Long chargeRuleId, String ruleCode, Integer chargeRuleVersion,
Long carrierRateId, String rateCode, Integer carrierRateVersion, Long priceBookVersionId,
Long customerId, String customerName, Long originWarehouseId, String originWarehouseName,
@@ -100,6 +101,8 @@ public final class FbaPricingDtos {
BigDecimal totalAmount, String currency,
LocalDateTime rateEffectiveFrom, LocalDateTime rateEffectiveTo,
LocalDateTime calculatedAt, LocalDateTime confirmedAt, LocalDateTime sentAt,
String nextProcessStatus
String nextProcessStatus, String customerConfirmationStatus,
Long customerConfirmedBy, String customerConfirmedByName,
LocalDateTime customerConfirmedAt
) {}
}
@@ -50,13 +50,14 @@ public final class InboundMeasurementRequests {
@NotNull @DecimalMin(value = "0.001", message = "箱规高度必须大于等于 0.001 cm")
@DecimalMax(value = "999999999.999", message = "箱规高度不能超过 999,999,999.999 cm")
@Digits(integer = 9, fraction = 3, message = "箱规高度最多 9 位整数、3 位小数") BigDecimal heightCm,
@DecimalMin("0.000") @Digits(integer = 9, fraction = 4) BigDecimal unitWeightKg,
@Positive Long intelligentImageFileId,
@Size(max = 500) String overLimitWarning,
@DecimalMin("0.000") @Digits(integer = 9, fraction = 3) BigDecimal outerLengthCm,
@DecimalMin("0.000") @Digits(integer = 9, fraction = 3) BigDecimal outerWidthCm,
@DecimalMin("0.000") @Digits(integer = 9, fraction = 3) BigDecimal outerHeightCm,
@Positive Long chargeRuleId,
@jakarta.validation.constraints.Pattern(regexp = "ADD|MULTIPLY") String coefficientMode,
@jakarta.validation.constraints.Pattern(regexp = "ACTUAL|ADD|MULTIPLY") String coefficientMode,
@Size(max = 500) String remark
) {
public ItemRequest(Long inboundDetailId, String itemNo, String boxSpecification, Integer packageCount,
@@ -65,7 +66,7 @@ public final class InboundMeasurementRequests {
BigDecimal outerLengthCm, BigDecimal outerWidthCm, BigDecimal outerHeightCm,
Long chargeRuleId, String remark) {
this(inboundDetailId, itemNo, boxSpecification, packageCount, lengthCm, widthCm, heightCm,
intelligentImageFileId, overLimitWarning, outerLengthCm, outerWidthCm, outerHeightCm,
null, intelligentImageFileId, overLimitWarning, outerLengthCm, outerWidthCm, outerHeightCm,
chargeRuleId, null, remark);
}
@@ -75,7 +76,7 @@ public final class InboundMeasurementRequests {
BigDecimal outerLengthCm, BigDecimal outerWidthCm, BigDecimal outerHeightCm,
String remark) {
this(inboundDetailId, itemNo, boxSpecification, packageCount, lengthCm, widthCm, heightCm,
intelligentImageFileId, overLimitWarning, outerLengthCm, outerWidthCm, outerHeightCm,
null, intelligentImageFileId, overLimitWarning, outerLengthCm, outerWidthCm, outerHeightCm,
null, null, remark);
}
}
@@ -90,6 +90,10 @@ public final class InboundMeasurementResponses {
BigDecimal outerLengthCm,
BigDecimal outerWidthCm,
BigDecimal outerHeightCm,
BigDecimal calculatedOuterLengthCm,
BigDecimal calculatedOuterWidthCm,
BigDecimal calculatedOuterHeightCm,
String calculationRuleVersion,
Long chargeRuleId,
String coefficientMode,
String remark,
@@ -35,6 +35,7 @@ public record MeasurementBatchCommand(
BigDecimal lengthCm,
BigDecimal widthCm,
BigDecimal heightCm,
BigDecimal unitWeightKg,
Long intelligentImageFileId,
String overLimitWarning,
BigDecimal outerLengthCm,
@@ -44,13 +45,23 @@ public record MeasurementBatchCommand(
String coefficientMode,
String remark
) {
public Item(Long inboundDetailId, String itemNo, String boxSpecification, int packageCount,
BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm,
Long intelligentImageFileId, String overLimitWarning,
BigDecimal outerLengthCm, BigDecimal outerWidthCm, BigDecimal outerHeightCm,
Long chargeRuleId, String coefficientMode, String remark) {
this(inboundDetailId, itemNo, boxSpecification, packageCount, lengthCm, widthCm, heightCm,
null, intelligentImageFileId, overLimitWarning, outerLengthCm, outerWidthCm, outerHeightCm,
chargeRuleId, coefficientMode, remark);
}
public Item(Long inboundDetailId, String itemNo, String boxSpecification, int packageCount,
BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm,
Long intelligentImageFileId, String overLimitWarning,
BigDecimal outerLengthCm, BigDecimal outerWidthCm, BigDecimal outerHeightCm,
Long chargeRuleId, String remark) {
this(inboundDetailId, itemNo, boxSpecification, packageCount, lengthCm, widthCm, heightCm,
intelligentImageFileId, overLimitWarning, outerLengthCm, outerWidthCm, outerHeightCm,
null, intelligentImageFileId, overLimitWarning, outerLengthCm, outerWidthCm, outerHeightCm,
chargeRuleId, null, remark);
}
@@ -60,7 +71,7 @@ public record MeasurementBatchCommand(
BigDecimal outerLengthCm, BigDecimal outerWidthCm, BigDecimal outerHeightCm,
String remark) {
this(inboundDetailId, itemNo, boxSpecification, packageCount, lengthCm, widthCm, heightCm,
intelligentImageFileId, overLimitWarning, outerLengthCm, outerWidthCm, outerHeightCm,
null, intelligentImageFileId, overLimitWarning, outerLengthCm, outerWidthCm, outerHeightCm,
null, null, remark);
}
}
@@ -1,6 +1,8 @@
package com.baiyue.firstleg.dto;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.math.BigDecimal;
import java.time.LocalDate;
@@ -16,11 +18,14 @@ public record MerchantInboundBillingResponse(InboundOrderDetail inbound,
BillingBasis billingBasis,
FeeSnapshotResponse latestFee,
PublishedFeeDetail publishedFeeDetail) {
public record ConfirmFeeRequest(@NotNull @Positive Integer expectedFeeVersion) {
}
public record MeasurementSummary(
Long measurementId, String sourceType, String measurementStatus,
Integer actualPackageCount, BigDecimal actualGrossWeightKg, BigDecimal actualNetWeightKg,
BigDecimal actualVolumeCbm, BigDecimal volumeWeightKg, String actualGoodsName) {
BigDecimal actualVolumeCbm, BigDecimal volumeWeightKg, String actualGoodsName,
String calculationSource) {
}
public record ImageReference(
@@ -32,7 +37,8 @@ public record MerchantInboundBillingResponse(InboundOrderDetail inbound,
Long id, String itemNo, String boxSpecification, Integer packageCount,
BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm,
BigDecimal unitVolumeCbm, BigDecimal totalVolumeCbm,
BigDecimal volumeWeightKg) {
BigDecimal volumeWeightKg, Long chargeRuleId, String coefficientMode,
String calculationRuleVersion) {
}
public record WeightResult(
@@ -10,7 +10,7 @@ import lombok.Setter;
import java.math.BigDecimal;
/** 客户固定箱规的内径和长、宽、高尺寸增加值配置。 */
/** 客户固定箱规的内径和每侧加宽配置。 */
@Getter
@Setter
@TableName("fl_customer_dimension_config")
@@ -18,6 +18,8 @@ public class InboundFeeEntity {
private Long id;
private Long tenantId;
private Long inboundId;
private Long measurementId;
private String measurementSource;
private String feeNo;
private Integer feeVersion;
private Long chargeRuleId;
@@ -67,6 +69,9 @@ public class InboundFeeEntity {
private LocalDateTime confirmedAt;
private Long sentBy;
private LocalDateTime sentAt;
private Long customerConfirmedBy;
private String customerConfirmedByName;
private LocalDateTime customerConfirmedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -44,6 +44,9 @@ public class InboundMeasurementItemEntity {
private BigDecimal outerLengthCm;
private BigDecimal outerWidthCm;
private BigDecimal outerHeightCm;
private BigDecimal manualOuterLengthCm;
private BigDecimal manualOuterWidthCm;
private BigDecimal manualOuterHeightCm;
private String remark;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@@ -331,6 +331,20 @@ public class FbaPricingRepository {
.set(InboundFeeEntity::getSentAt, now));
}
public int confirmFeeByCustomer(long tenantId, long feeId, int expectedFeeVersion,
long operatorId, String operatorName, LocalDateTime now) {
return fees.update(null, new LambdaUpdateWrapper<InboundFeeEntity>()
.eq(InboundFeeEntity::getTenantId, tenantId)
.eq(InboundFeeEntity::getId, feeId)
.eq(InboundFeeEntity::getFeeVersion, expectedFeeVersion)
.eq(InboundFeeEntity::getFeeStatus, "SENT")
.isNull(InboundFeeEntity::getCustomerConfirmedAt)
.set(InboundFeeEntity::getCustomerConfirmedBy, operatorId)
.set(InboundFeeEntity::getCustomerConfirmedByName, operatorName)
.set(InboundFeeEntity::getCustomerConfirmedAt, now)
.set(InboundFeeEntity::getUpdatedAt, now));
}
public int voidSentFee(long tenantId, long inboundId, long feeId, int feeVersion,
LocalDateTime now) {
return fees.update(null, new LambdaUpdateWrapper<InboundFeeEntity>()
@@ -26,7 +26,8 @@ import java.util.Map;
@Service
public class CustomerDimensionConfigService {
public static final BigDecimal DEFAULT_EDGE_ADDITION_CM = new BigDecimal("2.000");
public static final String DEFAULT_CALCULATION_MODE = "ADD";
public static final String DEFAULT_CALCULATION_MODE = "ACTUAL";
private static final BigDecimal TWO = new BigDecimal("2");
private static final BigDecimal CUBIC_CENTIMETERS_PER_CBM = new BigDecimal("1000000");
@Resource
@@ -115,7 +116,7 @@ public class CustomerDimensionConfigService {
requirePositive(rule.innerLengthCm(), "箱规内长");
requirePositive(rule.innerWidthCm(), "箱规内宽");
requirePositive(rule.innerHeightCm(), "箱规内高");
requirePositive(rule.edgeAdditionCm(), "箱规尺寸增加值");
requirePositive(rule.edgeAdditionCm(), "箱规单侧加宽");
if (rule.version() == null || rule.version() < 0) {
throw new BizException(ResultCode.BAD_REQUEST, "箱规版本号无效");
}
@@ -222,10 +223,10 @@ public class CustomerDimensionConfigService {
}
private static RuleResponse ruleResponse(CustomerDimensionConfigEntity value) {
BigDecimal addition = value.getEdgeAdditionCm();
BigDecimal outerLength = value.getInnerLengthCm().add(addition);
BigDecimal outerWidth = value.getInnerWidthCm().add(addition);
BigDecimal outerHeight = value.getInnerHeightCm().add(addition);
BigDecimal edgePair = value.getEdgeAdditionCm().multiply(TWO);
BigDecimal outerLength = value.getInnerLengthCm().add(edgePair);
BigDecimal outerWidth = value.getInnerWidthCm().add(edgePair);
BigDecimal outerHeight = value.getInnerHeightCm().add(edgePair);
BigDecimal volume = outerLength.multiply(outerWidth).multiply(outerHeight)
.divide(CUBIC_CENTIMETERS_PER_CBM)
.setScale(3, RoundingMode.HALF_UP);
@@ -253,7 +254,7 @@ public class CustomerDimensionConfigService {
private static String normalizeCalculationMode(String value) {
if (value == null || value.isBlank()) return DEFAULT_CALCULATION_MODE;
String normalized = value.trim().toUpperCase();
if (!"ADD".equals(normalized) && !"MULTIPLY".equals(normalized)) {
if (!"ACTUAL".equals(normalized) && !"ADD".equals(normalized) && !"MULTIPLY".equals(normalized)) {
throw new BizException(ResultCode.BAD_REQUEST, "默认箱规计算方式无效");
}
return normalized;
@@ -16,6 +16,7 @@ import java.util.List;
/** Resolves dimensions to the smallest containing rule, capped at the largest configured tier. */
@Service
public class CustomerDimensionWeightingService {
private static final BigDecimal TWO = new BigDecimal("2");
private static final BigDecimal CUBIC_CENTIMETERS_PER_CBM = new BigDecimal("1000000");
private final CustomerDimensionConfigRepository repository;
@@ -60,7 +61,7 @@ public class CustomerDimensionWeightingService {
return rules.stream()
.filter(rule -> rule.contains(measured))
.findFirst()
.map(Rule::weighted)
.map(rule -> rule.weightedActualDimensions(lengthCm, widthCm, heightCm))
.orElseGet(() -> rules.get(rules.size() - 1)
.weightedActualDimensions(lengthCm, widthCm, heightCm));
}
@@ -122,22 +123,12 @@ public class CustomerDimensionWeightingService {
&& measured.get(2).compareTo(inner.get(2)) <= 0;
}
private WeightedDimensions weighted() {
BigDecimal outerLength = innerLengthCm.add(edgeAdditionCm);
BigDecimal outerWidth = innerWidthCm.add(edgeAdditionCm);
BigDecimal outerHeight = innerHeightCm.add(edgeAdditionCm);
BigDecimal rawVolume = outerLength.multiply(outerWidth).multiply(outerHeight)
.divide(CUBIC_CENTIMETERS_PER_CBM, 6, RoundingMode.HALF_UP);
BigDecimal unitVolume = rawVolume.setScale(3, RoundingMode.HALF_UP);
return new WeightedDimensions(boxType, version, edgeAdditionCm, outerLength, outerWidth, outerHeight,
rawVolume, unitVolume, "CUSTOMER_DIMENSION_" + boxType.name() + "_V" + version);
}
private WeightedDimensions weightedActualDimensions(BigDecimal lengthCm, BigDecimal widthCm,
BigDecimal heightCm) {
BigDecimal outerLength = lengthCm.add(edgeAdditionCm);
BigDecimal outerWidth = widthCm.add(edgeAdditionCm);
BigDecimal outerHeight = heightCm.add(edgeAdditionCm);
BigDecimal edgePair = edgeAdditionCm.multiply(TWO);
BigDecimal outerLength = lengthCm.add(edgePair);
BigDecimal outerWidth = widthCm.add(edgePair);
BigDecimal outerHeight = heightCm.add(edgePair);
BigDecimal rawVolume = outerLength.multiply(outerWidth).multiply(outerHeight)
.divide(CUBIC_CENTIMETERS_PER_CBM, 6, RoundingMode.HALF_UP);
BigDecimal unitVolume = rawVolume.setScale(3, RoundingMode.HALF_UP);
@@ -38,6 +38,8 @@ public class FbaPricingService {
@Resource
private FbaPricingRepository repository;
@Resource
private com.baiyue.firstleg.repository.InboundMeasurementRepository measurementRepository;
@Resource
private ActivePriceBookEstimator priceBookEstimator;
@Resource
private Clock clock;
@@ -56,15 +58,41 @@ public class FbaPricingService {
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);
var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId);
if ("OUTER".equalsIgnoreCase(volumeSource) && measurement == null) {
throw conflict("外箱规测量批次不存在,不能生成对客费用");
}
if ("OUTER".equalsIgnoreCase(volumeSource)) {
var outerItems = measurementRepository.listItems(tenantId, measurement.getId());
boolean incomplete = outerItems.isEmpty() || outerItems.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("外箱规数据或绑定的计算规则不完整,不能生成对客费用");
}
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(new EstimateRequest(
inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(),
destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null,
@@ -84,6 +112,8 @@ public class FbaPricingService {
InboundFeeEntity fee = snapshot(tenantId, operatorId, inbound, estimate, rate, version,
weight, volume, converted, chargeable, subtotal, additional, total, now);
fee.setMeasurementId(measurement == null ? null : measurement.getId());
fee.setMeasurementSource("OUTER".equalsIgnoreCase(volumeSource) ? "OUTER" : volumeSource);
repository.supersedeCalculated(tenantId, inboundId);
repository.insertFee(fee);
repository.insertItem(baseItem(tenantId, fee, rate, chargeable, subtotal, now));
@@ -102,6 +132,10 @@ public class FbaPricingService {
@Transactional
public FeeSnapshotResponse confirm(long tenantId, long operatorId, long inboundId,
String idempotencyKey) {
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
if (!feeEditableBeforeLoading(inbound.getProcessStatus())) {
throw conflict("订单已进入装柜流程,不能再修改或发送费用");
}
InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId);
if (current == null) throw conflict("入库单尚无可确认的费用试算");
String hash = hash("CONFIRM", inboundId, current.getId().toString());
@@ -121,6 +155,9 @@ public class FbaPricingService {
public FeeSnapshotResponse send(long tenantId, long operatorId, long inboundId,
String idempotencyKey) {
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
if (!feeEditableBeforeLoading(inbound.getProcessStatus())) {
throw conflict("订单已进入装柜流程,不能再修改或发送费用");
}
InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId);
if (current == null) throw conflict("入库单尚无费用快照");
String hash = hash("SEND", inboundId, current.getId().toString());
@@ -151,6 +188,39 @@ public class FbaPricingService {
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) {
@@ -163,6 +233,9 @@ public class FbaPricingService {
if (current == null || !List.of("CALCULATED", "CONFIRMED", "SENT").contains(current.getFeeStatus())) {
throw conflict("入库单没有可修改的初算或已发布费用");
}
if (current.getCustomerConfirmedAt() != null) {
throw conflict("客户已确认该费用,不允许再修改");
}
if (!request.expectedFeeVersion().equals(current.getFeeVersion())) {
throw conflict("费用版本已变化,请刷新后重试");
}
@@ -237,7 +310,7 @@ public class FbaPricingService {
private InboundPricingEntity requireEligibleInbound(long tenantId, long inboundId) {
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
if (!List.of("WAIT_PRICING", "FEE_CONFIRMING", "WAIT_SPLIT").contains(inbound.getProcessStatus())
if (!feeEditableBeforeLoading(inbound.getProcessStatus())
|| !List.of("UNPRICED", "CALCULATED").contains(inbound.getFeeStatus())) {
throw conflict("当前入库单状态不允许费用试算");
}
@@ -250,11 +323,6 @@ public class FbaPricingService {
if (!"FBA".equals(inbound.getDestinationType()) && !"OVERSEAS".equals(inbound.getDestinationType())) {
throw conflict("当前目的地类型不支持自动计费");
}
if (!"APPROVED".equals(inbound.getMeasureReviewStatus())) throw conflict("只能消费审核通过的测量快照");
if (inbound.getActualNetWeightKg() == null || inbound.getActualNetWeightKg().signum() <= 0
|| inbound.getActualVolumeCbm() == null || inbound.getActualVolumeCbm().signum() <= 0) {
throw conflict("审核通过的测量快照必须包含大于零的货物净重和体积");
}
if (inbound.getCustomerId() == null || inbound.getCustomerId() <= 0) {
throw conflict("入库单缺少订单客户归属,不能生成对客费用");
}
@@ -268,6 +336,11 @@ public class FbaPricingService {
return "WAIT_SPLIT".equals(processStatus);
}
private static boolean feeEditableBeforeLoading(String processStatus) {
return List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
"WAIT_PRICING", "FEE_CONFIRMING", "WAIT_SPLIT").contains(processStatus);
}
private static PricingDestinationType destinationType(InboundPricingEntity inbound) {
try {
return PricingDestinationType.valueOf(inbound.getDestinationType());
@@ -383,6 +456,7 @@ public class FbaPricingService {
BigDecimal unitPrice, String nextStatus) {
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(),
@@ -394,7 +468,9 @@ public class FbaPricingService {
fee.getConvertedQuantity(), 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.getSentAt(), nextStatus,
fee.getCustomerConfirmedAt() == null ? "PENDING_CONFIRMATION" : "CONFIRMED",
fee.getCustomerConfirmedBy(), fee.getCustomerConfirmedByName(), fee.getCustomerConfirmedAt());
}
private static String hash(String operation, long inboundId, String discriminator) {
@@ -67,10 +67,22 @@ public class InboundMeasurementService {
public InboundMeasurementResponses.Detail detail(long tenantId, long inboundId) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
InboundMeasurementEntity measurement = repository.findLatestMeasurement(tenantId, inboundId);
return detail(tenantId, inbound, measurement);
}
public InboundMeasurementResponses.Detail detail(long tenantId, long inboundId, long measurementId) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
InboundMeasurementEntity measurement = repository.findMeasurement(tenantId, inboundId, measurementId);
if (measurement == null) throw new BizException(ResultCode.NOT_FOUND, "费用绑定的测量批次不存在");
return detail(tenantId, inbound, measurement);
}
private InboundMeasurementResponses.Detail detail(long tenantId, InboundOrderEntity inbound,
InboundMeasurementEntity measurement) {
List<InboundMeasurementItemEntity> items = measurement == null
? List.of() : repository.listItems(tenantId, measurement.getId());
List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inboundId);
MeasureReviewRecordEntity review = repository.findLatestReview(tenantId, inboundId);
List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inbound.getId());
MeasureReviewRecordEntity review = repository.findLatestReview(tenantId, inbound.getId());
InboundMeasurementResponses.DeclarationSnapshot latestDeclaration = loadLatestDeclaration(inbound);
return toDetail(inbound, measurement, items, weights, review, latestDeclaration);
}
@@ -119,7 +131,7 @@ public class InboundMeasurementService {
actualGoodsName,
operatorId, request.items().stream().map(item -> new MeasurementBatchCommand.Item(
item.inboundDetailId(), item.itemNo(), item.boxSpecification(), item.packageCount(), item.lengthCm(),
item.widthCm(), item.heightCm(), item.intelligentImageFileId(),
item.widthCm(), item.heightCm(), item.unitWeightKg(), item.intelligentImageFileId(),
item.overLimitWarning(), item.outerLengthCm(), item.outerWidthCm(), item.outerHeightCm(),
item.chargeRuleId(), item.coefficientMode(), item.remark())).toList());
InboundMeasurementEntity saved = batchService.saveBatch(command);
@@ -154,9 +166,6 @@ public class InboundMeasurementService {
inbound.setProcessStatus("MEASURE_PENDING");
inbound.setMeasureReviewStatus("PENDING");
inbound.setMeasureCompletedAt(LocalDateTime.now());
if (measurement.getTotalPackageCount() != null) {
inbound.setTotalPackageCount(measurement.getTotalPackageCount());
}
inbound.setActualGrossWeightKg(confirmedTotals.grossWeightKg());
inbound.setActualNetWeightKg(confirmedTotals.netWeightKg());
inbound.setPalletCount(confirmedTotals.palletCount());
@@ -534,36 +543,21 @@ public class InboundMeasurementService {
}
private InboundMeasurementResponses.Item toItem(InboundMeasurementItemEntity item) {
String overrideVersion = item.getCalculationRuleVersion();
boolean legacyFullOverride = "WAREHOUSE_OUTER_OVERRIDE_V1".equals(overrideVersion);
boolean lengthOverridden = legacyFullOverride || warehouseOverrideContains(overrideVersion, 'L');
boolean widthOverridden = legacyFullOverride || warehouseOverrideContains(overrideVersion, 'W');
boolean heightOverridden = legacyFullOverride || warehouseOverrideContains(overrideVersion, 'H');
return new InboundMeasurementResponses.Item(
item.getId(), item.getInboundDetailId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(),
item.getLengthCm(), item.getWidthCm(), item.getHeightCm(), item.getGirthCm(),
item.getUnitVolumeCbm(),
item.getTotalVolumeCbm(), item.getVolumeWeightKg(), item.getSourceImageFileId(),
item.getOverLimitWarning(), lengthOverridden ? item.getOuterLengthCm() : null,
widthOverridden ? item.getOuterWidthCm() : null,
heightOverridden ? item.getOuterHeightCm() : null,
item.getOverLimitWarning(), item.getManualOuterLengthCm(),
item.getManualOuterWidthCm(), item.getManualOuterHeightCm(),
item.getOuterLengthCm(), item.getOuterWidthCm(), item.getOuterHeightCm(),
item.getCalculationRuleVersion(),
item.getChargeRuleId(), item.getCoefficientMode(),
item.getRemark(), item.getBoxCode(), item.getScannedShipmentId(),
item.getPackageCount(),
item.getMachineNo(), item.getDeviceMeasuredAt(), item.getUpdatedAt());
}
private boolean warehouseOverrideContains(String calculationRuleVersion, char dimension) {
if (calculationRuleVersion == null
|| !calculationRuleVersion.startsWith("WAREHOUSE_OUTER_OVERRIDE_")
|| !calculationRuleVersion.endsWith("_V1")) {
return false;
}
String dimensions = calculationRuleVersion.substring(
"WAREHOUSE_OUTER_OVERRIDE_".length(), calculationRuleVersion.length() - 3);
return dimensions.indexOf(dimension) >= 0;
}
private void requireConfirmedPackageTotal(InboundOrderEntity inbound,
List<InboundMeasurementItemEntity> items) {
if (inbound.getTotalPackageCount() == null || inbound.getTotalPackageCount() < 1) {
@@ -44,7 +44,7 @@ public class MeasurementBatchService {
private static final BigDecimal MAX_RAW_VOLUME_CBM = new BigDecimal("999999999999.999999");
private static final BigDecimal MAX_TOTAL_VOLUME_CBM = new BigDecimal("99999999999999.9999");
private static final Pattern SPECIFICATION_COEFFICIENT_PATTERN = Pattern.compile(
"(SMALL|MEDIUM|LARGE|EXTRA_LARGE):(\\d+(?:\\.\\d+)?),(\\d+(?:\\.\\d+)?),(\\d+(?:\\.\\d+)?);系数:(\\d+(?:\\.\\d+)?)");
"(SMALL|MEDIUM|LARGE|EXTRA_LARGE):(\\d+(?:\\.\\d+)?),(\\d+(?:\\.\\d+)?),(\\d+(?:\\.\\d+)?);系数:(\\d+(?:\\.\\d+)?)(?:;重量:(\\d+(?:\\.\\d+)?))?");
@Resource
private InboundMeasurementRepository repository;
@Resource
@@ -167,7 +167,8 @@ public class MeasurementBatchService {
: requestedMode == null ? findDefaultCoefficientRule(command.tenantId(), customerId) : null;
String coefficientMode = coefficientRule != null ? "MULTIPLY" : requestedMode;
boolean useDimensionRule = "ADD".equals(coefficientMode);
BigDecimal addition = weighted.edgeAdditionCm();
BigDecimal addition = weighted.edgeAdditionCm() == null
? null : weighted.edgeAdditionCm().multiply(BigDecimal.valueOf(2));
boolean hasMatchedAddition = useDimensionRule && weighted.boxType() != null;
// ADD 只从匹配箱规取得加边值,外尺寸仍以本次实测尺寸为基准,
// 不能把箱规边界尺寸当成本次货物的实际外尺寸。
@@ -176,7 +177,7 @@ public class MeasurementBatchService {
BigDecimal outerHeight = hasMatchedAddition ? baseHeight.add(addition) : baseHeight;
if (coefficientRule != null) {
BigDecimal specificationCoefficient = resolveSpecificationCoefficient(
coefficientRule, baseLength, baseWidth, baseHeight);
coefficientRule, baseLength, baseWidth, baseHeight, source.unitWeightKg());
outerLength = applyCoefficient(outerLength, specificationCoefficient);
outerWidth = applyCoefficient(outerWidth, specificationCoefficient);
outerHeight = applyCoefficient(outerHeight, specificationCoefficient);
@@ -222,13 +223,13 @@ public class MeasurementBatchService {
: useDimensionRule ? weighted.calculationRuleVersion() : "ACTUAL_DIMENSION_V1");
item.setSourceImageFileId(source.intelligentImageFileId());
item.setOverLimitWarning(source.overLimitWarning());
// Override fields retain the operator's base values so repeated saves do not compound the strategy.
item.setOuterLengthCm((source.outerLengthCm() != null ? source.outerLengthCm() : outerLength)
.setScale(3, RoundingMode.HALF_UP));
item.setOuterWidthCm((source.outerWidthCm() != null ? source.outerWidthCm() : outerWidth)
.setScale(3, RoundingMode.HALF_UP));
item.setOuterHeightCm((source.outerHeightCm() != null ? source.outerHeightCm() : outerHeight)
.setScale(3, RoundingMode.HALF_UP));
// 人工外箱值是策略计算基数;最终外箱值单独落库,避免覆盖基数或重复叠加策略。
item.setManualOuterLengthCm(source.outerLengthCm());
item.setManualOuterWidthCm(source.outerWidthCm());
item.setManualOuterHeightCm(source.outerHeightCm());
item.setOuterLengthCm(outerLength.setScale(3, RoundingMode.HALF_UP));
item.setOuterWidthCm(outerWidth.setScale(3, RoundingMode.HALF_UP));
item.setOuterHeightCm(outerHeight.setScale(3, RoundingMode.HALF_UP));
item.setRemark(source.remark());
item.setCreatedAt(LocalDateTime.now());
return item;
@@ -250,7 +251,7 @@ public class MeasurementBatchService {
private String normalizeMode(String mode) {
if (mode == null || mode.isBlank()) return null;
String normalized = mode.trim().toUpperCase(java.util.Locale.ROOT);
if (!Set.of("ADD", "MULTIPLY").contains(normalized)) {
if (!Set.of("ACTUAL", "ADD", "MULTIPLY").contains(normalized)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "系数模式仅支持 ADD 或 MULTIPLY");
}
return normalized;
@@ -287,14 +288,14 @@ public class MeasurementBatchService {
private BigDecimal resolveSpecificationCoefficient(CustomerChargeRuleEntity rule,
BigDecimal length, BigDecimal width,
BigDecimal height) {
BigDecimal height, BigDecimal unitWeightKg) {
List<SpecificationCoefficient> tiers = parseSpecificationCoefficients(rule.getRemark());
if (tiers.isEmpty()) {
return rule.getLengthFactor() == null ? BigDecimal.ONE : rule.getLengthFactor();
}
List<BigDecimal> measured = sortedDimensions(length, width, height);
return tiers.stream()
.filter(tier -> tier.contains(measured))
.filter(tier -> tier.contains(measured, unitWeightKg))
.findFirst()
.orElse(tiers.get(tiers.size() - 1))
.coefficient();
@@ -308,7 +309,8 @@ public class MeasurementBatchService {
List<BigDecimal> dimensions = sortedDimensions(new BigDecimal(matcher.group(2)),
new BigDecimal(matcher.group(3)), new BigDecimal(matcher.group(4)));
BigDecimal coefficient = new BigDecimal(matcher.group(5));
if (coefficient.signum() > 0) tiers.add(new SpecificationCoefficient(dimensions, coefficient));
BigDecimal weight = matcher.group(6) == null ? null : new BigDecimal(matcher.group(6));
if (coefficient.signum() > 0) tiers.add(new SpecificationCoefficient(dimensions, coefficient, weight));
}
tiers.sort(Comparator.comparing(SpecificationCoefficient::volume));
return tiers;
@@ -320,15 +322,16 @@ public class MeasurementBatchService {
return dimensions;
}
private record SpecificationCoefficient(List<BigDecimal> dimensions, BigDecimal coefficient) {
private record SpecificationCoefficient(List<BigDecimal> dimensions, BigDecimal coefficient, BigDecimal maxWeightKg) {
private BigDecimal volume() {
return dimensions.get(0).multiply(dimensions.get(1)).multiply(dimensions.get(2));
}
private boolean contains(List<BigDecimal> measured) {
private boolean contains(List<BigDecimal> measured, BigDecimal unitWeightKg) {
return measured.get(0).compareTo(dimensions.get(0)) <= 0
&& measured.get(1).compareTo(dimensions.get(1)) <= 0
&& measured.get(2).compareTo(dimensions.get(2)) <= 0;
&& measured.get(2).compareTo(dimensions.get(2)) <= 0
&& (maxWeightKg == null || unitWeightKg == null || unitWeightKg.compareTo(maxWeightKg) <= 0);
}
}
@@ -18,6 +18,7 @@ import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Set;
import java.math.BigDecimal;
@Service
public class MerchantInboundBillingService {
@@ -55,13 +56,31 @@ public class MerchantInboundBillingService {
platformDetail.files().stream()
.filter(file -> "PUBLIC".equals(file.visibility()) || "MERCHANT".equals(file.visibility()))
.toList(), List.of(), List.of());
InboundMeasurementResponses.Detail measurement = measurements.detail(tenantId, inbound.getId());
FeeSnapshotResponse fee = pricing.latest(tenantId, inbound.getId());
InboundMeasurementResponses.Detail measurement = fee.measurementId() == null || fee.measurementId() <= 0
? measurements.detail(tenantId, inbound.getId())
: measurements.detail(tenantId, inbound.getId(), fee.measurementId());
var measurementItems = measurement.items().stream()
.map(item -> new MerchantInboundBillingResponse.MeasurementItem(
item.id(), item.itemNo(), item.boxSpecification(), item.packageCount(),
requireOuterDimension(item.calculatedOuterLengthCm(), item.itemNo(), "长"),
requireOuterDimension(item.calculatedOuterWidthCm(), item.itemNo(), "宽"),
requireOuterDimension(item.calculatedOuterHeightCm(), item.itemNo(), "高"),
item.unitVolumeCbm(), item.totalVolumeCbm(), item.volumeWeightKg(),
item.chargeRuleId(), item.coefficientMode(), item.calculationRuleVersion()))
.toList();
int outerPackageCount = measurementItems.stream()
.map(MerchantInboundBillingResponse.MeasurementItem::packageCount)
.filter(java.util.Objects::nonNull).mapToInt(Integer::intValue).sum();
BigDecimal outerVolume = sum(measurementItems.stream()
.map(MerchantInboundBillingResponse.MeasurementItem::totalVolumeCbm).toList());
BigDecimal outerVolumeWeight = sum(measurementItems.stream()
.map(MerchantInboundBillingResponse.MeasurementItem::volumeWeightKg).toList());
var summary = new MerchantInboundBillingResponse.MeasurementSummary(
measurement.measurementId(), measurement.sourceType(), measurement.measurementStatus(),
measurement.actualPackageCount(), measurement.actualGrossWeightKg(),
outerPackageCount, measurement.actualGrossWeightKg(),
measurement.actualNetWeightKg(),
measurement.actualVolumeCbm(), measurement.volumeWeightKg(), measurement.actualGoodsName());
outerVolume, outerVolumeWeight, measurement.actualGoodsName(), "OUTER");
var measurementImages = measurement.items().stream()
.filter(item -> item.intelligentImageFileId() != null)
.map(item -> new MerchantInboundBillingResponse.ImageReference(
@@ -73,12 +92,6 @@ public class MerchantInboundBillingService {
.map(weight -> new MerchantInboundBillingResponse.ImageReference(
weight.fileId(), "WEIGHT", null, weight.id(), previewUrl(orderId, weight.fileId())))
.toList();
var measurementItems = measurement.items().stream()
.map(item -> new MerchantInboundBillingResponse.MeasurementItem(
item.id(), item.itemNo(), item.boxSpecification(), item.packageCount(),
item.lengthCm(), item.widthCm(), item.heightCm(), item.unitVolumeCbm(),
item.totalVolumeCbm(), item.volumeWeightKg()))
.toList();
var weightResults = measurement.weightResults().stream()
.map(weight -> new MerchantInboundBillingResponse.WeightResult(
weight.id(), weight.fileId(), weight.palletId(), weight.palletCode(), weight.palletType(),
@@ -96,6 +109,14 @@ public class MerchantInboundBillingService {
weightResults, measurementImages, weightImages, basis, fee, publishedFee(publishedFee));
}
public FeeSnapshotResponse confirmFee(long tenantId, long operatorId, String operatorName,
long orderId, int expectedFeeVersion,
String idempotencyKey) {
InboundOrderEntity inbound = requirePublished(tenantId, orderId);
return pricing.confirmByCustomer(tenantId, operatorId, operatorName, inbound.getId(),
expectedFeeVersion, idempotencyKey);
}
public ImageDownload downloadImage(long tenantId, long orderId, long fileId) {
InboundOrderEntity inbound = requirePublished(tenantId, orderId);
InboundMeasurementResponses.Detail measurement = measurements.detail(tenantId, inbound.getId());
@@ -134,6 +155,19 @@ public class MerchantInboundBillingService {
+ "/inbound-billing/images/" + fileId;
}
private static BigDecimal requireOuterDimension(BigDecimal value, String itemNo, String dimension) {
if (value == null || value.signum() <= 0) {
throw new BizException(ResultCode.CONFLICT,
"外箱规数据不完整,明细 " + (itemNo == null ? "—" : itemNo) + " 缺少外箱" + dimension);
}
return value;
}
private static BigDecimal sum(List<BigDecimal> values) {
return values.stream().filter(java.util.Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
private String chargeableBasis(FeeSnapshotResponse fee) {
int compared = ("KG".equals(fee.chargeMode()) ? fee.actualWeightKg() : fee.actualVolumeCbm())
.compareTo(fee.convertedQuantity());