feat: generate dispatch BOL PDFs

This commit is contained in:
2026-09-11 10:09:06 +08:00
parent 81f4f9163c
commit 220d159cec
16 changed files with 484 additions and 4 deletions
@@ -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<byte[]> 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<Document> 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<CargoRow> cargoRows) {}
record CargoRow(String containerNo, String palletNo, String fbaWarehouseCode, String warehouseNo,
String fbaShipmentId, String poReferenceNo, int packageCount) {}
}
@@ -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<byte[]> 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);
@@ -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<PrintPdfClient.Document> documents = documents(detail);
String fileName = safeFilePart(detail.dispatchNo()) + "-BOL.pdf";
try {
ResponseEntity<byte[]> 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<PrintPdfClient.Document> documents(DispatchResponse detail) {
Map<String, List<DispatchResponse.BolLine>> groups = new LinkedHashMap<>();
for (DispatchResponse.BolLine line : detail.bolLines()) groups.computeIfAbsent(text(line.fbaWarehouseCode()), ignored -> new ArrayList<>()).add(line);
List<PrintPdfClient.Document> documents = new ArrayList<>();
for (Map.Entry<String, List<DispatchResponse.BolLine>> group : groups.entrySet()) {
List<DispatchResponse.BolLine> 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<PrintPdfClient.CargoRow> 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<DispatchResponse.BolLine> lines, boolean shipment) {
LinkedHashSet<String> 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) {}
}
@@ -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}
@@ -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: 查询智能排车候选托盘
@@ -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")
@@ -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() {
+3 -3
View File
@@ -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` |
+1
View File
@@ -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。
@@ -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<byte[]> 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);
}
}
@@ -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<Document> 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<CargoRow> cargoRows) {}
public record CargoRow(String containerNo, String palletNo, String fbaWarehouseCode,
String warehouseNo, String fbaShipmentId, String poReferenceNo,
int packageCount) {}
}
@@ -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;i<HEADERS.length;i++) centeredCell(s,u,f,HEADERS[i],5.3f,COLS[i],COLS[i+1],97.5);
for(int r=0;r<d.cargoRows().size();r++){
DispatchBolPdfDtos.CargoRow row=d.cargoRows().get(r); String[] values={row.containerNo(),row.palletNo(),row.fbaWarehouseCode(),row.warehouseNo(),row.fbaShipmentId(),row.poReferenceNo(),Integer.toString(row.packageCount())};
for(int c=0;c<values.length;c++) centeredCell(s,u,f,values[c],5.5f,COLS[c]+.7,COLS[c+1]-.7,107+r*rowHeight);
}
}
private void blackHeader(PDPageContentStream s,PdfUnits u,PDFont f,String label,double x1,double y1,double x2,double y2)throws IOException{
s.setNonStrokingColor(Color.BLACK); s.addRect(PdfUnits.mmToPt(x1),u.topDownMmToPdfY(y2),PdfUnits.mmToPt(x2-x1),PdfUnits.mmToPt(y2-y1)); s.fill();
s.setNonStrokingColor(Color.WHITE); text(s,u,f,label,6.5f,x1+2,y2-1.7); s.setNonStrokingColor(Color.BLACK);
}
private void signature(PDPageContentStream s,PdfUnits u,PDFont f,double x,String label)throws IOException{line(s,u,x,266,x+52,266); centeredCell(s,u,f,label,6.5f,x,x+52,274);}
private void centered(PDPageContentStream s,PdfUnits u,PDFont f,String value,float size,double centerX,double y)throws IOException{float w=f.getStringWidth(value)/1000*size;text(s,u,f,value,size,centerX-w/PdfUnits.mmToPt(1)/2,y);}
private void centeredCell(PDPageContentStream s,PdfUnits u,PDFont f,String value,float size,double x1,double x2,double y)throws IOException{String fit=fit(f,value,size,PdfUnits.mmToPt(x2-x1));float w=f.getStringWidth(fit)/1000*size;text(s,u,f,fit,size,x1+(x2-x1-w/PdfUnits.mmToPt(1))/2,y);}
private void right(PDPageContentStream s,PdfUnits u,PDFont f,String value,float size,double x,double y)throws IOException{float w=f.getStringWidth(value)/1000*size;text(s,u,f,value,size,x-w/PdfUnits.mmToPt(1),y);}
private String fit(PDFont f,String v,float size,float max)throws IOException{String value=v==null?"—":v;if(f.getStringWidth(value)/1000*size<=max)return value;String ell="…";int end=value.length();while(end>0&&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();}
}
@@ -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<Character> 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<DispatchBolPdfDtos.Document> 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<DispatchBolPdfDtos.CargoRow> 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) {}
}
@@ -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]
@@ -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;
@@ -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");
}
}
}