fix(firstleg): allow fee revision until final fulfillment lock

This commit is contained in:
2026-09-10 11:02:45 +08:00
parent a926a23b87
commit 8731efcbea
16 changed files with 294 additions and 108 deletions
@@ -93,6 +93,22 @@ public class PlatformInboundMeasurementController {
return ApiResponse.success(service.saveDraft(tenantId, inboundId, user.userId(), request));
}
@BusinessAudit(event = "INBOUND_MEASUREMENT_CALCULATION_STRATEGY_UPDATE", resourceType = "INBOUND",
resourceId = "#inboundId", description = "更新入库测量箱规计算规则")
@Operation(summary = "更新箱规计算规则",
description = "独立更新当前测量批次全部明细的箱规计算规则并重算体积;不改变测量提交或审核状态。"
+ "仅当客户已确认费用且该票已装柜完成时冻结。")
@PutMapping("/measurement/calculation-strategy")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:edit')")
public ApiResponse<InboundMeasurementResponses.CalculationStrategyUpdated> updateCalculationStrategy(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@Valid @RequestBody InboundMeasurementRequests.UpdateCalculationStrategyRequest request) {
requirePlatformTenant(user, tenantId);
return ApiResponse.success(service.updateCalculationStrategy(tenantId, inboundId, request));
}
@BusinessAudit(event = "INBOUND_MEASUREMENT_SUBMIT", resourceType = "INBOUND",
resourceId = "#inboundId", description = "确认测量并提交审核")
@Operation(summary = "确认测量并提交审核",
@@ -87,6 +87,14 @@ public final class InboundMeasurementRequests {
) {
}
public record UpdateCalculationStrategyRequest(
@NotNull @Positive Long measurementId,
@NotNull @PositiveOrZero Integer version,
@NotBlank @jakarta.validation.constraints.Pattern(regexp = "ACTUAL|ADD|MULTIPLY") String coefficientMode,
@Positive Long chargeRuleId
) {
}
public record ConfirmedPackageCountRequest(
@NotNull @PositiveOrZero Integer measurementVersion,
@NotNull @Positive @Max(1000000) Integer confirmedPackageCount
@@ -183,6 +183,11 @@ public final class InboundMeasurementResponses {
public record DraftSaved(Long measurementId, Integer version, String status) {
}
public record CalculationStrategyUpdated(
Long measurementId, Integer version, String status, String coefficientMode, Long chargeRuleId
) {
}
public record MeasurementSubmitted(Long measurementId, Integer version, String processStatus,
String measureReviewStatus,
@Schema(description = "测量完成后的推荐托盘类型,仅为建议,不代表已分配托盘",
@@ -15,7 +15,6 @@ import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.CompletionTarget;
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.loading.repository.LoadingRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.service.FbaPricingService;
import com.baiyue.firstleg.service.InboundHoldGuard;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -42,7 +41,6 @@ public class LoadingDeliveryService {
@Resource private LoadingCompletionGateService completionGate;
@Resource private InboundStatusHistoryMapper histories;
@Resource private CabinetLoadingEvidenceService cabinetEvidenceService;
@Resource private FbaPricingService pricingService;
@Resource private ObjectMapper objectMapper;
@Resource private InboundHoldGuard inboundHoldGuard;
@@ -69,7 +67,6 @@ public class LoadingDeliveryService {
}
if ("LOADED".equals(inbound.processStatus()) || "DELIVERED".equals(inbound.processStatus())) {
requireLoadingPlanGate(tenantId, inboundId, acceptedPdaCompletion);
pricingService.autoConfirmAfterLoading(tenantId, inboundId);
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse(inbound.processStatus()));
}
@@ -80,7 +77,6 @@ public class LoadingDeliveryService {
requireCabinetEvidence(tenantId, inboundId);
if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent();
addHistory(tenantId, inboundId, "LOADING", "LOADED", COMPLETE_LOADING, request.remark(), operatorId, operatorName);
pricingService.autoConfirmAfterLoading(tenantId, inboundId);
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse("LOADED"));
}
@@ -124,7 +120,6 @@ public class LoadingDeliveryService {
CompletionTarget target,
boolean acceptedPdaCompletion) {
if ("LOADED".equals(target.loadedChildStatus())) {
pricingService.autoConfirmAfterLoading(tenantId, target.completionInboundId());
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse("LOADED"));
}
@@ -140,7 +135,6 @@ public class LoadingDeliveryService {
repository.aggregateRootLoaded(tenantId, target.rootInboundId(), operatorId);
addHistory(tenantId, target.completionInboundId(), "LOADING", "LOADED", COMPLETE_LOADING,
request.remark(), operatorId, operatorName);
pricingService.autoConfirmAfterLoading(tenantId, target.completionInboundId());
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse("LOADED"));
}
@@ -285,12 +285,12 @@ public class FbaPricingRepository {
.set(InboundFeeEntity::getUpdatedAt, LocalDateTime.now()));
}
public int markInboundAdjusted(long tenantId, long inboundId, String feeStatus,
public int markInboundAdjusted(long tenantId, long inboundId, String processStatus, String feeStatus,
String chargeUnit, java.math.BigDecimal chargeableQuantity) {
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId)
.eq(InboundPricingEntity::getId, inboundId)
.eq(InboundPricingEntity::getProcessStatus, "WAIT_SPLIT")
.eq(InboundPricingEntity::getProcessStatus, processStatus)
.set(InboundPricingEntity::getFeeStatus, feeStatus);
if ("KG".equals(chargeUnit)) {
update.set(InboundPricingEntity::getChargeableWeightKg, chargeableQuantity);
@@ -308,7 +308,8 @@ public class FbaPricingRepository {
.eq(InboundPricingEntity::getProcessStatus, processStatus)
.in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED", "SENT"))
.set(InboundPricingEntity::getFeeStatus, "CALCULATED");
if (!List.of("WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING").contains(processStatus)) {
if (!List.of("WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED")
.contains(processStatus)) {
update.set(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING");
}
if ("KG".equals(chargeMode)) update.set(InboundPricingEntity::getChargeableWeightKg, chargeable);
@@ -71,6 +71,14 @@ public class InboundMeasurementRepository {
.eq(InboundMeasurementEntity::getId, measurementId));
}
public InboundMeasurementEntity lockMeasurement(long tenantId, long inboundId, long measurementId) {
return measurements.selectOne(new LambdaQueryWrapper<InboundMeasurementEntity>()
.eq(InboundMeasurementEntity::getTenantId, tenantId)
.eq(InboundMeasurementEntity::getInboundId, inboundId)
.eq(InboundMeasurementEntity::getId, measurementId)
.last("FOR UPDATE"));
}
public InboundMeasurementEntity findLatestMeasurement(long tenantId, long inboundId) {
return measurements.selectOne(new LambdaQueryWrapper<InboundMeasurementEntity>()
.eq(InboundMeasurementEntity::getTenantId, tenantId)
@@ -44,7 +44,6 @@ public class FbaPricingService {
private static final BigDecimal ADJUST_TOLERANCE_PERCENT = new BigDecimal("10.00");
private static final BigDecimal ONE_HUNDRED = new BigDecimal("100");
private static final long AUTOMATIC_CONFIRM_OPERATOR_ID = 0L;
private static final String AUTOMATIC_CONFIRM_OPERATOR_NAME = "装柜完成自动确认";
private static final String CONTAINER_RECALCULATE_OPERATION = "CONTAINER_FEE_RECALCULATE";
@Resource
private FbaPricingRepository repository;
@@ -99,8 +98,8 @@ public class FbaPricingService {
if (published == null || !"SENT".equals(published.getFeeStatus())) {
throw conflict("费用状态已变化,请刷新后重试");
}
if (published.getCustomerConfirmedAt() != null) {
throw conflict("客户已确认该费用,不允许再修改或重新发送");
if (feeRevisionLocked(inbound, published)) {
throw conflict("客户已确认且该票已装柜完成,不允许再修改或重新发送费用");
}
}
var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId);
@@ -390,8 +389,8 @@ public class FbaPricingService {
public FeeSnapshotResponse confirm(long tenantId, long operatorId, long inboundId,
String idempotencyKey) {
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
if (!feeEditableBeforeLoading(inbound.getProcessStatus())) {
throw conflict("订单已进入装柜流程,不能再修改或发送费用");
if (!feeRevisionStatusSupported(inbound.getProcessStatus())) {
throw conflict("当前入库单状态不允许确认费用");
}
InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId);
if (current == null) throw conflict("入库单尚无可确认的费用试算");
@@ -412,8 +411,8 @@ 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("订单已进入装柜流程,不能再修改或发送费用");
if (!feeRevisionStatusSupported(inbound.getProcessStatus())) {
throw conflict("当前入库单状态不允许发送费用");
}
InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId);
if (current == null) throw conflict("入库单尚无费用快照");
@@ -445,21 +444,6 @@ public class FbaPricingService {
return response(tenantId, fee.getId(), null);
}
/** 客户未操作时,装柜完成以系统身份确认最新一版已发送费用。 */
@Transactional
public void autoConfirmAfterLoading(long tenantId, long inboundId) {
InboundFeeEntity current = repository.findLatestFeeForUpdate(tenantId, inboundId);
if (current == null || !"SENT".equals(current.getFeeStatus())
|| current.getCustomerConfirmedAt() != null) {
return;
}
LocalDateTime now = LocalDateTime.now(clock);
if (repository.confirmFeeByCustomer(tenantId, current.getId(), current.getFeeVersion(),
AUTOMATIC_CONFIRM_OPERATOR_ID, AUTOMATIC_CONFIRM_OPERATOR_NAME, now) != 1) {
throw conflict("装柜完成自动确认费用失败,请重试");
}
}
@Transactional
public FeeSnapshotResponse confirmByCustomer(long tenantId, long operatorId, String operatorName,
long inboundId, int expectedFeeVersion,
@@ -498,15 +482,15 @@ public class FbaPricingService {
AdjustFeeRequest request, String idempotencyKey) {
InboundPricingEntity inbound = repository.findInbound(tenantId, inboundId);
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
if (!isCompletedInbound(inbound.getProcessStatus())) {
throw conflict("只能在待分柜阶段修改初算或已发布费用");
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 (current.getCustomerConfirmedAt() != null) {
throw conflict("客户已确认该费用,不允许再修改");
if (feeRevisionLocked(inbound, current)) {
throw conflict("客户已确认且该票已装柜完成,不允许再修改费用");
}
if (!request.expectedFeeVersion().equals(current.getFeeVersion())) {
throw conflict("费用版本已变化,请刷新后重试");
@@ -554,6 +538,9 @@ public class FbaPricingService {
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) {
@@ -576,7 +563,7 @@ public class FbaPricingService {
extra.setRemark(request.reason().trim());
repository.insertItem(extra);
}
if (repository.markInboundAdjusted(tenantId, inboundId, adjusted.getFeeStatus(),
if (repository.markInboundAdjusted(tenantId, inboundId, inbound.getProcessStatus(), adjusted.getFeeStatus(),
adjusted.getChargeUnit(), quantity) != 1) {
throw conflict("入库单状态已变化,费用调整未保存");
}
@@ -594,7 +581,7 @@ public class FbaPricingService {
private InboundPricingEntity requireEligibleInbound(long tenantId, long inboundId) {
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
if (!feeEditableBeforeLoading(inbound.getProcessStatus())
if (!feeRevisionStatusSupported(inbound.getProcessStatus())
|| !List.of("UNPRICED", "CALCULATED", "SENT").contains(inbound.getFeeStatus())) {
throw conflict("当前入库单状态不允许费用试算");
}
@@ -613,20 +600,23 @@ public class FbaPricingService {
return inbound;
}
private static boolean isCompletedInbound(String processStatus) {
return "WAIT_SPLIT".equals(processStatus);
}
private static boolean preserveProcessStatusDuringPricing(String processStatus) {
return List.of("WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING").contains(processStatus);
return List.of("WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED")
.contains(processStatus);
}
private static boolean feeEditableBeforeLoading(String 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")
"WAIT_PRICING", "FEE_CONFIRMING", "WAIT_PALLET", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING",
"LOADED", "DELIVERED")
.contains(processStatus);
}
private static boolean feeRevisionLocked(InboundPricingEntity inbound, InboundFeeEntity fee) {
return fee != null && fee.getCustomerConfirmedAt() != null
&& List.of("LOADED", "DELIVERED").contains(inbound.getProcessStatus());
}
private static PricingDestinationType destinationType(InboundPricingEntity inbound) {
try {
return PricingDestinationType.valueOf(inbound.getDestinationType());
@@ -20,6 +20,7 @@ import com.baiyue.firstleg.entity.InboundWeightRevisionEntity;
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
import com.baiyue.firstleg.repository.InboundPalletAllocationRepository;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@@ -61,6 +62,8 @@ public class InboundMeasurementService {
@Resource
private MeasurementBatchService batchService;
@Resource
private FbaPricingRepository pricingRepository;
@Resource
private FbaMasterDataService fbaMasterDataService;
@Resource
private OrderCargoClient orderCargoClient;
@@ -162,6 +165,40 @@ public class InboundMeasurementService {
return new InboundMeasurementResponses.DraftSaved(saved.getId(), saved.getVersion(), saved.getStatus());
}
@Transactional
public InboundMeasurementResponses.CalculationStrategyUpdated updateCalculationStrategy(
long tenantId, long inboundId,
InboundMeasurementRequests.UpdateCalculationStrategyRequest request) {
InboundOrderEntity inbound = repository.lockInbound(tenantId, inboundId);
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
var currentFee = pricingRepository.findLatestFeeForUpdate(tenantId, inboundId);
boolean customerConfirmed = currentFee != null && currentFee.getCustomerConfirmedAt() != null;
boolean loadingCompleted = Set.of("LOADED", "DELIVERED").contains(inbound.getProcessStatus());
if (customerConfirmed && loadingCompleted) {
throw new BizException(ResultCode.CONFLICT,
"客户已确认且该票已装柜完成,不能修改箱规计算规则");
}
if ("MULTIPLY".equals(request.coefficientMode()) && request.chargeRuleId() == null) {
throw new BizException(ResultCode.VALIDATION_FAILED, "箱规系数模式必须选择计费规则");
}
InboundMeasurementEntity measurement = repository.lockMeasurement(
tenantId, inboundId, request.measurementId());
if (measurement == null) throw new BizException(ResultCode.NOT_FOUND, "测量批次不存在");
requireVersion(request.version(), measurement.getVersion());
List<InboundMeasurementItemEntity> items = repository.listItems(tenantId, measurement.getId());
batchService.recalculateStrategy(tenantId, measurement, items,
request.coefficientMode(), request.chargeRuleId());
measurement.setUpdatedAt(LocalDateTime.now());
if (!repository.updateMeasurement(measurement)) throw concurrentModification();
inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm());
inbound.setVolumeWeightKg(measurement.getVolumeWeightKg());
if (!repository.updateInbound(inbound)) throw concurrentModification();
Long appliedRuleId = "MULTIPLY".equals(request.coefficientMode()) ? request.chargeRuleId() : null;
return new InboundMeasurementResponses.CalculationStrategyUpdated(
measurement.getId(), measurement.getVersion(), measurement.getStatus(),
request.coefficientMode(), appliedRuleId);
}
@Transactional
public InboundMeasurementResponses.MeasurementSubmitted submit(
long tenantId, long inboundId, long operatorId,
@@ -93,6 +93,49 @@ public class MeasurementBatchService {
return measurement;
}
void recalculateStrategy(long tenantId, InboundMeasurementEntity measurement,
List<InboundMeasurementItemEntity> existingItems,
String coefficientMode, Long chargeRuleId) {
if (existingItems.isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "当前测量批次没有可重算的明细");
}
MeasurementBatchCommand command = new MeasurementBatchCommand(
tenantId, measurement.getInboundId(), measurement.getId(), measurement.getVersion(),
measurement.getSourceType(), measurement.getSourceCode(), measurement.getExternalEventNo(),
measurement.getDeviceCode(), measurement.getWarehouseCode(), measurement.getMeasuredAt(),
measurement.getRawPayloadJson(), measurement.getOriginalDocumentNo(),
measurement.getOriginalGrossWeightKg(), measurement.getOriginalVolumeCbm(),
measurement.getActualGoodsName(), measurement.getCreatedBy(),
existingItems.stream().map(item -> new MeasurementBatchCommand.Item(
item.getInboundDetailId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(),
item.getLengthCm(), item.getWidthCm(), item.getHeightCm(), null, item.getSourceImageFileId(),
item.getOverLimitWarning(), item.getManualOuterLengthCm(), item.getManualOuterWidthCm(),
item.getManualOuterHeightCm(), chargeRuleId, coefficientMode, item.getRemark())).toList());
List<InboundMeasurementItemEntity> recalculated = calculateItems(
command, dimensionWeighting.load(tenantId));
for (int index = 0; index < existingItems.size(); index++) {
InboundMeasurementItemEntity target = existingItems.get(index);
InboundMeasurementItemEntity source = recalculated.get(index);
target.setGirthCm(source.getGirthCm());
target.setUnitVolumeCbm(source.getUnitVolumeCbm());
target.setVolumeRawCbm(source.getVolumeRawCbm());
target.setVolumeCbm(source.getVolumeCbm());
target.setTotalVolumeCbm(source.getTotalVolumeCbm());
target.setVolumeWeightKg(source.getVolumeWeightKg());
target.setChargeRuleId(source.getChargeRuleId());
target.setCoefficientMode(source.getCoefficientMode());
target.setCalculationRuleVersion(source.getCalculationRuleVersion());
target.setOuterLengthCm(source.getOuterLengthCm());
target.setOuterWidthCm(source.getOuterWidthCm());
target.setOuterHeightCm(source.getOuterHeightCm());
target.setUpdatedAt(LocalDateTime.now());
if (!repository.updateItem(target)) {
throw concurrentModification();
}
}
applyTotals(measurement, existingItems);
}
/**
* 人工补录会整批替换可编辑测量项,但不能抹掉同一项已冻结的扫码身份与设备取证。
* 只按入库货物明细和明细号精确匹配,避免把一个箱码猜测性地复制给其他测量项。