提交
This commit is contained in:
+10
@@ -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 + "')")
|
||||
|
||||
+7
@@ -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,
|
||||
|
||||
+18
-3
@@ -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,
|
||||
|
||||
+9
@@ -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);
|
||||
|
||||
+82
@@ -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);
|
||||
}
|
||||
}
|
||||
+16
@@ -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);
|
||||
}
|
||||
}
|
||||
+59
@@ -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);
|
||||
}
|
||||
}
|
||||
+59
-18
@@ -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
|
||||
|
||||
+11
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+80
-14
@@ -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());
|
||||
|
||||
+37
-6
@@ -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")) {
|
||||
|
||||
Reference in New Issue
Block a user