From 488c0318ffac6312d342ff3d9760fd31e739371a Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 16 Oct 2026 16:18:28 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MerchantWarehouseTransferController.java | 10 ++ .../PdaWarehouseTransferController.java | 7 ++ .../dto/WarehouseTransferRequests.java | 21 ++++- .../mapper/WarehouseTransferCartonMapper.java | 9 ++ .../TransferForecastRepository.java | 82 ++++++++++++++++ .../TransferMeasurementRepository.java | 16 ++++ .../WarehouseTransferForecastService.java | 59 ++++++++++++ .../service/WarehouseTransferService.java | 77 +++++++++++---- .../openapi/overseas-business-v1.yaml | 35 +++++-- .../openapi/overseas-pda-operations-v1.yaml | 1 + ...rchantWarehouseTransferControllerTest.java | 11 +++ .../WarehouseTransferCartonCountTest.java | 94 ++++++++++++++++--- .../service/WarehouseTransferServiceTest.java | 43 +++++++-- 13 files changed, 417 insertions(+), 48 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferForecastRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferMeasurementRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferForecastService.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java index de3ef32c6..35ba560dd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java @@ -27,6 +27,16 @@ import java.util.List; @RequestMapping("/api/merchant/v1/last-leg/warehouse-transfers") public class MerchantWarehouseTransferController { @Resource private WarehouseTransferService service; + @Resource private com.baiyue.lastleg.transfer.service.WarehouseTransferForecastService forecastService; + + /** 测量完成前追加预报箱唛,保留原编号和已测量内容。 */ + @PostMapping("/{transferNo}/increase-forecast-cartons") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRANSFER_CREATE + "')") + public ApiResponse increaseForecastCartons( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, + @Valid @RequestBody WarehouseTransferRequests.IncreaseForecastCartons request) { + return ApiResponse.success(forecastService.increase(customer(user), transferNo, operator(user), request)); + } @GetMapping("/inventory-source-cartons") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRANSFER_CREATE + "')") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/PdaWarehouseTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/PdaWarehouseTransferController.java index a7d10d98e..baa2a1707 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/PdaWarehouseTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/PdaWarehouseTransferController.java @@ -84,6 +84,13 @@ public class PdaWarehouseTransferController { return ApiResponse.success(service.pdaCartonLabel(identity.overseasWarehouseId(), transferNo)); } + @PostMapping("/{transferNo}/cartons/reopen") + public ApiResponse reopenMeasurement( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.reopenMeasurement(identity.overseasWarehouseId(), transferNo)); + } + @PostMapping("/{transferNo}/cartons/count") public ApiResponse countCartons( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java index eb97fc7e1..e9580c0da 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java @@ -105,10 +105,19 @@ public final class WarehouseTransferRequests { public record Outbound(@NotBlank @Size(max = 255) String idempotencyKey, @Size(max = 1000) String remark) {} + /** 仅增加既有SKU的预报箱数,携带原箱数用于并发核对及重试去重。 */ + public record IncreaseForecastCartons( + @Positive @Max(5000) int expectedPackageCount, + @NotNull Long transferItemId, + @Min(0) @Max(5000) int expectedItemPackageCount, + @Positive @Max(5000) int packageCount + ) {} + public record CountCartons( @NotBlank @Size(max = 255) String idempotencyKey, @NotEmpty @Size(max = 5000) List<@Valid @NotNull CountCarton> boxes, - @Size(max = 1000) String discrepancyReason + @Size(max = 1000) String discrepancyReason, + @NotNull @Positive Integer forecastPackageCount ) {} public record CountCarton( @@ -127,8 +136,14 @@ public final class WarehouseTransferRequests { public record Palletize( @NotBlank @Size(max = 255) String idempotencyKey, - @NotEmpty @Size(max = 20) List<@NotNull Long> photoFileIds - ) {} + @NotEmpty @Size(max = 20) List<@NotNull Long> photoFileIds, + @Size(max = 1000) String discrepancyReason + ) { + /** 无差异的既有调用不需要填写原因。 */ + public Palletize(String idempotencyKey, List photoFileIds) { + this(idempotencyKey, photoFileIds, null); + } + } public record PalletDraft( Long palletId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferCartonMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferCartonMapper.java index ae584e342..50e0884a5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferCartonMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferCartonMapper.java @@ -10,6 +10,15 @@ import java.util.List; @Mapper public interface WarehouseTransferCartonMapper extends BaseMapper { + /** 按客户及本单范围保存完成原因,允许清空重测前的差异说明。 */ + @org.apache.ibatis.annotations.Update("UPDATE ll_warehouse_transfer_carton SET discrepancy_reason=#{reason}, updated_at=CURRENT_TIMESTAMP WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId}") + int saveDiscrepancyReason(@Param("customerId") String customerId, @Param("transferOrderId") Long transferOrderId, @Param("reason") String reason); + + @org.apache.ibatis.annotations.Update("UPDATE ll_warehouse_transfer_carton SET counted_at=NULL, counted_by=NULL, count_idempotency_key=NULL, count_request_hash=NULL, updated_at=CURRENT_TIMESTAMP WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId}") + int reopenMeasurement(@Param("customerId") String customerId, @Param("transferOrderId") Long transferOrderId); + + @org.apache.ibatis.annotations.Update("UPDATE ll_warehouse_transfer_carton SET transfer_pallet_id=NULL, updated_at=CURRENT_TIMESTAMP WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} AND transfer_pallet_id=#{palletId}") + int unassignPallet(@Param("customerId") String customerId, @Param("transferOrderId") Long transferOrderId, @Param("palletId") Long palletId); @Select("SELECT * FROM ll_warehouse_transfer_carton WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} ORDER BY carton_index") List listOrder(@Param("customerId") String customerId, @Param("transferOrderId") Long transferOrderId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferForecastRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferForecastRepository.java new file mode 100644 index 000000000..0698295b2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferForecastRepository.java @@ -0,0 +1,82 @@ +package com.baiyue.lastleg.transfer.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baiyue.lastleg.transfer.entity.*; +import com.baiyue.lastleg.transfer.mapper.*; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegInboundOrder; +import com.baiyue.lastleg.entity.LastlegOrderDetail; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.mapper.*; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.List; + +/** 转运预报补箱的数据访问,统一保留原箱号及客户隔离。 */ +@Repository +public class TransferForecastRepository { + @Resource private WarehouseTransferOrderMapper transfers; + @Resource private WarehouseTransferItemMapper items; + @Resource private WarehouseTransferCartonMapper cartons; + @Resource private LastlegOrderMapper orders; + @Resource private LastlegInboundOrderMapper inboundOrders; + @Resource private LastlegOrderDetailMapper orderDetails; + @Resource private LastlegOrderStatusHistoryMapper histories; + @Resource private InboundPlanLineMapper planLines; + + /** 与PDA清点及完工锁定同一转运单,避免补箱与完工并发。 */ + public WarehouseTransferOrder lock(String customerId, String transferNo) { + return transfers.lockNo(customerId, transferNo); + } + + /** 按创建顺序读取预报明细,保持关联尾程明细的序号稳定。 */ + public List items(String customerId, Long transferId) { + return items.selectList(new LambdaQueryWrapper() + .eq(WarehouseTransferItem::getCustomerId, customerId) + .eq(WarehouseTransferItem::getTransferOrderId, transferId).orderByAsc(WarehouseTransferItem::getId)); + } + + /** 读取原箱号用于确定新增编号,不删除或重建原箱。 */ + public List cartons(String customerId, Long transferId) { + return cartons.listOrder(customerId, transferId); + } + + /** 追加预报箱并同步各预报口径;清点数据保留,整单清点确认失效。 */ + public void append(WarehouseTransferOrder transfer, WarehouseTransferItem item, int itemIndex, + List added, LocalDateTime now, String operatorId) { + String customerId = transfer.getCustomerId(); + var order = orders.selectOne(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getId, transfer.getLastlegOrderId())); + if (order == null) throw new IllegalStateException("转运关联尾程订单不存在"); + items.updateById(item); + transfers.updateById(transfer); + for (var carton : added) cartons.insert(carton); + cartons.update(null, new LambdaUpdateWrapper() + .eq(WarehouseTransferCarton::getCustomerId, customerId).eq(WarehouseTransferCarton::getTransferOrderId, transfer.getId()) + .set(WarehouseTransferCarton::getTotalCartons, transfer.getPackageCount()).set(WarehouseTransferCarton::getUpdatedAt, now)); + cartons.reopenMeasurement(customerId, transfer.getId()); + orders.update(null, new LambdaUpdateWrapper() + .eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getId, transfer.getLastlegOrderId()) + .set(LastlegOrder::getPackageCount, transfer.getPackageCount()).set(LastlegOrder::getUpdatedAt, now)); + inboundOrders.update(null, new LambdaUpdateWrapper() + .eq(LastlegInboundOrder::getCustomerId, customerId).eq(LastlegInboundOrder::getId, transfer.getInboundOrderId()) + .set(LastlegInboundOrder::getPackageCount, transfer.getPackageCount()).set(LastlegInboundOrder::getUpdatedAt, now)); + int updated = orderDetails.update(null, new LambdaUpdateWrapper() + .eq(LastlegOrderDetail::getCustomerId, customerId).eq(LastlegOrderDetail::getOrderId, transfer.getLastlegOrderId()) + .eq(LastlegOrderDetail::getSourceInboundDetailId, transfer.getTransferNo() + ":" + itemIndex) + .set(LastlegOrderDetail::getPackageCount, item.getPackageCount())); + if (updated != 1) throw new IllegalStateException("转运关联尾程货物明细不完整"); + planLines.update(null, new LambdaUpdateWrapper() + .eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getOrderId, transfer.getLastlegOrderId()) + .set(InboundPlanLine::getRequiredPackageCount, transfer.getPackageCount())); + var history = new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(transfer.getLastlegOrderId()); + history.setFromStatus(order.getCurrentStatus()); history.setToStatus(order.getCurrentStatus()); + history.setEventId(transfer.getTransferNo()); history.setCreatedAt(now); + history.setRemark("客户增加预报箱数,补充箱唛" + added.size() + "个;操作人:" + operatorId); + histories.insert(history); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferMeasurementRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferMeasurementRepository.java new file mode 100644 index 000000000..ff8ea0f62 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferMeasurementRepository.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.transfer.repository; + +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferCartonMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; + +/** 封装转运测量完成时的箱级差异说明持久化。 */ +@Repository +public class TransferMeasurementRepository { + @Resource private WarehouseTransferCartonMapper cartons; + + /** 整单统一保存原因,无差异时显式清空重测前的旧原因。 */ + public void saveDiscrepancyReason(String customerId, Long transferOrderId, String reason) { + cartons.saveDiscrepancyReason(customerId, transferOrderId, reason); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferForecastService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferForecastService.java new file mode 100644 index 000000000..710fa717a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferForecastService.java @@ -0,0 +1,59 @@ +package com.baiyue.lastleg.transfer.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests; +import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferCarton; +import com.baiyue.lastleg.transfer.repository.TransferForecastRepository; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Set; + +/** 测量完成前补充预报箱,只追加编号,不重建既有箱号。 */ +@Service +public class WarehouseTransferForecastService { + @Resource private TransferForecastRepository repository; + @Resource private WarehouseTransferService transfers; + + /** 锁单校验原预报快照,追加箱号并同步预报箱数,重复提交不重复生成。 */ + @Transactional + public WarehouseTransferResponses.Detail increase(String customerId, String transferNo, String operatorId, + WarehouseTransferRequests.IncreaseForecastCartons request) { + var transfer = repository.lock(customerId, transferNo); + if (transfer == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "转运单不存在"); + if (!transferNo.equals(transfer.getInboundCode()) || !Set.of("PENDING_REVIEW", "WAIT_PDA_RECEIVING").contains(transfer.getStatus())) + throw new ResponseStatusException(HttpStatus.CONFLICT, "仅测量完成前的外来转运单可增加预报箱数"); + var items = repository.items(customerId, transfer.getId()); + var item = items.stream().filter(row -> row.getId().equals(request.transferItemId())).findFirst() + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "SKU不属于当前转运单")); + int addedCount = request.packageCount() - request.expectedItemPackageCount(); + long total = (long) request.expectedPackageCount() + addedCount; + if (addedCount <= 0 || total > 5000 || request.expectedPackageCount() <= 0) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "只能增加预报箱数,整单最多5000箱"); + // 相同目标值视为已完成的重试;不同原快照则要求刷新,避免多端覆盖。 + if (item.getPackageCount() == request.packageCount() && transfer.getPackageCount() == total) + return transfers.detail(customerId, transferNo); + if (transfer.getPackageCount() != request.expectedPackageCount() || item.getPackageCount() != request.expectedItemPackageCount()) + throw new ResponseStatusException(HttpStatus.CONFLICT, "预报箱数已变化,请刷新后重试"); + var existing = repository.cartons(customerId, transfer.getId()); + int index = existing.stream().mapToInt(WarehouseTransferCarton::getCartonIndex).max().orElse(0); + LocalDateTime now = LocalDateTime.now(); + var added = new ArrayList(); + for (int i = 0; i < addedCount; i++) { + var carton = new WarehouseTransferCarton(); carton.setId(IdWorker.getId()); carton.setCustomerId(customerId); + carton.setTransferOrderId(transfer.getId()); carton.setTransferItemId(item.getId()); + carton.setCartonMark(LastlegOrderNoGenerator.compactCartonMark(carton.getId())); + carton.setCartonIndex(++index); carton.setTotalCartons((int) total); carton.setStatus("EXPECTED"); + carton.setCreatedAt(now); carton.setUpdatedAt(now); added.add(carton); + } + item.setPackageCount(request.packageCount()); transfer.setPackageCount((int) total); transfer.setUpdatedAt(now); + repository.append(transfer, item, items.indexOf(item), added, now, operatorId); + return transfers.detail(customerId, transferNo); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index 19431bedb..efa26edfd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -120,6 +120,7 @@ public class WarehouseTransferService { @Resource private WarehouseTransferOrderMapper transfers; @Resource private WarehouseTransferItemMapper transferItems; @Resource private WarehouseTransferCartonMapper transferCartons; + @Resource private com.baiyue.lastleg.transfer.repository.TransferMeasurementRepository transferMeasurementRepository; @Resource private WarehouseTransferSourceOrderMapper sourceOrders; @Resource private WarehouseTransferPalletMapper transferPallets; @Resource private WarehouseTransferPalletItemMapper transferPalletItems; @@ -697,13 +698,13 @@ public class WarehouseTransferService { return cartonLabel(transfer.getCustomerId(), transferNo); } + /** 提交预报后打印整单固定箱唛,测量及未收状态不改变箱号集合。 */ @Transactional(readOnly = true) public WarehouseTransferResponses.Label cartonLabel(String customerId, String transferNo) { WarehouseTransferOrder transfer = transfers.findNo(customerId, transferNo); if (transfer == null) throw notFound("Warehouse transfer order not found"); if (DRAFT.equals(transfer.getStatus())) throw conflict("Submit the warehouse transfer order before printing carton labels"); List cartons = transferCartons.listOrder(customerId, transfer.getId()).stream() - .filter(value -> !"NOT_RECEIVED".equals(value.getStatus())) .map(value -> new WarehouseTransferResponses.Carton(value.getCartonMark(), value.getCustomerCartonMark(), value.getCartonIndex(), value.getTotalCartons(), value.getStatus(), value.getTransferItemId(), value.getTransferPalletId(), value.getLengthCm(), value.getWidthCm(), value.getHeightCm(), value.getGrossWeightKg(), value.getCountedQuantity(), value.getDiscrepancyReason(), value.getCountedBy(), value.getCountedAt(), cartonContents(value))).toList(); StringBuilder labels = new StringBuilder(); for (WarehouseTransferResponses.Carton carton : cartons) { @@ -816,11 +817,24 @@ public class WarehouseTransferService { WarehouseTransferOrder transfer = requirePdaMeasuringTransfer(warehouseId, transferNo); WarehouseTransferPallet pallet = transferPallets.lockOne(transfer.getCustomerId(), transfer.getId(), palletId); if (pallet == null) return detail(transfer.getCustomerId(), transferNo); - if (!"DRAFT".equals(pallet.getStatus())) throw conflict("Generated pallet cannot be removed"); + if (!Set.of("DRAFT", "GENERATED", "BOUND").contains(pallet.getStatus())) throw conflict("当前托盘不能删除"); + if (pallet.getWarehouseResourceLockId() != null) { + warehouseResources.release(transfer.getCustomerId(), pallet.getWarehouseResourceLockId()); + } + transferCartons.unassignPallet(transfer.getCustomerId(), transfer.getId(), palletId); + transferCartons.reopenMeasurement(transfer.getCustomerId(), transfer.getId()); transferPallets.deleteById(palletId); return detail(transfer.getCustomerId(), transferNo); } + @Transactional + public WarehouseTransferResponses.Detail reopenMeasurement(long warehouseId, String transferNo) { + WarehouseTransferOrder transfer = requirePdaMeasuringTransfer(warehouseId, transferNo); + // Preserve dimensions, SKU contents and assignments; invalidate the previous count request. + transferCartons.reopenMeasurement(transfer.getCustomerId(), transfer.getId()); + return detail(transfer.getCustomerId(), transferNo); + } + @Transactional public WarehouseTransferResponses.Detail generatePallet(long warehouseId, String transferNo, long palletId, WarehouseTransferRequests.PalletAction request) { @@ -874,6 +888,7 @@ public class WarehouseTransferService { return detail(transfer.getCustomerId(), transferNo); } + /** 保存逐箱实测及托盘归属,按整托汇总体积并保留三位小数。 */ @Transactional public WarehouseTransferResponses.Detail countCartons(String operatorId, long warehouseId, String transferNo, WarehouseTransferRequests.CountCartons request) { @@ -881,6 +896,12 @@ public class WarehouseTransferService { if (transfer == null) throw notFound("当前仓没有该转运单"); String customerId = transfer.getCustomerId(); List cartons = transferCartons.listOrder(customerId, transfer.getId()); + // 客户补箱后旧设备快照不可确认短收,必须刷新取得新增的预报箱唛。 + if (!java.util.Objects.equals(request.forecastPackageCount(), transfer.getPackageCount())) + throw conflict("预报箱数已变化,请刷新后继续清点"); + // PDA只清点客户预报的固定箱唛;不足时由客户补充预报,禁止现场另造编号。 + if (request.boxes().stream().anyMatch(box -> !StringUtils.hasText(box.cartonMark()))) + throw badRequest("请先由客户增加预报箱数,刷新取得箱唛后再清点"); String requestHash = hash(request); boolean sameKey = cartons.stream().anyMatch(box -> request.idempotencyKey().equals(box.getCountIdempotencyKey())); if (sameKey) { @@ -891,8 +912,7 @@ public class WarehouseTransferService { if (!WAIT_PDA_RECEIVING.equals(transfer.getStatus())) throw conflict("转运单已结束入库测量"); if (cartons.stream().anyMatch(box -> box.getCountedAt() != null)) throw conflict("本单已完成清点,请刷新查看结果"); if (cartons.isEmpty()) throw conflict("转运单缺少箱号预报"); - if (request.boxes().size() != transfer.getPackageCount() && !StringUtils.hasText(request.discrepancyReason())) - throw badRequest("实收箱数与预报不一致时必须填写差异原因"); + // 清点阶段只记录实收事实,差异原因在最终完成测量时强制填写。 Map byMark = cartons.stream().collect(Collectors.toMap(WarehouseTransferCarton::getCartonMark, Function.identity())); Map byItem = transferItems.selectList(new LambdaQueryWrapper() .eq(WarehouseTransferItem::getCustomerId, customerId).eq(WarehouseTransferItem::getTransferOrderId, transfer.getId())) @@ -908,10 +928,9 @@ public class WarehouseTransferService { || !Set.of("GENERATED", "BOUND").contains(targetPallet.getStatus())) throw badRequest("请先创建本单托盘号,再选择箱子所属托盘"); var carton = byMark.get(input.cartonMark()); - if (StringUtils.hasText(input.cartonMark()) && (carton == null || !seen.add(input.cartonMark()))) + if (carton == null || !seen.add(input.cartonMark())) throw badRequest("箱号不属于本单或重复填写"); - if (carton == null && input.transferItemId() == null) throw badRequest("新增实收箱必须选择本单申报明细"); - var item = byItem.get(carton == null ? input.transferItemId() : carton.getTransferItemId()); + var item = byItem.get(carton.getTransferItemId()); if (item == null) throw badRequest("箱子申报明细不属于本单"); if (input.items() == null || input.items().isEmpty() || input.items().size() > 200) throw badRequest("每箱必须填写 1 至 200 行 SKU"); @@ -934,16 +953,8 @@ public class WarehouseTransferService { carton.setCountIdempotencyKey(request.idempotencyKey()); carton.setCountRequestHash(requestHash); carton.setDiscrepancyReason(trim(request.discrepancyReason())); carton.setUpdatedAt(now); transferCartons.updateById(carton); } - int nextIndex = cartons.stream().mapToInt(WarehouseTransferCarton::getCartonIndex).max().orElse(0); for (var input : request.boxes()) { var carton = byMark.get(input.cartonMark()); - boolean added = carton == null; - if (added) { - carton = new WarehouseTransferCarton(); carton.setId(IdWorker.getId()); - carton.setTransferOrderId(transfer.getId()); carton.setTransferItemId(input.transferItemId()); carton.setCustomerId(customerId); - carton.setCartonIndex(++nextIndex); carton.setTotalCartons(transfer.getPackageCount()); - carton.setCartonMark(LastlegOrderNoGenerator.compactCartonMark(carton.getId())); carton.setCreatedAt(now); - } carton.setTransferPalletId(input.transferPalletId()); carton.setLengthCm(input.length()); carton.setWidthCm(input.width()); carton.setHeightCm(input.height()); carton.setGrossWeightKg(input.weight()); @@ -957,14 +968,14 @@ public class WarehouseTransferService { carton.setCountedBy(operatorId); carton.setCountedAt(now); carton.setStatus("COUNTED"); carton.setCountIdempotencyKey(request.idempotencyKey()); carton.setCountRequestHash(requestHash); carton.setUpdatedAt(now); carton.setDiscrepancyReason(trim(request.discrepancyReason())); - if (added) transferCartons.insert(carton); else transferCartons.updateById(carton); + transferCartons.updateById(carton); } - // Aggregate only the submitted, physically assigned boxes; never infer SKU placement from declaration order. + // 仅汇总实际分配到本托的实收箱,体积在整托汇总后四舍五入保存三位,避免逐箱舍入累积误差。 for (var pallet : countedPallets) { var assigned = request.boxes().stream().filter(box -> pallet.getId().equals(box.transferPalletId())).toList(); pallet.setCartonCount(assigned.size()); pallet.setVolumeCbm(assigned.stream().map(box -> box.length().multiply(box.width()).multiply(box.height()) - .movePointLeft(6)).reduce(BigDecimal.ZERO, BigDecimal::add).setScale(6, java.math.RoundingMode.HALF_UP)); + .movePointLeft(6)).reduce(BigDecimal.ZERO, BigDecimal::add).setScale(3, java.math.RoundingMode.HALF_UP)); pallet.setGrossWeightKg(assigned.stream().map(WarehouseTransferRequests.CountCarton::weight).reduce(BigDecimal.ZERO, BigDecimal::add)); pallet.setUpdatedAt(now); transferPallets.updateById(pallet); } @@ -981,6 +992,26 @@ public class WarehouseTransferService { } } + /** 按 SKU 汇总预报及实收件数;混箱逐项累计,兼容原单 SKU 箱记录。 */ + boolean hasSkuReceiptDifference(List cartons, List items) { + Map expected = new java.util.HashMap<>(); + Map received = new java.util.HashMap<>(); + Map byId = items.stream().collect(Collectors.toMap(WarehouseTransferItem::getId, Function.identity())); + for (var item : items) expected.merge(item.getSkuCode(), item.getDeclaredQuantity() == null ? 0L : item.getDeclaredQuantity(), Long::sum); + for (var carton : cartons) { + var contents = cartonContents(carton); + if (contents.isEmpty()) { + var item = byId.get(carton.getTransferItemId()); + if (item == null) throw conflict("实收箱缺少有效SKU归属,请重新清点"); + received.merge(item.getSkuCode(), carton.getCountedQuantity() == null ? 0L : carton.getCountedQuantity(), Long::sum); + } else { + for (var line : contents) received.merge(line.skuCode(), line.quantity(), Long::sum); + } + } + return expected.entrySet().stream().anyMatch(entry -> !entry.getValue().equals(received.getOrDefault(entry.getKey(), 0L))) + || received.entrySet().stream().anyMatch(entry -> !entry.getValue().equals(expected.getOrDefault(entry.getKey(), 0L))); + } + private List requireCountedCartons(WarehouseTransferOrder transfer) { var cartons = transferCartons.listOrder(transfer.getCustomerId(), transfer.getId()); if (cartons.isEmpty() || cartons.stream().anyMatch(box -> box.getCountedAt() == null) @@ -1004,6 +1035,7 @@ public class WarehouseTransferService { } } + /** 核对清点、照片、托盘及差异说明,在同一事务内完成测量并入库。 */ @Transactional public WarehouseTransferResponses.Detail palletize(String operatorId, long overseasWarehouseId, String transferNo, @@ -1032,6 +1064,13 @@ public class WarehouseTransferService { if (items.isEmpty() || items.stream().mapToInt(WarehouseTransferItem::getPackageCount).sum() != transfer.getPackageCount()) { throw conflict("Warehouse transfer declared cargo is incomplete"); } + // 按每种 SKU 的件数核对实收,箱数变化不触发,不同 SKU 的差异不能抵消。 + boolean skuDifference = hasSkuReceiptDifference(countedCartons, items); + String discrepancyReason = skuDifference ? trim(request.discrepancyReason()) : null; + if (skuDifference && !StringUtils.hasText(discrepancyReason)) + throw badRequest("SKU实收件数与预报不一致时必须填写差异原因"); + if (discrepancyReason != null && discrepancyReason.length() > 1000) + throw badRequest("差异原因不能超过1000字"); if (request.photoFileIds() == null || request.photoFileIds().isEmpty() || request.photoFileIds().size() > 20) { throw conflict("Measurement and palletization require 1 to 20 on-site photos"); } @@ -1052,6 +1091,8 @@ public class WarehouseTransferService { List derivedAllocations = deriveCountedPalletAllocations(countedCartons, persistedPallets); LocalDateTime now = LocalDateTime.now(); List completedPalletItems = new ArrayList<>(); + // 与测量完成处于同一事务;短收箱与实收箱保持同一说明,供详情回显。 + transferMeasurementRepository.saveDiscrepancyReason(customerId, transfer.getId(), discrepancyReason); for (int index = 0; index < persistedPallets.size(); index++) { WarehouseTransferPallet pallet = persistedPallets.get(index); for (DerivedPalletAllocation allocation : derivedAllocations) { diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index cf682fd24..740f02964 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -4,10 +4,30 @@ info: description: 派送资料完整契约见同目录 delivery-documents-v1.yaml;派送列表/详情行返回 bolNo、documentSummary(三类状态与x/3),状态仍使用 version 和 allowedActions。 version: 1.0.0 paths: + /api/merchant/v1/last-leg/warehouse-transfers/{transferNo}/increase-forecast-cartons: + post: + summary: 测量完成前增加预报箱数并补充箱唛 + description: 客户身份及transfer:create权限;仅外来货物待审核或待PDA测量可用,按原预报快照追加既有SKU的箱数,原箱唛与件数不变。补箱及PDA完工锁定同一订单;成功后PDA刷新继续清点,无新增表字段。 + parameters: + - {name: transferNo, in: path, required: true, schema: {type: string}} + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [expectedPackageCount, transferItemId, expectedItemPackageCount, packageCount] + properties: + expectedPackageCount: {type: integer, minimum: 1, maximum: 5000, description: 修改前整单预报箱数} + transferItemId: {type: integer, format: int64, description: 当前单既有SKU明细} + expectedItemPackageCount: {type: integer, minimum: 0, maximum: 5000, description: 修改前该SKU预报箱数} + packageCount: {type: integer, minimum: 1, maximum: 5000, description: 增加后该SKU预报箱数} + responses: + '200': {description: 返回补箱后的转运详情,相同原快照和目标箱数重试不重复生成箱唛} /api/pda/v1/last-leg/warehouse-transfers/{transferNo}/carton-label: get: summary: PDA读取转运箱标签 - description: 需要 lastleg:pda:operate 与 OVERSEAS_WAREHOUSE_PDA_OPERATOR;只可读取Token绑定仓的转运单标签,包含新增实收箱号。 + description: 需要 lastleg:pda:operate 与 OVERSEAS_WAREHOUSE_PDA_OPERATOR;只可读取Token绑定仓的转运单标签,包含客户预报及后续补充预报的箱唛。 parameters: - {name: transferNo, in: path, required: true, schema: {type: string}} responses: @@ -33,7 +53,7 @@ paths: /api/pda/v1/last-leg/warehouse-transfers/{transferNo}/cartons/count: post: summary: 转运逐箱测量与清点 - description: 仅限 lastleg:pda:operate 与 OVERSEAS_WAREHOUSE_PDA_OPERATOR,绑定仓由统一Token确定。先创建本单托盘号,每箱选择所属托盘,可包含多个本单预报SKU,逐项记录实点件数;同箱SKU不可重复,尺寸重量和实收箱数按实物箱计算一次,托盘体积重量由分配箱子汇总,全部实收箱子测量清点后才可完成打托入库,打托箱数与已清点实收箱数核平;短收箱保留未收到事实,超收生成系统箱号;差异需原因。事务锁定转运单;原键原载荷重试不重复写入,同键不同载荷拒绝,已确认事实不可覆盖。 + description: 仅限 lastleg:pda:operate 与 OVERSEAS_WAREHOUSE_PDA_OPERATOR,绑定仓由统一Token确定。先创建本单托盘号,每箱选择所属托盘,可包含多个本单预报SKU,逐项记录实点件数;同箱SKU不可重复,尺寸重量和实收箱数按实物箱计算一次,托盘体积重量由分配箱子汇总,全部实收箱子测量清点后才可完成打托入库,打托箱数与已清点实收箱数核平;短收箱保留未收到事实,额外箱须客户先增加预报箱数补生成箱唛;清点允许暂不填写差异原因,最终完成测量时仅SKU实收件数有差异才必填原因。事务锁定转运单;原键原载荷重试不重复写入,同键不同载荷拒绝,已确认事实不可覆盖。 parameters: - {name: transferNo, in: path, required: true, schema: {type: string}} requestBody: @@ -42,20 +62,21 @@ paths: application/json: schema: type: object - required: [idempotencyKey, boxes] + required: [idempotencyKey, boxes, forecastPackageCount] properties: idempotencyKey: {type: string, maxLength: 255} - discrepancyReason: {type: string, maxLength: 1000, description: 短收超收时必填差异原因} + forecastPackageCount: {type: integer, minimum: 1, description: 设备读取的预报箱数,补箱后旧快照须刷新} + discrepancyReason: {type: string, maxLength: 1000, description: 清点阶段可选,最终完成测量时SKU实收件数有差异必填原因} boxes: type: array minItems: 1 maxItems: 5000 items: type: object - required: [transferPalletId, length, width, height, weight, items] + required: [cartonMark, transferPalletId, length, width, height, weight, items] properties: - cartonMark: {type: string, maxLength: 255, nullable: true, description: 已有箱号;新增超收箱不传,由服务端生成} - transferItemId: {type: integer, format: int64, nullable: true, description: 新增超收箱所属本单申报明细} + cartonMark: {type: string, maxLength: 255, description: 必须为当前单预报中已存在的仓库唛头} + transferItemId: {type: integer, format: int64, nullable: true, description: 箱子所属本单申报明细} transferPalletId: {type: integer, format: int64, description: 本单已生成的转运托盘事实ID,逐箱关联,托盘箱数体积重量由箱子汇总} length: {type: number, minimum: 0.01, description: 箱长厘米} width: {type: number, minimum: 0.01, description: 箱宽厘米} diff --git a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml index 425cb982d..06561c1e4 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml @@ -587,6 +587,7 @@ components: required: [idempotencyKey, photoFileIds] properties: idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + discrepancyReason: { type: string, maxLength: 1000, nullable: true, description: 任一SKU实收件数与预报不同时完成测量必填,原因随完成事务保存 } photoFileIds: type: array minItems: 1 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java index ef731e388..87425575d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java @@ -15,6 +15,7 @@ import static org.mockito.Mockito.verify; class MerchantWarehouseTransferControllerTest { private final WarehouseTransferService service = mock(WarehouseTransferService.class); + private final com.baiyue.lastleg.transfer.service.WarehouseTransferForecastService forecastService = mock(com.baiyue.lastleg.transfer.service.WarehouseTransferForecastService.class); private final MerchantWarehouseTransferController controller = controller(); private final WarehouseTransferRequests.Query query = new WarehouseTransferRequests.Query( 1, 20, null, null, null, null, null, null, null, null); @@ -50,6 +51,15 @@ class MerchantWarehouseTransferControllerTest { verify(service).cartonLabel("100", "TR001"); } + /** 补预报只使用登录客户身份,平台身份不可越权操作。 */ + @Test + void forecastIncreaseUsesAuthenticatedCustomerAndRejectsPlatform() { + var request = new WarehouseTransferRequests.IncreaseForecastCartons(2,9L,2,3); + controller.increaseForecastCartons(user(100L,"MERCHANT"),"TR001",request); + verify(forecastService).increase("100","TR001","1",request); + assertThrows(ResponseStatusException.class,()->controller.increaseForecastCartons(user(0L,"PLATFORM"),"TR001",request)); + } + @Test void merchantEndpointRejectsPlatformIdentity() { assertThrows(ResponseStatusException.class, @@ -63,6 +73,7 @@ class MerchantWarehouseTransferControllerTest { private MerchantWarehouseTransferController controller() { MerchantWarehouseTransferController controller = new MerchantWarehouseTransferController(); ReflectionTestUtils.setField(controller, "service", service); + ReflectionTestUtils.setField(controller, "forecastService", forecastService); return controller; } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java index 9f3329051..c9de1402a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java @@ -37,26 +37,61 @@ class WarehouseTransferCartonCountTest { a = box("BOX1", 10L); b = box("BOX2", 11L); when(orders.lockForPda("WT1", "77")).thenReturn(order); when(cartons.listOrder("C1", 1L)).thenReturn(List.of(a,b)); - var ia = new WarehouseTransferItem(); ia.setId(10L); ia.setSkuCode("A"); - var ib = new WarehouseTransferItem(); ib.setId(11L); ib.setSkuCode("B"); + var ia = new WarehouseTransferItem(); ia.setId(10L); ia.setSkuCode("A"); ia.setPackageCount(1); ia.setDeclaredQuantity(4L); + var ib = new WarehouseTransferItem(); ib.setId(11L); ib.setSkuCode("B"); ib.setPackageCount(1); ib.setDeclaredQuantity(0L); when(items.selectList(any())).thenReturn(List.of(ia,ib)); } WarehouseTransferCarton box(String mark, long item) { var box = new WarehouseTransferCarton(); box.setCartonMark(mark); box.setCartonIndex((int)item); box.setTransferItemId(item); return box; } WarehouseTransferRequests.CountCarton input(String mark, String sku, long quantity) { return new WarehouseTransferRequests.CountCarton(mark, null, 200L, new BigDecimal("20"),new BigDecimal("30"),new BigDecimal("40"),new BigDecimal("2.5"),List.of(new WarehouseTransferRequests.CountSku(sku,quantity))); } - WarehouseTransferRequests.CountCartons request(String key, WarehouseTransferRequests.CountCarton... rows) { return new WarehouseTransferRequests.CountCartons(key,List.of(rows),null); } + WarehouseTransferRequests.CountCartons request(String key, WarehouseTransferRequests.CountCarton... rows) { return new WarehouseTransferRequests.CountCartons(key,List.of(rows),null,2); } @Test void multiSkuBoxesPersistDimensionsPiecesAndReplayWithoutWriting() { var request = request("K1",input("BOX1","A",12),input("BOX2","B",7)); service.countCartons("OP1",77L,"WT1",request); assertEquals(12L,a.getCountedQuantity()); assertEquals(7L,b.getCountedQuantity()); assertNotNull(a.getCountedAt()); assertEquals("OP1",a.getCountedBy()); assertEquals(new BigDecimal("2.5"),b.getGrossWeightKg()); assertEquals(200L,a.getTransferPalletId()); assertEquals(2,pallet.getCartonCount()); - assertEquals(new BigDecimal("0.048000"),pallet.getVolumeCbm()); assertEquals(new BigDecimal("5.0"),pallet.getGrossWeightKg()); + assertEquals(new BigDecimal("0.048"),pallet.getVolumeCbm()); assertEquals(new BigDecimal("5.0"),pallet.getGrossWeightKg()); clearInvocations(cartons); order.setStatus("WAIT_CUSTOMER_CONFIRMATION"); service.countCartons("OP1",77L,"WT1",request); verify(cartons,never()).updateById(any(WarehouseTransferCarton.class)); assertThrows(ResponseStatusException.class,()->service.countCartons("OP1",77L,"WT1",request("K1",input("BOX1","A",13),input("BOX2","B",7)))); } + /** 整托汇总后再保留三位,不能把每箱先舍入后累加。 */ + @Test void volumeRoundsOnceAfterSummingBoxes() { + var first = new WarehouseTransferRequests.CountCarton("BOX1", null, 200L, + new BigDecimal("10"), new BigDecimal("10"), new BigDecimal("6"), new BigDecimal("2.5"), + List.of(new WarehouseTransferRequests.CountSku("A", 3))); + var second = new WarehouseTransferRequests.CountCarton("BOX2", null, 200L, + new BigDecimal("10"), new BigDecimal("10"), new BigDecimal("6"), new BigDecimal("2.5"), + List.of(new WarehouseTransferRequests.CountSku("B", 4))); + service.countCartons("OP1", 77L, "WT1", request("ROUND", first, second)); + assertEquals(new BigDecimal("0.001"), pallet.getVolumeCbm()); + assertEquals(new BigDecimal("6"), a.getHeightCm()); + assertEquals(new BigDecimal("5.0"), pallet.getGrossWeightKg()); + } + + @Test void remeasurementInvalidatesCountAndRejectsCompletedOrForeignTransfers() { + service.reopenMeasurement(77L, "WT1"); + verify(cartons).reopenMeasurement("C1", 1L); + assertThrows(ResponseStatusException.class, () -> service.reopenMeasurement(88L, "WT1")); + order.setStatus("WAIT_CUSTOMER_CONFIRMATION"); + assertThrows(ResponseStatusException.class, () -> service.reopenMeasurement(77L, "WT1")); + verify(cartons, times(1)).reopenMeasurement("C1", 1L); + } + + @Test void deletingBoundPalletUnassignsBoxesAndReleasesResource() { + var resources = mock(com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway.class); + ReflectionTestUtils.setField(service, "warehouseResources", resources); + pallet.setStatus("BOUND"); pallet.setWarehouseResourceLockId(901L); + when(palletMapper.lockOne("C1", 1L, 200L)).thenReturn(pallet); + service.deletePalletDraft(77L, "WT1", 200L); + verify(resources).release("C1", 901L); + verify(cartons).unassignPallet("C1", 1L, 200L); + verify(cartons).reopenMeasurement("C1", 1L); + verify(palletMapper).deleteById(200L); + } + @Test void missingDuplicateForeignSkuAndInvalidQuantityRejectBeforeWrites() { - var invalid = List.of(request("K",input("BOX1","A",1)),request("K",input("BOX1","A",1),input("BOX1","A",2)),request("K",input("OTHER","A",1),input("BOX2","B",2)),request("K",input("BOX1","FOREIGN",1),input("BOX2","B",2)),request("K",input("BOX1","A",0),input("BOX2","B",2))); + var invalid = List.of(request("K",input("BOX1","A",1),input("BOX1","A",2)),request("K",input("OTHER","A",1),input("BOX2","B",2)),request("K",input("BOX1","FOREIGN",1),input("BOX2","B",2)),request("K",input("BOX1","A",0),input("BOX2","B",2))); for(var request:invalid) assertThrows(ResponseStatusException.class,()->service.countCartons("OP1",77L,"WT1",request)); verify(cartons,never()).updateById(any(WarehouseTransferCarton.class)); } @@ -64,14 +99,14 @@ class WarehouseTransferCartonCountTest { var mixed = new WarehouseTransferRequests.CountCarton("BOX1", null, 200L, new BigDecimal("20"),new BigDecimal("30"),new BigDecimal("40"),new BigDecimal("2.5"), List.of(new WarehouseTransferRequests.CountSku("A",3),new WarehouseTransferRequests.CountSku("B",7))); - var request = new WarehouseTransferRequests.CountCartons("MIX",List.of(mixed),"两种SKU装一箱"); + var request = new WarehouseTransferRequests.CountCartons("MIX",List.of(mixed),"两种SKU装一箱",2); service.countCartons("OP1",77L,"WT1",request); assertEquals(10L,a.getCountedQuantity()); var contents = new ObjectMapper().readTree(a.getSkuItemsJson()); assertEquals(2,contents.size()); assertEquals("A",contents.get(0).get("skuCode").asText()); assertEquals(7,contents.get(1).get("quantity").asInt()); assertEquals(11L,contents.get(1).get("transferItemId").asLong()); - assertEquals(1,pallet.getCartonCount()); assertEquals(new BigDecimal("0.024000"),pallet.getVolumeCbm()); + assertEquals(1,pallet.getCartonCount()); assertEquals(new BigDecimal("0.024"),pallet.getVolumeCbm()); assertEquals(new BigDecimal("2.5"),pallet.getGrossWeightKg()); var allocation = WarehouseTransferService.deriveCountedPalletAllocations(List.of(a),List.of(pallet)); assertEquals(1,allocation.size()); assertEquals(1,allocation.get(0).packageCount()); @@ -83,7 +118,7 @@ class WarehouseTransferCartonCountTest { var changed = new WarehouseTransferRequests.CountCarton("BOX1",null,200L,mixed.length(),mixed.width(),mixed.height(),mixed.weight(), List.of(new WarehouseTransferRequests.CountSku("A",4),new WarehouseTransferRequests.CountSku("B",6))); assertThrows(ResponseStatusException.class,()->service.countCartons("OP1",77L,"WT1", - new WarehouseTransferRequests.CountCartons("MIX",List.of(changed),"两种SKU装一箱"))); + new WarehouseTransferRequests.CountCartons("MIX",List.of(changed),"两种SKU装一箱",2))); } @Test void duplicateUnknownEmptyAndInvalidSkuLinesRejectBeforeAnyWrites() { var invalidLines = List.of(List.of(), @@ -98,17 +133,48 @@ class WarehouseTransferCartonCountTest { verify(cartons,never()).updateById(any(WarehouseTransferCarton.class)); } @Test void shortReceiptNeedsReasonAndPreservesMissingBoxFact() { - var request = new WarehouseTransferRequests.CountCartons("SHORT",List.of(input("BOX1","A",3)),"少到一箱"); + var request = new WarehouseTransferRequests.CountCartons("SHORT",List.of(input("BOX1","A",3)),"少到一箱",2); service.countCartons("OP1",77L,"WT1",request); assertEquals("COUNTED",a.getStatus()); assertEquals("NOT_RECEIVED",b.getStatus()); assertEquals("少到一箱",b.getDiscrepancyReason()); assertNull(b.getCountedQuantity()); } - @Test void extraReceiptGeneratesOwnedBoxAndReconcilesToDeclaredSku() { + /** 短收可先清点,最终完成必须另行提供非空原因。 */ + @Test void shortReceiptCanCountWithoutReasonButCannotComplete() { + service.countCartons("OP1",77L,"WT1",request("SHORT-NO-REASON",input("BOX1","A",3))); + assertEquals("COUNTED", a.getStatus()); assertEquals("NOT_RECEIVED", b.getStatus()); + order.setOperatingWarehouseId("77"); + clearInvocations(cartons); + for (String reason : new String[] {"", " "}) { + var error = assertThrows(ResponseStatusException.class, () -> service.palletize("OP1",77L,"WT1", + new WarehouseTransferRequests.Palletize("DONE",List.of(9001L),reason))); + assertTrue(error.getReason().contains("必须填写差异原因")); + } + verify(cartons,never()).updateById(any(WarehouseTransferCarton.class)); + assertEquals("WAIT_PDA_RECEIVING", order.getStatus()); + } + /** 相同总件数下各SKU一短一超仍有差异,改变箱数但SKU核平则无差异。 */ + @Test void skuReceiptDifferenceDoesNotNetDifferentSkusOrUseBoxCount() { + var ia = new WarehouseTransferItem(); ia.setId(10L); ia.setSkuCode("A"); ia.setDeclaredQuantity(3L); + var ib = new WarehouseTransferItem(); ib.setId(11L); ib.setSkuCode("B"); ib.setDeclaredQuantity(7L); + a.setSkuItemsJson("[{\"transferItemId\":10,\"skuCode\":\"A\",\"quantity\":2},{\"transferItemId\":11,\"skuCode\":\"B\",\"quantity\":8}]"); + assertTrue(service.hasSkuReceiptDifference(List.of(a),List.of(ia,ib))); + a.setSkuItemsJson("[{\"transferItemId\":10,\"skuCode\":\"A\",\"quantity\":3},{\"transferItemId\":11,\"skuCode\":\"B\",\"quantity\":7}]"); + assertFalse(service.hasSkuReceiptDifference(List.of(a),List.of(ia,ib))); + ia.setDeclaredQuantity(4L); + assertTrue(service.hasSkuReceiptDifference(List.of(a),List.of(ia,ib))); + } + /** 新箱必须先补客户预报,PDA不能绕过预报生成另一套箱号。 */ + @Test void extraReceiptRequiresForecastCartonFirst() { var extra = new WarehouseTransferRequests.CountCarton(null,11L,200L,new BigDecimal("10"),new BigDecimal("20"),new BigDecimal("30"),BigDecimal.ONE,List.of(new WarehouseTransferRequests.CountSku("B",4))); - service.countCartons("OP1",77L,"WT1",new WarehouseTransferRequests.CountCartons("EXTRA",List.of(input("BOX1","A",1),input("BOX2","B",2),extra),"多到一箱")); - var captured = org.mockito.ArgumentCaptor.forClass(WarehouseTransferCarton.class); - verify(cartons).insert(captured.capture()); - assertEquals("WT1#EX-12",captured.getValue().getCartonMark()); assertEquals("C1",captured.getValue().getCustomerId()); assertEquals(4L,captured.getValue().getCountedQuantity()); + assertThrows(ResponseStatusException.class, () -> service.countCartons("OP1",77L,"WT1",new WarehouseTransferRequests.CountCartons("EXTRA",List.of(input("BOX1","A",1),input("BOX2","B",2),extra),null,2))); + verify(cartons,never()).insert(any(WarehouseTransferCarton.class)); + } + /** 旧预报版本不能把客户刚新增的箱唛标为未收到。 */ + @Test void staleForecastCannotCount() { + order.setPackageCount(3); + var error = assertThrows(ResponseStatusException.class, () -> service.countCartons("OP1",77L,"WT1",request("STALE",input("BOX1","A",1)))); + assertTrue(error.getReason().contains("预报箱数已变化")); + verify(cartons,never()).updateById(any(WarehouseTransferCarton.class)); } @Test void cartonsRequireGeneratedPalletOwnedByThisOrder() { when(palletMapper.listOrder("C1",1L)).thenReturn(List.of()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index 916557722..7619025d3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -208,6 +208,9 @@ class WarehouseTransferServiceTest { void setUp() { compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.CompactBusinessNumberService.class); service = new WarehouseTransferService(); + var measurementRepository = new com.baiyue.lastleg.transfer.repository.TransferMeasurementRepository(); + ReflectionTestUtils.setField(measurementRepository, "cartons", transferCartons); + ReflectionTestUtils.setField(service, "transferMeasurementRepository", measurementRepository); ReflectionTestUtils.setField(service, "compactNumbers", compactNumbers); org.mockito.Mockito.lenient().when(compactNumbers.next(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString())).thenAnswer(call -> call.getArgument(0) + "-CS-261016102030"); ReflectionTestUtils.setField(service, "customerDirectory", customerDirectory); @@ -327,7 +330,7 @@ class WarehouseTransferServiceTest { transfer.setOperatingWarehouseId("77"); WarehouseTransferPallet pallet = measuredPallet("GENERATED"); var counted = java.util.stream.IntStream.rangeClosed(1, 5).mapToObj(index -> { - var box = new WarehouseTransferCarton(); box.setStatus("COUNTED"); box.setCountedAt(LocalDateTime.now()); return box; + var box = new WarehouseTransferCarton(); box.setTransferItemId(51L); box.setStatus("COUNTED"); box.setCountedAt(LocalDateTime.now()); return box; }).toList(); when(transferCartons.listOrder("TENANT-A", transfer.getId())).thenReturn(counted); when(transfers.lockForPda("WTR-1", "77")).thenReturn(transfer); @@ -344,13 +347,16 @@ class WarehouseTransferServiceTest { verify(warehouseResources, never()).ensurePalletBatch(any()); } - @Test - void palletizeCompletesAfterEveryPalletIsBoundAndDoesNotGenerateAgain() { + /** 有无短收均可完成,差异原因应随完成事务保存并支持幂等重放。 */ + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void palletizeCompletesAfterEveryPalletIsBoundAndDoesNotGenerateAgain(boolean discrepancy) { WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING); transfer.setOperatingWarehouseId("77"); transfer.setInboundCode("WTR-1"); WarehouseTransferPallet pallet = measuredPallet("BOUND"); WarehouseTransferItem item = transferItem(51L, "SKU-1", 5); + transfer.setPackageCount(6); item.setPackageCount(6); item.setDeclaredQuantity(discrepancy ? 6L : 5L); LastlegOrder tail = tailOrder(); LastlegInboundOrder inbound = new LastlegInboundOrder(); inbound.setId(transfer.getInboundOrderId()); inbound.setCustomerId("TENANT-A"); @@ -375,11 +381,21 @@ class WarehouseTransferServiceTest { when(sourceOrders.selectList(any())).thenReturn(List.of()); var counted = java.util.stream.IntStream.rangeClosed(1, 5).mapToObj(index -> { - var box = new WarehouseTransferCarton(); box.setTransferPalletId(pallet.getId()); box.setTransferItemId(item.getId()); box.setCartonIndex(index); box.setTotalCartons(5); box.setCartonMark("BOX-" + index); box.setStatus("COUNTED"); box.setCountedAt(LocalDateTime.now()); return box; + var box = new WarehouseTransferCarton(); box.setTransferPalletId(pallet.getId()); box.setTransferItemId(item.getId()); box.setCartonIndex(index); box.setTotalCartons(5); box.setCountedQuantity(1L); box.setCartonMark("BOX-" + index); box.setStatus("COUNTED"); box.setCountedAt(LocalDateTime.now()); return box; }).toList(); when(transferCartons.listOrder("TENANT-A", transfer.getId())).thenReturn(counted); - var result = service.palletize("OP-1", 77L, "WTR-1", - new WarehouseTransferRequests.Palletize("DONE-1", List.of(9001L))); + var request = new WarehouseTransferRequests.Palletize("DONE-1", List.of(9001L), discrepancy ? " 客户少发一箱 " : null); + when(transferCartons.saveDiscrepancyReason(eq("TENANT-A"), eq(transfer.getId()), any())).thenAnswer(call -> { + counted.forEach(box -> box.setDiscrepancyReason(call.getArgument(2))); + return counted.size(); + }); + var result = service.palletize("OP-1", 77L, "WTR-1", request); + assertTrue(counted.stream().allMatch(box -> java.util.Objects.equals(discrepancy ? "客户少发一箱" : null, box.getDiscrepancyReason()))); + assertTrue(result.cartons().stream().allMatch(box -> java.util.Objects.equals(discrepancy ? "客户少发一箱" : null, box.discrepancyReason()))); + clearInvocations(transferCartons); + service.palletize("OP-1", 77L, "WTR-1", request); + verify(transferCartons, never()).updateById(any(WarehouseTransferCarton.class)); + verify(transferCartons, never()).saveDiscrepancyReason(anyString(), anyLong(), any()); assertEquals(WarehouseTransferService.WAIT_CUSTOMER_CONFIRMATION, result.status()); assertEquals("COMPLETED", pallet.getStatus()); @@ -787,6 +803,21 @@ class WarehouseTransferServiceTest { verify(transferCartons, never()).listOrder(anyString(), anyLong()); } + /** 提交预报后即可打印全部预报箱唛,不依赖测量或照片完成。 */ + @Test + void submittedForecastLabelsArePrintableBeforeCounting() { + var transfer = transfer(WarehouseTransferService.PENDING_REVIEW); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + var box = new WarehouseTransferCarton(); box.setCartonMark("FORECAST-BOX"); box.setCartonIndex(1); box.setTotalCartons(2); box.setStatus("EXPECTED"); + var missing = new WarehouseTransferCarton(); missing.setCartonMark("FORECAST-BOX-2"); missing.setCartonIndex(2); missing.setTotalCartons(2); missing.setStatus("NOT_RECEIVED"); + when(transferCartons.listOrder("TENANT-A", transfer.getId())).thenReturn(List.of(box, missing)); + var label = service.cartonLabel("TENANT-A", "WTR-1"); + assertEquals(2, label.cartons().size()); + assertTrue(label.printableHtml().contains("FORECAST-BOX")); + assertNull(box.getCountedAt()); + verify(inventory, never()).stockIn(anyString(), anyString(), any()); + } + @Test void rejectionRequiresNonBlankReasonBeforeAnyStateOrInventoryChange() { for (String decision : List.of("REJECT", "REJECTED")) { From ecded9e58b24606b62dcc8efa2da1dc9dfb04376 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 16 Oct 2026 16:18:39 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WarehouseTransferForecastServiceTest.java | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferForecastServiceTest.java diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferForecastServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferForecastServiceTest.java new file mode 100644 index 000000000..737881332 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferForecastServiceTest.java @@ -0,0 +1,56 @@ +package com.baiyue.lastleg.transfer.service; + +import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests; +import com.baiyue.lastleg.transfer.entity.*; +import com.baiyue.lastleg.transfer.repository.TransferForecastRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class WarehouseTransferForecastServiceTest { + final TransferForecastRepository repository = mock(TransferForecastRepository.class); + final WarehouseTransferService transfers = mock(WarehouseTransferService.class); + final WarehouseTransferForecastService service = new WarehouseTransferForecastService(); + WarehouseTransferOrder order; + WarehouseTransferItem item; + WarehouseTransferCarton carton; + + /** 创建已有箱唛及清点数据,补箱不得重建原记录。 */ + @BeforeEach void setup() { + ReflectionTestUtils.setField(service,"repository",repository); ReflectionTestUtils.setField(service,"transfers",transfers); + order = new WarehouseTransferOrder(); order.setId(1L); order.setCustomerId("C"); order.setTransferNo("WT1"); order.setInboundCode("WT1"); order.setStatus("WAIT_PDA_RECEIVING"); order.setPackageCount(2); + item = new WarehouseTransferItem(); item.setId(2L); item.setPackageCount(2); item.setDeclaredQuantity(10L); + carton = new WarehouseTransferCarton(); carton.setCartonIndex(2); carton.setCartonMark("ORIGINAL"); carton.setStatus("COUNTED"); + when(repository.lock("C","WT1")).thenReturn(order); when(repository.items("C",1L)).thenReturn(List.of(item)); + } + + /** 补箱追加编号,预报件数与原箱号保持不变,相同快照重放不重复增加。 */ + @Test void appendPreservesMarksAndQuantityAndReplays() { + when(repository.cartons("C",1L)).thenReturn(List.of(carton)); + var request = new WarehouseTransferRequests.IncreaseForecastCartons(2,2L,2,4); + service.increase("C","WT1","OP",request); + var added = org.mockito.ArgumentCaptor.forClass(List.class); + verify(repository).append(eq(order),eq(item),eq(0),added.capture(),any(),eq("OP")); + var rows = (List) added.getValue(); + assertEquals(2,rows.size()); assertEquals(3,rows.get(0).getCartonIndex()); assertEquals(4,rows.get(1).getCartonIndex()); + assertNotEquals(rows.get(0).getCartonMark(),rows.get(1).getCartonMark()); assertEquals("ORIGINAL",carton.getCartonMark()); + assertEquals(10L,item.getDeclaredQuantity()); assertEquals(4,order.getPackageCount()); + clearInvocations(repository); service.increase("C","WT1","OP",request); + verify(repository,never()).append(any(),any(),anyInt(),anyList(),any(),anyString()); + } + + /** 不允许完工后、跨单SKU、旧快照或减少箱数。 */ + @Test void rejectsCompletedForeignStaleAndDecreasing() { + order.setStatus("WAIT_CUSTOMER_CONFIRMATION"); + assertThrows(ResponseStatusException.class,()->service.increase("C","WT1","OP",new WarehouseTransferRequests.IncreaseForecastCartons(2,2L,2,3))); + order.setStatus("WAIT_PDA_RECEIVING"); + for(var request:List.of(new WarehouseTransferRequests.IncreaseForecastCartons(2,99L,2,3),new WarehouseTransferRequests.IncreaseForecastCartons(1,2L,2,3),new WarehouseTransferRequests.IncreaseForecastCartons(2,2L,2,1))) + assertThrows(ResponseStatusException.class,()->service.increase("C","WT1","OP",request)); + verify(repository,never()).append(any(),any(),anyInt(),anyList(),any(),anyString()); + } +}