diff --git a/lastleg-service/docs/business-index.md b/lastleg-service/docs/business-index.md index 68efc8d2b..0f2288008 100644 --- a/lastleg-service/docs/business-index.md +++ b/lastleg-service/docs/business-index.md @@ -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` | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java index 38ab2823b..45907639b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -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 lines = repository.lines(plan.getId()); List 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 pallets = new ArrayList<>(repository.pallets(plan.getId())); + Set 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 lines, + List 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 pallets = new ArrayList<>(repository.pallets(plan.getId())); - Set 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; } /** 操作员明确选择仅将当前已收箱数入库。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java index dd77ddf57..d656b34c0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java @@ -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 lines, List allocations, @@ -105,6 +108,12 @@ public class InboundStockReceiptService { throw conflict("PDA picked packages must equal the inbound order package total"); } List 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 cartonSkuReceipts(String customerId, InboundPlan plan, InboundPlanLine line, + Map pallets) { + Map 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> 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 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 persistExactSkuReceipts(String customerId, InboundPlan plan, InboundPdaTask task, InboundPlanLine line, List lineAllocations, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index 71619b2f6..2d619e9d7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -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() @@ -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() @@ -902,20 +904,8 @@ public class ReceivingContainerService { return values; } - private InboundPlanPallet choosePallet(InboundRef ref, WarehouseResourceGateway.WarehousePalletReference resource) { - List candidates = pallets.selectList(new LambdaQueryWrapper() - .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 reserved = pallets.selectList(new LambdaQueryWrapper() .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); } diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 90de9c80e..b07c621c1 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -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 } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java index 9570fcd6f..ef952934b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java @@ -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"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundShortageConfirmationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundShortageConfirmationTest.java index bc06cb1fa..5f82ac2c4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundShortageConfirmationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundShortageConfirmationTest.java @@ -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))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java index 461c1de86..5422fd790 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java @@ -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 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 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); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index e35c6aef8..e3b7c1164 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -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")); diff --git a/print/src/test/java/com/baiyue/print/service/GenericPalletPdfServiceGenerationTest.java b/print/src/test/java/com/baiyue/print/service/GenericPalletPdfServiceGenerationTest.java index 44e851183..6cf2bbf52 100644 --- a/print/src/test/java/com/baiyue/print/service/GenericPalletPdfServiceGenerationTest.java +++ b/print/src/test/java/com/baiyue/print/service/GenericPalletPdfServiceGenerationTest.java @@ -7,12 +7,21 @@ import com.baiyue.print.dto.GenericPalletPdfDtos; import com.baiyue.print.renderer.GenericPalletRenderer; import com.baiyue.print.renderer.PdfBarcodeRenderer; 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.nio.file.Files; import java.nio.file.Path; +import java.text.Normalizer; import java.util.List; +import java.util.Map; import org.apache.pdfbox.Loader; import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.rendering.PDFRenderer; import org.apache.pdfbox.text.PDFTextStripper; import org.junit.jupiter.api.Test; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; @@ -22,22 +31,43 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; class GenericPalletPdfServiceGenerationTest { @jakarta.annotation.Resource private GenericPalletPdfService service; + /** 从真实横向 A4 PDF 反解短码和最长托盘码,同时核对中文、页序和字体嵌入。 */ @Test 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( GenericPalletPdfDtos.TEMPLATE, new GenericPalletPdfDtos.PageSpec(new BigDecimal("297"), new BigDecimal("210")), - "pallet.pdf", List.of(new GenericPalletPdfDtos.Label( - "Los Angeles Warehouse", "Transfer WTR-1", "PALLET-001")))); + "pallet.pdf", labels)); Files.createDirectories(Path.of("target")); Files.write(Path.of("target", "generic-pallet-a4-preview.pdf"), generated.content()); try (PDDocument pdf = Loader.loadPDF(generated.content())) { - assertThat(pdf.getNumberOfPages()).isEqualTo(1); - assertThat(pdf.getPage(0).getMediaBox().getWidth()).isCloseTo(PdfUnits.mmToPt(297), org.assertj.core.data.Offset.offset(.002f)); - assertThat(pdf.getPage(0).getMediaBox().getHeight()).isCloseTo(PdfUnits.mmToPt(210), org.assertj.core.data.Offset.offset(.002f)); - assertThat(new PDFTextStripper().getText(pdf)).contains( - "Los Angeles Warehouse", "Transfer WTR-1", "PALLET-001"); + assertThat(pdf.getNumberOfPages()).isEqualTo(labels.size()); + var renderer = new PDFRenderer(pdf); + for (int index = 0; index < labels.size(); index++) { + var page = pdf.getPage(index); + 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()); + } } } }