This commit is contained in:
2026-09-14 15:33:29 +08:00
parent febe265c50
commit 46b4d14898
30 changed files with 502 additions and 200 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
- 本模块是无数据库的无状态 PDF 渲染服务;调用方传入完整 `labels[]` 或 `documents[]`,模块在内存中生成一次性多页 PDF 并同步返回。
- 不保存模板、任务、历史或文件,不连接数据库、File 服务或打印机,不反查或修正业务数据。
- 当前支持 `INBOUND_MARK_V1`(`100mm x 100mm`)、`DROPSHIP_PALLET_A4_V1`、`SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1` 和 `BILL_OF_LADING_CONFIRMATION_A4_V1`(后三者均为 `210mm x 297mm`);一件代发托盘模板每托固定一页,展示托盘 Code 128 一维码、最多两行入仓码和最多 34 行完整 SKU 归托信息,不展示作业状态。新尺寸或布局必须使用新模板编码,不得静默改变既有 V1。
- 当前支持 `INBOUND_MARK_V1`(`100mm x 100mm`)、`DROPSHIP_PALLET_A4_V2`、`SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1` 和 `BILL_OF_LADING_CONFIRMATION_A4_V1`(后三者均为 `210mm x 297mm`);一件代发托盘模板每托固定一页,展示托盘 Code 128 一维码、目的仓、最多 10 行入仓码/客户昵称汇总和最多 34 行 SKU 汇总,不展示作业状态、重量或体积。新尺寸或布局必须使用新模板编码。
## 快速索引
+1 -1
View File
@@ -8,7 +8,7 @@
- 服务发现名:`print-service`。
- 内部地址:`POST /internal/print/v1/pdfs/inbound-marks`。
- 一件代发 A4 托盘信息单:`POST /internal/print/v1/pdfs/dropship-pallet-a4-documents`;模板固定为 `DROPSHIP_PALLET_A4_V1`,每托一页 A4,包含托盘 Code 128 一维码、最多两行入仓码、总箱数和最多 34 行 SKU/商品名称/箱数/实点数量,不展示作业状态。
- 一件代发 A4 托盘信息单:`POST /internal/print/v1/pdfs/dropship-pallet-a4-documents`;模板固定为 `DROPSHIP_PALLET_A4_V2`,每托一页 A4,包含托盘 Code 128 一维码、目的仓、托盘序号、最多 10 行入仓码/客户昵称汇总和最多 34 行 SKU 汇总,不展示作业状态、重量或体积。
- A4 海运单据内部地址:`POST /internal/print/v1/pdfs/freight-a4-documents`。
- 海外仓排车 BOL 内部地址:`POST /internal/print/v1/pdfs/dispatch-bol-documents`;模板固定为 `DISPATCH_BILL_OF_LADING_A4_V2`,每个 document 对应一张 A4、包含 1-26 行明细。调用方首张按 18 行、续页按 26 行稳定分页;同页连续相同柜号和托盘号由 Print 纵向合并显示。
- 本地直连:`http://127.0.0.1:9207`。
Binary file not shown.
@@ -5,7 +5,7 @@ import java.util.List;
/** 一件代发托盘 A4 信息单的内部渲染契约。 */
public final class DropshipPalletA4PdfDtos {
public static final String TEMPLATE = "DROPSHIP_PALLET_A4_V1";
public static final String TEMPLATE = "DROPSHIP_PALLET_A4_V2";
private DropshipPalletA4PdfDtos() {}
@@ -14,10 +14,14 @@ public final class DropshipPalletA4PdfDtos {
public record PageSpec(BigDecimal widthMm, BigDecimal heightMm) {}
public record Document(String palletNo, String inboundCodes, int cartonCount,
int skuCount, BigDecimal weightKg, BigDecimal volumeCbm,
String barcodeContent, List<SkuRow> skuRows) {}
public record Document(String palletNo, String destinationWarehouse,
int palletSequence, int totalPallets,
String barcodeContent, List<InboundRow> inboundRows,
List<SkuRow> skuRows) {}
public record SkuRow(String inboundCode, String skuCode, String cargoName,
int cartonCount, int skuQuantity) {}
public record InboundRow(String inboundCode, String customerNickname,
int cartonCount, int itemQuantity) {}
public record SkuRow(String skuCode, int cartonCount,
int itemQuantity, String unitsPerCarton) {}
}
@@ -13,17 +13,18 @@ import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.springframework.stereotype.Component;
/** 每个托盘一页 A4,Code 128 托盘码与完整 SKU 归托信息同页展示。 */
/** 每个托盘一页 A4:托盘码、目的仓、入仓码汇总和 SKU 汇总。 */
@Component
public class DropshipPalletA4Renderer {
private static final float[] COLS = {8, 18, 57, 98, 161, 181, 202};
private static final String[] HEADERS = {"序号", "入仓码", "SKU", "商品名称", "箱数", "实点数量"};
private static final double TABLE_TOP = 105;
private static final double HEADER_HEIGHT = 8;
private static final double REGULAR_ROW_HEIGHT = 7;
private static final double DENSE_ROW_HEIGHT = 5.2;
private static final float BARCODE_WIDTH_PT = PdfUnits.mmToPt(158);
private static final float BARCODE_HEIGHT_PT = PdfUnits.mmToPt(22);
private static final Color BLUE = new Color(20, 89, 156);
private static final Color LIGHT_BLUE = new Color(225, 239, 251);
private static final Color TABLE_HEADER = new Color(238, 242, 247);
private static final Color RED = new Color(180, 43, 36);
private static final float[] INBOUND_COLS = {18, 27, 79, 146, 169, 192};
private static final String[] INBOUND_HEADERS = {"#", "入仓码", "客户", "箱数", "件数"};
private static final float[] SKU_COLS = {18, 132, 154, 176, 192};
private static final String[] SKU_HEADERS = {"SKU", "箱数", "件数", "每箱件数"};
@Resource private PdfBarcodeRenderer barcodeRenderer;
public void renderPage(PDDocument pdf, PDFont font, DropshipPalletA4PdfDtos.Document data,
@@ -32,93 +33,144 @@ public class DropshipPalletA4Renderer {
pdf.addPage(page);
PdfUnits units = new PdfUnits(297);
try (PDPageContentStream stream = new PDPageContentStream(pdf, page)) {
stream.setNonStrokingColor(Color.BLACK);
stream.setLineWidth(PdfUnits.mmToPt(.3));
centered(stream, units, font, "一件代发托盘信息单", 15, 105, 13);
centered(stream, units, font, data.palletNo(), 22, 105, 25);
barcode(stream, units, barcodeMatrix);
fill(stream, units, new Color(245, 247, 250), 8, 58, 202, 94);
inboundCodes(stream, units, font, data.inboundCodes());
line(stream, units, 8, 78, 202, 78);
metric(stream, units, font, "总箱数", Integer.toString(data.cartonCount()), 8, 56.5);
metric(stream, units, font, "SKU 数", Integer.toString(data.skuCount()), 56.5, 105);
metric(stream, units, font, "重量", decimal(data.weightKg()) + " kg", 105, 153.5);
metric(stream, units, font, "体积", decimal(data.volumeCbm()) + " m³", 153.5, 202);
line(stream, units, 56.5, 78, 56.5, 94);
line(stream, units, 105, 78, 105, 94);
line(stream, units, 153.5, 78, 153.5, 94);
line(stream, units, 8, 99, 202, 99);
text(stream, units, font, "SKU 明细", 11, 8, 104);
table(stream, units, font, data.skuRows());
stream.setLineWidth(PdfUnits.mmToPt(.25));
border(stream, units);
colorText(stream, units, font, "一件代发备货托", 14, 18, 22, BLUE);
rightText(stream, units, font,
"第 " + data.palletSequence() + " 托 / 共 " + data.totalPallets() + " 托",
10, 192, 22, new Color(75, 75, 75));
barcodeRenderer.draw(stream, barcodeMatrix, PdfUnits.mmToPt(18), units.topDownMmToPdfY(51),
PdfUnits.mmToPt(80), PdfUnits.mmToPt(22));
fittedText(stream, units, font, data.palletNo(), 25, 105, 44, 87, Color.BLACK);
fill(stream, units, LIGHT_BLUE, 10, 57, 200, 82);
colorText(stream, units, font, "目的仓", 7.5f, 18, 66, BLUE);
fittedText(stream, units, font, data.destinationWarehouse(), 20, 18, 77, 174, BLUE);
int rowCount = data.inboundRows().size() + 1 + Math.max(1, data.skuRows().size());
double rowHeight = rowCount > 36 ? 2.9 : rowCount > 24 ? 3.6 : 6.0;
float rowFont = rowCount > 36 ? 4.4f : rowCount > 24 ? 5.0f : 7.0f;
int inboundCartons = data.inboundRows().stream().mapToInt(DropshipPalletA4PdfDtos.InboundRow::cartonCount).sum();
int inboundItems = data.inboundRows().stream().mapToInt(DropshipPalletA4PdfDtos.InboundRow::itemQuantity).sum();
colorText(stream, units, font,
"入仓码信息 · 共 " + data.inboundRows().size() + " 票",
9, 18, 92, BLUE);
double inboundBottom = inboundTable(stream, units, font, data.inboundRows(),
inboundCartons, inboundItems, 97, rowHeight, rowFont);
double skuTitleY = inboundBottom + 8;
colorText(stream, units, font, "SKU 数:" + data.skuRows().size(), 9, 18, skuTitleY, new Color(70, 70, 70));
skuTable(stream, units, font, data.skuRows(), skuTitleY + 5, rowHeight, rowFont);
line(stream, units, 10, 280, 200, 280);
colorText(stream, units, font, "拆托 / 合托后此标作废,须重打", 8.5f, 18, 288, RED);
}
}
private void barcode(PDPageContentStream stream, PdfUnits units, BitMatrix matrix) throws IOException {
float left = PdfUnits.mmToPt(26);
float bottom = units.topDownMmToPdfY(54);
barcodeRenderer.draw(stream, matrix, left, bottom, BARCODE_WIDTH_PT, BARCODE_HEIGHT_PT);
private double inboundTable(PDPageContentStream s, PdfUnits u, PDFont f,
List<DropshipPalletA4PdfDtos.InboundRow> rows,
int totalCartons, int totalItems, double top,
double rowHeight, float rowFont) throws IOException {
double headerHeight = 7;
double bottom = top + headerHeight + (rows.size() + 1) * rowHeight;
tableGrid(s, u, INBOUND_COLS, top, bottom, headerHeight, rows.size() + 1, rowHeight);
tableHeaders(s, u, f, INBOUND_COLS, INBOUND_HEADERS, top, headerHeight);
for (int index = 0; index < rows.size(); index++) {
var row = rows.get(index);
String[] values = {Integer.toString(index + 1), row.inboundCode(), row.customerNickname(),
Integer.toString(row.cartonCount()), Integer.toString(row.itemQuantity())};
tableRow(s, u, f, INBOUND_COLS, values, top + headerHeight + index * rowHeight,
rowHeight, rowFont);
}
String[] totals = {"计", "跨 " + rows.size() + " 票合计", "", Integer.toString(totalCartons),
Integer.toString(totalItems)};
tableRow(s, u, f, INBOUND_COLS, totals, top + headerHeight + rows.size() * rowHeight,
rowHeight, rowFont);
return bottom;
}
private void table(PDPageContentStream stream, PdfUnits units, PDFont font,
List<DropshipPalletA4PdfDtos.SkuRow> rows) throws IOException {
private void skuTable(PDPageContentStream s, PdfUnits u, PDFont f,
List<DropshipPalletA4PdfDtos.SkuRow> rows, double top,
double rowHeight, float rowFont) throws IOException {
double headerHeight = 7;
int visibleRows = Math.max(1, rows.size());
double rowHeight = rows.size() > 24 ? DENSE_ROW_HEIGHT : REGULAR_ROW_HEIGHT;
float rowFontSize = rows.size() > 24 ? 5.3f : 6.4f;
double headerBottom = TABLE_TOP + HEADER_HEIGHT;
double bottom = headerBottom + visibleRows * rowHeight;
fill(stream, units, new Color(235, 239, 245), COLS[0], TABLE_TOP, COLS[COLS.length - 1], headerBottom);
for (float x : COLS) line(stream, units, x, TABLE_TOP, x, bottom);
for (int boundary = 0; boundary <= visibleRows; boundary++) {
line(stream, units, COLS[0], headerBottom + boundary * rowHeight,
COLS[COLS.length - 1], headerBottom + boundary * rowHeight);
}
line(stream, units, COLS[0], TABLE_TOP, COLS[COLS.length - 1], TABLE_TOP);
for (int column = 0; column < HEADERS.length; column++) {
centeredCell(stream, units, font, HEADERS[column], 7.2f,
COLS[column] + .5, COLS[column + 1] - .5, TABLE_TOP + 5.5);
}
double bottom = top + headerHeight + visibleRows * rowHeight;
tableGrid(s, u, SKU_COLS, top, bottom, headerHeight, visibleRows, rowHeight);
tableHeaders(s, u, f, SKU_COLS, SKU_HEADERS, top, headerHeight);
if (rows.isEmpty()) {
centeredCell(stream, units, font, "暂无已归托 SKU", 7.2f, COLS[0], COLS[COLS.length - 1], headerBottom + 4.8);
tableRow(s, u, f, SKU_COLS, new String[]{"暂无已归托 SKU", "", "", ""},
top + headerHeight, rowHeight, rowFont);
return;
}
for (int rowIndex = 0; rowIndex < rows.size(); rowIndex++) {
DropshipPalletA4PdfDtos.SkuRow row = rows.get(rowIndex);
String[] values = {Integer.toString(rowIndex + 1), row.inboundCode(), row.skuCode(), row.cargoName(),
Integer.toString(row.cartonCount()), Integer.toString(row.skuQuantity())};
double baseline = headerBottom + rowIndex * rowHeight + rowHeight * .7;
for (int column = 0; column < values.length; column++) {
centeredCell(stream, units, font, values[column], rowFontSize,
COLS[column] + .7, COLS[column + 1] - .7, baseline);
}
for (int index = 0; index < rows.size(); index++) {
var row = rows.get(index);
String[] values = {row.skuCode(), Integer.toString(row.cartonCount()),
Integer.toString(row.itemQuantity()), row.unitsPerCarton()};
tableRow(s, u, f, SKU_COLS, values, top + headerHeight + index * rowHeight,
rowHeight, rowFont);
}
}
private String decimal(java.math.BigDecimal value) {
return value == null ? "0" : value.stripTrailingZeros().toPlainString();
}
private void inboundCodes(PDPageContentStream s, PdfUnits u, PDFont f, String value) throws IOException {
text(s, u, f, "入仓码:", 8, 14, 66);
List<String> lines = wrap(f, value, 8, PdfUnits.mmToPt(163), 2);
for (int index = 0; index < lines.size(); index++) {
text(s, u, f, lines.get(index), 8, 33, 66 + index * 6);
private void tableGrid(PDPageContentStream s, PdfUnits u, float[] columns, double top,
double bottom, double headerHeight, int rows, double rowHeight) throws IOException {
fill(s, u, TABLE_HEADER, columns[0], top, columns[columns.length - 1], top + headerHeight);
for (float column : columns) line(s, u, column, top, column, bottom);
line(s, u, columns[0], top, columns[columns.length - 1], top);
line(s, u, columns[0], top + headerHeight, columns[columns.length - 1], top + headerHeight);
for (int index = 1; index <= rows; index++) {
line(s, u, columns[0], top + headerHeight + index * rowHeight,
columns[columns.length - 1], top + headerHeight + index * rowHeight);
}
}
private void metric(PDPageContentStream s, PdfUnits u, PDFont f, String label, String value,
double x1, double x2) throws IOException {
centeredCell(s, u, f, label, 7.2f, x1 + 1, x2 - 1, 84);
centeredCell(s, u, f, value, 10.5f, x1 + 1, x2 - 1, 91);
private void tableHeaders(PDPageContentStream s, PdfUnits u, PDFont f, float[] columns,
String[] headers, double top, double headerHeight) throws IOException {
for (int index = 0; index < headers.length; index++) {
centeredCell(s, u, f, headers[index], 7, columns[index] + .5,
columns[index + 1] - .5, top + headerHeight * .68);
}
}
private void centered(PDPageContentStream s, PdfUnits u, PDFont f, String value, float size,
double centerX, double y) throws IOException {
float width = f.getStringWidth(value) / 1000 * size;
text(s, u, f, value, size, centerX - width / PdfUnits.mmToPt(1) / 2, y);
private void tableRow(PDPageContentStream s, PdfUnits u, PDFont f, float[] columns,
String[] values, double top, double height, float size) throws IOException {
for (int index = 0; index < values.length; index++) {
centeredCell(s, u, f, values[index], size, columns[index] + .7,
columns[index + 1] - .7, top + height * .7);
}
}
private void border(PDPageContentStream s, PdfUnits u) throws IOException {
s.setStrokingColor(BLUE);
s.setLineWidth(PdfUnits.mmToPt(.55));
s.addRect(PdfUnits.mmToPt(10), u.topDownMmToPdfY(290), PdfUnits.mmToPt(190), PdfUnits.mmToPt(280));
s.stroke();
s.setStrokingColor(new Color(140, 140, 140));
s.setLineWidth(PdfUnits.mmToPt(.25));
}
private void fittedText(PDPageContentStream s, PdfUnits u, PDFont f, String value,
float preferredSize, double x, double y, double widthMm,
Color color) throws IOException {
float size = preferredSize;
while (size > 9 && f.getStringWidth(value) / 1000 * size > PdfUnits.mmToPt(widthMm)) size -= .5f;
colorText(s, u, f, fit(f, value, size, PdfUnits.mmToPt(widthMm)), size, x, y, color);
}
private void rightText(PDPageContentStream s, PdfUnits u, PDFont f, String value, float size,
double right, double y, Color color) throws IOException {
double widthMm = f.getStringWidth(value) / 1000 * size / PdfUnits.mmToPt(1);
colorText(s, u, f, value, size, right - widthMm, y, color);
}
private void centeredCell(PDPageContentStream s, PdfUnits u, PDFont f, String value, float size,
double x1, double x2, double y) throws IOException {
String fitted = fit(f, value, size, PdfUnits.mmToPt(x2 - x1));
float width = f.getStringWidth(fitted) / 1000 * size;
text(s, u, f, fitted, size, x1 + (x2 - x1 - width / PdfUnits.mmToPt(1)) / 2, y);
}
private String fit(PDFont font, String raw, float size, float maxWidth) throws IOException {
String value = raw == null || raw.isBlank() ? "—" : raw;
if (font.getStringWidth(value) / 1000 * size <= maxWidth) return value;
@@ -127,26 +179,7 @@ public class DropshipPalletA4Renderer {
while (end > 0 && font.getStringWidth(value.substring(0, end) + ellipsis) / 1000 * size > maxWidth) end--;
return end == 0 ? ellipsis : value.substring(0, end) + ellipsis;
}
private List<String> wrap(PDFont font, String raw, float size, float maxWidth,
int maxLines) throws IOException {
String remaining = raw == null || raw.isBlank() ? "—" : raw;
List<String> lines = new java.util.ArrayList<>();
while (!remaining.isEmpty() && lines.size() < maxLines) {
if (font.getStringWidth(remaining) / 1000 * size <= maxWidth) {
lines.add(remaining);
break;
}
int split = remaining.length();
while (split > 0 && font.getStringWidth(remaining.substring(0, split)) / 1000 * size > maxWidth) split--;
int delimiter = remaining.lastIndexOf('/', split - 1);
if (delimiter > 0) split = delimiter + 1;
String line = remaining.substring(0, Math.max(1, split));
remaining = remaining.substring(Math.max(1, split));
if (lines.size() == maxLines - 1 && !remaining.isEmpty()) line = fit(font, line + remaining, size, maxWidth);
lines.add(line);
}
return lines;
}
private void fill(PDPageContentStream s, PdfUnits u, Color color, double x1, double y1,
double x2, double y2) throws IOException {
s.setNonStrokingColor(color);
@@ -154,14 +187,27 @@ public class DropshipPalletA4Renderer {
s.fill();
s.setNonStrokingColor(Color.BLACK);
}
private void colorText(PDPageContentStream s, PdfUnits u, PDFont f, String value, float size,
double x, double y, Color color) throws IOException {
s.setNonStrokingColor(color);
text(s, u, f, value, size, x, y);
s.setNonStrokingColor(Color.BLACK);
}
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();
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();
s.lineTo(PdfUnits.mmToPt(x2), u.topDownMmToPdfY(y2));
s.stroke();
}
}
@@ -25,6 +25,7 @@ import org.springframework.stereotype.Service;
public class DropshipPalletA4PdfService {
private static final BigDecimal A4_WIDTH = new BigDecimal("210");
private static final BigDecimal A4_HEIGHT = new BigDecimal("297");
private static final int MAX_INBOUND_ROWS = 10;
private static final int MAX_SKU_ROWS = 34;
private static final Set<Character> FORBIDDEN_FILE_CHARS = Set.of('/', '\\', ':', '*', '?', '"', '<', '>', '|');
@@ -67,21 +68,28 @@ public class DropshipPalletA4PdfService {
private DropshipPalletA4PdfDtos.Document normalizeDocument(DropshipPalletA4PdfDtos.Document value, int index) {
if (value == null) invalid("documents[" + index + "] is required");
if (value.cartonCount() < 0 || value.skuCount() < 0 || negative(value.weightKg()) || negative(value.volumeCbm())) invalid("documents[" + index + "] totals are invalid");
if (value.palletSequence() <= 0 || value.totalPallets() <= 0
|| value.palletSequence() > value.totalPallets()) invalid("documents[" + index + "] pallet position is invalid");
if (value.inboundRows() == null || value.inboundRows().isEmpty()
|| value.inboundRows().size() > MAX_INBOUND_ROWS) invalid("documents[" + index + "].inboundRows exceeds the one-page A4 capacity");
if (value.skuRows() == null || value.skuRows().size() > MAX_SKU_ROWS) invalid("documents[" + index + "].skuRows exceeds the one-page A4 capacity");
List<DropshipPalletA4PdfDtos.InboundRow> inboundRows = new ArrayList<>();
for (DropshipPalletA4PdfDtos.InboundRow row : value.inboundRows()) {
if (row == null || row.cartonCount() < 0 || row.itemQuantity() < 0) invalid("inbound row is invalid");
inboundRows.add(new DropshipPalletA4PdfDtos.InboundRow(text(row.inboundCode(), 80),
text(row.customerNickname(), 80), row.cartonCount(), row.itemQuantity()));
}
List<DropshipPalletA4PdfDtos.SkuRow> rows = new ArrayList<>();
for (DropshipPalletA4PdfDtos.SkuRow row : value.skuRows()) {
if (row == null || row.cartonCount() < 0 || row.skuQuantity() < 0) invalid("SKU row is invalid");
rows.add(new DropshipPalletA4PdfDtos.SkuRow(text(row.inboundCode(), 80), text(row.skuCode(), 128),
text(row.cargoName(), 200), row.cartonCount(), row.skuQuantity()));
if (row == null || row.cartonCount() < 0 || row.itemQuantity() < 0) invalid("SKU row is invalid");
rows.add(new DropshipPalletA4PdfDtos.SkuRow(text(row.skuCode(), 128), row.cartonCount(),
row.itemQuantity(), text(row.unitsPerCarton(), 30)));
}
return new DropshipPalletA4PdfDtos.Document(text(value.palletNo(), 80), text(value.inboundCodes(), 300),
value.cartonCount(), value.skuCount(), decimal(value.weightKg()), decimal(value.volumeCbm()),
barcode(value.barcodeContent()), List.copyOf(rows));
return new DropshipPalletA4PdfDtos.Document(text(value.palletNo(), 80),
text(value.destinationWarehouse(), 200), value.palletSequence(), value.totalPallets(),
barcode(value.barcodeContent()), List.copyOf(inboundRows), List.copyOf(rows));
}
private BigDecimal decimal(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; }
private boolean negative(BigDecimal value) { return value != null && value.signum() < 0; }
private String barcode(String value) {
String normalized = text(value, 80);
if (normalized.codePoints().anyMatch(codePoint -> codePoint < 0x20 || codePoint > 0x7e)) invalid("barcodeContent must contain printable ASCII only");
@@ -279,7 +279,7 @@ components:
type: object
required: [templateCode, pageSpec, downloadFileName, documents]
properties:
templateCode: { type: string, enum: [DROPSHIP_PALLET_A4_V1] }
templateCode: { type: string, enum: [DROPSHIP_PALLET_A4_V2] }
pageSpec: { $ref: '#/components/schemas/FreightA4PageSpec' }
downloadFileName: { type: string, maxLength: 120 }
documents:
@@ -290,33 +290,43 @@ components:
items: { $ref: '#/components/schemas/DropshipPalletA4Document' }
DropshipPalletA4Document:
type: object
required: [palletNo, inboundCodes, cartonCount, skuCount, weightKg, volumeCbm, barcodeContent, skuRows]
required: [palletNo, destinationWarehouse, palletSequence, totalPallets, barcodeContent, inboundRows, skuRows]
properties:
palletNo: { type: string, maxLength: 80 }
inboundCodes: { type: string, maxLength: 300 }
cartonCount: { type: integer, minimum: 0 }
skuCount: { type: integer, minimum: 0 }
weightKg: { type: number, minimum: 0 }
volumeCbm: { type: number, minimum: 0 }
destinationWarehouse: { type: string, maxLength: 200 }
palletSequence: { type: integer, minimum: 1 }
totalPallets: { type: integer, minimum: 1 }
barcodeContent:
type: string
maxLength: 80
pattern: '^[\\x20-\\x7E]+$'
description: Code 128 一维码内容,当前固定为稳定托盘码
inboundRows:
type: array
minItems: 1
maxItems: 10
items: { $ref: '#/components/schemas/DropshipPalletInboundRow' }
skuRows:
type: array
minItems: 0
maxItems: 34
items: { $ref: '#/components/schemas/DropshipPalletSkuRow' }
DropshipPalletSkuRow:
DropshipPalletInboundRow:
type: object
required: [inboundCode, skuCode, cargoName, cartonCount, skuQuantity]
required: [inboundCode, customerNickname, cartonCount, itemQuantity]
properties:
inboundCode: { type: string, maxLength: 80 }
skuCode: { type: string, maxLength: 128 }
cargoName: { type: string, maxLength: 200 }
customerNickname: { type: string, maxLength: 80 }
cartonCount: { type: integer, minimum: 0 }
skuQuantity: { type: integer, minimum: 0 }
itemQuantity: { type: integer, minimum: 0 }
DropshipPalletSkuRow:
type: object
required: [skuCode, cartonCount, itemQuantity, unitsPerCarton]
properties:
skuCode: { type: string, maxLength: 128 }
cartonCount: { type: integer, minimum: 0 }
itemQuantity: { type: integer, minimum: 0 }
unitsPerCarton: { type: string, maxLength: 30 }
FreightA4PageSpec:
type: object
required: [widthMm, heightMm]
@@ -216,7 +216,7 @@ class InternalPdfControllerTest {
.contains("name: X-Internal-Service-Token")
.contains("SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1:")
.contains("BILL_OF_LADING_CONFIRMATION_A4_V1:")
.contains("DROPSHIP_PALLET_A4_V1")
.contains("DROPSHIP_PALLET_A4_V2")
.contains("additionalProperties: false")
.contains("minItems: 1")
.contains("required: [marksAndNos, quantity, packaging, descriptionOfGoods, grossWeight, measurement]")
@@ -268,23 +268,26 @@ class InternalPdfControllerTest {
private String validDropshipPalletA4RequestJson() {
return """
{
"templateCode": "DROPSHIP_PALLET_A4_V1",
"templateCode": "DROPSHIP_PALLET_A4_V2",
"pageSpec": {"widthMm": 210, "heightMm": 297},
"downloadFileName": "DP-260911-0034-A4托盘信息单.pdf",
"documents": [{
"palletNo": "DP-260911-0034",
"inboundCodes": "NYEXX2609110249",
"cartonCount": 3,
"skuCount": 1,
"weightKg": 90,
"volumeCbm": 0.534,
"destinationWarehouse": "纽约 N.JDF",
"palletSequence": 1,
"totalPallets": 3,
"barcodeContent": "DP-260911-0034",
"skuRows": [{
"inboundRows": [{
"inboundCode": "NYEXX2609110249",
"skuCode": "SKU-001",
"cargoName": "蓝牙音箱",
"customerNickname": "小虫科技",
"cartonCount": 3,
"skuQuantity": 90
"itemQuantity": 90
}],
"skuRows": [{
"skuCode": "SKU-001",
"cartonCount": 3,
"itemQuantity": 90,
"unitsPerCarton": "30"
}]
}]
}
@@ -27,11 +27,12 @@ class DropshipPalletA4PdfServiceGenerationTest {
@Test
void generatesOneA4PageWithPalletBarcodeAndSkuDetails() throws Exception {
DropshipPalletA4PdfDtos.Document document = new DropshipPalletA4PdfDtos.Document(
"DP-260911-0034", "NYEXX2609110249", 3, 2,
new BigDecimal("90"), new BigDecimal("0.534"), "DP-260911-0034",
"DP-260911-0034", "纽约 N.JDF", 1, 3, "DP-260911-0034",
List.of(new DropshipPalletA4PdfDtos.InboundRow(
"NYEXX2609110249", "小虫科技", 3, 90)),
List.of(
new DropshipPalletA4PdfDtos.SkuRow("NYEXX2609110249", "SKU-001", "蓝牙音箱", 2, 40),
new DropshipPalletA4PdfDtos.SkuRow("NYEXX2609110249", "SKU-002", "充电线", 1, 50)));
new DropshipPalletA4PdfDtos.SkuRow("SKU-001", 2, 40, "20"),
new DropshipPalletA4PdfDtos.SkuRow("SKU-002", 1, 50, "50")));
var generated = service.generate(new DropshipPalletA4PdfDtos.Request(
DropshipPalletA4PdfDtos.TEMPLATE,
new DropshipPalletA4PdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297")),
@@ -43,24 +44,28 @@ class DropshipPalletA4PdfServiceGenerationTest {
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("DP-260911-0034", "NYEXX2609110249", "SKU 明细", "SKU-001", "SKU-002", "40", "50")
.contains("DP-260911-0034", "N.JDF", "NYEXX2609110249",
"SKU-001", "SKU-002", "40", "50")
.containsOnlyOnce("DP-260911-0034")
.doesNotContain("状态", "待封托", "已封托");
.doesNotContain("状态", "待封托", "已封托", "重量", "体积");
}
}
@Test
void keepsTenInboundCodesAndThirtyTwoSkuRowsOnOneA4Page() throws Exception {
List<String> inboundCodes = new ArrayList<>();
for (int index = 1; index <= 10; index++) inboundCodes.add("NYEXX260911" + String.format("%04d", 240 + index));
List<DropshipPalletA4PdfDtos.InboundRow> inboundRows = new ArrayList<>();
for (int index = 1; index <= 10; index++) {
inboundRows.add(new DropshipPalletA4PdfDtos.InboundRow(
"NYEXX260911" + String.format("%04d", 240 + index), "客户" + index, 2, index * 5));
}
List<DropshipPalletA4PdfDtos.SkuRow> rows = new ArrayList<>();
for (int index = 1; index <= 32; index++) {
rows.add(new DropshipPalletA4PdfDtos.SkuRow(inboundCodes.get((index - 1) % inboundCodes.size()),
"SKU-" + String.format("%03d", index), "测试商品" + index, 2, index * 5));
rows.add(new DropshipPalletA4PdfDtos.SkuRow(
"SKU-" + String.format("%03d", index), 2, index * 5, "5-10"));
}
DropshipPalletA4PdfDtos.Document document = new DropshipPalletA4PdfDtos.Document(
"DP-260911-0034", String.join("/", inboundCodes), 64, 32,
new BigDecimal("860.5"), new BigDecimal("6.728"), "DP-260911-0034", rows);
"DP-260911-0034", "纽约 N.JDF", 1, 1,
"DP-260911-0034", inboundRows, rows);
var generated = service.generate(new DropshipPalletA4PdfDtos.Request(
DropshipPalletA4PdfDtos.TEMPLATE,
new DropshipPalletA4PdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297")),
@@ -77,11 +82,11 @@ class DropshipPalletA4PdfServiceGenerationTest {
void rejectsSkuRowsThatCannotFitOnOneA4Page() {
List<DropshipPalletA4PdfDtos.SkuRow> rows = new ArrayList<>();
for (int index = 0; index < 35; index++) {
rows.add(new DropshipPalletA4PdfDtos.SkuRow("INBOUND", "SKU-" + index, "商品", 1, 1));
rows.add(new DropshipPalletA4PdfDtos.SkuRow("SKU-" + index, 1, 1, "1"));
}
DropshipPalletA4PdfDtos.Document document = new DropshipPalletA4PdfDtos.Document(
"DP-260911-0034", "INBOUND", 35, 35,
BigDecimal.ZERO, BigDecimal.ZERO, "DP-260911-0034", rows);
"DP-260911-0034", "纽约 N.JDF", 1, 1, "DP-260911-0034",
List.of(new DropshipPalletA4PdfDtos.InboundRow("INBOUND", "客户", 35, 35)), rows);
assertThatThrownBy(() -> service.generate(new DropshipPalletA4PdfDtos.Request(
DropshipPalletA4PdfDtos.TEMPLATE,