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 index 403628775..e3c92791e 100644 --- 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 @@ -20,8 +20,9 @@ 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 String TEMPLATE = "DISPATCH_BILL_OF_LADING_A4_V2"; + private static final int FIRST_PAGE_ROWS = 18; + private static final int CONTINUATION_PAGE_ROWS = 26; private static final PrintPdfClient.PageSpec A4 = new PrintPdfClient.PageSpec(new BigDecimal("210"), new BigDecimal("297")); @Resource private DispatchService dispatches; @@ -56,22 +57,31 @@ public class DispatchBolPrintService { 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 pages = pageCount(lines.size()); 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("—"); + int offset = 0; 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() + int capacity = page == 0 ? FIRST_PAGE_ROWS : CONTINUATION_PAGE_ROWS; + int end = Math.min(lines.size(), offset + capacity); + List rows = lines.subList(offset, end).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)); + offset = end; } } return List.copyOf(documents); } + static int pageCount(int rowCount) { + if (rowCount <= FIRST_PAGE_ROWS) return 1; + return 1 + (rowCount - FIRST_PAGE_ROWS + CONTINUATION_PAGE_ROWS - 1) / CONTINUATION_PAGE_ROWS; + } + private String unique(List lines, boolean shipment) { LinkedHashSet values = new LinkedHashSet<>(); for (DispatchResponse.BolLine line : lines) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java new file mode 100644 index 000000000..ff6bad8f5 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java @@ -0,0 +1,18 @@ +package com.baiyue.lastleg.dispatch.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class DispatchBolPrintServiceTest { + @Test + void usesDenseFirstAndContinuationPageCapacities() { + assertThat(DispatchBolPrintService.pageCount(1)).isEqualTo(1); + assertThat(DispatchBolPrintService.pageCount(18)).isEqualTo(1); + assertThat(DispatchBolPrintService.pageCount(19)).isEqualTo(2); + assertThat(DispatchBolPrintService.pageCount(44)).isEqualTo(2); + assertThat(DispatchBolPrintService.pageCount(45)).isEqualTo(3); + assertThat(DispatchBolPrintService.pageCount(70)).isEqualTo(3); + assertThat(DispatchBolPrintService.pageCount(71)).isEqualTo(4); + } +} diff --git a/print/README.md b/print/README.md index 6eb56a150..990fbd657 100644 --- a/print/README.md +++ b/print/README.md @@ -9,7 +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 行明细。 +- 海外仓排车 BOL 内部地址:`POST /internal/print/v1/pdfs/dispatch-bol-documents`;模板固定为 `DISPATCH_BILL_OF_LADING_A4_V2`,每个 document 对应一张 A4、包含 1-26 行明细。调用方首张按 18 行、续页按 26 行稳定分页;同页连续相同柜号和托盘号由 Print 纵向合并显示。 - 本地直连:`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/dto/DispatchBolPdfDtos.java b/print/src/main/java/com/baiyue/print/dto/DispatchBolPdfDtos.java index 83983ca89..1a8dcf26b 100644 --- a/print/src/main/java/com/baiyue/print/dto/DispatchBolPdfDtos.java +++ b/print/src/main/java/com/baiyue/print/dto/DispatchBolPdfDtos.java @@ -5,7 +5,7 @@ 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"; + public static final String TEMPLATE = "DISPATCH_BILL_OF_LADING_A4_V2"; private DispatchBolPdfDtos() {} diff --git a/print/src/main/java/com/baiyue/print/renderer/DispatchBolRenderer.java b/print/src/main/java/com/baiyue/print/renderer/DispatchBolRenderer.java index 3e1d41aa6..772fff41f 100644 --- a/print/src/main/java/com/baiyue/print/renderer/DispatchBolRenderer.java +++ b/print/src/main/java/com/baiyue/print/renderer/DispatchBolRenderer.java @@ -3,6 +3,8 @@ package com.baiyue.print.renderer; import com.baiyue.print.dto.DispatchBolPdfDtos; import java.awt.Color; import java.io.IOException; +import java.util.List; +import java.util.function.Function; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.PDPageContentStream; @@ -12,62 +14,125 @@ 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"}; + private static final float[] COLS = {8, 23, 39, 62, 89, 123, 160, 190, 202}; + private static final String[][] HEADERS = { + {""}, {"Container", "#"}, {"Pallet", "#"}, {"FBA", "Warehouse #"}, + {"Warehouse No #"}, {"FBA Shipment ID"}, {"PO / Reference #"}, {"CTNS"} + }; + private static final double FIRST_TABLE_TOP = 82; + private static final double CONTINUATION_TABLE_TOP = 10; + private static final double HEADER_HEIGHT = 10; + private static final double ROW_HEIGHT = 7.2; 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"); + stream.setLineWidth(PdfUnits.mmToPt(.3)); + double tableTop; + if (data.pageNumber() == 1) { + title(stream, units, font); + block(stream, units, font, data, 8, 202, 21, FIRST_TABLE_TOP); + tableTop = FIRST_TABLE_TOP; + } else { + tableTop = CONTINUATION_TABLE_TOP; + } + double tableBottom = table(stream, units, font, data, tableTop); + if (data.pageNumber() == data.pageCount()) { + summaryAndSignatures(stream, units, font, data, tableBottom); + } + if (data.pageCount() > 1) { + right(stream, units, font, data.pageNumber() + " / " + data.pageCount(), 6.5f, 202, 290); + } } } + private void title(PDPageContentStream s, PdfUnits u, PDFont f) throws IOException { + fill(s, u, Color.BLACK, 8, 8, 202, 21); + s.setNonStrokingColor(Color.WHITE); + centered(s, u, f, "BILL OF LADING", 15, 105, 17); + s.setNonStrokingColor(Color.BLACK); + } + 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); + line(s,u,105,top,105,bottom); line(s,u,left,50,right,50); + blackHeader(s,u,f,"SHIP FROM",left,top,105,27); blackHeader(s,u,f,"CARRIER",105,top,right,27); + blackHeader(s,u,f,"SHIP TO",left,50,105,56); blackHeader(s,u,f,"REFERENCE",105,50,right,56); + text(s,u,f,d.shipFromName(),9.5f,left+2,33); text(s,u,f,"Address: " + d.shipFromAddress(),7.5f,left+2,40); + text(s,u,f,"Carrier Name: " + d.carrierName(),7.5f,107,32); text(s,u,f,"ISA #: " + d.isaNumber(),7.5f,107,36.5); + text(s,u,f,"Appointment time: " + d.appointmentTime(),7.5f,107,41); text(s,u,f,"Trailer #: " + d.trailerNumber(),7.5f,107,45.5); + text(s,u,f,"Seal #: " + d.sealNumber(),7.5f,107,49); + text(s,u,f,"Amazon FBA Warehouse",7.5f,left+2,61); text(s,u,f,"FC Code: " + d.fbaWarehouseCode(),7.5f,left+2,66); + text(s,u,f,"Name: " + d.shipToName(),7.5f,left+2,71); text(s,u,f,"Address: " + d.shipToAddress(),7.5f,left+2,76); + text(s,u,f,"BOL / Dispatch #: " + d.dispatchNo(),7.5f,107,61); text(s,u,f,"FBA Shipment ID: " + d.fbaShipmentIds(),7.5f,107,68); + text(s,u,f,"PO / Reference #: " + d.poReferenceNos(),7.5f,107,75); } - 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 rows = d.cargoRows(); + double headerBottom = top + HEADER_HEIGHT; + double bottom = headerBottom + rows.size() * ROW_HEIGHT; + 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 = 0; i < HEADERS.length; i++) centeredLines(s,u,f,HEADERS[i],5.2f,COLS[i],COLS[i+1],top+4.2); + + for (int boundary = 1; boundary <= rows.size(); boundary++) { + double y = headerBottom + boundary * ROW_HEIGHT; + line(s,u,COLS[0],y,COLS[1],y); + boolean last = boundary == rows.size(); + if (last || !same(rows.get(boundary - 1).containerNo(), rows.get(boundary).containerNo())) line(s,u,COLS[1],y,COLS[2],y); + if (last || !same(rows.get(boundary - 1).palletNo(), rows.get(boundary).palletNo())) line(s,u,COLS[2],y,COLS[3],y); + line(s,u,COLS[3],y,COLS[COLS.length-1],y); + } + + drawMergedColumn(s,u,f,rows,DispatchBolPdfDtos.CargoRow::containerNo,1,headerBottom); + drawMergedColumn(s,u,f,rows,DispatchBolPdfDtos.CargoRow::palletNo,2,headerBottom); + for (int rowIndex = 0; rowIndex < rows.size(); rowIndex++) { + DispatchBolPdfDtos.CargoRow row = rows.get(rowIndex); + String[] values = {row.fbaWarehouseCode(), row.warehouseNo(), row.fbaShipmentId(), row.poReferenceNo(), Integer.toString(row.packageCount())}; + double baseline = headerBottom + rowIndex * ROW_HEIGHT + 4.7; + for (int column = 0; column < values.length; column++) { + centeredCell(s,u,f,values[column],5.25f,COLS[column+3]+.5,COLS[column+4]-.5,baseline); + } + } + return bottom; + } + + private void drawMergedColumn(PDPageContentStream s, PdfUnits u, PDFont f, List rows, + Function value, int column, double headerBottom) throws IOException { + int start = 0; + while (start < rows.size()) { + int end = start + 1; + while (end < rows.size() && same(value.apply(rows.get(start)), value.apply(rows.get(end)))) end++; + double baseline = headerBottom + ((start + end) * ROW_HEIGHT / 2) + 1.8; + centeredCell(s,u,f,value.apply(rows.get(start)),5.25f,COLS[column]+.5,COLS[column+1]-.5,baseline); + start = end; } } - 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 summaryAndSignatures(PDPageContentStream s, PdfUnits u, PDFont f, DispatchBolPdfDtos.Document d, double tableBottom) throws IOException { + double summaryY = Math.max(tableBottom + 14, 236); + line(s,u,12,summaryY+4,198,summaryY+4); + text(s,u,f,"总箱数 / Total CTNS:",8,12,summaryY); + right(s,u,f,Integer.toString(d.totalPackages()),12,198,summaryY); + double signatureY = Math.max(summaryY + 30, 266); + signature(s,u,f,24,signatureY,"Driver signature"); + signature(s,u,f,93,signatureY,"Warehouse signature"); + signature(s,u,f,160,signatureY,"Date"); } - 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 boolean same(String left, String right) { return left != null && left.equals(right); } + private void blackHeader(PDPageContentStream s,PdfUnits u,PDFont f,String label,double x1,double y1,double x2,double y2)throws IOException{ + fill(s,u,Color.BLACK,x1,y1,x2,y2); s.setNonStrokingColor(Color.WHITE); text(s,u,f,label,6.2f,x1+2,y2-1.7); s.setNonStrokingColor(Color.BLACK); + } + private void fill(PDPageContentStream s,PdfUnits u,Color color,double x1,double y1,double x2,double y2)throws IOException{s.setNonStrokingColor(color);s.addRect(PdfUnits.mmToPt(x1),u.topDownMmToPdfY(y2),PdfUnits.mmToPt(x2-x1),PdfUnits.mmToPt(y2-y1));s.fill();} + private void signature(PDPageContentStream s,PdfUnits u,PDFont f,double x,double y,String label)throws IOException{line(s,u,x,y,x+52,y);centeredCell(s,u,f,label,6.2f,x,x+52,y+8);} 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 centeredLines(PDPageContentStream s,PdfUnits u,PDFont f,String[] values,float size,double x1,double x2,double y)throws IOException{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;} diff --git a/print/src/main/java/com/baiyue/print/service/DispatchBolPdfService.java b/print/src/main/java/com/baiyue/print/service/DispatchBolPdfService.java index daac87f0e..213c1a826 100644 --- a/print/src/main/java/com/baiyue/print/service/DispatchBolPdfService.java +++ b/print/src/main/java/com/baiyue/print/service/DispatchBolPdfService.java @@ -24,7 +24,8 @@ 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; + private static final int MAX_FIRST_PAGE_ROWS = 18; + private static final int MAX_CONTINUATION_PAGE_ROWS = 26; @Resource private PrintPdfProperties properties; @Resource private PdfGenerationLimiter limiter; @@ -64,7 +65,10 @@ public class DispatchBolPdfService { } 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"); + int maxRows = value != null && value.pageNumber() == 1 ? MAX_FIRST_PAGE_ROWS : MAX_CONTINUATION_PAGE_ROWS; + if (value == null || value.cargoRows() == null || value.cargoRows().isEmpty() || value.cargoRows().size() > maxRows) { + invalid("documents[" + index + "].cargoRows exceeds the page layout capacity"); + } 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()) { diff --git a/print/src/main/resources/openapi/print-internal-v1.yaml b/print/src/main/resources/openapi/print-internal-v1.yaml index 1e75378ad..b44e93c8c 100644 --- a/print/src/main/resources/openapi/print-internal-v1.yaml +++ b/print/src/main/resources/openapi/print-internal-v1.yaml @@ -208,7 +208,7 @@ components: type: object required: [templateCode, pageSpec, downloadFileName, documents] properties: - templateCode: { type: string, enum: [DISPATCH_BILL_OF_LADING_A4_V1] } + templateCode: { type: string, enum: [DISPATCH_BILL_OF_LADING_A4_V2] } pageSpec: { $ref: '#/components/schemas/FreightA4PageSpec' } downloadFileName: { type: string, maxLength: 120 } documents: @@ -237,7 +237,12 @@ components: 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' } } + cargoRows: + type: array + minItems: 1 + maxItems: 26 + description: First page accepts at most 18 rows; continuation pages accept at most 26 rows. + items: { $ref: '#/components/schemas/DispatchBolCargoRow' } DispatchBolCargoRow: type: object required: [containerNo, palletNo, fbaWarehouseCode, warehouseNo, fbaShipmentId, poReferenceNo, packageCount] diff --git a/print/src/test/java/com/baiyue/print/service/DispatchBolPdfServiceGenerationTest.java b/print/src/test/java/com/baiyue/print/service/DispatchBolPdfServiceGenerationTest.java index 091348903..7e8aaf050 100644 --- a/print/src/test/java/com/baiyue/print/service/DispatchBolPdfServiceGenerationTest.java +++ b/print/src/test/java/com/baiyue/print/service/DispatchBolPdfServiceGenerationTest.java @@ -8,6 +8,7 @@ import com.baiyue.print.renderer.DispatchBolRenderer; import com.baiyue.print.renderer.PdfUnits; import jakarta.annotation.Resource; import java.math.BigDecimal; +import java.util.ArrayList; import java.util.List; import org.apache.pdfbox.Loader; import org.apache.pdfbox.pdmodel.PDDocument; @@ -40,4 +41,45 @@ class DispatchBolPdfServiceGenerationTest { assertThat(new PDFTextStripper().getText(pdf)).contains("BILL OF LADING", "洛杉矶仓", "DS-WH-20260911-692865", "TSTU2186171", "Total CTNS:", "4"); } } + + @Test + void rendersDenseContinuationPageAndPrintsTotalOnlyOnLastPage() throws Exception { + List firstRows = rows(1, 18); + List continuationRows = rows(19, 44); + DispatchBolPdfDtos.Document first = page(1, 2, firstRows); + DispatchBolPdfDtos.Document second = page(2, 2, continuationRows); + + var generated = service.generate(new DispatchBolPdfDtos.Request(DispatchBolPdfDtos.TEMPLATE, + new DispatchBolPdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297")), + "dense-bol.pdf", List.of(first, second))); + + try (PDDocument pdf = Loader.loadPDF(generated.content())) { + assertThat(pdf.getNumberOfPages()).isEqualTo(2); + PDFTextStripper stripper = new PDFTextStripper(); + stripper.setStartPage(1); + stripper.setEndPage(1); + String firstText = stripper.getText(pdf); + stripper.setStartPage(2); + stripper.setEndPage(2); + String secondText = stripper.getText(pdf); + assertThat(firstText).contains("BILL OF LADING", "PALLET-01", "1 / 2").doesNotContain("Total CTNS:"); + assertThat(secondText).contains("PALLET-44", "Total CTNS:", "44", "2 / 2").doesNotContain("SHIP FROM"); + } + } + + private DispatchBolPdfDtos.Document page(int pageNumber, int pageCount, List rows) { + return new DispatchBolPdfDtos.Document("NOBLE LOGISTICS INC", "10767 7th Street", "ECHO", "14068390973 F", + "09/14/2026 08:00 EDT", "—", "—", "IND9", "IND9", "—", "DS-WH-20260911-692865", + "FBA19TEST", "PO-TEST", 44, pageNumber, pageCount, rows); + } + + private List rows(int start, int end) { + List rows = new ArrayList<>(); + for (int index = start; index <= end; index++) { + String pallet = index <= 4 ? "PALLET-01" : "PALLET-" + String.format("%02d", index); + rows.add(new DispatchBolPdfDtos.CargoRow("D2250", pallet, "CLT2转IND9", "HDHK2608" + String.format("%06d", index), + "FBA19TEST" + index + "-" + index + "件", "PO" + index, 1)); + } + return rows; + } }