From 220d159cec7261cdd73d7ac750e7d0ce30a500cd Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 11 Sep 2026 10:09:06 +0800 Subject: [PATCH] feat: generate dispatch BOL PDFs --- .../baiyue/lastleg/client/PrintPdfClient.java | 28 ++++++ .../controller/DispatchQueryController.java | 24 +++++ .../service/DispatchBolPrintService.java | 88 ++++++++++++++++++ .../src/main/resources/application.yml | 2 + .../openapi/overseas-business-v1.yaml | 15 ++++ .../Phase4BrowserApiBoundaryTest.java | 2 +- .../DispatchQueryControllerTest.java | 7 ++ print/AGENTS.md | 6 +- print/README.md | 1 + .../controller/InternalPdfController.java | 16 ++++ .../baiyue/print/dto/DispatchBolPdfDtos.java | 24 +++++ .../print/renderer/DispatchBolRenderer.java | 76 ++++++++++++++++ .../print/service/DispatchBolPdfService.java | 90 +++++++++++++++++++ .../resources/openapi/print-internal-v1.yaml | 63 +++++++++++++ .../controller/InternalPdfControllerTest.java | 3 + .../DispatchBolPdfServiceGenerationTest.java | 43 +++++++++ 16 files changed, 484 insertions(+), 4 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/client/PrintPdfClient.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java create mode 100644 print/src/main/java/com/baiyue/print/dto/DispatchBolPdfDtos.java create mode 100644 print/src/main/java/com/baiyue/print/renderer/DispatchBolRenderer.java create mode 100644 print/src/main/java/com/baiyue/print/service/DispatchBolPdfService.java create mode 100644 print/src/test/java/com/baiyue/print/service/DispatchBolPdfServiceGenerationTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/client/PrintPdfClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/client/PrintPdfClient.java new file mode 100644 index 000000000..ed4ef5325 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/client/PrintPdfClient.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.client; + +import java.math.BigDecimal; +import java.util.List; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +@FeignClient(name = "print-service", contextId = "lastlegPrintPdfClient", + url = "${baiyue.clients.print.url:}", path = "/internal/print/v1/pdfs") +public interface PrintPdfClient { + @PostMapping(value = "/dispatch-bol-documents", consumes = "application/json") + ResponseEntity generateDispatchBol( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader(value = "X-Request-Id", required = false) String requestId, + @RequestBody Request request); + + record Request(String templateCode, PageSpec pageSpec, String downloadFileName, List documents) {} + record PageSpec(BigDecimal widthMm, BigDecimal heightMm) {} + record Document(String shipFromName, String shipFromAddress, String carrierName, String isaNumber, + String appointmentTime, String trailerNumber, String sealNumber, String fbaWarehouseCode, + String shipToName, String shipToAddress, String dispatchNo, String fbaShipmentIds, + String poReferenceNos, int totalPackages, int pageNumber, int pageCount, List cargoRows) {} + record CargoRow(String containerNo, String palletNo, String fbaWarehouseCode, String warehouseNo, + String fbaShipmentId, String poReferenceNo, int packageCount) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java index e01581530..feac81de7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java @@ -6,6 +6,7 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.dispatch.dto.DispatchRequests; import com.baiyue.lastleg.dispatch.dto.DispatchResponse; import com.baiyue.lastleg.dispatch.service.DispatchService; +import com.baiyue.lastleg.dispatch.service.DispatchBolPrintService; import com.baiyue.lastleg.security.OverseasPermissions; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -13,6 +14,11 @@ import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.HttpStatus; +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; @@ -20,6 +26,8 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; +import jakarta.servlet.http.HttpServletRequest; +import java.nio.charset.StandardCharsets; @RestController @ConditionalOnProperty(name = "lastleg.dispatch-query-api.enabled", havingValue = "true") @@ -27,6 +35,7 @@ import org.springframework.web.server.ResponseStatusException; @Tag(name = "Tail-leg vehicle dispatch query") public class DispatchQueryController { @Resource private DispatchService service; + @Resource private DispatchBolPrintService printService; @GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')") @@ -48,6 +57,21 @@ public class DispatchQueryController { return ApiResponse.success(service.platformDetail(dispatchNo)); } + @GetMapping(value = "/{dispatchNo}/prints/bol", produces = MediaType.APPLICATION_PDF_VALUE) + @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')") + @Operation(summary = "Generate dispatch BOL as an A4 PDF") + public ResponseEntity printBol(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String dispatchNo, HttpServletRequest request) { + requirePlatform(user); + DispatchBolPrintService.GeneratedPdf pdf = printService.generate(dispatchNo, request.getHeader("X-Request-Id")); + ResponseEntity.BodyBuilder response = ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF) + .cacheControl(CacheControl.noStore()).header(HttpHeaders.PRAGMA, "no-cache") + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(pdf.fileName(), StandardCharsets.UTF_8).build().toString()) + .header("X-Content-Type-Options", "nosniff"); + if (pdf.pageCount() != null) response.header("X-Print-Page-Count", pdf.pageCount()); + return response.contentLength(pdf.content().length).body(pdf.content()); + } + private void requirePlatform(AuthenticatedUser user) { if (user == null || user.tenantId() == null) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java new file mode 100644 index 000000000..403628775 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java @@ -0,0 +1,88 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.client.PrintPdfClient; +import com.baiyue.lastleg.dispatch.dto.DispatchResponse; +import feign.FeignException; +import jakarta.annotation.Resource; +import java.math.BigDecimal; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +@Service +public class DispatchBolPrintService { + private static final String TEMPLATE = "DISPATCH_BILL_OF_LADING_A4_V1"; + private static final int ROWS_PER_PAGE = 12; + private static final PrintPdfClient.PageSpec A4 = new PrintPdfClient.PageSpec(new BigDecimal("210"), new BigDecimal("297")); + + @Resource private DispatchService dispatches; + @Resource private PrintPdfClient print; + @Value("${baiyue.security.internal-service-token:}") private String internalToken; + + public GeneratedPdf generate(String dispatchNo, String requestId) { + DispatchResponse detail = dispatches.platformDetail(dispatchNo); + if (detail.bolLines() == null || detail.bolLines().isEmpty()) { + throw new BizException(ResultCode.VALIDATION_FAILED, "排车单没有可打印的BOL明细"); + } + List documents = documents(detail); + String fileName = safeFilePart(detail.dispatchNo()) + "-BOL.pdf"; + try { + ResponseEntity response = print.generateDispatchBol(internalToken, requestId, + new PrintPdfClient.Request(TEMPLATE, A4, fileName, documents)); + byte[] content = response.getBody(); + MediaType contentType = response.getHeaders().getContentType(); + if (!response.getStatusCode().is2xxSuccessful() || content == null || content.length == 0 + || contentType == null || !MediaType.APPLICATION_PDF.isCompatibleWith(contentType)) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务未返回有效PDF"); + } + return new GeneratedPdf(content, fileName, response.getHeaders().getFirst("X-Print-Page-Count")); + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用"); + } + } + + private List documents(DispatchResponse detail) { + Map> groups = new LinkedHashMap<>(); + for (DispatchResponse.BolLine line : detail.bolLines()) groups.computeIfAbsent(text(line.fbaWarehouseCode()), ignored -> new ArrayList<>()).add(line); + List documents = new ArrayList<>(); + for (Map.Entry> group : groups.entrySet()) { + List lines = group.getValue(); + int pages = (lines.size() + ROWS_PER_PAGE - 1) / ROWS_PER_PAGE; + int total = lines.stream().mapToInt(DispatchResponse.BolLine::packageCount).sum(); + String destination = lines.stream().map(DispatchResponse.BolLine::destinationWarehouseName).filter(v -> v != null && !v.isBlank()).findFirst().orElse("—"); + for (int page = 0; page < pages; page++) { + List rows = lines.subList(page * ROWS_PER_PAGE, Math.min(lines.size(), (page + 1) * ROWS_PER_PAGE)).stream() + .map(line -> new PrintPdfClient.CargoRow(text(line.containerNo()), text(line.palletNo()), text(line.fbaWarehouseCode()), + text(line.warehouseNo()), text(line.fbaShipmentId()), text(line.poReferenceNo()), line.packageCount())).toList(); + documents.add(new PrintPdfClient.Document(text(detail.overseasWarehouseName()), "—", text(detail.carrierName()), + text(detail.fbaAppointmentNo()), dateTime(detail.estimatedArrivalAt()), text(detail.vehiclePlate()), "—", + group.getKey(), destination, "—", text(detail.dispatchNo()), unique(lines, true), unique(lines, false), + total, page + 1, pages, rows)); + } + } + return List.copyOf(documents); + } + + private String unique(List lines, boolean shipment) { + LinkedHashSet values = new LinkedHashSet<>(); + for (DispatchResponse.BolLine line : lines) { + String raw = shipment ? line.fbaShipmentId() : line.poReferenceNo(); + if (raw != null) for (String value : raw.split(",")) if (!value.isBlank()) values.add(value.strip()); + } + return values.isEmpty() ? "—" : String.join(", ", values); + } + private String dateTime(java.time.LocalDateTime value) { return value == null ? "—" : value.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); } + private String text(String value) { return value == null || value.isBlank() ? "—" : value.strip(); } + private String safeFilePart(String value) { return text(value).replaceAll("[\\\\/:*?\"<>|]", "-"); } + + public record GeneratedPdf(byte[] content, String fileName, String pageCount) {} +} diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml index 5cf972a05..e4625e6c6 100644 --- a/lastleg-service/src/main/resources/application.yml +++ b/lastleg-service/src/main/resources/application.yml @@ -67,6 +67,8 @@ baiyue: connect-timeout: ${LASTLEG_PALLET_PHYSICAL_CONNECT_TIMEOUT:3s} read-timeout: ${LASTLEG_PALLET_PHYSICAL_READ_TIMEOUT:5s} clients: + print: + url: ${PRINT_SERVICE_URL:} domestic-warehouse: url: ${DOMESTIC_WAREHOUSE_URL:} cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} 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 27cf15481..06efa2723 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -513,6 +513,21 @@ paths: security: [{ bearerAuth: [] }] parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] responses: { '200': { description: 取消成功或已取消 }, '404': { description: 排车单不存在 }, '409': { description: 排车已开始执行 } } + /api/platform/v1/last-leg/dispatches/{dispatchNo}/prints/bol: + get: + summary: 生成排车 BOL PDF + description: 权限 platform:overseas-warehouse:dispatch:list;服务端按目的 FBA 仓分组并分页生成固定 A4 PDF,浏览器不直接访问内部打印服务。 + security: [{ bearerAuth: [] }] + parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] + responses: + '200': + description: BOL PDF + headers: + Content-Disposition: { schema: { type: string } } + X-Print-Page-Count: { schema: { type: integer, minimum: 1 } } + content: { application/pdf: { schema: { type: string, format: binary } } } + '404': { description: 排车单不存在 } + '422': { description: 排车单没有可打印的 BOL 明细 } /api/platform/v1/last-leg/dispatches/candidates: get: summary: 查询智能排车候选托盘 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java index f7b89b42b..bd545b2de 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java @@ -58,7 +58,7 @@ class Phase4BrowserApiBoundaryTest { assertTrue(Arrays.asList(condition.name()).contains("lastleg.dispatch-query-api.enabled")); assertFalse(condition.matchIfMissing()); assertTrue(Arrays.stream(DispatchQueryController.class.getDeclaredMethods()) - .filter(method -> method.isAnnotationPresent(GetMapping.class)).count() == 2); + .filter(method -> method.isAnnotationPresent(GetMapping.class)).count() == 3); assertFalse(Arrays.stream(DispatchQueryController.class.getDeclaredMethods()) .flatMap(method -> Arrays.stream(method.getAnnotations())) .anyMatch(annotation -> Set.of("PostMapping", "PutMapping", "DeleteMapping") diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryControllerTest.java index cd20a2fb0..483f033f3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryControllerTest.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.dispatch.dto.DispatchRequests; import com.baiyue.lastleg.dispatch.service.DispatchService; +import com.baiyue.lastleg.dispatch.service.DispatchBolPrintService; import com.baiyue.lastleg.security.OverseasPermissions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -25,12 +26,14 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class DispatchQueryControllerTest { @Mock DispatchService service; + @Mock DispatchBolPrintService printService; private DispatchQueryController controller; @BeforeEach void setUp() { controller = new DispatchQueryController(); ReflectionTestUtils.setField(controller, "service", service); + ReflectionTestUtils.setField(controller, "printService", printService); } @Test @@ -57,9 +60,13 @@ class DispatchQueryControllerTest { PreAuthorize detail = DispatchQueryController.class .getMethod("detail", AuthenticatedUser.class, String.class) .getAnnotation(PreAuthorize.class); + PreAuthorize print = DispatchQueryController.class + .getMethod("printBol", AuthenticatedUser.class, String.class, jakarta.servlet.http.HttpServletRequest.class) + .getAnnotation(PreAuthorize.class); assertThat(page.value()).isEqualTo("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')"); assertThat(detail.value()).isEqualTo("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')"); + assertThat(print.value()).isEqualTo("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')"); } private static AuthenticatedUser platform() { diff --git a/print/AGENTS.md b/print/AGENTS.md index 85608b755..2542e3f2e 100644 --- a/print/AGENTS.md +++ b/print/AGENTS.md @@ -15,9 +15,9 @@ | 健康状态 API | `controller/PrintHealthController.java`(`/api/platform/v1/print/status`) | | 内部 PDF API | `controller/InternalPdfController.java`(`/internal/print/v1/pdfs/inbound-marks`、`/internal/print/v1/pdfs/freight-a4-documents`) | | 内部令牌校验 | `controller/InternalServiceTokenGuard.java` | -| 请求与响应模型 | `dto/InboundMarkPdfDtos.java`、`dto/FreightA4PdfDtos.java` | -| 固定模板与渲染 | `renderer/InboundMarkV1Renderer.java`、`renderer/FreightA4Renderer.java`、`renderer/PdfQrRenderer.java`、`renderer/PdfUnits.java` | -| 请求校验与资源保护 | `controller/PrintRequestSizeFilter.java`、`service/InboundMarkPdfService.java`、`service/FreightA4PdfService.java`、`service/PdfGenerationLimiter.java` | +| 请求与响应模型 | `dto/InboundMarkPdfDtos.java`、`dto/FreightA4PdfDtos.java`、`dto/DispatchBolPdfDtos.java` | +| 固定模板与渲染 | `renderer/InboundMarkV1Renderer.java`、`renderer/FreightA4Renderer.java`、`renderer/DispatchBolRenderer.java`、`renderer/PdfQrRenderer.java`、`renderer/PdfUnits.java` | +| 请求校验与资源保护 | `controller/PrintRequestSizeFilter.java`、`service/InboundMarkPdfService.java`、`service/FreightA4PdfService.java`、`service/DispatchBolPdfService.java`、`service/PdfGenerationLimiter.java` | | PDF 参数配置 | `config/PrintPdfProperties.java` | | 接口契约 | `src/main/resources/openapi/print-internal-v1.yaml` | | 字体及授权 | `src/main/resources/fonts/NotoSansSC-Bold.ttf`、`src/main/resources/fonts/OFL.txt` | diff --git a/print/README.md b/print/README.md index 3fec0f4a3..6eb56a150 100644 --- a/print/README.md +++ b/print/README.md @@ -9,6 +9,7 @@ - 服务发现名:`print-service`。 - 内部地址:`POST /internal/print/v1/pdfs/inbound-marks`。 - A4 海运单据内部地址:`POST /internal/print/v1/pdfs/freight-a4-documents`。 +- 海外仓排车 BOL 内部地址:`POST /internal/print/v1/pdfs/dispatch-bol-documents`;模板固定为 `DISPATCH_BILL_OF_LADING_A4_V1`,每个 document 对应一张 A4、包含 1-12 行明细。 - 本地直连:`http://127.0.0.1:9207`。 - 请求必须携带 `X-Internal-Service-Token`,值使用各服务已有的 `baiyue.security.internal-service-token` 配置。 - 不把该接口加入 Gateway,也不允许 Admin 浏览器直接携带内部令牌访问 Print。 diff --git a/print/src/main/java/com/baiyue/print/controller/InternalPdfController.java b/print/src/main/java/com/baiyue/print/controller/InternalPdfController.java index 1aff9298d..11f9b2fa2 100644 --- a/print/src/main/java/com/baiyue/print/controller/InternalPdfController.java +++ b/print/src/main/java/com/baiyue/print/controller/InternalPdfController.java @@ -4,8 +4,10 @@ import jakarta.annotation.Resource; import com.baiyue.common.web.CorrelationContext; import com.baiyue.print.dto.FreightA4PdfDtos; +import com.baiyue.print.dto.DispatchBolPdfDtos; import com.baiyue.print.dto.InboundMarkPdfDtos; import com.baiyue.print.service.FreightA4PdfService; +import com.baiyue.print.service.DispatchBolPdfService; import com.baiyue.print.service.InboundMarkPdfService; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; @@ -30,6 +32,7 @@ public class InternalPdfController { @Resource private InboundMarkPdfService pdfService; @Resource private FreightA4PdfService freightA4PdfService; + @Resource private DispatchBolPdfService dispatchBolPdfService; @Resource private InternalServiceTokenGuard tokenGuard; @PostMapping(value = "/inbound-marks", consumes = MediaType.APPLICATION_JSON_VALUE) @@ -85,4 +88,17 @@ public class InternalPdfController { return response.body(content); } + + @PostMapping(value = "/dispatch-bol-documents", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity generateDispatchBolDocuments( + @RequestHeader(value = INTERNAL_TOKEN_HEADER, required = false) String internalToken, + @Valid @RequestBody DispatchBolPdfDtos.Request request) { + tokenGuard.verify(internalToken); + DispatchBolPdfService.GeneratedPdf pdf = dispatchBolPdfService.generate(request); + byte[] content = pdf.content(); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header(HttpHeaders.PRAGMA, "no-cache") + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(pdf.fileName(), StandardCharsets.UTF_8).build().toString()) + .header("X-Content-Type-Options", "nosniff").header(PAGE_COUNT_HEADER, Integer.toString(pdf.pageCount())) + .contentType(MediaType.APPLICATION_PDF).contentLength(content.length).body(content); + } } diff --git a/print/src/main/java/com/baiyue/print/dto/DispatchBolPdfDtos.java b/print/src/main/java/com/baiyue/print/dto/DispatchBolPdfDtos.java new file mode 100644 index 000000000..83983ca89 --- /dev/null +++ b/print/src/main/java/com/baiyue/print/dto/DispatchBolPdfDtos.java @@ -0,0 +1,24 @@ +package com.baiyue.print.dto; + +import java.math.BigDecimal; +import java.util.List; + +/** A4 vehicle-dispatch bill of lading request contract. */ +public final class DispatchBolPdfDtos { + public static final String TEMPLATE = "DISPATCH_BILL_OF_LADING_A4_V1"; + + private DispatchBolPdfDtos() {} + + public record Request(String templateCode, PageSpec pageSpec, String downloadFileName, + List documents) {} + public record PageSpec(BigDecimal widthMm, BigDecimal heightMm) {} + public record Document(String shipFromName, String shipFromAddress, String carrierName, + String isaNumber, String appointmentTime, String trailerNumber, + String sealNumber, String fbaWarehouseCode, String shipToName, + String shipToAddress, String dispatchNo, String fbaShipmentIds, + String poReferenceNos, int totalPackages, int pageNumber, int pageCount, + List cargoRows) {} + public record CargoRow(String containerNo, String palletNo, String fbaWarehouseCode, + String warehouseNo, String fbaShipmentId, String poReferenceNo, + int packageCount) {} +} diff --git a/print/src/main/java/com/baiyue/print/renderer/DispatchBolRenderer.java b/print/src/main/java/com/baiyue/print/renderer/DispatchBolRenderer.java new file mode 100644 index 000000000..3e1d41aa6 --- /dev/null +++ b/print/src/main/java/com/baiyue/print/renderer/DispatchBolRenderer.java @@ -0,0 +1,76 @@ +package com.baiyue.print.renderer; + +import com.baiyue.print.dto.DispatchBolPdfDtos; +import java.awt.Color; +import java.io.IOException; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.springframework.stereotype.Component; + +@Component +public class DispatchBolRenderer { + private static final float[] COLS = {8, 31, 58, 84, 119, 160, 195, 202}; + private static final String[] HEADERS = {"Container #", "Pallet #", "FBA Warehouse #", "Warehouse No #", "FBA Shipment ID", "PO / Reference #", "CTNS"}; + + public void renderPage(PDDocument document, PDFont font, DispatchBolPdfDtos.Document data) throws IOException { + PDPage page = new PDPage(PDRectangle.A4); + document.addPage(page); + PdfUnits units = new PdfUnits(297); + try (PDPageContentStream stream = new PDPageContentStream(document, page)) { + stream.setLineWidth(PdfUnits.mmToPt(.35)); + centered(stream, units, font, "BILL OF LADING", 17, 105, 13); + block(stream, units, font, data, 18, 202, 18, 82); + table(stream, units, font, data, 92); + line(stream, units, 12, 244, 198, 244); + text(stream, units, font, "总箱数 / Total CTNS:", 8, 12, 241); + right(stream, units, font, Integer.toString(data.totalPackages()), 12, 198, 241); + if (data.pageCount() > 1) right(stream, units, font, data.pageNumber() + " / " + data.pageCount(), 7, 198, 251); + signature(stream, units, font, 24, "Driver signature"); + signature(stream, units, font, 93, "Warehouse signature"); + signature(stream, units, font, 160, "Date"); + } + } + + private void block(PDPageContentStream s, PdfUnits u, PDFont f, DispatchBolPdfDtos.Document d, + double left, double right, double top, double bottom) throws IOException { + line(s,u,left,top,right,top); line(s,u,left,bottom,right,bottom); line(s,u,left,top,left,bottom); line(s,u,right,top,right,bottom); + line(s,u,105,top,105,bottom); line(s,u,left,51,right,51); + blackHeader(s,u,f,"SHIP FROM",left,top,105,24); blackHeader(s,u,f,"CARRIER",105,top,right,24); + blackHeader(s,u,f,"SHIP TO",left,51,105,57); blackHeader(s,u,f,"REFERENCE",105,51,right,57); + text(s,u,f,d.shipFromName(),10,left+2,30); text(s,u,f,"Address: " + d.shipFromAddress(),8,left+2,37); + text(s,u,f,"Carrier Name: " + d.carrierName(),8,107,29); text(s,u,f,"ISA #: " + d.isaNumber(),8,107,34); + text(s,u,f,"Appointment time: " + d.appointmentTime(),8,107,39); text(s,u,f,"Trailer #: " + d.trailerNumber(),8,107,44); + text(s,u,f,"Seal #: " + d.sealNumber(),8,107,49); + text(s,u,f,"Amazon FBA Warehouse",8,left+2,63); text(s,u,f,"FC Code: " + d.fbaWarehouseCode(),8,left+2,69); + text(s,u,f,"Name: " + d.shipToName(),8,left+2,75); text(s,u,f,"Address: " + d.shipToAddress(),8,left+2,80); + text(s,u,f,"BOL / Dispatch #: " + d.dispatchNo(),8,107,63); text(s,u,f,"FBA Shipment ID: " + d.fbaShipmentIds(),8,107,70); + text(s,u,f,"PO / Reference #: " + d.poReferenceNos(),8,107,77); + } + + private void table(PDPageContentStream s, PdfUnits u, PDFont f, DispatchBolPdfDtos.Document d, double top) throws IOException { + double headerBottom=101, rowHeight=9, bottom=headerBottom+12*rowHeight; + for(float x:COLS) line(s,u,x,top,x,bottom); + line(s,u,COLS[0],top,COLS[COLS.length-1],top); line(s,u,COLS[0],headerBottom,COLS[COLS.length-1],headerBottom); + for(int i=1;i<=12;i++) line(s,u,COLS[0],headerBottom+i*rowHeight,COLS[COLS.length-1],headerBottom+i*rowHeight); + for(int i=0;i0&&f.getStringWidth(value.substring(0,end)+ell)/1000*size>max)end--;return end==0?ell:value.substring(0,end)+ell;} + private void text(PDPageContentStream s,PdfUnits u,PDFont f,String value,float size,double x,double y)throws IOException{s.beginText();s.setFont(f,size);s.newLineAtOffset(PdfUnits.mmToPt(x),u.topDownMmToPdfY(y));s.showText(value==null?"—":value);s.endText();} + private void line(PDPageContentStream s,PdfUnits u,double x1,double y1,double x2,double y2)throws IOException{s.moveTo(PdfUnits.mmToPt(x1),u.topDownMmToPdfY(y1));s.lineTo(PdfUnits.mmToPt(x2),u.topDownMmToPdfY(y2));s.stroke();} +} diff --git a/print/src/main/java/com/baiyue/print/service/DispatchBolPdfService.java b/print/src/main/java/com/baiyue/print/service/DispatchBolPdfService.java new file mode 100644 index 000000000..daac87f0e --- /dev/null +++ b/print/src/main/java/com/baiyue/print/service/DispatchBolPdfService.java @@ -0,0 +1,90 @@ +package com.baiyue.print.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.print.config.PrintPdfProperties; +import com.baiyue.print.dto.DispatchBolPdfDtos; +import com.baiyue.print.renderer.DispatchBolRenderer; +import jakarta.annotation.Resource; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.math.BigDecimal; +import java.text.Normalizer; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.font.PDType0Font; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Service; + +@Service +public class DispatchBolPdfService { + private static final BigDecimal A4_WIDTH = new BigDecimal("210"); + private static final BigDecimal A4_HEIGHT = new BigDecimal("297"); + private static final Set FORBIDDEN_FILE_CHARS = Set.of('/', '\\', ':', '*', '?', '"', '<', '>', '|'); + private static final int MAX_ROWS_PER_PAGE = 12; + + @Resource private PrintPdfProperties properties; + @Resource private PdfGenerationLimiter limiter; + @Resource private DispatchBolRenderer renderer; + + public GeneratedPdf generate(DispatchBolPdfDtos.Request request) { + DispatchBolPdfDtos.Request normalized = normalize(request); + try (PdfGenerationLimiter.Permit ignored = limiter.acquire(); + PDDocument pdf = new PDDocument(); InputStream fontStream = openFont(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + PDType0Font font = PDType0Font.load(pdf, fontStream, true); + for (DispatchBolPdfDtos.Document document : normalized.documents()) renderer.renderPage(pdf, font, document); + pdf.save(output); + byte[] content = output.toByteArray(); + if (content.length > properties.getMaxOutputBytes()) invalid("生成的 PDF 超过输出大小上限"); + return new GeneratedPdf(content, normalized.downloadFileName(), normalized.documents().size()); + } catch (IOException exception) { + throw new BizException(ResultCode.INTERNAL_ERROR, "PDF 生成失败"); + } + } + + private InputStream openFont() throws IOException { + return new ClassPathResource(properties.getFontResource()).getInputStream(); + } + + private DispatchBolPdfDtos.Request normalize(DispatchBolPdfDtos.Request request) { + if (request == null || !DispatchBolPdfDtos.TEMPLATE.equals(line(request.templateCode()))) invalid("templateCode is not supported"); + if (request.pageSpec() == null || request.pageSpec().widthMm() == null || request.pageSpec().heightMm() == null + || request.pageSpec().widthMm().compareTo(A4_WIDTH) != 0 || request.pageSpec().heightMm().compareTo(A4_HEIGHT) != 0) invalid("pageSpec must be A4"); + String fileName = line(request.downloadFileName()); + if (fileName.isBlank() || !fileName.toLowerCase(java.util.Locale.ROOT).endsWith(".pdf") || fileName.length() > 120 + || fileName.chars().anyMatch(c -> FORBIDDEN_FILE_CHARS.contains((char) c))) invalid("downloadFileName is invalid"); + if (request.documents() == null || request.documents().isEmpty() || request.documents().size() > properties.getMaxPages()) invalid("documents count is invalid"); + List documents = new ArrayList<>(); + for (int i = 0; i < request.documents().size(); i++) documents.add(normalizeDocument(request.documents().get(i), i)); + return new DispatchBolPdfDtos.Request(DispatchBolPdfDtos.TEMPLATE, request.pageSpec(), fileName, List.copyOf(documents)); + } + + private DispatchBolPdfDtos.Document normalizeDocument(DispatchBolPdfDtos.Document value, int index) { + if (value == null || value.cargoRows() == null || value.cargoRows().isEmpty() || value.cargoRows().size() > MAX_ROWS_PER_PAGE) invalid("documents[" + index + "].cargoRows must contain 1 to 12 items"); + if (value.totalPackages() < 0 || value.pageNumber() < 1 || value.pageCount() < value.pageNumber()) invalid("documents[" + index + "] page metadata is invalid"); + List rows = new ArrayList<>(); + for (DispatchBolPdfDtos.CargoRow row : value.cargoRows()) { + if (row == null || row.packageCount() < 0) invalid("cargo row is invalid"); + rows.add(new DispatchBolPdfDtos.CargoRow(text(row.containerNo()), text(row.palletNo()), text(row.fbaWarehouseCode()), + text(row.warehouseNo()), text(row.fbaShipmentId()), text(row.poReferenceNo()), row.packageCount())); + } + return new DispatchBolPdfDtos.Document(text(value.shipFromName()), text(value.shipFromAddress()), text(value.carrierName()), + text(value.isaNumber()), text(value.appointmentTime()), text(value.trailerNumber()), text(value.sealNumber()), + text(value.fbaWarehouseCode()), text(value.shipToName()), text(value.shipToAddress()), text(value.dispatchNo()), + text(value.fbaShipmentIds()), text(value.poReferenceNos()), value.totalPackages(), value.pageNumber(), value.pageCount(), List.copyOf(rows)); + } + + private String text(String value) { + String normalized = line(value); + if (normalized.codePointCount(0, normalized.length()) > 300 || normalized.codePoints().anyMatch(c -> Character.isISOControl(c))) invalid("document text is invalid"); + return normalized.isBlank() ? "—" : normalized; + } + private String line(String value) { return value == null ? "" : Normalizer.normalize(value, Normalizer.Form.NFC).strip(); } + private void invalid(String message) { throw new BizException(ResultCode.VALIDATION_FAILED, message); } + + public record GeneratedPdf(byte[] content, String fileName, int pageCount) {} +} diff --git a/print/src/main/resources/openapi/print-internal-v1.yaml b/print/src/main/resources/openapi/print-internal-v1.yaml index 8ea325df9..1e75378ad 100644 --- a/print/src/main/resources/openapi/print-internal-v1.yaml +++ b/print/src/main/resources/openapi/print-internal-v1.yaml @@ -100,6 +100,24 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiError' + /internal/print/v1/pdfs/dispatch-bol-documents: + post: + summary: Generate vehicle-dispatch BOL documents as fixed A4 PDF pages + operationId: generateDispatchBolPdf + security: [{ internalServiceToken: [] }] + parameters: [{ $ref: '#/components/parameters/RequestId' }] + requestBody: + required: true + content: { application/json: { schema: { $ref: '#/components/schemas/DispatchBolPdfRequest' } } } + responses: + '200': + description: PDF generation succeeded, or a business error was returned as ApiResponse JSON. + headers: + Content-Disposition: { schema: { type: string } } + 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' } } components: securitySchemes: internalServiceToken: @@ -186,6 +204,51 @@ components: description: One document item renders one A4 page in request order. items: $ref: '#/components/schemas/FreightA4Document' + DispatchBolPdfRequest: + type: object + required: [templateCode, pageSpec, downloadFileName, documents] + properties: + templateCode: { type: string, enum: [DISPATCH_BILL_OF_LADING_A4_V1] } + pageSpec: { $ref: '#/components/schemas/FreightA4PageSpec' } + downloadFileName: { type: string, maxLength: 120 } + documents: + type: array + minItems: 1 + maxItems: 2000 + description: One item renders one A4 page; the caller groups and chunks BOL rows in stable order. + items: { $ref: '#/components/schemas/DispatchBolDocument' } + DispatchBolDocument: + type: object + required: [shipFromName, carrierName, fbaWarehouseCode, shipToName, dispatchNo, totalPackages, pageNumber, pageCount, cargoRows] + properties: + shipFromName: { type: string } + shipFromAddress: { type: string } + carrierName: { type: string } + isaNumber: { type: string } + appointmentTime: { type: string } + trailerNumber: { type: string } + sealNumber: { type: string } + fbaWarehouseCode: { type: string } + shipToName: { type: string } + shipToAddress: { type: string } + dispatchNo: { type: string } + fbaShipmentIds: { type: string } + poReferenceNos: { type: string } + totalPackages: { type: integer, minimum: 0 } + pageNumber: { type: integer, minimum: 1 } + pageCount: { type: integer, minimum: 1 } + cargoRows: { type: array, minItems: 1, maxItems: 12, items: { $ref: '#/components/schemas/DispatchBolCargoRow' } } + DispatchBolCargoRow: + type: object + required: [containerNo, palletNo, fbaWarehouseCode, warehouseNo, fbaShipmentId, poReferenceNo, packageCount] + properties: + containerNo: { type: string } + palletNo: { type: string } + fbaWarehouseCode: { type: string } + warehouseNo: { type: string } + fbaShipmentId: { type: string } + poReferenceNo: { type: string } + packageCount: { type: integer, minimum: 0 } FreightA4PageSpec: type: object required: [widthMm, heightMm] 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 f0d45499e..9c059f7d9 100644 --- a/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java +++ b/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java @@ -6,6 +6,7 @@ import com.baiyue.common.web.GlobalExceptionHandler; import com.baiyue.print.dto.FreightA4PdfDtos; import com.baiyue.print.dto.InboundMarkPdfDtos; import com.baiyue.print.service.FreightA4PdfService; +import com.baiyue.print.service.DispatchBolPdfService; import com.baiyue.print.service.InboundMarkPdfService; import jakarta.annotation.Resource; import org.junit.jupiter.api.BeforeEach; @@ -42,6 +43,8 @@ class InternalPdfControllerTest { private InboundMarkPdfService service; @MockBean private FreightA4PdfService freightA4PdfService; + @MockBean + private DispatchBolPdfService dispatchBolPdfService; @Resource private InternalPdfController controller; private MockMvc mockMvc; diff --git a/print/src/test/java/com/baiyue/print/service/DispatchBolPdfServiceGenerationTest.java b/print/src/test/java/com/baiyue/print/service/DispatchBolPdfServiceGenerationTest.java new file mode 100644 index 000000000..091348903 --- /dev/null +++ b/print/src/test/java/com/baiyue/print/service/DispatchBolPdfServiceGenerationTest.java @@ -0,0 +1,43 @@ +package com.baiyue.print.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.baiyue.print.config.PrintPdfProperties; +import com.baiyue.print.dto.DispatchBolPdfDtos; +import com.baiyue.print.renderer.DispatchBolRenderer; +import com.baiyue.print.renderer.PdfUnits; +import jakarta.annotation.Resource; +import java.math.BigDecimal; +import java.util.List; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +@SpringJUnitConfig(classes = {DispatchBolPdfService.class, PrintPdfProperties.class, + PdfGenerationLimiter.class, DispatchBolRenderer.class}) +class DispatchBolPdfServiceGenerationTest { + @Resource private DispatchBolPdfService service; + + @Test + void generatesStableA4BolWithEmbeddedChineseFontAndBlackHeaders() throws Exception { + DispatchBolPdfDtos.Document page = new DispatchBolPdfDtos.Document( + "洛杉矶仓", "—", "五月开发验收车队", "ISA-446646", "2026-09-11 18:39:37", + "DEV-83222028", "—", "ABE4", "ABE4", "—", "DS-WH-20260911-692865", + "FBA-SHIP-1", "PO-1", 4, 1, 1, + List.of(new DispatchBolPdfDtos.CargoRow("TSTU2186171", "TSTU2186171-126-001", "ABE4", + "NYEXX2609110181", "FBA-SHIP-1", "PO-1", 4))); + var generated = service.generate(new DispatchBolPdfDtos.Request(DispatchBolPdfDtos.TEMPLATE, + new DispatchBolPdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297")), + "DS-WH-20260911-692865-BOL.pdf", List.of(page))); + + assertThat(generated.content()).startsWith("%PDF-".getBytes(java.nio.charset.StandardCharsets.US_ASCII)); + try (PDDocument pdf = Loader.loadPDF(generated.content())) { + assertThat(pdf.getNumberOfPages()).isEqualTo(1); + assertThat(pdf.getPage(0).getMediaBox().getWidth()).isCloseTo(PdfUnits.mmToPt(210), org.assertj.core.data.Offset.offset(.002f)); + assertThat(pdf.getPage(0).getMediaBox().getHeight()).isCloseTo(PdfUnits.mmToPt(297), org.assertj.core.data.Offset.offset(.002f)); + assertThat(new PDFTextStripper().getText(pdf)).contains("BILL OF LADING", "洛杉矶仓", "DS-WH-20260911-692865", "TSTU2186171", "Total CTNS:", "4"); + } + } +}