diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 7d925c740..d92bc8c77 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -86,7 +86,7 @@ ## 国内货柜履约装柜与 PDA 资源结算 - 智能装柜全局工作台由服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选支持按创建日期和预计到货日期闭区间筛选,状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入以响应 `canAssign` 为准,由有效柜次占用、剩余箱数、容量、组锁和版本等事实决定;人工加入允许订单国内仓及渠道与柜次不同,智能推荐允许跨国内仓但仍只选择柜次渠道。容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核事实必须经 `PendingReviewOrderClient` 重新核验 Order 权威 `SUBMITTED` 订单,待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。计划明细通过 `totalPackageCount/remainingPackageCount` 返回可调总量和余量:测量前取 Order 原始申报箱数,进入 `WAIT_SPLIT/LOADING` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。 -- 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间并将该费用版本永久锁定,平台不得再调整;旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。 +- 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间;客户未确认或该票尚未装柜完成时,平台仍可修改箱规规则、生成新费用版本并重新发送,新版本必须清空旧版客户确认快照。仅当“客户已确认且该票已装柜完成”同时成立时最终冻结;装柜完成不得代替客户自动确认。旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。 - PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。 - PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。 - 首张称重图片确认时按 `max(已确认称重图片数, ceil(有效体积 / 默认托盘容量))` 尽力预留并确认整票库位块;有效体积先取入库单已落地的正数实测体积,否则对最新测量批次的正数 `totalVolumeCbm/originalVolumeCbm` 取较大值,再无有效值才查询 Order 最新客户申报体积,全部无效则拒绝。后续确认绑定最小空槽,所需槽数增长时通过 Warehouse `/blocks/expand` 原子追加,既有 `blockId/slotNo/reservationId/locationId/sharedOverflow` 不得变化。库位指引失败只记录警告,不阻断称重确认;测量提交后以最新已确认图片调用 `ensureForSubmission` 再次补齐和校验,失败同样不回滚已提交的测量主流程。Firstleg 必须显式保存 `weightResultId -> blockId/slotNo/reservationId/locationId/locationCode`,按 `confirmedAt + weightResultId` 稳定顺序绑定,禁止按列表下标推断;普通连续位不足的新增槽由 Warehouse 放入共享 A0,不阻断确认。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java index a7196bb7b..697053bd9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java @@ -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 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 = "确认测量并提交审核", diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java index 957556113..92b66a040 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java @@ -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 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java index 7934f7b4f..8dd559f63 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java @@ -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 = "测量完成后的推荐托盘类型,仅为建议,不代表已分配托盘", diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java index 096ee9003..aa064081f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java @@ -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")); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java index 60356812d..35d8e9f2a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java @@ -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 update = new LambdaUpdateWrapper() .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); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java index 8ae36d4cb..dc0b5298a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java @@ -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() + .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() .eq(InboundMeasurementEntity::getTenantId, tenantId) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index ace5297cd..0120b6d46 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -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()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java index 6e15a20d8..d08402195 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -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 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, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java index b2345e2b5..2dd2ef2b5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java @@ -93,6 +93,49 @@ public class MeasurementBatchService { return measurement; } + void recalculateStrategy(long tenantId, InboundMeasurementEntity measurement, + List 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 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); + } + /** * 人工补录会整批替换可编辑测量项,但不能抹掉同一项已冻结的扫码身份与设备取证。 * 只按入库货物明细和明细号精确匹配,避免把一个箱码猜测性地复制给其他测量项。 diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index fcf2441e8..39b21b216 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -964,6 +964,24 @@ paths: responses: '200': {description: 成功或业务失败,按响应体 code 判断} '403': {description: 无测量编辑权限} + /api/platform/v1/first-leg/inbounds/{inboundId}/measurement/calculation-strategy: + put: + summary: 更新箱规计算规则 + description: >- + 独立更新当前测量批次全部明细的箱规计算规则并重算体积,不覆盖整批测量数据, + 不改变测量提交或审核状态。客户未确认或该票尚未装柜完成时均可修改; + 仅当客户已确认费用且该票已装柜完成时冻结。 + parameters: + - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/InboundMeasurementCalculationStrategyRequest'} + responses: + '200': {description: 成功或业务失败,按响应体 code 判断} + '403': {description: 无测量编辑权限} /api/platform/v1/first-leg/inbounds/{inboundId}/measurement/carton-scans: post: summary: 平台模拟单箱设备扫描 @@ -1932,6 +1950,15 @@ components: minItems: 1 maxItems: 500 items: {$ref: '#/components/schemas/InboundMeasurementItemRequest'} + InboundMeasurementCalculationStrategyRequest: + type: object + required: [measurementId, version, coefficientMode] + additionalProperties: false + properties: + measurementId: {type: string, pattern: '^[1-9][0-9]*$'} + version: {type: integer, minimum: 0} + coefficientMode: {type: string, enum: [ACTUAL, ADD, MULTIPLY]} + chargeRuleId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: MULTIPLY 模式必填} InboundMeasurementItemRequest: type: object required: [inboundDetailId, itemNo, boxSpecification, packageCount, lengthCm, widthCm, heightCm] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java index b033f0119..b1a2b50db 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java @@ -61,6 +61,13 @@ class InboundMeasurementEvidenceOpenApiContractTest { assertThat(properties(schemas, "InboundMeasurementItemRequest")).containsKeys( "inboundDetailId", "itemNo", "boxSpecification", "packageCount", "lengthCm", "widthCm", "heightCm", "intelligentImageFileId", "remark"); + String strategyPath = "/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/calculation-strategy"; + assertThat(paths).containsKey(strategyPath); + Map strategyOperation = stringMap(stringMap(paths.get(strategyPath)).get("put")); + assertThat(strategyOperation.get("description").toString()) + .contains("不覆盖整批测量数据", "不改变测量提交或审核状态", "客户已确认费用且该票已装柜完成"); + assertThat(properties(schemas, "InboundMeasurementCalculationStrategyRequest")) + .containsKeys("measurementId", "version", "coefficientMode", "chargeRuleId"); Map itemProperties = properties(schemas, "InboundMeasurementItem"); assertThat(itemProperties).containsKeys( diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java index 2118e71a4..49977142c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java @@ -6,7 +6,6 @@ import com.baiyue.firstleg.loading.repository.LoadingRepository; import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; import com.baiyue.firstleg.mapper.LoadingPickItemMapper.InboundProgress; import com.baiyue.firstleg.repository.LoadingPickRepository; -import com.baiyue.firstleg.service.FbaPricingService; import com.baiyue.firstleg.service.InboundHoldGuard; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; @@ -29,7 +28,6 @@ class LoadingDeliveryServiceTest { @MockBean LoadingCompletionGateService completionGate; @MockBean InboundStatusHistoryMapper histories; @MockBean CabinetLoadingEvidenceService cabinetEvidence; - @MockBean FbaPricingService pricingService; @MockBean ObjectMapper objectMapper; @MockBean InboundHoldGuard inboundHoldGuard; @Resource LoadingDeliveryService service; @@ -50,7 +48,6 @@ class LoadingDeliveryServiceTest { new com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest(3, "done")); verify(cabinetEvidence).requireComplete(99L); - verify(pricingService).autoConfirmAfterLoading(10L, 20L); } @Test @@ -91,7 +88,6 @@ class LoadingDeliveryServiceTest { verify(repository).findInboundForUpdate(10L, 21L); verify(repository).completeLoadedChild(10L, 20L, 9L, 9); - verify(pricingService).autoConfirmAfterLoading(10L, 21L); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java index 407eded52..d1f8e72cd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -77,48 +77,6 @@ class FbaPricingServiceTest { verify(repository, never()).findLatestFee(TENANT, INBOUND); } - @Test - @DisplayName("装柜完成后系统自动确认客户未确认的已发送费用") - void automaticallyConfirmsSentFeeAfterLoading() { - InboundFeeEntity sent = savedFee("SENT"); - when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent); - when(repository.confirmFeeByCustomer(TENANT, 501L, 1, 0L, - "装柜完成自动确认", NOW)).thenReturn(1); - - service.autoConfirmAfterLoading(TENANT, INBOUND); - - verify(repository).confirmFeeByCustomer(TENANT, 501L, 1, 0L, - "装柜完成自动确认", NOW); - } - - @Test - @DisplayName("客户已经确认时装柜完成不覆盖原确认信息") - void keepsExistingCustomerConfirmationAfterLoading() { - InboundFeeEntity sent = savedFee("SENT"); - sent.setCustomerConfirmedBy(88L); - sent.setCustomerConfirmedByName("客户成员"); - sent.setCustomerConfirmedAt(NOW.minusHours(1)); - when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent); - - service.autoConfirmAfterLoading(TENANT, INBOUND); - - verify(repository, never()).confirmFeeByCustomer(anyLong(), anyLong(), anyInt(), - anyLong(), anyString(), any()); - } - - @Test - @DisplayName("没有已发送费用时装柜完成不受影响") - void ignoresMissingOrUnsentFeeAfterLoading() { - when(repository.findLatestFeeForUpdate(TENANT, INBOUND)) - .thenReturn(null, savedFee("CONFIRMED")); - - service.autoConfirmAfterLoading(TENANT, INBOUND); - service.autoConfirmAfterLoading(TENANT, INBOUND); - - verify(repository, never()).confirmFeeByCustomer(anyLong(), anyLong(), anyInt(), - anyLong(), anyString(), any()); - } - @Test @DisplayName("包税KG按生效价格表计费且不依赖客户规则或承运商") void calculatesTaxIncludedKgWithoutCustomerRuleOrCarrier() { @@ -289,17 +247,27 @@ class FbaPricingServiceTest { } @Test - @DisplayName("装柜完成后禁止确认费用") - void rejectsConfirmAfterLoadingCompleted() { + @DisplayName("装柜完成但客户未确认时仍允许确认新费用版本") + void confirmsNewFeeVersionAfterLoadingCompleted() { InboundPricingEntity inbound = inbound("10.000", "1.0000"); inbound.setProcessStatus("LOADED"); inbound.setFeeStatus("CALCULATED"); + InboundFeeEntity fee = savedFee("CALCULATED"); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(fee); + when(repository.findIdempotency(TENANT, OPERATOR, "FEE_CONFIRM", "confirm-loaded")) + .thenReturn(null); + doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(899L); return null; }) + .when(repository).insertIdempotency(any()); + when(repository.confirmFee(TENANT, 501L, OPERATOR, NOW)).thenReturn(1); + when(repository.markInboundConfirmed(TENANT, INBOUND)).thenReturn(1); + when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1); + when(repository.findFee(TENANT, 501L)).thenReturn(fee); - assertThatThrownBy(() -> service.confirm(TENANT, OPERATOR, INBOUND, "confirm-loaded")) - .isInstanceOf(BizException.class) - .hasMessageContaining("装柜流程"); - verify(repository, never()).markInboundConfirmed(TENANT, INBOUND); + FeeSnapshotResponse response = service.confirm(TENANT, OPERATOR, INBOUND, "confirm-loaded"); + + assertThat(response.nextProcessStatus()).isEqualTo("LOADED"); + verify(repository).markInboundConfirmed(TENANT, INBOUND); } @Test @@ -335,11 +303,11 @@ class FbaPricingServiceTest { } @Test - @DisplayName("客户未确认时待装柜订单可重新计算并替换已发送费用") + @DisplayName("客户未确认时装柜完成订单仍可重新计算并替换已发送费用") void recalculatesSentFeeBeforeCustomerConfirmation() { readyRepository(); InboundPricingEntity inbound = inbound("10", "1"); - inbound.setProcessStatus("WAIT_PALLET"); + inbound.setProcessStatus("LOADED"); inbound.setFeeStatus("SENT"); InboundFeeEntity sent = savedFee("SENT"); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); @@ -354,18 +322,18 @@ class FbaPricingServiceTest { assertThat(response.feeVersion()).isEqualTo(2); assertThat(response.feeStatus()).isEqualTo("CALCULATED"); - assertThat(response.nextProcessStatus()).isEqualTo("WAIT_PALLET"); + assertThat(response.nextProcessStatus()).isEqualTo("LOADED"); verify(repository).supersedeCurrent(TENANT, 501L, "SENT"); - verify(repository).markInboundCalculated(TENANT, INBOUND, "WAIT_PALLET", "CBM", + verify(repository).markInboundCalculated(TENANT, INBOUND, "LOADED", "CBM", new BigDecimal("1.0000")); } @Test - @DisplayName("客户已确认时拒绝重新计算已发送费用") + @DisplayName("客户已确认且装柜完成时拒绝重新计算已发送费用") void rejectsRecalculationAfterCustomerConfirmation() { readyRepository(); InboundPricingEntity inbound = inbound("10", "1"); - inbound.setProcessStatus("WAIT_PALLET"); + inbound.setProcessStatus("LOADED"); inbound.setFeeStatus("SENT"); InboundFeeEntity sent = savedFee("SENT"); sent.setCustomerConfirmedAt(NOW); @@ -493,6 +461,7 @@ class FbaPricingServiceTest { inbound.setProcessStatus("WAIT_SPLIT"); inbound.setFeeStatus("SENT"); InboundFeeEntity current = savedFee("SENT"); + current.setCustomerConfirmedAt(NOW.minusHours(1)); AtomicReference inserted = new AtomicReference<>(); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current); @@ -501,7 +470,7 @@ class FbaPricingServiceTest { .when(repository).insertIdempotency(any()); when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2); when(repository.supersedeCurrent(TENANT, 501L, "SENT")).thenReturn(1); - when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("SENT"), eq("KG"), any())) + when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("WAIT_SPLIT"), eq("SENT"), eq("KG"), any())) .thenReturn(1); doAnswer(invocation -> { InboundFeeEntity fee = invocation.getArgument(0); @@ -561,10 +530,10 @@ class FbaPricingServiceTest { } @Test - @DisplayName("客户已确认费用时拒绝平台再次修改") + @DisplayName("客户已确认且装柜完成时拒绝平台再次修改") void rejectsAdjustmentAfterCustomerConfirmation() { InboundPricingEntity inbound = inbound("10", "1"); - inbound.setProcessStatus("WAIT_SPLIT"); + inbound.setProcessStatus("LOADED"); InboundFeeEntity current = savedFee("SENT"); current.setCustomerConfirmedAt(NOW); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); @@ -596,7 +565,7 @@ class FbaPricingServiceTest { .when(repository).insertIdempotency(any()); when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2); when(repository.supersedeCurrent(TENANT, 501L, "CALCULATED")).thenReturn(1); - when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("CALCULATED"), eq("KG"), any())) + when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("WAIT_SPLIT"), eq("CALCULATED"), eq("KG"), any())) .thenReturn(1); doAnswer(invocation -> { InboundFeeEntity fee = invocation.getArgument(0); @@ -655,7 +624,7 @@ class FbaPricingServiceTest { .when(repository).insertIdempotency(any()); when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2); when(repository.supersedeCurrent(TENANT, 501L, "CALCULATED")).thenReturn(1); - when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("CALCULATED"), eq("KG"), any())) + when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("WAIT_SPLIT"), eq("CALCULATED"), eq("KG"), any())) .thenReturn(1); doAnswer(invocation -> { ((InboundFeeEntity) invocation.getArgument(0)).setId(504L); return null; }) .when(repository).insertFee(any()); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java index 65b4a70dd..0036f2af1 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -11,12 +11,14 @@ import com.baiyue.firstleg.dto.MeasurementBatchCommand; import com.baiyue.firstleg.dto.InboundMeasurementResponses; import com.baiyue.firstleg.entity.InboundMeasurementEntity; import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; +import com.baiyue.firstleg.entity.InboundFeeEntity; import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.entity.InboundPalletAllocationEntity; import com.baiyue.firstleg.entity.InboundWeightResultEntity; import com.baiyue.firstleg.entity.InboundWeightRevisionEntity; import com.baiyue.firstleg.repository.InboundMeasurementRepository; import com.baiyue.firstleg.repository.InboundPalletAllocationRepository; +import com.baiyue.firstleg.repository.FbaPricingRepository; import jakarta.annotation.Resource; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -57,6 +59,7 @@ import static org.mockito.Mockito.when; class InboundMeasurementServiceTest { @MockBean private InboundMeasurementRepository repository; @MockBean private MeasurementBatchService batchService; + @MockBean private FbaPricingRepository pricingRepository; @MockBean private FbaMasterDataService fbaMasterDataService; @MockBean private OrderCargoClient orderCargoClient; @MockBean private MeasurementImageClient imageClient; @@ -90,6 +93,52 @@ class InboundMeasurementServiceTest { verify(repository, never()).listWeights(anyLong(), anyLong()); } + @Test + void updatesSubmittedCalculationStrategyWithoutReplacingMeasurementBatch() { + InboundOrderEntity inbound = inbound("MEASURE_PENDING"); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(30L); + measurement.setTenantId(1L); + measurement.setInboundId(10L); + measurement.setStatus("SUBMITTED"); + measurement.setVersion(5); + measurement.setTotalVolumeCbm(new BigDecimal("2.5000")); + measurement.setVolumeWeightKg(new BigDecimal("417.500")); + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setId(31L); + item.setTenantId(1L); + item.setMeasurementId(30L); + when(repository.lockInbound(1L, 10L)).thenReturn(inbound); + when(repository.lockMeasurement(1L, 10L, 30L)).thenReturn(measurement); + when(repository.listItems(1L, 30L)).thenReturn(List.of(item)); + when(repository.updateMeasurement(measurement)).thenReturn(true); + when(repository.updateInbound(inbound)).thenReturn(true); + + var response = service.updateCalculationStrategy(1L, 10L, + new InboundMeasurementRequests.UpdateCalculationStrategyRequest(30L, 5, "ADD", null)); + + assertThat(response.status()).isEqualTo("SUBMITTED"); + verify(batchService).recalculateStrategy(1L, measurement, List.of(item), "ADD", null); + verify(repository, never()).replaceItems(anyLong(), anyLong(), any()); + assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("2.5000"); + } + + @Test + void locksCalculationStrategyOnlyAfterCustomerConfirmationAndLoadingCompletion() { + InboundOrderEntity inbound = inbound("LOADED"); + InboundFeeEntity fee = new InboundFeeEntity(); + fee.setCustomerConfirmedAt(LocalDateTime.now()); + when(repository.lockInbound(1L, 10L)).thenReturn(inbound); + when(pricingRepository.findLatestFeeForUpdate(1L, 10L)).thenReturn(fee); + + assertThatThrownBy(() -> service.updateCalculationStrategy(1L, 10L, + new InboundMeasurementRequests.UpdateCalculationStrategyRequest(30L, 5, "ACTUAL", null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("客户已确认且该票已装柜完成"); + + verify(repository, never()).lockMeasurement(anyLong(), anyLong(), anyLong()); + } + @Test void correctingWeightKeepsOcrOriginalAndWritesImmutableRevision() { InboundOrderEntity inbound = inbound("MEASURING"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java index 4ba98e21f..6fd4e1f37 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java @@ -26,6 +26,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -97,6 +98,41 @@ class MeasurementBatchServiceTest { assertThat(items.getValue().get(0).getInboundDetailId()).isEqualTo(80001L); } + @Test + void recalculatesSubmittedItemsInPlaceWithoutChangingTheirIdentityOrStatus() { + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(100L); + measurement.setTenantId(1L); + measurement.setInboundId(10L); + measurement.setSourceType("MANUAL"); + measurement.setStatus("SUBMITTED"); + measurement.setVersion(4); + measurement.setCreatedBy(9L); + InboundMeasurementItemEntity existing = new InboundMeasurementItemEntity(); + existing.setId(200L); + existing.setTenantId(1L); + existing.setMeasurementId(100L); + existing.setInboundDetailId(80001L); + existing.setBoxCode("RK-001#1"); + existing.setItemNo("001"); + existing.setBoxSpecification("40x30x20cm"); + existing.setPackageCount(2); + existing.setLengthCm(new BigDecimal("40")); + existing.setWidthCm(new BigDecimal("30")); + existing.setHeightCm(new BigDecimal("20")); + when(repository.updateItem(existing)).thenReturn(true); + + service.recalculateStrategy(1L, measurement, List.of(existing), "ACTUAL", null); + + assertThat(existing.getId()).isEqualTo(200L); + assertThat(existing.getBoxCode()).isEqualTo("RK-001#1"); + assertThat(existing.getOuterLengthCm()).isEqualByComparingTo("40.000"); + assertThat(measurement.getStatus()).isEqualTo("SUBMITTED"); + assertThat(measurement.getTotalVolumeCbm()).isEqualByComparingTo("0.0480"); + verify(repository).updateItem(existing); + verify(repository, never()).replaceItems(anyLong(), anyLong(), any()); + } + @Test void automaticallyAppliesCustomerDefaultRuleToActualMeasurement() { CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity();