修复海外散货托盘收货与短少入库闭环
This commit is contained in:
@@ -35,8 +35,8 @@
|
||||
| 入仓计划分页、柜级预览、转换与详情 | 平台 `inboundplan/controller/InboundPlanController.java`,客户只读 `inboundplan/controller/MerchantInboundPlanController.java`;共用 `inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`ContainerInboundPlanPreviewResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;客户入口固定 `/api/merchant/v1/last-leg/inbound-plans`,范围只取 Token。`/container-preview` 按全柜返回 FBA/一件代发/私人地址分组、托数、就绪、阻断项及 `snapshotVersion/snapshotHash`;`ContainerInboundPlanConversionCoordinator.java` 用唯一柜级 Saga 锁柜重算,先经 `WarehouseResourceGateway` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,本地失败只补偿未确认资源,远端确认失败的重放只重试确认,同柜不同幂等键也不得产生第二批资源 |
|
||||
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
|
||||
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` |
|
||||
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`;普通 FBA/OVERSEAS 分别使用 bulk/carton 收货,一件代发只允许 `/current/receipts/pallet` 按国内冻结来源托盘整托收货。合托只作现场可选提示;全部计划完整绑定真实 pallet/location/lock 前不得查看、准备或签收该柜,首扫只确认已预留绑定,不得重新分配计划资源 |
|
||||
| FBA收货短少确认 | `inboundplan/controller/InboundPlanController.java` 的 `/{planNo}/confirm-shortage` → `InboundPdaWorkflowService.confirmShortage`;仅平台入库编辑权限指定客户操作,要求待差异核对、原版本及应收/实收快照、原因和幂等键。仅同柜次同目的仓收货组单票且冻结申报单SKU可按总数确认,多SKU及跨票自动分摊拒绝;逐SKU实收不得超原申报,确认后保留应收与现场结束时间,按实收生成库存和原计租批次,计划/PDA任务进入 `PARTIAL_INBOUND`,尾程订单完成入库;同事务交接原预排,明确零实收的托盘同步移除预排明细、释放本车占用及预约分配并重算整车货量;缺少零实收证据或已有拣货拒绝。不重复发布卸柜事件。无实收、超收或逐SKU事实不一致拒绝;空预留托盘不能再次作为计划货源排车。 |
|
||||
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`;普通 FBA/OVERSEAS 分别使用 bulk/carton 收货,一件代发只允许 `/current/receipts/pallet` 按国内冻结来源托盘整托收货。普通 OVERSEAS 允许在本票已预留托盘间按现场实际分配箱数,逐托建议箱数不作为收货上限,仍校验本票总箱数、单托66箱、目的仓及客户归属;库存按已扫箱码的SKU和真实托盘归属入账。合托只作现场可选提示;全部计划完整绑定真实 pallet/location/lock 前不得查看、准备或签收该柜,托盘校验和提交均只确认本票已预留绑定。Admin 入库单详情可按行打印已预留真实托盘标签,复用海外托盘列表权限和标签PDF接口,不生成第二个托盘码 |
|
||||
| 普通散货收货短少确认 | `inboundplan/controller/InboundPlanController.java` 的 `/{planNo}/confirm-shortage` → `InboundPdaWorkflowService.confirmShortage`;仅平台入库编辑权限指定客户操作,要求待差异核对、原版本及应收/实收快照、原因和幂等键。FBA仅同柜次同目的仓收货组单票且冻结申报单SKU可按总数确认,多SKU及跨票自动分摊拒绝;普通OVERSEAS按已扫箱码精确核对SKU与逐托实收,可处理多SKU短少,一件代发整托货物拒绝此操作。逐SKU实收不得超原申报,确认后保留应收与现场结束时间,按实收生成库存和原计租批次,OVERSEAS已收箱转AVAILABLE、未收箱保留原状态,计划/PDA任务进入 `PARTIAL_INBOUND`,尾程订单完成入库;同事务交接原预排,明确零实收的托盘同步移除预排明细、释放本车占用及预约分配并重算整车货量;缺少零实收证据或已有拣货拒绝。不重复发布卸柜事件。无实收、超收或逐SKU事实不一致拒绝;空预留托盘不能再次作为计划货源排车。 |
|
||||
| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` |
|
||||
| PDA 国内装柜照片对照 | `ReceivingContainerController.java` 的 `/current/domestic-loading-photos/{evidenceType}/content`、`ContainerLoadingEvidenceMapper.java`、`DomesticLoadingEvidenceGateway.java`;元数据按物理柜投影,不保存客户,下载必须经当前 PDA 会话鉴权并复用 `FILE_SERVICE_URL` 只读访问 File 服务,单图不可用不改变拆柜状态 |
|
||||
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` |
|
||||
|
||||
+38
-25
@@ -402,10 +402,8 @@ public class InboundPdaWorkflowService {
|
||||
allocation.setLineId(line.getId()); allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(ZERO_VOLUME);
|
||||
allocation.setPlannedPackageCount(0); allocation.setCountedPackageCount(0); allocation.setPickedPackageCount(0);
|
||||
allocation.setActualVolumeCbm(ZERO_VOLUME); allocation.setStatus("PICKING"); allocation.setVersion(0); repository.insert(allocation);
|
||||
} else if (value(allocation.getPlannedPackageCount()) > 0
|
||||
&& value(allocation.getPickedPackageCount()) + requestedCount > value(allocation.getPlannedPackageCount())) {
|
||||
throw conflict("Requested carton quantity exceeds the recommended pallet package count");
|
||||
}
|
||||
// 普通海外仓货物按现场实际打托;本票总箱数在上方核对,单托容量由收货入口校验。
|
||||
BigDecimal perPackage = perPackageVolume(line);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
for (InboundPlanCarton item : cartonsToScan) {
|
||||
@@ -716,7 +714,7 @@ public class InboundPdaWorkflowService {
|
||||
repository.update(plan);
|
||||
}
|
||||
|
||||
/** 平台确认 FBA 短少后将真实实收入库;原应收、现场结束时间和短少证据保留。 */
|
||||
/** 平台确认普通散货短少后按真实实收入库,保留原应收、现场时间及箱码证据。 */
|
||||
@Transactional
|
||||
public PdaResponses.Task confirmShortage(String customerId, String planNo, String operatorId,
|
||||
String idempotencyKey, com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest request) {
|
||||
@@ -738,14 +736,16 @@ public class InboundPdaWorkflowService {
|
||||
throw conflict("仅待差异核对的入库单可确认短少,请刷新后重试");
|
||||
}
|
||||
if (!Objects.equals(plan.getVersion(), request.version())) throw conflict("入库单已变化,请刷新后重新确认短少");
|
||||
if (mode(plan) != InboundMode.FBA) throw conflict("当前短少确认仅适用于按 SKU 登记实收的 FBA 入库单");
|
||||
InboundMode inboundMode = mode(plan);
|
||||
if (inboundMode == InboundMode.DROPSHIP) throw conflict("一件代发必须按完整来源托盘收货,不能确认散货短少");
|
||||
if (unloadPhotoFileIds(task).isEmpty() || task.getUnloadPhotoUploadedAt() == null || task.getCompletedAt() == null) {
|
||||
throw conflict("现场收货尚未结束或缺少卸柜凭证,不能确认短少");
|
||||
}
|
||||
List<InboundPlanLine> lines = repository.lines(plan.getId());
|
||||
List<InboundPlanPalletAllocation> allocations = repository.allocations(plan.getId());
|
||||
if (lines.isEmpty() || lines.stream().anyMatch(line -> !"FBA".equals(destinationType(line)))) {
|
||||
throw conflict("短少确认要求每条明细均为 FBA 实收货物");
|
||||
String expectedType = inboundMode == InboundMode.FBA ? "FBA" : "OVERSEAS";
|
||||
if (lines.isEmpty() || lines.stream().anyMatch(line -> !expectedType.equals(destinationType(line)))) {
|
||||
throw conflict("短少确认要求各条明细使用一致的收货方式");
|
||||
}
|
||||
int expected = lines.stream().mapToInt(line -> value(line.getRequiredPackageCount())).sum();
|
||||
int received = allocations.stream().mapToInt(allocation -> value(allocation.getPickedPackageCount())).sum();
|
||||
@@ -753,6 +753,37 @@ public class InboundPdaWorkflowService {
|
||||
throw conflict("应收或实收数量已变化,请刷新后重新确认");
|
||||
}
|
||||
if (received <= 0 || received >= expected) throw conflict("仅有实际收货且实收少于应收时可确认短少");
|
||||
if (inboundMode == InboundMode.FBA) validateFbaShortage(customerId, plan, lines, allocations, expected);
|
||||
if (allocations.stream().anyMatch(a -> value(a.getPickedPackageCount()) < 0
|
||||
|| value(a.getPickedPackageCount()) != value(a.getCountedPackageCount()))) {
|
||||
throw conflict("清点与收货数量不一致,不能确认短少");
|
||||
}
|
||||
List<InboundPlanPallet> pallets = new ArrayList<>(repository.pallets(plan.getId()));
|
||||
Set<Long> palletIds = pallets.stream().map(InboundPlanPallet::getId).collect(java.util.stream.Collectors.toSet());
|
||||
pallets.addAll(repository.palletsByIds(allocations.stream().map(InboundPlanPalletAllocation::getPalletId)
|
||||
.filter(id -> id != null && !palletIds.contains(id)).collect(java.util.stream.Collectors.toSet())));
|
||||
// 普通海外仓的 SKU、逐托箱数由库存入口按已收箱码复验,缺失精确事实时整次失败。
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
stockReceipts.stockIn(customerId, plan, task, lines, allocations, pallets, task.getCompletedAt(), true);
|
||||
if (inboundMode == InboundMode.OWN_OVERSEAS) {
|
||||
for (InboundPlanCarton carton : repository.cartons(plan.getId())) {
|
||||
if (!"ON_PALLET".equals(carton.getStatus())) continue;
|
||||
carton.setStatus("AVAILABLE"); carton.setReceivedAt(task.getCompletedAt()); carton.setUpdatedAt(now);
|
||||
carton.setVersion(value(carton.getVersion()) + 1); repository.update(carton);
|
||||
}
|
||||
}
|
||||
task.setStatus("PARTIAL_INBOUND"); task.setUpdatedAt(now); task.setVersion(value(task.getVersion()) + 1);
|
||||
repository.update(task);
|
||||
plan.setStatus("PARTIAL_INBOUND"); plan.setUpdatedAt(now); repository.update(plan);
|
||||
dispatchReceipts.complete(List.of(plan.getId()), operatorId, now);
|
||||
PdaResponses.Task result = taskResponse(task, plan, planNo);
|
||||
saveOperation(customerId, task.getId(), null, "CONFIRM_SHORTAGE", idempotencyKey, command, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** FBA 按总数分摊的收货组仅允许单票单 SKU 确认短少,避免猜测缺货归属。 */
|
||||
private void validateFbaShortage(String customerId, InboundPlan plan, List<InboundPlanLine> lines,
|
||||
List<InboundPlanPalletAllocation> allocations, int expected) {
|
||||
var skuReceipts = repository.skuReceipts(customerId, plan.getId());
|
||||
// 批量收货的 SKU 行来自计划顺序分摊;只有单票单 SKU 的整组总数才是无歧义实收。
|
||||
if (lines.size() != 1) throw conflict("多票收货不能仅按总箱数确认短少,请先核实每票及每个 SKU 的实际数量");
|
||||
@@ -776,24 +807,6 @@ public class InboundPdaWorkflowService {
|
||||
throw conflict("逐票 SKU 实收与托盘数量不一致或存在超收,不能按短少确认");
|
||||
}
|
||||
}
|
||||
if (allocations.stream().anyMatch(a -> value(a.getPickedPackageCount()) < 0
|
||||
|| value(a.getPickedPackageCount()) != value(a.getCountedPackageCount()))) {
|
||||
throw conflict("清点与收货数量不一致,不能确认短少");
|
||||
}
|
||||
List<InboundPlanPallet> pallets = new ArrayList<>(repository.pallets(plan.getId()));
|
||||
Set<Long> palletIds = pallets.stream().map(InboundPlanPallet::getId).collect(java.util.stream.Collectors.toSet());
|
||||
pallets.addAll(repository.palletsByIds(allocations.stream().map(InboundPlanPalletAllocation::getPalletId)
|
||||
.filter(id -> id != null && !palletIds.contains(id)).collect(java.util.stream.Collectors.toSet())));
|
||||
// 库存和库龄采用原现场收货时间,不因后台核对延迟重置;不重复发布柜级卸货事件。
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
stockReceipts.stockIn(customerId, plan, task, lines, allocations, pallets, task.getCompletedAt(), true);
|
||||
task.setStatus("PARTIAL_INBOUND"); task.setUpdatedAt(now); task.setVersion(value(task.getVersion()) + 1);
|
||||
repository.update(task);
|
||||
plan.setStatus("PARTIAL_INBOUND"); plan.setUpdatedAt(now); repository.update(plan);
|
||||
dispatchReceipts.complete(List.of(plan.getId()), operatorId, now);
|
||||
PdaResponses.Task result = taskResponse(task, plan, planNo);
|
||||
saveOperation(customerId, task.getId(), null, "CONFIRM_SHORTAGE", idempotencyKey, command, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 操作员明确选择仅将当前已收箱数入库。 */
|
||||
|
||||
+41
-1
@@ -7,6 +7,8 @@ import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
|
||||
import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt;
|
||||
@@ -45,6 +47,7 @@ public class InboundStockReceiptService {
|
||||
@Resource private LastlegOrderDetailMapper orderDetails;
|
||||
@Resource private InboundStockReceiptMapper receipts;
|
||||
@Resource private ReceivingSkuReceiptMapper receivingSkuReceipts;
|
||||
@Resource private PdaWorkflowRepository workflowRepository;
|
||||
@Resource private InventoryCommandService inventory;
|
||||
@Resource private WarehouseResourceGateway warehouseGateway;
|
||||
@Resource private DropshipOrderService dropshipOrders;
|
||||
@@ -58,7 +61,7 @@ public class InboundStockReceiptService {
|
||||
stockIn(customerId, plan, task, lines, allocations, pallets, receivedAt, false);
|
||||
}
|
||||
|
||||
/** 部分入库仍按逐 SKU 实收生成凭证,原申报箱数仅作为上限。 */
|
||||
/** 按逐 SKU 实收生成凭证;普通海外仓货物以已扫描箱码和实托归属为准。 */
|
||||
@Transactional
|
||||
public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task,
|
||||
List<InboundPlanLine> lines, List<InboundPlanPalletAllocation> allocations,
|
||||
@@ -105,6 +108,12 @@ public class InboundStockReceiptService {
|
||||
throw conflict("PDA picked packages must equal the inbound order package total");
|
||||
}
|
||||
List<ReceivingSkuReceipt> exactSkuReceipts = skuReceiptsByLine.getOrDefault(line.getId(), List.of());
|
||||
if (!"ONE_CLICK_DROPSHIP".equals(order.getOrderType())
|
||||
&& "OVERSEAS".equals(first(line.getDestinationType(), order.getDestinationType()))) {
|
||||
created.addAll(persistExactSkuReceipts(customerId, plan, task, line, lineAllocations,
|
||||
palletById, order, cartonSkuReceipts(customerId, plan, line, palletById), pickedPackages, receivedAt, allowPartial));
|
||||
continue;
|
||||
}
|
||||
if (!exactSkuReceipts.isEmpty()) {
|
||||
created.addAll(persistExactSkuReceipts(customerId, plan, task, line, lineAllocations,
|
||||
palletById, order, exactSkuReceipts, pickedPackages, receivedAt, allowPartial));
|
||||
@@ -147,6 +156,37 @@ public class InboundStockReceiptService {
|
||||
completeOrders(customerId, plan, orderById, receivedAt, allowPartial);
|
||||
}
|
||||
|
||||
/** 从本票已收箱码汇总各实托的真实 SKU,防止短少或改托后按申报顺序猜测归属。 */
|
||||
private List<ReceivingSkuReceipt> cartonSkuReceipts(String customerId, InboundPlan plan, InboundPlanLine line,
|
||||
Map<Long, InboundPlanPallet> pallets) {
|
||||
Map<String, SkuRemainder> expected = skuRemainders(line.getOrderId(), value(line.getRequiredPackageCount())).stream()
|
||||
.collect(Collectors.toMap(sku -> sku.skuCode.toUpperCase(java.util.Locale.ROOT), sku -> sku, (left, right) -> left));
|
||||
Map<Long, Map<String, Integer>> counts = new java.util.LinkedHashMap<>();
|
||||
for (InboundPlanCarton carton : workflowRepository.cartons(plan.getId())) {
|
||||
if (!Objects.equals(line.getId(), carton.getLineId()) || !"ON_PALLET".equals(carton.getStatus())) continue;
|
||||
String skuCode = trim(carton.getSkuCode());
|
||||
SkuRemainder sku = skuCode == null ? null : expected.get(skuCode.toUpperCase(java.util.Locale.ROOT));
|
||||
InboundPlanPallet pallet = pallets.get(carton.getPlanPalletId());
|
||||
if (!customerId.equals(carton.getCustomerId()) || !Objects.equals(plan.getId(), carton.getPlanId())
|
||||
|| !Objects.equals(line.getOrderId(), carton.getOrderId()) || pallet == null || sku == null
|
||||
|| !Objects.equals(pallet.getWarehousePalletId(), carton.getWarehousePalletId())
|
||||
|| !Objects.equals(pallet.getWarehouseLocationId(), carton.getWarehouseLocationId())) {
|
||||
throw conflict("已收箱码的客户、计划、托盘或 SKU 与本票冻结货物不一致");
|
||||
}
|
||||
counts.computeIfAbsent(carton.getPlanPalletId(), ignored -> new java.util.LinkedHashMap<>())
|
||||
.merge(sku.skuCode, 1, Integer::sum);
|
||||
}
|
||||
List<ReceivingSkuReceipt> result = new java.util.ArrayList<>();
|
||||
counts.forEach((palletId, skus) -> skus.forEach((skuCode, packages) -> {
|
||||
SkuRemainder sku = expected.get(skuCode.toUpperCase(java.util.Locale.ROOT));
|
||||
ReceivingSkuReceipt receipt = new ReceivingSkuReceipt();
|
||||
receipt.setLineId(line.getId()); receipt.setPalletId(palletId); receipt.setSkuCode(skuCode);
|
||||
receipt.setProductName(first(sku.productNameZh, sku.productNameEn)); receipt.setReceivedPackages(packages);
|
||||
result.add(receipt);
|
||||
}));
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 逐 SKU 校验实收归属;短少可以少于冻结箱数,但不允许其他 SKU 超收抵扣。 */
|
||||
private List<InboundStockReceipt> persistExactSkuReceipts(String customerId, InboundPlan plan,
|
||||
InboundPdaTask task, InboundPlanLine line, List<InboundPlanPalletAllocation> lineAllocations,
|
||||
|
||||
+13
-33
@@ -393,6 +393,7 @@ public class ReceivingContainerService {
|
||||
inbound.assigned(), inbound.mergeAllowed(), inbound.urgent(), inbound.mergeHint());
|
||||
}
|
||||
|
||||
/** 校验当前入仓单已预留的实物托盘,与实收提交使用相同资源范围。 */
|
||||
public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey,
|
||||
ReceivingContainerRequests.PalletResolve request) {
|
||||
ReceivingContainerSession session = requireSigned(operatorKey);
|
||||
@@ -402,7 +403,7 @@ public class ReceivingContainerService {
|
||||
&& !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())
|
||||
? requireFbaPlanRefs(session.getSourceContainerBookingId(), session.getContainerNo(), ref) : List.of(ref);
|
||||
PalletSelection selection = group.size() == 1 && !"FBA".equals(destinationType(ref.line(), ref.order()))
|
||||
? resolveReceivingPallet(ref, request.palletCode(), false) : resolveFbaGroupPallet(group, request.palletCode());
|
||||
? resolveReceivingPallet(ref, request.palletCode()) : resolveFbaGroupPallet(group, request.palletCode());
|
||||
WarehouseResourceGateway.WarehousePalletReference pallet = selection.resource();
|
||||
int received = selection.pallet() == null ? 0 : group.stream().mapToInt(value -> allocations.selectList(
|
||||
new LambdaQueryWrapper<InboundPlanPalletAllocation>()
|
||||
@@ -506,6 +507,7 @@ public class ReceivingContainerService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
/** 按冻结箱码接收一箱货物,并确认本计划预留的托盘资源。 */
|
||||
public ReceivingContainerResponses.Receipt receiveCarton(String operatorKey, String operatorName,
|
||||
ReceivingContainerRequests.CartonReceive request) {
|
||||
ReceivingContainerOperation replay = replay(operatorKey, "CARTON_RECEIVE", request.idempotencyKey(), request);
|
||||
@@ -525,7 +527,7 @@ public class ReceivingContainerService {
|
||||
if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) {
|
||||
throw conflict("FBA destination must use bulk count");
|
||||
}
|
||||
PalletSelection selection = resolveReceivingPallet(ref, request.palletCode(), true);
|
||||
PalletSelection selection = resolveReceivingPallet(ref, request.palletCode());
|
||||
WarehouseResourceGateway.WarehousePalletReference resource = selection.resource();
|
||||
InboundPlanPallet pallet = selection.pallet();
|
||||
long packagesOnPallet = cartons.selectCount(new LambdaQueryWrapper<InboundPlanCarton>()
|
||||
@@ -902,20 +904,8 @@ public class ReceivingContainerService {
|
||||
return values;
|
||||
}
|
||||
|
||||
private InboundPlanPallet choosePallet(InboundRef ref, WarehouseResourceGateway.WarehousePalletReference resource) {
|
||||
List<InboundPlanPallet> candidates = pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
|
||||
.eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId())
|
||||
.eq(InboundPlanPallet::getPlanId, ref.plan().getId())).stream()
|
||||
.filter(value -> sameDestination(ref.line(), value))
|
||||
.filter(value -> value.getWarehousePalletId() == null || Objects.equals(value.getWarehousePalletId(), resource.palletId()))
|
||||
.sorted(Comparator.comparing((InboundPlanPallet value) -> !Objects.equals(value.getWarehousePalletId(), resource.palletId()))
|
||||
.thenComparing(value -> value.getActualPackageCount() == null ? 0 : value.getActualPackageCount()))
|
||||
.toList();
|
||||
if (candidates.isEmpty()) throw conflict("Pallet does not match an available destination pallet in this inbound code");
|
||||
return candidates.get(0);
|
||||
}
|
||||
|
||||
private PalletSelection resolveReceivingPallet(InboundRef ref, String palletCode, boolean requirePlanPallet) {
|
||||
/** 收货只确认已预留的托盘,避免空闲托盘校验通过后在实收时才失败。 */
|
||||
private PalletSelection resolveReceivingPallet(InboundRef ref, String palletCode) {
|
||||
String normalizedPalletCode = palletCode.trim();
|
||||
List<InboundPlanPallet> reserved = pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
|
||||
.eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId())
|
||||
@@ -927,23 +917,13 @@ public class ReceivingContainerService {
|
||||
if (reserved.size() > 1) {
|
||||
throw conflict("Pallet code matches multiple reserved resources in this inbound plan");
|
||||
}
|
||||
WarehouseResourceGateway.WarehousePalletReference resource;
|
||||
InboundPlanPallet pallet;
|
||||
if (reserved.size() == 1) {
|
||||
pallet = reserved.get(0);
|
||||
long warehouseId = ref.plan().getOverseasWarehouseId() == null
|
||||
? Long.parseLong(ref.order().getOverseasWarehouseId()) : ref.plan().getOverseasWarehouseId();
|
||||
resource = warehouse.resolveReservedPallet(pallet.getPalletNo(), ref.order().getCustomerId(), warehouseId,
|
||||
pallet.getWarehousePalletId(), pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId());
|
||||
} else {
|
||||
try {
|
||||
resource = warehouse.resolveAvailablePallet(normalizedPalletCode);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw notFound("Pallet was not found");
|
||||
}
|
||||
if (resource == null) throw notFound("Pallet was not found");
|
||||
pallet = requirePlanPallet ? choosePallet(ref, resource) : null;
|
||||
}
|
||||
if (reserved.isEmpty()) throw conflict("Pallet code is not reserved for this inbound code");
|
||||
InboundPlanPallet pallet = reserved.get(0);
|
||||
long warehouseId = ref.plan().getOverseasWarehouseId() == null
|
||||
? Long.parseLong(ref.order().getOverseasWarehouseId()) : ref.plan().getOverseasWarehouseId();
|
||||
WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolveReservedPallet(
|
||||
pallet.getPalletNo(), ref.order().getCustomerId(), warehouseId, pallet.getWarehousePalletId(),
|
||||
pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId());
|
||||
validatePallet(ref, resource);
|
||||
return new PalletSelection(pallet, resource);
|
||||
}
|
||||
|
||||
@@ -711,8 +711,8 @@ paths:
|
||||
'404': { description: 入库订单不存在或不属于指定客户 }
|
||||
/api/platform/v1/last-leg/inbound-plans/{planNo}/confirm-shortage:
|
||||
post:
|
||||
summary: 确认FBA入库短少并按实收入库
|
||||
description: 权限 platform:overseas-warehouse:inbound-order:edit;仅平台指定客户操作。仅支持同柜次同目的仓收货组单票、冻结申报单SKU;多SKU或跨票合收不能按自动分摊确认。PENDING_RECONCILIATION且0<实收<应收时,核对版本及逐SKU实收,保留原应收和现场收货时间,按实收写入库存并进入PARTIAL_INBOUND,同事务交接原预排并移除明确零实收托盘的排车明细、占用和预约分配。操作原因与操作人写入收货操作记录;不重复发布卸柜事件。超收、SKU不一致、客户不匹配和陈旧版本拒绝;相同请求键重放不会重复入库。
|
||||
summary: 确认普通散货入库短少并按实收入库
|
||||
description: 权限 platform:overseas-warehouse:inbound-order:edit;仅平台指定客户操作。FBA仅支持同柜次同目的仓收货组单票、冻结申报单SKU,多SKU或跨票合收不能按自动分摊确认。普通OVERSEAS货物按已扫描箱码的SKU及实际托盘归属确认,可处理多SKU短少,一件代发整托货物拒绝本操作。PENDING_RECONCILIATION且0<实收<应收时,核对版本及精确实收,保留原应收和现场收货时间,按实收写入库存并进入PARTIAL_INBOUND;OVERSEAS已收箱转AVAILABLE,未收箱保留原状态。同事务交接原预排并移除明确零实收托盘的排车明细、占用和预约分配。操作原因与操作人写入收货操作记录;不重复发布卸柜事件。超收、SKU不一致、客户不匹配和陈旧版本拒绝;相同请求键重放不会重复入库。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: planNo, in: path, required: true, schema: { type: string } }
|
||||
|
||||
+24
@@ -348,6 +348,30 @@ class InboundPdaWorkflowServiceTest {
|
||||
assertEquals("UNASSIGNED", second.getStatus());
|
||||
}
|
||||
|
||||
/** 同票允许按实际打托超过单托建议数,同时保留整票箱数上限。 */
|
||||
@Test
|
||||
void cartonScanCanExceedPalletSuggestionButCannotExceedInboundTotal() {
|
||||
InboundPlanCarton carton = carton("BOX-REAL", "UNASSIGNED", targetLine.getId(), "WH-A");
|
||||
activeTask(); target.setPickedPackageCount(3);
|
||||
when(repository.lockCarton("100", "BOX-REAL")).thenReturn(carton);
|
||||
when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet);
|
||||
when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine);
|
||||
when(repository.lockAllocation(plan.getId(), targetLine.getId(), targetPallet.getId())).thenReturn(target);
|
||||
when(repository.scannedCartons(plan.getId(), targetLine.getId())).thenReturn(3L);
|
||||
|
||||
service.scanCarton("100", "LIP1", new PdaRequests.ScanCarton(
|
||||
"BOX-REAL", targetPallet.getId(), "actual-pallet", "7", "worker"));
|
||||
assertEquals(4, target.getPickedPackageCount());
|
||||
assertEquals(3, target.getPlannedPackageCount());
|
||||
|
||||
InboundPlanCarton extra = carton("BOX-EXTRA", "UNASSIGNED", targetLine.getId(), "WH-A");
|
||||
when(repository.lockCarton("100", "BOX-EXTRA")).thenReturn(extra);
|
||||
when(repository.scannedCartons(plan.getId(), targetLine.getId())).thenReturn(5L);
|
||||
assertThrows(ResponseStatusException.class, () -> service.scanCarton("100", "LIP1", new PdaRequests.ScanCarton(
|
||||
"BOX-EXTRA", targetPallet.getId(), "extra-pallet", "7", "worker")));
|
||||
assertEquals("UNASSIGNED", extra.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cartonScanRejectsDuplicateAndWrongDestination() {
|
||||
InboundPlanCarton repeated = carton("BOX-002", "ON_PALLET", targetLine.getId(), "WH-A");
|
||||
|
||||
+22
@@ -81,6 +81,28 @@ class InboundShortageConfirmationTest {
|
||||
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 19)));
|
||||
}
|
||||
|
||||
/** 普通海外仓逐箱货物确认短少后,已收箱可用、缺失箱保留,原现场时间不变。 */
|
||||
@Test void overseasShortageMakesOnlyReceivedCartonsAvailable() {
|
||||
line.setFbaWarehouseCode(null); line.setDestinationType("OVERSEAS");
|
||||
InboundPlanCarton received = new InboundPlanCarton();
|
||||
received.setId(20L); received.setStatus("ON_PALLET"); received.setVersion(1);
|
||||
InboundPlanCarton missing = new InboundPlanCarton();
|
||||
missing.setId(21L); missing.setStatus("UNASSIGNED"); missing.setVersion(0);
|
||||
when(repository.cartons(1L)).thenReturn(List.of(received, missing));
|
||||
|
||||
var result = service.confirmShortage("100", "IP-1", "admin", "overseas-shortage", request(4, 20));
|
||||
|
||||
assertEquals("PARTIAL_INBOUND", result.status());
|
||||
assertEquals("AVAILABLE", received.getStatus());
|
||||
assertEquals(receivedAt, received.getReceivedAt());
|
||||
assertEquals("UNASSIGNED", missing.getStatus());
|
||||
assertEquals(21, line.getRequiredPackageCount());
|
||||
verify(stock).stockIn(eq("100"), eq(plan), eq(task), eq(List.of(line)),
|
||||
eq(List.of(allocation)), anyList(), eq(receivedAt), eq(true));
|
||||
verify(repository).update(received); verify(repository, never()).update(missing);
|
||||
verifyNoInteractions(outbox);
|
||||
}
|
||||
|
||||
/** 页面旧版本或旧实收数不能确认库存。 */
|
||||
@Test void rejectsStaleVersionAndCounts() {
|
||||
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(3, 20)));
|
||||
|
||||
+71
@@ -6,6 +6,8 @@ import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
|
||||
import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt;
|
||||
@@ -38,6 +40,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -50,6 +53,7 @@ class InboundStockReceiptServiceTest {
|
||||
@Mock LastlegOrderDetailMapper orderDetails;
|
||||
@Mock InboundStockReceiptMapper receipts;
|
||||
@Mock ReceivingSkuReceiptMapper receivingSkuReceipts;
|
||||
@Mock PdaWorkflowRepository workflowRepository;
|
||||
@Mock InventoryCommandService inventory;
|
||||
@Mock WarehouseResourceGateway warehouseGateway;
|
||||
@Mock DropshipOrderService dropshipOrders;
|
||||
@@ -57,6 +61,7 @@ class InboundStockReceiptServiceTest {
|
||||
private InboundStockReceiptService service;
|
||||
|
||||
@BeforeEach
|
||||
/** 组装库存、资源与逐箱事实的隔离依赖。 */
|
||||
void setUp() {
|
||||
service = new InboundStockReceiptService();
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
|
||||
@@ -64,6 +69,7 @@ class InboundStockReceiptServiceTest {
|
||||
ReflectionTestUtils.setField(service, "orderDetails", orderDetails);
|
||||
ReflectionTestUtils.setField(service, "receipts", receipts);
|
||||
ReflectionTestUtils.setField(service, "receivingSkuReceipts", receivingSkuReceipts);
|
||||
ReflectionTestUtils.setField(service, "workflowRepository", workflowRepository);
|
||||
ReflectionTestUtils.setField(service, "inventory", inventory);
|
||||
ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway);
|
||||
org.mockito.Mockito.lenient().when(warehouseGateway.resolveLocation(eq(900L), anyLong())).thenAnswer(invocation -> {
|
||||
@@ -174,6 +180,71 @@ class InboundStockReceiptServiceTest {
|
||||
assertEquals(5L, requests.getAllValues().stream().mapToLong(InventoryCommandRequests.StockIn::packages).sum());
|
||||
}
|
||||
|
||||
/** 海外逐箱收货按箱码保留实际 SKU,短少时也能准确识别缺货商品。 */
|
||||
@Test
|
||||
void overseasShortageStocksTheScannedSkuInsteadOfTheFirstDeclaredSku() {
|
||||
Fixture fixture = fixture(4, 1, 1, 4);
|
||||
fixture.line.setDestinationType("OVERSEAS"); fixture.order.setDestinationType("OVERSEAS");
|
||||
fixture.detail.setPackageCount(2);
|
||||
when(orderDetails.selectList(any())).thenReturn(List.of(fixture.detail, detail(202L, "SKU-2", 2, 2)));
|
||||
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
|
||||
when(workflowRepository.cartons(1L)).thenReturn(List.of(receivedCarton(1L, fixture.pallet, "SKU-2")));
|
||||
when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class)))
|
||||
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 1, 0, 0, 0, false, false));
|
||||
|
||||
service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line),
|
||||
List.of(fixture.allocation), List.of(fixture.pallet), LocalDateTime.now(), true);
|
||||
|
||||
ArgumentCaptor<InventoryCommandRequests.StockIn> request = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class);
|
||||
verify(inventory).stockIn(eq("100"), eq("operator"), request.capture());
|
||||
assertEquals("SKU-2", request.getValue().skuCode());
|
||||
assertEquals(1L, request.getValue().packages());
|
||||
assertEquals(2001L, request.getValue().palletId());
|
||||
}
|
||||
|
||||
/** 同票现场调整打托后,两种 SKU 的实托归属仍由箱码决定。 */
|
||||
@Test
|
||||
void overseasCompleteReceiptKeepsReversedSkuPalletAssignments() {
|
||||
Fixture fixture = fixture(2, 1, 1, 2);
|
||||
fixture.line.setDestinationType("OVERSEAS"); fixture.detail.setPackageCount(1);
|
||||
InboundPlanPallet secondPallet = pallet(22L, 2002L);
|
||||
InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 1);
|
||||
when(orderDetails.selectList(any())).thenReturn(List.of(fixture.detail, detail(202L, "SKU-2", 1, 1)));
|
||||
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
|
||||
when(workflowRepository.cartons(1L)).thenReturn(List.of(
|
||||
receivedCarton(1L, fixture.pallet, "SKU-2"), receivedCarton(2L, secondPallet, "SKU-1")));
|
||||
when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class)))
|
||||
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 1, 0, 0, 0, false, false));
|
||||
|
||||
service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line),
|
||||
List.of(fixture.allocation, secondAllocation), List.of(fixture.pallet, secondPallet), LocalDateTime.now());
|
||||
|
||||
ArgumentCaptor<InventoryCommandRequests.StockIn> requests = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class);
|
||||
verify(inventory, times(2)).stockIn(eq("100"), eq("operator"), requests.capture());
|
||||
assertEquals(java.util.Set.of("SKU-2:2001", "SKU-1:2002"), requests.getAllValues().stream()
|
||||
.map(value -> value.skuCode() + ":" + value.palletId()).collect(java.util.stream.Collectors.toSet()));
|
||||
}
|
||||
|
||||
/** 缺少逐箱证据时拒绝库存入账,不按申报数量补出实收。 */
|
||||
@Test
|
||||
void overseasReceiptRequiresScannedCartonEvidence() {
|
||||
Fixture fixture = fixture(4, 1, 1, 4);
|
||||
fixture.line.setDestinationType("OVERSEAS");
|
||||
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
|
||||
assertThrows(ResponseStatusException.class, () -> service.stockIn("100", fixture.plan, fixture.task,
|
||||
List.of(fixture.line), List.of(fixture.allocation), List.of(fixture.pallet), LocalDateTime.now(), true));
|
||||
verify(inventory, never()).stockIn(anyString(), anyString(), any());
|
||||
}
|
||||
|
||||
/** 构造已扫描且绑定真实托盘的箱码事实。 */
|
||||
private InboundPlanCarton receivedCarton(long id, InboundPlanPallet pallet, String sku) {
|
||||
InboundPlanCarton carton = new InboundPlanCarton();
|
||||
carton.setId(id); carton.setCustomerId("100"); carton.setPlanId(1L); carton.setLineId(12L);
|
||||
carton.setOrderId(101L); carton.setPlanPalletId(pallet.getId()); carton.setSkuCode(sku);
|
||||
carton.setWarehousePalletId(pallet.getWarehousePalletId()); carton.setWarehouseLocationId(pallet.getWarehouseLocationId());
|
||||
carton.setStatus("ON_PALLET"); return carton;
|
||||
}
|
||||
|
||||
@Test
|
||||
void stocksInToHeldInventoryWhenInboundOrderIsDetained() {
|
||||
Fixture fixture = fixture(5, 5, 5, 10);
|
||||
|
||||
+23
-1
@@ -652,12 +652,34 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
/** 非本票预留托盘在校验和提交阶段均拒绝,避免误报可收货。 */
|
||||
void overseasUnreservedPalletIsRejectedBeforeWarehouseLookup() {
|
||||
stubCurrent("OVERSEAS", "CONT-A");
|
||||
when(cartons.selectOne(any())).thenReturn(carton(1L, "IN-1#1"));
|
||||
|
||||
assertThatThrownBy(() -> service.resolvePallet("7",
|
||||
new ReceivingContainerRequests.PalletResolve("PAL-OTHER", "IN-1")))
|
||||
.hasMessageContaining("Pallet code is not reserved for this inbound code");
|
||||
assertThatThrownBy(() -> service.receiveCarton("7", "receiver",
|
||||
new ReceivingContainerRequests.CartonReceive("IN-1#1", "PAL-OTHER", "wrong-pallet")))
|
||||
.hasMessageContaining("Pallet code is not reserved for this inbound code");
|
||||
verifyNoInteractions(warehouse);
|
||||
}
|
||||
|
||||
@Test
|
||||
/** 业务目的仓与接柜仓不同,仍按已预留的实际接柜仓校验。 */
|
||||
void overseasPalletUsesActualReceivingWarehouseInsteadOfBusinessDestinationId() {
|
||||
stubCurrent("OVERSEAS", "CONT-A");
|
||||
InboundPlanLine overseasLine = line("OVERSEAS");
|
||||
overseasLine.setDestinationWarehouseId("900");
|
||||
when(lines.selectList(any())).thenReturn(List.of(overseasLine));
|
||||
when(warehouse.resolveAvailablePallet("PAL-1")).thenReturn(resource(100L, null));
|
||||
InboundPlanPallet reserved = planPallet();
|
||||
reserved.setDestinationWarehouseId("900"); reserved.setPalletNo("PAL-1");
|
||||
reserved.setWarehousePalletId(30L); reserved.setWarehouseLocationId(40L);
|
||||
reserved.setWarehouseResourceLockId(50L); reserved.setAssignmentStatus("RESERVED");
|
||||
when(pallets.selectList(any())).thenReturn(List.of(reserved));
|
||||
when(warehouse.resolveReservedPallet("PAL-1", "100", 100L, 30L, 40L, 50L))
|
||||
.thenReturn(resource(100L, null));
|
||||
|
||||
var resolved = service.resolvePallet("7",
|
||||
new ReceivingContainerRequests.PalletResolve("PAL-1", "IN-1"));
|
||||
|
||||
Reference in New Issue
Block a user