修复海外散货托盘收货与短少入库闭环

This commit is contained in:
2026-11-04 09:57:14 +08:00
parent b95281bacb
commit 2eb0b65ba5
10 changed files with 273 additions and 71 deletions
+2 -2
View File
@@ -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/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` | | 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.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 前不得查看、准备或签收该柜,首扫只确认已预留绑定,不得重新分配计划资源 | | 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接口,不生成第二个托盘码 |
| FBA收货短少确认 | `inboundplan/controller/InboundPlanController.java` 的 `/{planNo}/confirm-shortage` → `InboundPdaWorkflowService.confirmShortage`;仅平台入库编辑权限指定客户操作,要求待差异核对、原版本及应收/实收快照、原因和幂等键。仅同柜次同目的仓收货组单票且冻结申报单SKU可按总数确认,多SKU及跨票自动分摊拒绝;逐SKU实收不得超原申报,确认后保留应收与现场结束时间,按实收生成库存和原计租批次,计划/PDA任务进入 `PARTIAL_INBOUND`,尾程订单完成入库;同事务交接原预排,明确零实收的托盘同步移除预排明细、释放本车占用及预约分配并重算整车货量;缺少零实收证据或已有拣货拒绝。不重复发布卸柜事件。无实收、超收或逐SKU事实不一致拒绝;空预留托盘不能再次作为计划货源排车。 | | 普通散货收货短少确认 | `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/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 服务,单图不可用不改变拆柜状态 | | 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` | | 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` |
@@ -402,10 +402,8 @@ public class InboundPdaWorkflowService {
allocation.setLineId(line.getId()); allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(ZERO_VOLUME); allocation.setLineId(line.getId()); allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(ZERO_VOLUME);
allocation.setPlannedPackageCount(0); allocation.setCountedPackageCount(0); allocation.setPickedPackageCount(0); allocation.setPlannedPackageCount(0); allocation.setCountedPackageCount(0); allocation.setPickedPackageCount(0);
allocation.setActualVolumeCbm(ZERO_VOLUME); allocation.setStatus("PICKING"); allocation.setVersion(0); repository.insert(allocation); 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); BigDecimal perPackage = perPackageVolume(line);
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
for (InboundPlanCarton item : cartonsToScan) { for (InboundPlanCarton item : cartonsToScan) {
@@ -716,7 +714,7 @@ public class InboundPdaWorkflowService {
repository.update(plan); repository.update(plan);
} }
/** 平台确认 FBA 短少后将真实实收入库;原应收、现场结束时间和短少证据保留。 */ /** 平台确认普通散货短少后按真实实收入库,保留原应收、现场时间及箱码证据。 */
@Transactional @Transactional
public PdaResponses.Task confirmShortage(String customerId, String planNo, String operatorId, public PdaResponses.Task confirmShortage(String customerId, String planNo, String operatorId,
String idempotencyKey, com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest request) { String idempotencyKey, com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest request) {
@@ -738,14 +736,16 @@ public class InboundPdaWorkflowService {
throw conflict("仅待差异核对的入库单可确认短少,请刷新后重试"); throw conflict("仅待差异核对的入库单可确认短少,请刷新后重试");
} }
if (!Objects.equals(plan.getVersion(), request.version())) 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) { if (unloadPhotoFileIds(task).isEmpty() || task.getUnloadPhotoUploadedAt() == null || task.getCompletedAt() == null) {
throw conflict("现场收货尚未结束或缺少卸柜凭证,不能确认短少"); throw conflict("现场收货尚未结束或缺少卸柜凭证,不能确认短少");
} }
List<InboundPlanLine> lines = repository.lines(plan.getId()); List<InboundPlanLine> lines = repository.lines(plan.getId());
List<InboundPlanPalletAllocation> allocations = repository.allocations(plan.getId()); List<InboundPlanPalletAllocation> allocations = repository.allocations(plan.getId());
if (lines.isEmpty() || lines.stream().anyMatch(line -> !"FBA".equals(destinationType(line)))) { String expectedType = inboundMode == InboundMode.FBA ? "FBA" : "OVERSEAS";
throw conflict("短少确认要求每条明细均为 FBA 实收货物"); if (lines.isEmpty() || lines.stream().anyMatch(line -> !expectedType.equals(destinationType(line)))) {
throw conflict("短少确认要求各条明细使用一致的收货方式");
} }
int expected = lines.stream().mapToInt(line -> value(line.getRequiredPackageCount())).sum(); int expected = lines.stream().mapToInt(line -> value(line.getRequiredPackageCount())).sum();
int received = allocations.stream().mapToInt(allocation -> value(allocation.getPickedPackageCount())).sum(); int received = allocations.stream().mapToInt(allocation -> value(allocation.getPickedPackageCount())).sum();
@@ -753,6 +753,37 @@ public class InboundPdaWorkflowService {
throw conflict("应收或实收数量已变化,请刷新后重新确认"); throw conflict("应收或实收数量已变化,请刷新后重新确认");
} }
if (received <= 0 || received >= expected) 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()); var skuReceipts = repository.skuReceipts(customerId, plan.getId());
// 批量收货的 SKU 行来自计划顺序分摊;只有单票单 SKU 的整组总数才是无歧义实收。 // 批量收货的 SKU 行来自计划顺序分摊;只有单票单 SKU 的整组总数才是无歧义实收。
if (lines.size() != 1) throw conflict("多票收货不能仅按总箱数确认短少,请先核实每票及每个 SKU 的实际数量"); if (lines.size() != 1) throw conflict("多票收货不能仅按总箱数确认短少,请先核实每票及每个 SKU 的实际数量");
@@ -776,24 +807,6 @@ public class InboundPdaWorkflowService {
throw conflict("逐票 SKU 实收与托盘数量不一致或存在超收,不能按短少确认"); 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;
} }
/** 操作员明确选择仅将当前已收箱数入库。 */ /** 操作员明确选择仅将当前已收箱数入库。 */
@@ -7,6 +7,8 @@ import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; 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.InboundPdaTask;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt; import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt;
@@ -45,6 +47,7 @@ public class InboundStockReceiptService {
@Resource private LastlegOrderDetailMapper orderDetails; @Resource private LastlegOrderDetailMapper orderDetails;
@Resource private InboundStockReceiptMapper receipts; @Resource private InboundStockReceiptMapper receipts;
@Resource private ReceivingSkuReceiptMapper receivingSkuReceipts; @Resource private ReceivingSkuReceiptMapper receivingSkuReceipts;
@Resource private PdaWorkflowRepository workflowRepository;
@Resource private InventoryCommandService inventory; @Resource private InventoryCommandService inventory;
@Resource private WarehouseResourceGateway warehouseGateway; @Resource private WarehouseResourceGateway warehouseGateway;
@Resource private DropshipOrderService dropshipOrders; @Resource private DropshipOrderService dropshipOrders;
@@ -58,7 +61,7 @@ public class InboundStockReceiptService {
stockIn(customerId, plan, task, lines, allocations, pallets, receivedAt, false); stockIn(customerId, plan, task, lines, allocations, pallets, receivedAt, false);
} }
/** 部分入库仍按逐 SKU 实收生成凭证,原申报箱数仅作为上限。 */ /** 按逐 SKU 实收生成凭证;普通海外仓货物以已扫描箱码和实托归属为准。 */
@Transactional @Transactional
public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task, public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task,
List<InboundPlanLine> lines, List<InboundPlanPalletAllocation> allocations, 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"); throw conflict("PDA picked packages must equal the inbound order package total");
} }
List<ReceivingSkuReceipt> exactSkuReceipts = skuReceiptsByLine.getOrDefault(line.getId(), List.of()); 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()) { if (!exactSkuReceipts.isEmpty()) {
created.addAll(persistExactSkuReceipts(customerId, plan, task, line, lineAllocations, created.addAll(persistExactSkuReceipts(customerId, plan, task, line, lineAllocations,
palletById, order, exactSkuReceipts, pickedPackages, receivedAt, allowPartial)); palletById, order, exactSkuReceipts, pickedPackages, receivedAt, allowPartial));
@@ -147,6 +156,37 @@ public class InboundStockReceiptService {
completeOrders(customerId, plan, orderById, receivedAt, allowPartial); 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 超收抵扣。 */ /** 逐 SKU 校验实收归属;短少可以少于冻结箱数,但不允许其他 SKU 超收抵扣。 */
private List<InboundStockReceipt> persistExactSkuReceipts(String customerId, InboundPlan plan, private List<InboundStockReceipt> persistExactSkuReceipts(String customerId, InboundPlan plan,
InboundPdaTask task, InboundPlanLine line, List<InboundPlanPalletAllocation> lineAllocations, InboundPdaTask task, InboundPlanLine line, List<InboundPlanPalletAllocation> lineAllocations,
@@ -393,6 +393,7 @@ public class ReceivingContainerService {
inbound.assigned(), inbound.mergeAllowed(), inbound.urgent(), inbound.mergeHint()); inbound.assigned(), inbound.mergeAllowed(), inbound.urgent(), inbound.mergeHint());
} }
/** 校验当前入仓单已预留的实物托盘,与实收提交使用相同资源范围。 */
public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey, public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey,
ReceivingContainerRequests.PalletResolve request) { ReceivingContainerRequests.PalletResolve request) {
ReceivingContainerSession session = requireSigned(operatorKey); ReceivingContainerSession session = requireSigned(operatorKey);
@@ -402,7 +403,7 @@ public class ReceivingContainerService {
&& !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType()) && !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())
? requireFbaPlanRefs(session.getSourceContainerBookingId(), session.getContainerNo(), ref) : List.of(ref); ? requireFbaPlanRefs(session.getSourceContainerBookingId(), session.getContainerNo(), ref) : List.of(ref);
PalletSelection selection = group.size() == 1 && !"FBA".equals(destinationType(ref.line(), ref.order())) 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(); WarehouseResourceGateway.WarehousePalletReference pallet = selection.resource();
int received = selection.pallet() == null ? 0 : group.stream().mapToInt(value -> allocations.selectList( int received = selection.pallet() == null ? 0 : group.stream().mapToInt(value -> allocations.selectList(
new LambdaQueryWrapper<InboundPlanPalletAllocation>() new LambdaQueryWrapper<InboundPlanPalletAllocation>()
@@ -506,6 +507,7 @@ public class ReceivingContainerService {
} }
@Transactional @Transactional
/** 按冻结箱码接收一箱货物,并确认本计划预留的托盘资源。 */
public ReceivingContainerResponses.Receipt receiveCarton(String operatorKey, String operatorName, public ReceivingContainerResponses.Receipt receiveCarton(String operatorKey, String operatorName,
ReceivingContainerRequests.CartonReceive request) { ReceivingContainerRequests.CartonReceive request) {
ReceivingContainerOperation replay = replay(operatorKey, "CARTON_RECEIVE", request.idempotencyKey(), 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()))) { if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) {
throw conflict("FBA destination must use bulk count"); 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(); WarehouseResourceGateway.WarehousePalletReference resource = selection.resource();
InboundPlanPallet pallet = selection.pallet(); InboundPlanPallet pallet = selection.pallet();
long packagesOnPallet = cartons.selectCount(new LambdaQueryWrapper<InboundPlanCarton>() long packagesOnPallet = cartons.selectCount(new LambdaQueryWrapper<InboundPlanCarton>()
@@ -902,20 +904,8 @@ public class ReceivingContainerService {
return values; return values;
} }
private InboundPlanPallet choosePallet(InboundRef ref, WarehouseResourceGateway.WarehousePalletReference resource) { /** 收货只确认已预留的托盘,避免空闲托盘校验通过后在实收时才失败。 */
List<InboundPlanPallet> candidates = pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>() private PalletSelection resolveReceivingPallet(InboundRef ref, String palletCode) {
.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) {
String normalizedPalletCode = palletCode.trim(); String normalizedPalletCode = palletCode.trim();
List<InboundPlanPallet> reserved = pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>() List<InboundPlanPallet> reserved = pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId()) .eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId())
@@ -927,23 +917,13 @@ public class ReceivingContainerService {
if (reserved.size() > 1) { if (reserved.size() > 1) {
throw conflict("Pallet code matches multiple reserved resources in this inbound plan"); throw conflict("Pallet code matches multiple reserved resources in this inbound plan");
} }
WarehouseResourceGateway.WarehousePalletReference resource; if (reserved.isEmpty()) throw conflict("Pallet code is not reserved for this inbound code");
InboundPlanPallet pallet; InboundPlanPallet pallet = reserved.get(0);
if (reserved.size() == 1) { long warehouseId = ref.plan().getOverseasWarehouseId() == null
pallet = reserved.get(0); ? Long.parseLong(ref.order().getOverseasWarehouseId()) : ref.plan().getOverseasWarehouseId();
long warehouseId = ref.plan().getOverseasWarehouseId() == null WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolveReservedPallet(
? Long.parseLong(ref.order().getOverseasWarehouseId()) : ref.plan().getOverseasWarehouseId(); pallet.getPalletNo(), ref.order().getCustomerId(), warehouseId, pallet.getWarehousePalletId(),
resource = warehouse.resolveReservedPallet(pallet.getPalletNo(), ref.order().getCustomerId(), warehouseId, pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId());
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;
}
validatePallet(ref, resource); validatePallet(ref, resource);
return new PalletSelection(pallet, resource); return new PalletSelection(pallet, resource);
} }
@@ -711,8 +711,8 @@ paths:
'404': { description: 入库订单不存在或不属于指定客户 } '404': { description: 入库订单不存在或不属于指定客户 }
/api/platform/v1/last-leg/inbound-plans/{planNo}/confirm-shortage: /api/platform/v1/last-leg/inbound-plans/{planNo}/confirm-shortage:
post: post:
summary: 确认FBA入库短少并按实收入库 summary: 确认普通散货入库短少并按实收入库
description: 权限 platform:overseas-warehouse:inbound-order:edit;仅平台指定客户操作。仅支持同柜次同目的仓收货组单票、冻结申报单SKU;多SKU或跨票合收不能按自动分摊确认。PENDING_RECONCILIATION且0<实收<应收时,核对版本及逐SKU实收,保留原应收和现场收货时间,按实收写入库存并进入PARTIAL_INBOUND,同事务交接原预排并移除明确零实收托盘的排车明细、占用和预约分配。操作原因与操作人写入收货操作记录;不重复发布卸柜事件。超收、SKU不一致、客户不匹配和陈旧版本拒绝;相同请求键重放不会重复入库。 description: 权限 platform:overseas-warehouse:inbound-order:edit;仅平台指定客户操作。FBA仅支持同柜次同目的仓收货组单票、冻结申报单SKU,多SKU或跨票合收不能按自动分摊确认。普通OVERSEAS货物按已扫描箱码的SKU及实际托盘归属确认,可处理多SKU短少,一件代发整托货物拒绝本操作。PENDING_RECONCILIATION且0<实收<应收时,核对版本及精确实收,保留原应收和现场收货时间,按实收写入库存并进入PARTIAL_INBOUND;OVERSEAS已收箱转AVAILABLE,未收箱保留原状态。同事务交接原预排并移除明确零实收托盘的排车明细、占用和预约分配。操作原因与操作人写入收货操作记录;不重复发布卸柜事件。超收、SKU不一致、客户不匹配和陈旧版本拒绝;相同请求键重放不会重复入库。
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
parameters: parameters:
- { name: planNo, in: path, required: true, schema: { type: string } } - { name: planNo, in: path, required: true, schema: { type: string } }
@@ -348,6 +348,30 @@ class InboundPdaWorkflowServiceTest {
assertEquals("UNASSIGNED", second.getStatus()); 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 @Test
void cartonScanRejectsDuplicateAndWrongDestination() { void cartonScanRejectsDuplicateAndWrongDestination() {
InboundPlanCarton repeated = carton("BOX-002", "ON_PALLET", targetLine.getId(), "WH-A"); InboundPlanCarton repeated = carton("BOX-002", "ON_PALLET", targetLine.getId(), "WH-A");
@@ -81,6 +81,28 @@ class InboundShortageConfirmationTest {
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 19))); 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() { @Test void rejectsStaleVersionAndCounts() {
assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(3, 20))); assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(3, 20)));
@@ -6,6 +6,8 @@ import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; 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.InboundPdaTask;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt; 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.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -50,6 +53,7 @@ class InboundStockReceiptServiceTest {
@Mock LastlegOrderDetailMapper orderDetails; @Mock LastlegOrderDetailMapper orderDetails;
@Mock InboundStockReceiptMapper receipts; @Mock InboundStockReceiptMapper receipts;
@Mock ReceivingSkuReceiptMapper receivingSkuReceipts; @Mock ReceivingSkuReceiptMapper receivingSkuReceipts;
@Mock PdaWorkflowRepository workflowRepository;
@Mock InventoryCommandService inventory; @Mock InventoryCommandService inventory;
@Mock WarehouseResourceGateway warehouseGateway; @Mock WarehouseResourceGateway warehouseGateway;
@Mock DropshipOrderService dropshipOrders; @Mock DropshipOrderService dropshipOrders;
@@ -57,6 +61,7 @@ class InboundStockReceiptServiceTest {
private InboundStockReceiptService service; private InboundStockReceiptService service;
@BeforeEach @BeforeEach
/** 组装库存、资源与逐箱事实的隔离依赖。 */
void setUp() { void setUp() {
service = new InboundStockReceiptService(); service = new InboundStockReceiptService();
org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class)); 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, "orderDetails", orderDetails);
ReflectionTestUtils.setField(service, "receipts", receipts); ReflectionTestUtils.setField(service, "receipts", receipts);
ReflectionTestUtils.setField(service, "receivingSkuReceipts", receivingSkuReceipts); ReflectionTestUtils.setField(service, "receivingSkuReceipts", receivingSkuReceipts);
ReflectionTestUtils.setField(service, "workflowRepository", workflowRepository);
ReflectionTestUtils.setField(service, "inventory", inventory); ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway); ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway);
org.mockito.Mockito.lenient().when(warehouseGateway.resolveLocation(eq(900L), anyLong())).thenAnswer(invocation -> { 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()); 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 @Test
void stocksInToHeldInventoryWhenInboundOrderIsDetained() { void stocksInToHeldInventoryWhenInboundOrderIsDetained() {
Fixture fixture = fixture(5, 5, 5, 10); Fixture fixture = fixture(5, 5, 5, 10);
@@ -652,12 +652,34 @@ class ReceivingContainerServiceBehaviorTest {
} }
@Test @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() { void overseasPalletUsesActualReceivingWarehouseInsteadOfBusinessDestinationId() {
stubCurrent("OVERSEAS", "CONT-A"); stubCurrent("OVERSEAS", "CONT-A");
InboundPlanLine overseasLine = line("OVERSEAS"); InboundPlanLine overseasLine = line("OVERSEAS");
overseasLine.setDestinationWarehouseId("900"); overseasLine.setDestinationWarehouseId("900");
when(lines.selectList(any())).thenReturn(List.of(overseasLine)); 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", var resolved = service.resolvePallet("7",
new ReceivingContainerRequests.PalletResolve("PAL-1", "IN-1")); new ReceivingContainerRequests.PalletResolve("PAL-1", "IN-1"));
@@ -7,12 +7,21 @@ import com.baiyue.print.dto.GenericPalletPdfDtos;
import com.baiyue.print.renderer.GenericPalletRenderer; import com.baiyue.print.renderer.GenericPalletRenderer;
import com.baiyue.print.renderer.PdfBarcodeRenderer; import com.baiyue.print.renderer.PdfBarcodeRenderer;
import com.baiyue.print.renderer.PdfUnits; import com.baiyue.print.renderer.PdfUnits;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.BinaryBitmap;
import com.google.zxing.DecodeHintType;
import com.google.zxing.MultiFormatReader;
import com.google.zxing.RGBLuminanceSource;
import com.google.zxing.common.HybridBinarizer;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.text.Normalizer;
import java.util.List; import java.util.List;
import java.util.Map;
import org.apache.pdfbox.Loader; import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.apache.pdfbox.text.PDFTextStripper; import org.apache.pdfbox.text.PDFTextStripper;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
@@ -22,22 +31,43 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
class GenericPalletPdfServiceGenerationTest { class GenericPalletPdfServiceGenerationTest {
@jakarta.annotation.Resource private GenericPalletPdfService service; @jakarta.annotation.Resource private GenericPalletPdfService service;
/** 从真实横向 A4 PDF 反解短码和最长托盘码,同时核对中文、页序和字体嵌入。 */
@Test @Test
void generatesLandscapeA4WithoutUsingTheCartonMarkTemplate() throws Exception { void generatesLandscapeA4WithoutUsingTheCartonMarkTemplate() throws Exception {
var labels = List.of(
new GenericPalletPdfDtos.Label("Los Angeles Warehouse", "Transfer WTR-1", "PALLET-001"),
new GenericPalletPdfDtos.Label("洛杉矶收货仓", "入库单 IP-DEMO-1", "D0001-IN-DEMO-001"),
new GenericPalletPdfDtos.Label("Private warehouse", "入库单 IP-DEMO-2", "A".repeat(80)));
var generated = service.generate(new GenericPalletPdfDtos.Request( var generated = service.generate(new GenericPalletPdfDtos.Request(
GenericPalletPdfDtos.TEMPLATE, GenericPalletPdfDtos.TEMPLATE,
new GenericPalletPdfDtos.PageSpec(new BigDecimal("297"), new BigDecimal("210")), new GenericPalletPdfDtos.PageSpec(new BigDecimal("297"), new BigDecimal("210")),
"pallet.pdf", List.of(new GenericPalletPdfDtos.Label( "pallet.pdf", labels));
"Los Angeles Warehouse", "Transfer WTR-1", "PALLET-001"))));
Files.createDirectories(Path.of("target")); Files.createDirectories(Path.of("target"));
Files.write(Path.of("target", "generic-pallet-a4-preview.pdf"), generated.content()); Files.write(Path.of("target", "generic-pallet-a4-preview.pdf"), generated.content());
try (PDDocument pdf = Loader.loadPDF(generated.content())) { try (PDDocument pdf = Loader.loadPDF(generated.content())) {
assertThat(pdf.getNumberOfPages()).isEqualTo(1); assertThat(pdf.getNumberOfPages()).isEqualTo(labels.size());
assertThat(pdf.getPage(0).getMediaBox().getWidth()).isCloseTo(PdfUnits.mmToPt(297), org.assertj.core.data.Offset.offset(.002f)); var renderer = new PDFRenderer(pdf);
assertThat(pdf.getPage(0).getMediaBox().getHeight()).isCloseTo(PdfUnits.mmToPt(210), org.assertj.core.data.Offset.offset(.002f)); for (int index = 0; index < labels.size(); index++) {
assertThat(new PDFTextStripper().getText(pdf)).contains( var page = pdf.getPage(index);
"Los Angeles Warehouse", "Transfer WTR-1", "PALLET-001"); assertThat(page.getMediaBox().getWidth()).isCloseTo(PdfUnits.mmToPt(297), org.assertj.core.data.Offset.offset(.002f));
assertThat(page.getMediaBox().getHeight()).isCloseTo(PdfUnits.mmToPt(210), org.assertj.core.data.Offset.offset(.002f));
var stripper = new PDFTextStripper();
stripper.setStartPage(index + 1); stripper.setEndPage(index + 1);
var label = labels.get(index);
// 字体的共享字形可能反解为康熙部首,中文语义按 Unicode 兼容标准化核对。
assertThat(Normalizer.normalize(stripper.getText(pdf), Normalizer.Form.NFKC))
.contains(label.heading(), label.reference(), label.palletCode());
for (var name : page.getResources().getFontNames()) {
assertThat(page.getResources().getFont(name).isEmbedded()).isTrue();
}
var image = renderer.renderImageWithDPI(index, 300);
var source = new RGBLuminanceSource(image.getWidth(), image.getHeight(),
image.getRGB(0, 0, image.getWidth(), image.getHeight(), null, 0, image.getWidth()));
var decoded = new MultiFormatReader().decode(new BinaryBitmap(new HybridBinarizer(source)),
Map.of(DecodeHintType.POSSIBLE_FORMATS, List.of(BarcodeFormat.CODE_128)));
assertThat(decoded.getText()).isEqualTo(label.palletCode());
}
} }
} }
} }