diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 469423af4..6dbfe5da7 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -9,7 +9,7 @@ ## 模块入口 -散货托盘码:`service/LoadingPalletLabelService.java`、`mapper/LoadingPalletLabelMapper.java`、`controller/PlatformLoadingListController.java`,契约 `src/main/resources/openapi/loading-lists-v1.yaml` 的 `/pallet-labels/prepare` 与 `/pallet-labels/print`。仅在计划 `LOADING/COMPLETED` 且真实柜号有效时,为非一件代发的 FBA 明细按柜号、FBA 地址内部编号、三位序号生成冻结标签;内部编号从 Warehouse 启用主数据读取,缺失或同柜重复时拒绝生成。重复生成须核对已有标签与明细一致,冲突直接拒绝。支持全部或按仓指定张数输出 100×100 mm PDF;装柜完成通过 `LoadingCompletedOutboxService.java` 传递来源托盘。 +散货托盘码:`service/LoadingPalletLabelService.java`、`mapper/LoadingPalletLabelMapper.java`、`controller/PlatformLoadingListController.java`,契约 `src/main/resources/openapi/loading-lists-v1.yaml` 的 `/pallet-labels/prepare` 与 `/pallet-labels/print`。仅在计划 `LOADING/COMPLETED` 且真实柜号有效时,为非一件代发的 FBA 明细按柜号、FBA 地址内部编号、三位序号生成冻结标签;内部编号从 Warehouse 启用主数据读取,缺失或同柜重复时拒绝生成。重复生成须核对已有标签与明细一致,冲突直接拒绝。全部或按仓指定张数打印统一调用 Print `/internal/print/v1/pdfs/fba-pallet-labels`,模板 `FBA_PALLET_A4_LANDSCAPE_V1` 每托一页 297×210 mm,顶部为 SCK8 等 FBA 仓码,条码和底部文字为包含 141 等内部编号的完整托盘号,与海外补打共用版式;装柜完成通过 `LoadingCompletedOutboxService.java` 传递来源托盘。 | 目标 | 入口 | |---|---| diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index d950edd13..8ca4b5716 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -624,8 +624,7 @@ public class SmartLoadingService { } ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); InboundOrderEntity lockedInbound = repository.inboundForUpdate(request.customerId(), inboundId); - if (lockedInbound == null || !Set.of("PREPARING", "WAIT_MEASURE", "WAIT_SPLIT") - .contains(lockedInbound.getProcessStatus())) { + if (lockedInbound == null || !SplitPreloadService.supportsPreAllocation(lockedInbound.getProcessStatus())) { throw conflict("订单当前状态不能加入装柜清单"); } if (!repository.activeItemsByInbound(request.customerId(), inboundId).isEmpty()) { @@ -1170,8 +1169,13 @@ public class SmartLoadingService { if(repository.remove(planId,item.getId(),item.getVersion(),operatorId,operatorName)!=1) throw conflict("整托装柜明细已被并发修改"); } - LoadingPlanItemEntity first=palletItems.get(0); - loadingModelBridge.removeIfUnallocated(planId,first.getAccountId(),first.getInboundId()); + Set releasedInbounds = new HashSet<>(); + for (LoadingPlanItemEntity item : palletItems) { + String key = item.getAccountId() + "|" + item.getInboundId(); + if (releasedInbounds.add(key)) { + loadingModelBridge.removeIfUnallocated(planId, item.getAccountId(), item.getInboundId()); + } + } history(planId,null,"REMOVE_PALLET",plan.getPlanStatus(),plan.getPlanStatus(),operation, json(values("palletId",palletId,"version",request.expectedPlanVersion())), json(values("requestHash",requestHash,"removedLineCount",palletItems.size())),operatorId,operatorName); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java index 3d9fb3f29..78dbc1a09 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java @@ -27,6 +27,9 @@ import java.util.Set; @Service public class SplitPreloadService { private static final String SMART_SPLIT = "SMART_SPLIT"; + private static final Set PRE_ALLOCATION_STATES = Set.of( + "PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", + "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT"); @Resource private SplitRepository repository; @Resource private ObjectMapper objectMapper; @@ -72,12 +75,12 @@ public class SplitPreloadService { ensureLoadingAllocationLifecycle(tenantId, inboundId, operatorId, operatorName, false); } - /** Creates a provisional preload lifecycle for an order selected before review/measurement. */ + /** Creates a provisional preload lifecycle for an order selected before it reaches WAIT_SPLIT. */ @Transactional public void ensurePendingForPreAllocation(long tenantId, long inboundId, long operatorId, String operatorName) { InboundSnapshot inbound = requireInbound(tenantId, inboundId); - if (!Set.of("PREPARING", "WAIT_MEASURE", "WAIT_SPLIT").contains(inbound.processStatus())) { + if (!supportsPreAllocation(inbound.processStatus())) { throw new BizException(ResultCode.CONFLICT, "订单当前状态不能加入装柜清单"); } PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId); @@ -89,7 +92,7 @@ public class SplitPreloadService { } plan = new PreloadPlanEntity(); plan.setTenantId(tenantId); plan.setInboundId(inboundId); plan.setStatus(PreloadStatus.PENDING); - plan.setVersion(0); plan.setItemsJson("[]"); plan.setRemark("订单审核前加入装柜清单自动建立"); + plan.setVersion(0); plan.setItemsJson("[]"); plan.setRemark("智能装柜提前分配自动建立"); plan.setCreatedBy(operatorId); plan.setCreatedByName(operatorName); plan.setUpdatedBy(operatorId); plan.setUpdatedByName(operatorName); try { @@ -102,6 +105,10 @@ public class SplitPreloadService { } } + public static boolean supportsPreAllocation(String processStatus) { + return PRE_ALLOCATION_STATES.contains(processStatus); + } + /** Reuses the confirmed preload lifecycle while a running PDA task owns the mutation lock. */ @Transactional public void ensureForRunningLoadingAllocation(long tenantId, long inboundId, long operatorId, diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPalletLabelServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPalletLabelServiceTest.java index 2e34d2b2a..56569c323 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPalletLabelServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPalletLabelServiceTest.java @@ -15,6 +15,38 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; class LoadingPalletLabelServiceTest { + @Test + void printsAllAndWarehouseSubsetThroughSharedFbaRendererWithoutRegeneratingCodes() { + var service = org.mockito.Mockito.spy(new LoadingPalletLabelService()); + var print = mock(com.baiyue.firstleg.client.PrintPdfClient.class); + ReflectionTestUtils.setField(service, "printClient", print); + ReflectionTestUtils.setField(service, "internalToken", "test-token"); + var label1 = new com.baiyue.firstleg.dto.LoadingListDtos.PalletLabel(1L, 1L, 1L, 1L, + "IN-1", "MSCU6639870", "SCK8", "141", "FBA destination", 1, "MSCU6639870-141-001"); + var label2 = new com.baiyue.firstleg.dto.LoadingListDtos.PalletLabel(2L, 1L, 1L, 1L, + "IN-1", "MSCU6639870", "SCK8", "141", "FBA destination", 2, "MSCU6639870-141-002"); + var manifest = new com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelManifest(10L, "LP-1", "MSCU6639870", 2, + List.of(new com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelGroup("SCK8", "141", "FBA destination", 2, List.of(label1, label2)))); + org.mockito.Mockito.doReturn(manifest).when(service).prepare(10L, 20L); + when(print.generateFbaPalletLabels(any(), any(), any())).thenReturn(org.springframework.http.ResponseEntity.ok() + .contentType(org.springframework.http.MediaType.APPLICATION_PDF).body("%PDF-test".getBytes())); + service.print(10L, 20L, null, "request-1"); + service.print(10L, 20L, new com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelPrintRequest("SCK8", 1), "request-2"); + var captor = org.mockito.ArgumentCaptor.forClass(com.baiyue.firstleg.client.PrintPdfClient.FbaPalletRequest.class); + org.mockito.Mockito.verify(print, org.mockito.Mockito.times(2)).generateFbaPalletLabels(any(), any(), captor.capture()); + var requests = captor.getAllValues(); + assertThat(requests.get(0).templateCode()).isEqualTo("FBA_PALLET_A4_LANDSCAPE_V1"); + assertThat(requests.get(0).pageSpec().widthMm()).isEqualByComparingTo("297"); + assertThat(requests.get(0).pageSpec().heightMm()).isEqualByComparingTo("210"); + assertThat(requests.get(0).labels()).extracting(com.baiyue.firstleg.client.PrintPdfClient.FbaPalletLabel::palletCode) + .containsExactly("MSCU6639870-141-001", "MSCU6639870-141-002"); + assertThat(requests.get(1).labels()).singleElement().satisfies(label -> { + assertThat(label.fbaWarehouseCode()).isEqualTo("SCK8"); + assertThat(label.palletCode()).isEqualTo("MSCU6639870-141-001"); + }); + org.mockito.Mockito.verify(print, org.mockito.Mockito.never()).generateInboundMarks(any(), any(), any()); + } + @Test void generatesStableIndexesPerContainerAndFbaAndSkipsDropship() { LoadingPalletLabelMapper mapper = mock(LoadingPalletLabelMapper.class); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index 63e5ce856..d19daa972 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -495,7 +495,7 @@ class SmartLoadingServiceTest { } @Test - void manuallyAssignsCrossChannelWaitMeasureInboundUsingDeclaredFacts() { + void manuallyAssignsCrossChannelMeasurePendingInboundUsingDeclaredFacts() { long customerId = 9L; long inboundId = 10L; long orderId = 30L; @@ -506,7 +506,7 @@ class SmartLoadingServiceTest { projection.setInboundCode("I10"); projection.setWarehouseId(100L); projection.setChannelCode("SLOW"); - projection.setProcessStatus("WAIT_MEASURE"); + projection.setProcessStatus("MEASURE_PENDING"); var snapshot = new com.baiyue.firstleg.dto.OrderApprovedInboundCommand( customerId, orderId, "O30", "I10", customerId, "客户", 100L, "义乌仓", null, null, null, "FIRST_LEG", "SLOW", "慢船", null, null, null, @@ -1046,6 +1046,56 @@ class SmartLoadingServiceTest { verify(fbaPricingService).recalculateUnlockedContainerCustomerFees(eq(99L), eq(19L), anyString()); } + @Test + void removingSharedPalletChecksEveryInboundOnceAfterAllLinesAreRemoved() { + LoadingPlanItemEntity first = item(8L, 9L, 20L, 4); + LoadingPlanItemEntity sameInbound = item(9L, 9L, 21L, 2); + LoadingPlanItemEntity secondInbound = item(10L, 9L, 22L, 3); + secondInbound.setInboundId(11L); + LoadingPlanItemEntity otherCustomer = item(11L, 19L, 23L, 1); + List members = List.of(first, sameInbound, secondInbound, otherCustomer); + members.forEach(value -> value.setPalletId(801L)); + when(repository.plan(1L)).thenReturn(plan("EDITING", 2), plan("EDITING", 3)); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.activeItemsByPallet(801L)).thenReturn(members); + when(repository.touch(1L, 2, 7L, "u")).thenReturn(1); + when(repository.remove(eq(1L), anyLong(), eq(0), eq(7L), eq("u"))).thenReturn(1); + + Plan result = service.removePallet(1L, 801L, + new com.baiyue.firstleg.dto.SmartLoadingDtos.RemovePalletRequest(2, "remove-shared-pallet"), + 7L, "u"); + + assertThat(result.items()).isEmpty(); + var ordered = inOrder(repository, loadingModelBridge); + for (LoadingPlanItemEntity member : members) { + ordered.verify(repository).remove(1L, member.getId(), 0, 7L, "u"); + } + ordered.verify(loadingModelBridge).removeIfUnallocated(1L, 9L, 10L); + ordered.verify(loadingModelBridge).removeIfUnallocated(1L, 9L, 11L); + ordered.verify(loadingModelBridge).removeIfUnallocated(1L, 19L, 10L); + verify(loadingModelBridge, times(3)).removeIfUnallocated(anyLong(), anyLong(), anyLong()); + verify(repository).touch(1L, 2, 7L, "u"); + } + + @Test + void sharedPalletWithAnyPickedMemberCannotRemoveLinesOrLinks() { + LoadingPlanItemEntity first = item(8L, 9L, 20L, 4); + LoadingPlanItemEntity second = item(9L, 9L, 21L, 3); + second.setInboundId(11L); + when(repository.plan(1L)).thenReturn(plan("LOADING", 2)); + when(repository.activeItemsByPallet(801L)).thenReturn(List.of(first, second)); + when(loadingPickService.mutationSnapshot(1L)).thenReturn( + new LoadingPickService.MutationSnapshot(77L, "LOADING", 5, Map.of(9L, 3))); + + assertThatThrownBy(() -> service.removePallet(1L, 801L, + new com.baiyue.firstleg.dto.SmartLoadingDtos.RemovePalletRequest(2, "keep-picked-pallet"), + 7L, "u")).isInstanceOf(BizException.class).hasMessageContaining("已发生实物拣货"); + + verify(repository, never()).remove(anyLong(), anyLong(), anyInt(), anyLong(), anyString()); + verify(loadingPickService, never()).remove(anyLong()); + verify(loadingModelBridge, never()).removeIfUnallocated(anyLong(), anyLong(), anyLong()); + } + @Test void removesAllUnpickedItemsWithOnePlanVersionChange() { LoadingPlanEntity initial = plan("EDITING", 2); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java index 0f52cade2..29294fc3f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java @@ -10,6 +10,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -69,9 +71,11 @@ class SplitPreloadServiceTest { assertThat(captor.getValue().getRemark()).isEqualTo("智能装柜分配自动建立"); } - @Test - void pendingReviewPreAllocationCreatesAProvisionalPreloadLifecycle() { - when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "PREPARING", 0)); + @ParameterizedTest + @ValueSource(strings = {"PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", + "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT"}) + void preAllocationCreatesAProvisionalPreloadLifecycleForEveryCandidateState(String processStatus) { + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, processStatus, 0)); when(repository.findPlan(10, 20)).thenReturn(null); service.ensurePendingForPreAllocation(10, 20, 7, "admin"); @@ -80,7 +84,17 @@ class SplitPreloadServiceTest { org.mockito.ArgumentCaptor.forClass(PreloadPlanEntity.class); verify(repository).createPlan(captor.capture()); assertThat(captor.getValue().getStatus()).isEqualTo("PENDING"); - assertThat(captor.getValue().getRemark()).contains("审核前"); + assertThat(captor.getValue().getRemark()).isEqualTo("智能装柜提前分配自动建立"); + } + + @Test + void preAllocationRejectsPalletizingState() { + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_PALLETIZE", 0)); + + assertThatThrownBy(() -> service.ensurePendingForPreAllocation(10, 20, 7, "admin")) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class) + .hasMessage("订单当前状态不能加入装柜清单"); + verify(repository, never()).createPlan(any()); } @Test diff --git a/print/AGENTS.md b/print/AGENTS.md index f1589d6f3..dce9598d2 100644 --- a/print/AGENTS.md +++ b/print/AGENTS.md @@ -4,13 +4,14 @@ - 本模块是无数据库的无状态 PDF 渲染服务;调用方传入完整 `labels[]` 或 `documents[]`,模块在内存中生成一次性多页 PDF 并同步返回。 - 不保存模板、任务、历史或文件,不连接数据库、File 服务或打印机,不反查或修正业务数据。 -- 当前支持 `INBOUND_MARK_V1`(`100mm x 100mm`)、`DROPSHIP_PALLET_A4_V2`、`SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1` 和 `BILL_OF_LADING_CONFIRMATION_A4_V1`(后三者均为 `210mm x 297mm`);一件代发托盘模板采用黑白打印版,每托固定一页,展示托盘 Code 128 一维码、目的仓、最多 10 行入仓码/客户昵称汇总和最多 34 行 SKU 汇总,不展示作业状态、重量或体积。新尺寸或布局必须使用新模板编码。 +- 当前支持 `INBOUND_MARK_V1`(100×100 mm)、`FBA_PALLET_A4_LANDSCAPE_V1`(297×210 mm),以及纵向 A4 的 `DROPSHIP_PALLET_A4_V2`、`SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1`、`BILL_OF_LADING_CONFIRMATION_A4_V1`、`DISPATCH_BILL_OF_LADING_A4_V2`。FBA 模板由国内和海外统一使用,每托一页,只展示 SCK8 等 FBA 仓码、完整托盘号的 Code 128 条码和完整托盘号;141 等内部编号保留在托盘号中。一件代发每托一页,展示托盘 Code 128、目的仓、最多 10 行入仓码/客户昵称和最多 34 行 SKU,不展示状态、重量或体积。新尺寸或布局必须使用新模板编码。 ## 快速索引 | 功能 | 入口 | |---|---| | 调用方接入指南 | `README.md` | +| 国内/海外统一 FBA 托盘标 | `dto/FbaPalletPdfDtos.java`、`service/FbaPalletPdfService.java`、`renderer/FbaPalletRenderer.java`;内部 `/internal/print/v1/pdfs/fba-pallet-labels`,回归 `src/test/java/com/baiyue/print/service/FbaPalletPdfServiceTest.java` | | 应用入口 | `src/main/java/com/baiyue/print/PrintServiceApplication.java` | | 健康状态 API | `controller/PrintHealthController.java`(`/api/platform/v1/print/status`) | | 内部 PDF API | `controller/InternalPdfController.java`(`/internal/print/v1/pdfs/inbound-marks`、`/internal/print/v1/pdfs/dropship-pallet-a4-documents`、`/internal/print/v1/pdfs/freight-a4-documents`) | diff --git a/print/README.md b/print/README.md index 031bb4aaf..fd61ae3cc 100644 --- a/print/README.md +++ b/print/README.md @@ -8,6 +8,7 @@ - 服务发现名:`print-service`。 - 内部地址:`POST /internal/print/v1/pdfs/inbound-marks`。 +- FBA 亚马逊托盘标:`POST /internal/print/v1/pdfs/fba-pallet-labels`;国内散货打印与海外 FBA 单托补打统一使用 `FBA_PALLET_A4_LANDSCAPE_V1`。每托一页 297×210 mm 横向 A4,仅展示 FBA 仓码、完整托盘号的 Code 128 条码和完整托盘号文字。 - 一件代发 A4 托盘信息单:`POST /internal/print/v1/pdfs/dropship-pallet-a4-documents`;模板固定为 `DROPSHIP_PALLET_A4_V2`,采用黑白打印版,每托一页 A4,包含托盘 Code 128 一维码、目的仓、最多 10 行入仓码/客户昵称汇总和最多 34 行 SKU 汇总,不展示托盘序号、作业状态、重量或体积。 - A4 海运单据内部地址:`POST /internal/print/v1/pdfs/freight-a4-documents`。 - 海外仓排车 BOL 内部地址:`POST /internal/print/v1/pdfs/dispatch-bol-documents`;模板固定为 `DISPATCH_BILL_OF_LADING_A4_V2`,每个 document 对应一张 A4、包含 1-26 行明细。调用方首张按 18 行、续页按 26 行稳定分页;同页连续相同柜号和托盘号由 Print 纵向合并显示。 @@ -20,6 +21,21 @@ ## 2. 请求契约 +### FBA 亚马逊托盘标 + +```json +{ + "templateCode": "FBA_PALLET_A4_LANDSCAPE_V1", + "pageSpec": { "widthMm": 297, "heightMm": 210 }, + "downloadFileName": "FBA托盘标.pdf", + "labels": [{ "fbaWarehouseCode": "SCK8", "palletCode": "MSCU6639870-141-001" }] +} +``` + +两个业务字段均必填:`fbaWarehouseCode` 为正式 FBA 仓码(1–32 位大写字母、数字或连字符,首位不得为连字符);`palletCode` 为已保存的完整托盘业务编码(1–80 位可打印 ASCII,无首尾空格),不另传条码内容以免文字与条码不一致。`141` 等地址内部编号保留在托盘号中,不取代顶部的 `SCK8`。调用方不重新编号,Print 不裁切、不补号、不反查仓库;缺失或不合法数据返回 `A0422`。每个请求支持 1–2000 张,沿用全局请求体、输出大小及并发上限。文件名必填,最多 120 字符,以 `.pdf` 结尾且无路径、保留字符或控制字符。 + +Firstleg 从冻结标签的 `destinationWarehouseCode/palletCode` 映射;海外资源服务从当前托盘的 `fbaWarehouseCode/palletCode` 映射。两个入口共用本接口、模板和校验;非 FBA 托盘与头程箱唛使用各自模板,不由 FBA 模板推断类型。 + ### 2.1 入仓标签 ```http diff --git a/print/src/main/java/com/baiyue/print/renderer/FbaPalletRenderer.java b/print/src/main/java/com/baiyue/print/renderer/FbaPalletRenderer.java index 33029c6c2..094a4e6e9 100644 --- a/print/src/main/java/com/baiyue/print/renderer/FbaPalletRenderer.java +++ b/print/src/main/java/com/baiyue/print/renderer/FbaPalletRenderer.java @@ -23,9 +23,9 @@ public class FbaPalletRenderer { PdfUnits units = new PdfUnits(210); try (PDPageContentStream stream = new PDPageContentStream(pdf, page)) { stream.setNonStrokingColor(0, 0, 0); - centered(stream, units, font, label.fbaWarehouseCode(), 96, 49); - barcodeRenderer.draw(stream, barcode, PdfUnits.mmToPt(33.5), units.topDownMmToPdfY(140), - PdfUnits.mmToPt(230), PdfUnits.mmToPt(55)); + centered(stream, units, font, label.fbaWarehouseCode(), 280, 60); + barcodeRenderer.draw(stream, barcode, PdfUnits.mmToPt(33.5), units.topDownMmToPdfY(146), + PdfUnits.mmToPt(230), PdfUnits.mmToPt(30)); centered(stream, units, font, label.palletCode(), 64, 174); } } diff --git a/print/src/main/resources/openapi/print-internal-v1.yaml b/print/src/main/resources/openapi/print-internal-v1.yaml index a0c53fbbc..c10c4e152 100644 --- a/print/src/main/resources/openapi/print-internal-v1.yaml +++ b/print/src/main/resources/openapi/print-internal-v1.yaml @@ -4,6 +4,29 @@ info: version: 1.0.0 description: Internal stateless PDF rendering API. This path must not be exposed through Gateway. paths: + /internal/print/v1/pdfs/fba-pallet-labels: + post: + summary: Generate shared domestic and overseas FBA pallet labels as landscape A4 pages + operationId: generateFbaPalletLabels + description: One label per page in input order. The destination is the FBA code such as SCK8; the stored pallet code (including internal warehouse number such as 141) is printed verbatim and encoded as Code 128. No business lookup or new numbering. + security: [{ internalServiceToken: [] }] + parameters: [{ $ref: '#/components/parameters/RequestId' }] + requestBody: + required: true + content: { application/json: { schema: { $ref: '#/components/schemas/FbaPalletPdfRequest' } } } + responses: + '200': + description: PDF generation succeeded, or a business error was returned as ApiResponse JSON. + headers: + Content-Disposition: { schema: { type: string } } + Cache-Control: { schema: { type: string, example: no-store } } + X-Print-Page-Count: { schema: { type: integer, minimum: 1, maximum: 2000 } } + content: + application/pdf: { schema: { type: string, format: binary } } + application/json: { schema: { $ref: '#/components/schemas/ApiError' } } + '413': + description: Request body exceeds the configured limit. + content: { application/json: { schema: { $ref: '#/components/schemas/ApiError' } } } /internal/print/v1/pdfs/inbound-marks: post: summary: Generate an inbound-mark PDF @@ -153,6 +176,28 @@ components: schema: type: string schemas: + FbaPalletPdfRequest: + type: object + required: [templateCode, pageSpec, downloadFileName, labels] + properties: + templateCode: { type: string, enum: [FBA_PALLET_A4_LANDSCAPE_V1] } + pageSpec: + type: object + required: [widthMm, heightMm] + properties: + widthMm: { type: number, enum: [297] } + heightMm: { type: number, enum: [210] } + downloadFileName: { type: string, minLength: 4, maxLength: 120, description: PDF filename without path separators or control characters } + labels: + type: array + minItems: 1 + maxItems: 2000 + items: + type: object + required: [fbaWarehouseCode, palletCode] + properties: + fbaWarehouseCode: { type: string, pattern: '^[A-Z0-9][A-Z0-9-]{0,31}$', example: SCK8 } + palletCode: { type: string, minLength: 1, maxLength: 80, description: Printable ASCII with no surrounding spaces; unchanged text and Code 128 payload, example: MSCU6639870-141-001 } InboundMarkPdfRequest: type: object required: [templateCode, pageSpec, labels] diff --git a/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java b/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java index ec08c6df6..6e6b3cd3e 100644 --- a/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java +++ b/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java @@ -10,6 +10,7 @@ import com.baiyue.print.service.FreightA4PdfService; import com.baiyue.print.service.DispatchBolPdfService; import com.baiyue.print.service.DropshipPalletA4PdfService; import com.baiyue.print.service.InboundMarkPdfService; +import com.baiyue.print.service.FbaPalletPdfService; import jakarta.annotation.Resource; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -49,6 +50,8 @@ class InternalPdfControllerTest { private DispatchBolPdfService dispatchBolPdfService; @MockBean private DropshipPalletA4PdfService dropshipPalletA4PdfService; + @MockBean + private FbaPalletPdfService fbaPalletPdfService; @Resource private InternalPdfController controller; private MockMvc mockMvc; @@ -60,6 +63,24 @@ class InternalPdfControllerTest { .build(); } + @Test + void protectsFbaLabelsAndReturnsPdfHeaders() throws Exception { + String json = """ + {"templateCode":"FBA_PALLET_A4_LANDSCAPE_V1","pageSpec":{"widthMm":297,"heightMm":210}, + "downloadFileName":"fba.pdf","labels":[{"fbaWarehouseCode":"SCK8","palletCode":"MSCU6639870-141-001"}]} + """; + mockMvc.perform(post("/internal/print/v1/pdfs/fba-pallet-labels").contentType(MediaType.APPLICATION_JSON).content(json)) + .andExpect(jsonPath("$.code").value("A0403")); + verify(fbaPalletPdfService, never()).generate(any()); + byte[] pdf = "%PDF-fba".getBytes(StandardCharsets.US_ASCII); + when(fbaPalletPdfService.generate(any())).thenReturn(new FbaPalletPdfService.GeneratedPdf(pdf, "fba.pdf", 1)); + mockMvc.perform(post("/internal/print/v1/pdfs/fba-pallet-labels").header(InternalPdfController.INTERNAL_TOKEN_HEADER, TOKEN) + .contentType(MediaType.APPLICATION_JSON).content(json)) + .andExpect(status().isOk()).andExpect(content().contentType(MediaType.APPLICATION_PDF)) + .andExpect(content().bytes(pdf)).andExpect(header().string("X-Print-Page-Count", "1")) + .andExpect(header().string("Cache-Control", "no-store")); + } + @Test void returnsPdfWithDownloadHeaders() throws Exception { byte[] pdf = "%PDF-test".getBytes(StandardCharsets.US_ASCII); diff --git a/print/src/test/java/com/baiyue/print/controller/PrintRequestSizeFilterTest.java b/print/src/test/java/com/baiyue/print/controller/PrintRequestSizeFilterTest.java index 16ed3c315..2e24244e3 100644 --- a/print/src/test/java/com/baiyue/print/controller/PrintRequestSizeFilterTest.java +++ b/print/src/test/java/com/baiyue/print/controller/PrintRequestSizeFilterTest.java @@ -52,6 +52,16 @@ class PrintRequestSizeFilterTest { assertTooLarge(response); } + @Test + void capsFbaPalletRequestsBeforeDeserialization() throws Exception { + var response = new MockHttpServletResponse(); + AtomicReference called = new AtomicReference<>(false); + filter.doFilter(request("12345678901", true, "/internal/print/v1/pdfs/fba-pallet-labels"), response, + (ignoredRequest, ignoredResponse) -> called.set(true)); + assertThat(called).hasValue(false); + assertTooLarge(response); + } + @Test void rejectsOversizedDeclaredContentLengthWithoutCallingDownstream() throws Exception { MockHttpServletRequest request = new MockHttpServletRequest() { diff --git a/print/src/test/java/com/baiyue/print/service/FbaPalletPdfServiceTest.java b/print/src/test/java/com/baiyue/print/service/FbaPalletPdfServiceTest.java new file mode 100644 index 000000000..0b8ed7432 --- /dev/null +++ b/print/src/test/java/com/baiyue/print/service/FbaPalletPdfServiceTest.java @@ -0,0 +1,90 @@ +package com.baiyue.print.service; + +import static org.assertj.core.api.Assertions.*; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.print.config.PrintPdfProperties; +import com.baiyue.print.dto.FbaPalletPdfDtos; +import com.baiyue.print.renderer.FbaPalletRenderer; +import com.baiyue.print.renderer.PdfBarcodeRenderer; +import com.baiyue.print.renderer.PdfUnits; +import com.google.zxing.*; +import com.google.zxing.common.HybridBinarizer; +import jakarta.annotation.Resource; +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import org.apache.pdfbox.Loader; +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; + +@SpringJUnitConfig(classes = {FbaPalletPdfService.class, PrintPdfProperties.class, + PdfGenerationLimiter.class, FbaPalletRenderer.class, PdfBarcodeRenderer.class}) +class FbaPalletPdfServiceTest { + @Resource private FbaPalletPdfService service; + + @Test + void rendersLandscapePagesWithOnlyFbaCodeAndUnchangedDecodablePalletIdentity() throws Exception { + var labels = List.of(new FbaPalletPdfDtos.Label("SCK8", "MSCU6639870-141-001"), + new FbaPalletPdfDtos.Label("ONT8", "MSCU6639870-CADF-002"), + new FbaPalletPdfDtos.Label("SCK8", "A".repeat(80))); + var result = service.generate(request(labels)); + assertThat(result.pageCount()).isEqualTo(3); + assertThat(result.fileName()).isEqualTo("FBA托盘标.pdf"); + try (var pdf = Loader.loadPDF(result.content())) { + assertThat(pdf.getNumberOfPages()).isEqualTo(labels.size()); + var renderer = new PDFRenderer(pdf); + for (int i = 0; i < labels.size(); i++) { + var page = pdf.getPage(i); + assertThat(page.getMediaBox().getWidth()).isCloseTo(PdfUnits.mmToPt(297), within(.002f)); + assertThat(page.getMediaBox().getHeight()).isCloseTo(PdfUnits.mmToPt(210), within(.002f)); + assertThat(page.getRotation()).isZero(); + var stripper = new PDFTextStripper(); + stripper.setStartPage(i + 1); stripper.setEndPage(i + 1); + assertThat(stripper.getText(pdf).strip().lines().toList()) + .containsExactly(labels.get(i).fbaWarehouseCode(), labels.get(i).palletCode()); + for (var name : page.getResources().getFontNames()) { + assertThat(page.getResources().getFont(name).isEmbedded()).isTrue(); + } + var image = renderer.renderImageWithDPI(i, 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(labels.get(i).palletCode()); + assertThat(decoded.getBarcodeFormat()).isEqualTo(BarcodeFormat.CODE_128); + } + } + } + + @Test + void rejectsInvalidIdentifiersWithoutChangingThem() { + for (String code : new String[] {"", "托盘001", "A".repeat(81), " PALLET", "PALLET\n"}) { + assertThatThrownBy(() -> service.generate(request(List.of(new FbaPalletPdfDtos.Label("SCK8", code))))) + .isInstanceOf(BizException.class).hasMessageContaining("palletCode"); + } + assertThatThrownBy(() -> service.generate(request(List.of(new FbaPalletPdfDtos.Label(null, "MSCU6639870-141-001"))))) + .isInstanceOf(BizException.class).hasMessageContaining("fbaWarehouseCode"); + } + + @Test + void rejectsPortraitWrongTemplateEmptyBatchAndUnsafeFilename() { + var valid = request(List.of(new FbaPalletPdfDtos.Label("SCK8", "MSCU6639870-141-001"))); + assertThatThrownBy(() -> service.generate(new FbaPalletPdfDtos.Request(valid.templateCode(), + new FbaPalletPdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297")), valid.downloadFileName(), valid.labels()))) + .isInstanceOf(BizException.class).hasMessageContaining("landscape A4"); + assertThatThrownBy(() -> service.generate(new FbaPalletPdfDtos.Request("INBOUND_MARK_V1", valid.pageSpec(), valid.downloadFileName(), valid.labels()))) + .isInstanceOf(BizException.class).hasMessageContaining("templateCode"); + assertThatThrownBy(() -> service.generate(request(List.of()))) + .isInstanceOf(BizException.class).hasMessageContaining("labels count"); + assertThatThrownBy(() -> service.generate(new FbaPalletPdfDtos.Request(valid.templateCode(), valid.pageSpec(), "../label.pdf", valid.labels()))) + .isInstanceOf(BizException.class).hasMessageContaining("downloadFileName"); + } + + private FbaPalletPdfDtos.Request request(List labels) { + return new FbaPalletPdfDtos.Request(FbaPalletPdfDtos.TEMPLATE, + new FbaPalletPdfDtos.PageSpec(new BigDecimal("297"), new BigDecimal("210")), "FBA托盘标.pdf", labels); + } +} diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 0b9ee3e0c..e92f077ca 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -11,7 +11,7 @@ const OPTIONAL_MODULES = ['phase4-overseas-return-followups', 'manual-order-spli 'manual-pda-ready-fixture', 'manual-pda-ready-verify', 'manual-pda-batch-fixture', 'manual-pda-batch-verify', 'manual-loading-complete-fixture', 'manual-loading-complete-verify', - 'coload-groups', 'dropship-pallet-matrix', 'dropship-browser-fixture', 'mixed-container-receiving'] + 'coload-groups', 'dropship-pallet-matrix', 'dropship-browser-fixture', 'mixed-container-receiving', 'pda-loading-link-regression'] const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES]) const A0_LOCATION_PAGE_SIZE = 200 const A0_MAX_LOCATION_MUTATIONS = 8 @@ -42,6 +42,7 @@ export function planA0LocationIsolation(locations, requiredSlots = 1, } const MODULE_DEPENDENCIES = new Map([ + ['pda-loading-link-regression', ['merchant-qualification-fixture']], ['phase4-overseas-return-followups', ['phase4-overseas-return-receipt']], ['dropship-pallet-matrix', ['merchant-qualification-fixture']], ['dropship-browser-fixture', ['merchant-qualification-fixture']], diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index c700799d1..5b2d5d1ea 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -4,6 +4,8 @@ 总入口是 `../admin-test-env-acceptance.mjs`。每个业务模块使用独立的同名 `.mjs` 文件,避免多个对话同时修改总入口。 +`pda-loading-link-regression.mjs` 通过总入口 `--modules pda-loading-link-regression` 运行,复用 `dropship-pallet-matrix.mjs` 的国内正式订单、测量、封托、订舱、证据和完成生产链,仅执行国内关联回归。覆盖单票加入后全移除换柜完成、多票共托反复换三个旧柜后完成、逐托即时提交及同键重放,以及一单多柜中仍有实际分配的未开始柜导致 `OWNER_READY/A0409`。每次重新创建带 marker 的业务数据,通过公开 HTTP 回查旧柜有效分配数、PDA 终态和入仓状态;不写业务数据库伪造前置。零分配残留由真实整托移除链观察,报告保存 `createdResources.pdaLoadingLinkRegression`。负向柜保留为 `COMPLETION_FAILED`,成功柜保留为 `COMPLETED`,不自动清理;失败报告保留且不得算通过。使用显式测试环境文件及 `--output reports/<本轮名称>.json`,须有真实 HTTP 写入授权;不启动海外收货或出库。 + 退货专项同时核验新建退货单、退货主单、签收单、批次和自动箱唛的完整业务编号:`RT/RR/RB/RC/RETU-` 加 25 位大写 Base36 UUID 编码;不再截取内部 ID 六位尾号。幂等重放仍核对同一已保存编号,手工指定箱唛不受系统生成格式约束。编号生成器另有完整 128 位编码往返、原截断碰撞和本地并行生成单测;本地生成压测不增加 HTTP 并发。 退货专项只运行 `--modules phase4-overseas-return-receipt`,自动准备本轮客户;覆盖非法/重复预报、退货单号和 SKU 查询、非法/未知 SKU 实收、超收及失败不落账、分批签收完成、部分签收禁止合箱、合箱非法箱规/数量/来源、幂等重放前后库存快照一致、逐箱分页无重复遗漏、客户 SKU 数量守恒及标签。通过显式测试环境文件运行总入口,JSON 路径由 `--output` 指定;会创建隔离测试数据,finally 停用临时 PDA,不删除业务记录。失败保留报告,先核对记录再决定是否新批次重跑。 diff --git a/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs b/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs index c459da015..fd3121c28 100644 --- a/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs +++ b/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs @@ -13,6 +13,7 @@ import { runUntilPickupReady } from './phase4-carrier-nodes.mjs' import { resolvePickerAccess, createPicker, disablePicker, uploadPhotos } from './phase4-overseas-pda.mjs' import { receivingProgressMatches, outboundCandidateGuards, outboundCreationGuards, outboundTaskGuards, probePartialPick } from './dropship-outbound-edge-cases.mjs' import { receiveMixedContainer } from './mixed-container-receiving.mjs' +import { exerciseOldCabinets } from './pda-loading-link-regression.mjs' const MODULE = 'dropship-pallet-matrix' const BASE = '/api/platform/v1/first-leg/pda-dropship' @@ -42,7 +43,11 @@ export async function runMixedContainer(context) { return runWorkflow(context, false, true) } -async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = false) { +export async function runDomesticLinkRegression(context) { + return runWorkflow(context, false, false, true) +} + +async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = false, linkRegression = false) { const marker = context.marker const savedOptions = context.options.prepareDropshipManualCount const savedContext = { ...context.context } @@ -122,7 +127,7 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals record.summary = inbound.summary record.domesticVerified = true } - async function allocate(group) { + async function allocate(group, regression = {}) { const label = group.map((item) => item.label).join('+') context.marker = `${marker}-${group[0].label}-LOAD` const smart = '/api/platform/v1/first-leg/smart-loading' @@ -141,6 +146,7 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals check(`${record.label}初始费用已计算`, null, Boolean(fee)) } const summary = group[0].summary + if (regression.rounds) await exerciseOldCabinets({ context, group, pallets, command, check, rounds: regression.rounds }) let cabinet = await command('创建本场景模拟柜', 'POST', '/api/platform/v1/containers', { channelGroupCode: channelGroup(summary.channelCode), warehouseId: String(summary.warehouseId), containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, @@ -205,6 +211,13 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals await conserve('智能推荐后') } for (const record of group) record.loading = { cabinet, plan, allocationVerified: true } + if (regression.activeUnstarted) { + const pallet = pallets.at(-1) + plan = await command('分配另一实托到未开始柜前移除', 'DELETE', `${path}/pallet-assignments/${pallet.palletId}`, { + expectedPlanVersion: Number(plan.version), operationId: `${context.marker}-split-remove`, + }) + await exerciseOldCabinets({ context, group, pallets: [pallet], command, check, rounds: 1, active: true }) + } const carrier = await ensureMatsonCarrierPartner(context, MODULE, OPTIONS) const agent = await resolveOceanBookingAgent(context, MODULE, 'MATSON', OPTIONS) check(`${label}真实船司和订舱代理齐全`, null, Boolean(carrier?.id && agent?.id)) @@ -259,8 +272,22 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals claimed.items.length > 0 && (mixedContainer ? claimed.items.some(item => item.unitType === 'SEALED_PALLET') && claimed.items.some(item => item.unitType !== 'SEALED_PALLET') : claimed.items.every((item) => item.unitType === 'SEALED_PALLET'))) - const picked = await command('整托拣货', 'POST', `${pda}/batch`, batch, - { 'Idempotency-Key': `${context.marker}-pick` }) + let picked = claimed + if (linkRegression) { + for (const palletNo of [...new Set(claimed.items.map(item => item.palletNo))]) { + const priorVersion = Number(picked.version) + const payload = { warehouseId: String(summary.warehouseId), palletNo, + expectedTaskVersion: priorVersion, operationId: `${context.marker}-pick-${palletNo}` } + picked = await command('逐托即时提交', 'POST', `${pda}/pallets/pick`, payload) + check('即时提交返回最新任务版本', null, Number(picked.version) > priorVersion) + const replay = await command('逐托提交同键重放', 'POST', `${pda}/pallets/pick`, payload) + check('重放不重复累加数量或版本', null, + replay.version === picked.version && JSON.stringify(replay.items) === JSON.stringify(picked.items)) + } + } else { + picked = await command('整托拣货', 'POST', `${pda}/batch`, batch, + { 'Idempotency-Key': `${context.marker}-pick` }) + } check(`${label}整托拣货等待最终确认`, null, picked.workflowStatus === 'AWAITING_REVIEW') for (const evidenceType of ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']) { const response = await uploadEvidence(context, taskId, String(summary.warehouseId), evidenceType, 0, @@ -276,6 +303,18 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals }, { 'Idempotency-Key': `${context.marker}-complete` }) const terminal = await waitForCompletion(context, `${pda}?warehouseId=${summary.warehouseId}`, `${pda}/operations`) const finalTask = terminal.task + if (regression.activeUnstarted) { + check('有实际货物的未开始柜次阻止PDA完成', terminal.taskResponse, + finalTask?.taskStatus === 'COMPLETION_FAILED' + && finalTask.completionCommand?.failureStage === 'OWNER_READY' + && finalTask.completionCommand?.failureCode === 'A0409') + const inbound = await command('失败后回查入仓状态未完成', 'GET', + `/api/platform/inbounds/${group[0].source.inboundId}?tenantId=${group[0].order.tenantId}`) + check('负向完成未推进LOADED', null, inbound.summary?.processStatus === 'LOADING') + context.createdResources.pdaLoadingLinkRegression.scenarios.push({ name: label, taskId, + outcome: 'ACTIVE_UNSTARTED_BLOCKED', task: finalTask }) + return + } check(`${label}任务真实完成`, terminal.taskResponse, finalTask?.taskStatus === 'COMPLETED', `status=${finalTask?.taskStatus}, attempts=${terminal.attempts}`) for (const record of group) { @@ -285,6 +324,8 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals record.loading = { cabinet: linked, plan, task: finalTask, completed, booking: released, allocationVerified: true, loadingVerified: true, containerNo } } + if (linkRegression) context.createdResources.pdaLoadingLinkRegression.scenarios.push({ + name: label, taskId, outcome: 'COMPLETED', task: finalTask }) } async function progressShipping(group) { const record = group[0] @@ -580,6 +621,21 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals context.createdResources.dropshipPalletMatrix.stage = 'MIXED_CONTAINER_INBOUND_COMPLETED' return } + if (linkRegression) { + const single = await prepare('LINK-SINGLE', 2) + await verify(single, 2) + await allocate([single], { rounds: 1 }) + const first = await prepare('MULTI-MERGE-LINK-A', 1, { deferSeal: true }) + const second = await prepare('MULTI-MERGE-LINK-B', 1, { targetPalletNo: first.task.pallets[0].palletNo }) + await verify(first, 1) + await verify(second, 1) + await allocate([first, second], { rounds: 3 }) + const negative = await prepare('LINK-NEGATIVE', 2) + await verify(negative, 2) + await allocate([negative], { activeUnstarted: true }) + context.createdResources.dropshipPalletMatrix.stage = 'DOMESTIC_LINK_REGRESSION_VERIFIED' + return + } if (stopBeforeOverseasPda) { const record = await prepare('BROWSER-MULTI', 2) await verify(record, 2) diff --git a/scripts/admin-test-env-modules/pda-loading-link-regression.mjs b/scripts/admin-test-env-modules/pda-loading-link-regression.mjs new file mode 100644 index 000000000..e360dc35f --- /dev/null +++ b/scripts/admin-test-env-modules/pda-loading-link-regression.mjs @@ -0,0 +1,46 @@ +import { runDomesticLinkRegression } from './dropship-pallet-matrix.mjs' +import { channelGroup } from './smart-loading-channel-group.mjs' + +export const run = runDomesticLinkRegression + +// All resources come from this run's HTTP producer. No direct database writes. +export async function exerciseOldCabinets({ context, group, pallets, command, check, rounds, active = false }) { + const summary = group[0].summary + const evidence = context.createdResources.pdaLoadingLinkRegression ||= { cabinets: [], scenarios: [] } + for (let round = 0; round < rounds; round++) { + const cabinet = await command('创建回归旧柜', 'POST', '/api/platform/v1/containers', { + channelGroupCode: channelGroup(summary.channelCode), warehouseId: String(summary.warehouseId), + containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, + }) + const smart = '/api/platform/v1/first-leg/smart-loading' + let plan = await command('读取回归旧柜计划', 'GET', `${smart}/plans/by-booking/${cabinet.id}`) + const path = `${smart}/plans/${plan.id}` + const record = { cabinetNo: cabinet.cabinetNo, cabinetId: cabinet.id, planId: plan.id, active, rounds: round + 1 } + evidence.cabinets.push(record) + for (const pallet of pallets) { + plan = await command('旧柜加入整托', 'POST', `${path}/pallet-assignments`, { + palletId: String(pallet.palletId), expectedVersion: Number(plan.version), + operationId: `${context.marker}-old-add-${plan.id}-${pallet.palletId}`, + }) + } + check('旧柜真实加入箱数大于零', null, plan.items?.reduce((sum, i) => sum + Number(i.packageCount), 0) > 0) + if (!active) { + for (const pallet of pallets) { + plan = await command('旧柜全部移除整托', 'DELETE', `${path}/pallet-assignments/${pallet.palletId}`, { + expectedPlanVersion: Number(plan.version), operationId: `${context.marker}-old-del-${plan.id}-${pallet.palletId}`, + }) + } + check('旧柜移除后没有有效明细', null, plan.items?.length === 0) + } + record.links = [] + for (const item of group) { + const links = await command('回查旧柜关联及有效箱数', 'GET', + `/api/platform/v1/inbounds/${item.source.inboundId}/preload/loading-plans?tenantId=${item.order.tenantId}`) + const current = links.filter(link => String(link.loadingPlanId) === String(plan.id)) + record.links.push(...current) + check('旧柜关联数量与真实分配一致', null, active + ? current.some(link => Number(link.allocatedPackageCount) > 0) + : current.every(link => Number(link.allocatedPackageCount) === 0)) + } + } +} diff --git a/warehouse-overseas-service/AGENTS.md b/warehouse-overseas-service/AGENTS.md index 72157a55e..95a4c29b2 100644 --- a/warehouse-overseas-service/AGENTS.md +++ b/warehouse-overseas-service/AGENTS.md @@ -17,7 +17,7 @@ | 托盘解析与业务绑定 | `controller/InternalWarehousePalletController.java`、`dto/WarehousePalletResolveResponse.java`、`entity/WarehousePalletBindingEntity.java` | | 柜级入库计划批量托盘 | `controller/InternalWarehousePalletBatchController.java`、`service/WarehousePalletBatchService.java`、`warehouse-pallet-batch-internal-v1.yaml`;以稳定批次键幂等执行 `ensure-reserved → confirm`,权威托盘码已存在且资源可预留时必须复用,未提供或不存在时才创建;Lastleg 本地计划事务失败时仅补偿仍为 `RESERVED` 的未确认资源,已 `CONFIRMED` 批次不得回退,同柜重放不得产生第二批资源 | | 仓储资源服务 | `service/WarehouseResourceService.java`、`WarehouseResourceLockService.java` | -| 单张托盘标签 PDF | `controller/PlatformWarehouseResourceController.java` 的 `/pallets/{id}/label`、`service/PalletLabelPrintService.java`、`client/PrintPdfClient.java`;契约 `src/main/resources/openapi/pallet-label-v1.yaml`。使用公共托盘真实编码并复用 Print `INBOUND_MARK_V1`,模板仍只在 Print 维护;沿用托盘列表权限与海外 Gateway 的 `warehouse-admin/**` 路由。`baiyue.clients.print.url` 对应 `PRINT_SERVICE_URL`,海外 Compose 必须与 BOL 一样显式注入国内 Print 地址,不能依赖海外 Nacos 发现国内服务;Feign 及熔断包装异常统一返回 `S0503` | +| 单张托盘标签 PDF | `controller/PlatformWarehouseResourceController.java` 的 `/pallets/{id}/label`、`service/PalletLabelPrintService.java`、`client/PrintPdfClient.java`;契约 `src/main/resources/openapi/pallet-label-v1.yaml`。有 FBA 仓码的托盘与国内统一调用 Print `/internal/print/v1/pdfs/fba-pallet-labels`、`FBA_PALLET_A4_LANDSCAPE_V1`:297×210 mm,顶部 SCK8 等仓码,完整托盘号作为 Code 128 与底部文字,141 等内部编号原样保留;其他托盘使用 `INBOUND_MARK_V1`。模板只在 Print 维护;沿用托盘列表权限与海外 Gateway 的 `warehouse-admin/**` 路由。`baiyue.clients.print.url` 对应 `PRINT_SERVICE_URL`,海外 Compose 必须与 BOL 一样显式注入国内 Print 地址,不能依赖海外 Nacos 发现国内服务;Feign 及熔断包装异常统一返回 `S0503` | | 内部服务令牌 | `security/InternalServiceTokenGuard.java` | | 数据库基线与中文注释契约 | `src/main/resources/db/warehouse.sql`、`src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java` | | 测试 | `src/test/java/com/baiyue/warehouse/` | diff --git a/warehouse-overseas-service/src/main/resources/openapi/pallet-label-v1.yaml b/warehouse-overseas-service/src/main/resources/openapi/pallet-label-v1.yaml index 91bcfbccb..76648b372 100644 --- a/warehouse-overseas-service/src/main/resources/openapi/pallet-label-v1.yaml +++ b/warehouse-overseas-service/src/main/resources/openapi/pallet-label-v1.yaml @@ -7,7 +7,7 @@ paths: get: operationId: printOverseasPalletLabel summary: 下载指定托盘的一页标签 PDF - description: 平台公共资源,要求 platform:overseas-warehouse:pallet:list。校验仓库和托盘归属后复用 Print 的 INBOUND_MARK_V1(100×100mm),原样使用托盘编码作为二维码,不创建或修改托盘。当前模板要求编码为 1–34 位可打印 ASCII 字符。 + description: 平台公共资源,要求 platform:overseas-warehouse:pallet:list。校验仓库和托盘归属后,有 FBA 仓码的托盘统一调用 Print FBA_PALLET_A4_LANDSCAPE_V1(297×210mm),顶部显示 SCK8 等 FBA 仓码,托盘完整编码同时作为 Code 128 内容和底部文字;内部编号如 141 原样保留。FBA 编码限 1–80 位可打印 ASCII,禁止首尾空格。不创建或修改托盘。其他托盘继续使用其现有 INBOUND_MARK_V1(100×100mm,编码限 1–34 位)。 security: - bearerAuth: [] parameters: diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/PalletLabelPrintServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/PalletLabelPrintServiceTest.java index dce619ee6..5035541d1 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/PalletLabelPrintServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/PalletLabelPrintServiceTest.java @@ -30,34 +30,33 @@ class PalletLabelPrintServiceTest { ReflectionTestUtils.setField(service, "warehouses", warehouses); ReflectionTestUtils.setField(service, "print", print); ReflectionTestUtils.setField(service, "internalToken", "test-token"); - pallet.setPalletCode("ABCD1234567-ABE3-003"); - pallet.setFbaWarehouseCode("ABE3"); + pallet.setPalletCode("MSCU6639870-141-001"); + pallet.setFbaWarehouseCode("SCK8"); when(resources.pallet(0L, 200L, 9L)).thenReturn(pallet); when(warehouses.required(200L)).thenReturn(new SharedWarehouseReference(200L, "LAX", "洛杉矶仓", "ACTIVE")); } @Test - void reusesExistingRendererWithExactlyTheSelectedCode() { + void usesSharedFbaRendererWithExactlyTheSelectedCode() { byte[] pdf = "%PDF-label".getBytes(java.nio.charset.StandardCharsets.US_ASCII); - when(print.generateInboundMarks(anyString(), anyString(), any())) + when(print.generateFbaPalletLabels(anyString(), anyString(), any())) .thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).body(pdf)); var response = service.print(200L, 9L, "request-1"); - var request = ArgumentCaptor.forClass(PrintPdfClient.Request.class); - verify(print).generateInboundMarks(eq("test-token"), eq("request-1"), request.capture()); - assertThat(request.getValue().templateCode()).isEqualTo("INBOUND_MARK_V1"); - assertThat(request.getValue().pageSpec().widthMm()).isEqualByComparingTo("100"); - assertThat(request.getValue().pageSpec().heightMm()).isEqualByComparingTo("100"); + var request = ArgumentCaptor.forClass(PrintPdfClient.FbaPalletRequest.class); + verify(print).generateFbaPalletLabels(eq("test-token"), eq("request-1"), request.capture()); + assertThat(request.getValue().templateCode()).isEqualTo("FBA_PALLET_A4_LANDSCAPE_V1"); + assertThat(request.getValue().pageSpec().widthMm()).isEqualByComparingTo("297"); + assertThat(request.getValue().pageSpec().heightMm()).isEqualByComparingTo("210"); assertThat(request.getValue().labels()).singleElement().satisfies(label -> { - assertThat(label.topCode()).isEqualTo("ABCD1234567-ABE3-003"); - assertThat(label.qrContent()).isEqualTo(label.topCode()); - assertThat(label.warehouseText()).isEqualTo("ABE3"); - assertThat(label.routeText()).isEqualTo("洛杉矶仓"); + assertThat(label.palletCode()).isEqualTo("MSCU6639870-141-001"); + assertThat(label.fbaWarehouseCode()).isEqualTo("SCK8"); }); assertThat(response.getBody()).isEqualTo(pdf); verify(resources).pallet(0L, 200L, 9L); verifyNoMoreInteractions(resources); + verify(print, never()).generateInboundMarks(any(), any(), any()); } @Test @@ -69,7 +68,7 @@ class PalletLabelPrintServiceTest { @Test void mapsCircuitBreakerWrappedPrintFailureToServiceUnavailable() { - when(print.generateInboundMarks(anyString(), anyString(), any())) + when(print.generateFbaPalletLabels(anyString(), anyString(), any())) .thenThrow(new NoFallbackAvailableException("No fallback available.", new IllegalStateException("No instances available for print-service"))); @@ -82,7 +81,7 @@ class PalletLabelPrintServiceTest { @Test void rejectsJsonErrorInsteadOfDownloadingItAsPdf() { - when(print.generateInboundMarks(anyString(), anyString(), any())) + when(print.generateFbaPalletLabels(anyString(), anyString(), any())) .thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body("{}".getBytes())); assertThatThrownBy(() -> service.print(200L, 9L, "request-1")) .isInstanceOf(BizException.class).hasMessageContaining("有效 PDF"); @@ -90,11 +89,23 @@ class PalletLabelPrintServiceTest { @Test void neverTruncatesOrSubstitutesUnsupportedPalletCodes() { - for (String code : new String[] { "", "托盘001", "A".repeat(35) }) { + for (String code : new String[] { "", "托盘001", "A".repeat(81), " PALLET" }) { pallet.setPalletCode(code); assertThatThrownBy(() -> service.print(200L, 9L, "request-1")) .isInstanceOf(BizException.class).hasMessageContaining("托盘编码"); } verifyNoInteractions(print); } + + @Test + void keepsNonFbaLabelsOnTheirOwnTemplate() { + pallet.setFbaWarehouseCode(null); + when(print.generateInboundMarks(anyString(), anyString(), any())) + .thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).body("%PDF-other".getBytes())); + service.print(200L, 9L, "request-1"); + var captor = ArgumentCaptor.forClass(PrintPdfClient.Request.class); + verify(print).generateInboundMarks(anyString(), anyString(), captor.capture()); + assertThat(captor.getValue().templateCode()).isEqualTo("INBOUND_MARK_V1"); + verify(print, never()).generateFbaPalletLabels(any(), any(), any()); + } }