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
+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");
}
}
}