This commit is contained in:
2026-10-16 16:18:28 +08:00
parent 000125c3ec
commit 488c0318ff
13 changed files with 417 additions and 48 deletions
@@ -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<WarehouseTransferResponses.Detail> 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 + "')")
@@ -84,6 +84,13 @@ public class PdaWarehouseTransferController {
return ApiResponse.success(service.pdaCartonLabel(identity.overseasWarehouseId(), transferNo));
}
@PostMapping("/{transferNo}/cartons/reopen")
public ApiResponse<WarehouseTransferResponses.Detail> 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<WarehouseTransferResponses.Detail> countCartons(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
@@ -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<Long> photoFileIds) {
this(idempotencyKey, photoFileIds, null);
}
}
public record PalletDraft(
Long palletId,
@@ -10,6 +10,15 @@ import java.util.List;
@Mapper
public interface WarehouseTransferCartonMapper extends BaseMapper<WarehouseTransferCarton> {
/** 按客户及本单范围保存完成原因,允许清空重测前的差异说明。 */
@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<WarehouseTransferCarton> listOrder(@Param("customerId") String customerId,
@Param("transferOrderId") Long transferOrderId);
@@ -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<WarehouseTransferItem> items(String customerId, Long transferId) {
return items.selectList(new LambdaQueryWrapper<WarehouseTransferItem>()
.eq(WarehouseTransferItem::getCustomerId, customerId)
.eq(WarehouseTransferItem::getTransferOrderId, transferId).orderByAsc(WarehouseTransferItem::getId));
}
/** 读取原箱号用于确定新增编号,不删除或重建原箱。 */
public List<WarehouseTransferCarton> cartons(String customerId, Long transferId) {
return cartons.listOrder(customerId, transferId);
}
/** 追加预报箱并同步各预报口径;清点数据保留,整单清点确认失效。 */
public void append(WarehouseTransferOrder transfer, WarehouseTransferItem item, int itemIndex,
List<WarehouseTransferCarton> added, LocalDateTime now, String operatorId) {
String customerId = transfer.getCustomerId();
var order = orders.selectOne(new LambdaQueryWrapper<LastlegOrder>()
.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<WarehouseTransferCarton>()
.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<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getId, transfer.getLastlegOrderId())
.set(LastlegOrder::getPackageCount, transfer.getPackageCount()).set(LastlegOrder::getUpdatedAt, now));
inboundOrders.update(null, new LambdaUpdateWrapper<LastlegInboundOrder>()
.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<LastlegOrderDetail>()
.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<InboundPlanLine>()
.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);
}
}
@@ -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);
}
}
@@ -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<WarehouseTransferCarton>();
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);
}
}
@@ -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<WarehouseTransferResponses.Carton> 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<WarehouseTransferCarton> 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<String, WarehouseTransferCarton> byMark = cartons.stream().collect(Collectors.toMap(WarehouseTransferCarton::getCartonMark, Function.identity()));
Map<Long, WarehouseTransferItem> byItem = transferItems.selectList(new LambdaQueryWrapper<WarehouseTransferItem>()
.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<WarehouseTransferCarton> cartons, List<WarehouseTransferItem> items) {
Map<String, Long> expected = new java.util.HashMap<>();
Map<String, Long> received = new java.util.HashMap<>();
Map<Long, WarehouseTransferItem> 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<WarehouseTransferCarton> 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<DerivedPalletAllocation> derivedAllocations = deriveCountedPalletAllocations(countedCartons, persistedPallets);
LocalDateTime now = LocalDateTime.now();
List<WarehouseTransferPalletItem> 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) {
@@ -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: 箱宽厘米}
@@ -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
@@ -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;
}
}
@@ -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.<WarehouseTransferRequests.CountSku>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());
@@ -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")) {