feat: support mixed-SKU dropship cartons
This commit is contained in:
@@ -5,9 +5,11 @@ 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.DropshipPalletA4PdfDtos;
|
||||
import com.baiyue.print.dto.InboundMarkPdfDtos;
|
||||
import com.baiyue.print.service.FreightA4PdfService;
|
||||
import com.baiyue.print.service.DispatchBolPdfService;
|
||||
import com.baiyue.print.service.DropshipPalletA4PdfService;
|
||||
import com.baiyue.print.service.InboundMarkPdfService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
@@ -33,6 +35,7 @@ public class InternalPdfController {
|
||||
@Resource private InboundMarkPdfService pdfService;
|
||||
@Resource private FreightA4PdfService freightA4PdfService;
|
||||
@Resource private DispatchBolPdfService dispatchBolPdfService;
|
||||
@Resource private DropshipPalletA4PdfService dropshipPalletA4PdfService;
|
||||
@Resource private InternalServiceTokenGuard tokenGuard;
|
||||
|
||||
@PostMapping(value = "/inbound-marks", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@@ -101,4 +104,17 @@ public class InternalPdfController {
|
||||
.header("X-Content-Type-Options", "nosniff").header(PAGE_COUNT_HEADER, Integer.toString(pdf.pageCount()))
|
||||
.contentType(MediaType.APPLICATION_PDF).contentLength(content.length).body(content);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/dropship-pallet-a4-documents", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<byte[]> generateDropshipPalletA4Documents(
|
||||
@RequestHeader(value = INTERNAL_TOKEN_HEADER, required = false) String internalToken,
|
||||
@Valid @RequestBody DropshipPalletA4PdfDtos.Request request) {
|
||||
tokenGuard.verify(internalToken);
|
||||
DropshipPalletA4PdfService.GeneratedPdf pdf = dropshipPalletA4PdfService.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,23 @@
|
||||
package com.baiyue.print.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/** 一件代发托盘 A4 信息单的内部渲染契约。 */
|
||||
public final class DropshipPalletA4PdfDtos {
|
||||
public static final String TEMPLATE = "DROPSHIP_PALLET_A4_V1";
|
||||
|
||||
private DropshipPalletA4PdfDtos() {}
|
||||
|
||||
public record Request(String templateCode, PageSpec pageSpec, String downloadFileName,
|
||||
List<Document> documents) {}
|
||||
|
||||
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 SkuRow(String inboundCode, String skuCode, String cargoName,
|
||||
int cartonCount, int skuQuantity) {}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.baiyue.print.renderer;
|
||||
|
||||
import com.baiyue.print.dto.DropshipPalletA4PdfDtos;
|
||||
import com.google.zxing.common.BitMatrix;
|
||||
import jakarta.annotation.Resource;
|
||||
import java.awt.Color;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
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;
|
||||
|
||||
/** 每个托盘一页 A4,Code 128 托盘码与完整 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);
|
||||
@Resource private PdfBarcodeRenderer barcodeRenderer;
|
||||
|
||||
public void renderPage(PDDocument pdf, PDFont font, DropshipPalletA4PdfDtos.Document data,
|
||||
BitMatrix barcodeMatrix) throws IOException {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
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 void table(PDPageContentStream stream, PdfUnits units, PDFont font,
|
||||
List<DropshipPalletA4PdfDtos.SkuRow> rows) throws IOException {
|
||||
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);
|
||||
}
|
||||
if (rows.isEmpty()) {
|
||||
centeredCell(stream, units, font, "暂无已归托 SKU", 7.2f, COLS[0], COLS[COLS.length - 1], headerBottom + 4.8);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 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 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 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;
|
||||
String ellipsis = "…";
|
||||
int end = value.length();
|
||||
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);
|
||||
s.addRect(PdfUnits.mmToPt(x1), u.topDownMmToPdfY(y2), PdfUnits.mmToPt(x2 - x1), PdfUnits.mmToPt(y2 - y1));
|
||||
s.fill();
|
||||
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();
|
||||
}
|
||||
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,36 @@
|
||||
package com.baiyue.print.renderer;
|
||||
|
||||
import com.google.zxing.BarcodeFormat;
|
||||
import com.google.zxing.EncodeHintType;
|
||||
import com.google.zxing.common.BitMatrix;
|
||||
import com.google.zxing.oned.Code128Writer;
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** 将 Code 128 一维码以矢量矩形绘制到 PDF,避免打印缩放造成模糊。 */
|
||||
@Component
|
||||
public class PdfBarcodeRenderer {
|
||||
private static final int QUIET_ZONE_MODULES = 10;
|
||||
|
||||
public BitMatrix encode(String content) {
|
||||
try {
|
||||
return new Code128Writer().encode(content, BarcodeFormat.CODE_128, 1, 1,
|
||||
Map.of(EncodeHintType.MARGIN, QUIET_ZONE_MODULES));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new IllegalArgumentException("barcodeContent cannot be encoded", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public void draw(PDPageContentStream stream, BitMatrix matrix, float leftPt,
|
||||
float bottomPt, float widthPt, float heightPt) throws IOException {
|
||||
float moduleWidth = widthPt / matrix.getWidth();
|
||||
for (int x = 0; x < matrix.getWidth(); x++) {
|
||||
if (matrix.get(x, 0)) {
|
||||
stream.addRect(leftPt + x * moduleWidth, bottomPt, moduleWidth + .02f, heightPt);
|
||||
}
|
||||
}
|
||||
stream.fill();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
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.DropshipPalletA4PdfDtos;
|
||||
import com.baiyue.print.renderer.DropshipPalletA4Renderer;
|
||||
import com.baiyue.print.renderer.PdfBarcodeRenderer;
|
||||
import com.google.zxing.common.BitMatrix;
|
||||
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 DropshipPalletA4PdfService {
|
||||
private static final BigDecimal A4_WIDTH = new BigDecimal("210");
|
||||
private static final BigDecimal A4_HEIGHT = new BigDecimal("297");
|
||||
private static final int MAX_SKU_ROWS = 34;
|
||||
private static final Set<Character> FORBIDDEN_FILE_CHARS = Set.of('/', '\\', ':', '*', '?', '"', '<', '>', '|');
|
||||
|
||||
@Resource private PrintPdfProperties properties;
|
||||
@Resource private PdfGenerationLimiter limiter;
|
||||
@Resource private DropshipPalletA4Renderer renderer;
|
||||
@Resource private PdfBarcodeRenderer barcodeRenderer;
|
||||
|
||||
public GeneratedPdf generate(DropshipPalletA4PdfDtos.Request request) {
|
||||
DropshipPalletA4PdfDtos.Request normalized = normalize(request);
|
||||
try (PdfGenerationLimiter.Permit ignored = limiter.acquire(); PDDocument pdf = new PDDocument();
|
||||
InputStream fontStream = new ClassPathResource(properties.getFontResource()).getInputStream();
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
|
||||
PDType0Font font = PDType0Font.load(pdf, fontStream, true);
|
||||
for (DropshipPalletA4PdfDtos.Document document : normalized.documents()) {
|
||||
BitMatrix barcode = barcodeRenderer.encode(document.barcodeContent());
|
||||
renderer.renderPage(pdf, font, document, barcode);
|
||||
}
|
||||
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 DropshipPalletA4PdfDtos.Request normalize(DropshipPalletA4PdfDtos.Request request) {
|
||||
if (request == null || !DropshipPalletA4PdfDtos.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(value -> FORBIDDEN_FILE_CHARS.contains((char) value))) invalid("downloadFileName is invalid");
|
||||
if (request.documents() == null || request.documents().isEmpty() || request.documents().size() > properties.getMaxPages()) invalid("documents count is invalid");
|
||||
List<DropshipPalletA4PdfDtos.Document> documents = new ArrayList<>();
|
||||
for (int index = 0; index < request.documents().size(); index++) documents.add(normalizeDocument(request.documents().get(index), index));
|
||||
return new DropshipPalletA4PdfDtos.Request(DropshipPalletA4PdfDtos.TEMPLATE, request.pageSpec(), fileName, List.copyOf(documents));
|
||||
}
|
||||
|
||||
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.skuRows() == null || value.skuRows().size() > MAX_SKU_ROWS) invalid("documents[" + index + "].skuRows exceeds the one-page A4 capacity");
|
||||
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()));
|
||||
}
|
||||
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));
|
||||
}
|
||||
|
||||
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");
|
||||
return normalized;
|
||||
}
|
||||
private String text(String value, int maxCodePoints) {
|
||||
String normalized = line(value);
|
||||
if (normalized.isBlank() || normalized.codePointCount(0, normalized.length()) > maxCodePoints
|
||||
|| normalized.codePoints().anyMatch(Character::isISOControl)) invalid("document text is invalid");
|
||||
return 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) {}
|
||||
}
|
||||
@@ -118,6 +118,27 @@ paths:
|
||||
content:
|
||||
application/pdf: { schema: { type: string, format: binary } }
|
||||
application/json: { schema: { $ref: '#/components/schemas/ApiError' } }
|
||||
/internal/print/v1/pdfs/dropship-pallet-a4-documents:
|
||||
post:
|
||||
summary: Generate one A4 information sheet per dropship pallet
|
||||
operationId: generateDropshipPalletA4Pdf
|
||||
security: [{ internalServiceToken: [] }]
|
||||
parameters: [{ $ref: '#/components/parameters/RequestId' }]
|
||||
requestBody:
|
||||
required: true
|
||||
content: { application/json: { schema: { $ref: '#/components/schemas/DropshipPalletA4PdfRequest' } } }
|
||||
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' } }
|
||||
'413':
|
||||
description: Request body exceeds the configured limit.
|
||||
content: { application/json: { schema: { $ref: '#/components/schemas/ApiError' } } }
|
||||
components:
|
||||
securitySchemes:
|
||||
internalServiceToken:
|
||||
@@ -254,6 +275,48 @@ components:
|
||||
fbaShipmentId: { type: string }
|
||||
poReferenceNo: { type: string }
|
||||
packageCount: { type: integer, minimum: 0 }
|
||||
DropshipPalletA4PdfRequest:
|
||||
type: object
|
||||
required: [templateCode, pageSpec, downloadFileName, documents]
|
||||
properties:
|
||||
templateCode: { type: string, enum: [DROPSHIP_PALLET_A4_V1] }
|
||||
pageSpec: { $ref: '#/components/schemas/FreightA4PageSpec' }
|
||||
downloadFileName: { type: string, maxLength: 120 }
|
||||
documents:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 2000
|
||||
description: One pallet renders as exactly one A4 page in request order.
|
||||
items: { $ref: '#/components/schemas/DropshipPalletA4Document' }
|
||||
DropshipPalletA4Document:
|
||||
type: object
|
||||
required: [palletNo, inboundCodes, cartonCount, skuCount, weightKg, volumeCbm, barcodeContent, 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 }
|
||||
barcodeContent:
|
||||
type: string
|
||||
maxLength: 80
|
||||
pattern: '^[\\x20-\\x7E]+$'
|
||||
description: Code 128 一维码内容,当前固定为稳定托盘码
|
||||
skuRows:
|
||||
type: array
|
||||
minItems: 0
|
||||
maxItems: 34
|
||||
items: { $ref: '#/components/schemas/DropshipPalletSkuRow' }
|
||||
DropshipPalletSkuRow:
|
||||
type: object
|
||||
required: [inboundCode, skuCode, cargoName, cartonCount, skuQuantity]
|
||||
properties:
|
||||
inboundCode: { type: string, maxLength: 80 }
|
||||
skuCode: { type: string, maxLength: 128 }
|
||||
cargoName: { type: string, maxLength: 200 }
|
||||
cartonCount: { type: integer, minimum: 0 }
|
||||
skuQuantity: { type: integer, minimum: 0 }
|
||||
FreightA4PageSpec:
|
||||
type: object
|
||||
required: [widthMm, heightMm]
|
||||
|
||||
@@ -4,9 +4,11 @@ import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.web.GlobalExceptionHandler;
|
||||
import com.baiyue.print.dto.FreightA4PdfDtos;
|
||||
import com.baiyue.print.dto.DropshipPalletA4PdfDtos;
|
||||
import com.baiyue.print.dto.InboundMarkPdfDtos;
|
||||
import com.baiyue.print.service.FreightA4PdfService;
|
||||
import com.baiyue.print.service.DispatchBolPdfService;
|
||||
import com.baiyue.print.service.DropshipPalletA4PdfService;
|
||||
import com.baiyue.print.service.InboundMarkPdfService;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -45,6 +47,8 @@ class InternalPdfControllerTest {
|
||||
private FreightA4PdfService freightA4PdfService;
|
||||
@MockBean
|
||||
private DispatchBolPdfService dispatchBolPdfService;
|
||||
@MockBean
|
||||
private DropshipPalletA4PdfService dropshipPalletA4PdfService;
|
||||
@Resource
|
||||
private InternalPdfController controller;
|
||||
private MockMvc mockMvc;
|
||||
@@ -169,6 +173,27 @@ class InternalPdfControllerTest {
|
||||
verify(freightA4PdfService).generate(any(FreightA4PdfDtos.Request.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsDropshipPalletA4PdfWithDownloadHeaders() throws Exception {
|
||||
byte[] pdf = "%PDF-pallet-a4".getBytes(StandardCharsets.US_ASCII);
|
||||
when(dropshipPalletA4PdfService.generate(any())).thenReturn(
|
||||
new DropshipPalletA4PdfService.GeneratedPdf(pdf, "DP-260911-0034-A4托盘信息单.pdf", 1));
|
||||
|
||||
mockMvc.perform(post("/internal/print/v1/pdfs/dropship-pallet-a4-documents")
|
||||
.header(InternalPdfController.INTERNAL_TOKEN_HEADER, TOKEN)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.accept(MediaType.APPLICATION_PDF, MediaType.APPLICATION_JSON)
|
||||
.content(validDropshipPalletA4RequestJson()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().contentType(MediaType.APPLICATION_PDF))
|
||||
.andExpect(content().bytes(pdf))
|
||||
.andExpect(header().string("X-Print-Page-Count", "1"))
|
||||
.andExpect(header().string("Content-Disposition",
|
||||
org.hamcrest.Matchers.containsString("filename*=")));
|
||||
|
||||
verify(dropshipPalletA4PdfService).generate(any(DropshipPalletA4PdfDtos.Request.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void endpointDoesNotPinProducedMediaTypeToPdf() throws Exception {
|
||||
PostMapping mapping = InternalPdfController.class
|
||||
@@ -185,11 +210,13 @@ class InternalPdfControllerTest {
|
||||
assertThat(contract)
|
||||
.contains("/internal/print/v1/pdfs/inbound-marks:")
|
||||
.contains("/internal/print/v1/pdfs/freight-a4-documents:")
|
||||
.contains("/internal/print/v1/pdfs/dropship-pallet-a4-documents:")
|
||||
.contains("application/pdf:")
|
||||
.contains("application/json:")
|
||||
.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("additionalProperties: false")
|
||||
.contains("minItems: 1")
|
||||
.contains("required: [marksAndNos, quantity, packaging, descriptionOfGoods, grossWeight, measurement]")
|
||||
@@ -238,4 +265,30 @@ class InternalPdfControllerTest {
|
||||
""";
|
||||
}
|
||||
|
||||
private String validDropshipPalletA4RequestJson() {
|
||||
return """
|
||||
{
|
||||
"templateCode": "DROPSHIP_PALLET_A4_V1",
|
||||
"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,
|
||||
"barcodeContent": "DP-260911-0034",
|
||||
"skuRows": [{
|
||||
"inboundCode": "NYEXX2609110249",
|
||||
"skuCode": "SKU-001",
|
||||
"cargoName": "蓝牙音箱",
|
||||
"cartonCount": 3,
|
||||
"skuQuantity": 90
|
||||
}]
|
||||
}]
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.print.renderer;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.google.zxing.common.BitMatrix;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class PdfBarcodeRendererTest {
|
||||
@Test
|
||||
void encodesCode128DeterministicallyWithQuietZone() {
|
||||
PdfBarcodeRenderer renderer = new PdfBarcodeRenderer();
|
||||
|
||||
BitMatrix first = renderer.encode("DP-260911-0034");
|
||||
BitMatrix second = renderer.encode("DP-260911-0034");
|
||||
|
||||
assertThat(first).isEqualTo(second);
|
||||
assertThat(first.getHeight()).isEqualTo(1);
|
||||
assertThat(first.getWidth()).isGreaterThan(100);
|
||||
assertThat(first.getEnclosingRectangle()).isNotNull();
|
||||
assertThat(first.getEnclosingRectangle()[0]).isGreaterThanOrEqualTo(5);
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package com.baiyue.print.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.print.config.PrintPdfProperties;
|
||||
import com.baiyue.print.dto.DropshipPalletA4PdfDtos;
|
||||
import com.baiyue.print.renderer.DropshipPalletA4Renderer;
|
||||
import com.baiyue.print.renderer.PdfBarcodeRenderer;
|
||||
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;
|
||||
import org.apache.pdfbox.text.PDFTextStripper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
@SpringJUnitConfig(classes = {DropshipPalletA4PdfService.class, PrintPdfProperties.class,
|
||||
PdfGenerationLimiter.class, DropshipPalletA4Renderer.class, PdfBarcodeRenderer.class})
|
||||
class DropshipPalletA4PdfServiceGenerationTest {
|
||||
@Resource private DropshipPalletA4PdfService service;
|
||||
|
||||
@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",
|
||||
List.of(
|
||||
new DropshipPalletA4PdfDtos.SkuRow("NYEXX2609110249", "SKU-001", "蓝牙音箱", 2, 40),
|
||||
new DropshipPalletA4PdfDtos.SkuRow("NYEXX2609110249", "SKU-002", "充电线", 1, 50)));
|
||||
var generated = service.generate(new DropshipPalletA4PdfDtos.Request(
|
||||
DropshipPalletA4PdfDtos.TEMPLATE,
|
||||
new DropshipPalletA4PdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297")),
|
||||
"DP-260911-0034-A4托盘信息单.pdf", List.of(document)));
|
||||
assertThat(generated.pageCount()).isEqualTo(1);
|
||||
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("DP-260911-0034", "NYEXX2609110249", "SKU 明细", "SKU-001", "SKU-002", "40", "50")
|
||||
.containsOnlyOnce("DP-260911-0034")
|
||||
.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.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));
|
||||
}
|
||||
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);
|
||||
var generated = service.generate(new DropshipPalletA4PdfDtos.Request(
|
||||
DropshipPalletA4PdfDtos.TEMPLATE,
|
||||
new DropshipPalletA4PdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297")),
|
||||
"DP-260911-0034-高密度预览.pdf", List.of(document)));
|
||||
try (PDDocument pdf = Loader.loadPDF(generated.content())) {
|
||||
assertThat(pdf.getNumberOfPages()).isEqualTo(1);
|
||||
assertThat(new PDFTextStripper().getText(pdf))
|
||||
.contains("NYEXX2609110241", "NYEXX2609110250", "SKU-001", "SKU-032")
|
||||
.containsOnlyOnce("DP-260911-0034");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
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));
|
||||
}
|
||||
DropshipPalletA4PdfDtos.Document document = new DropshipPalletA4PdfDtos.Document(
|
||||
"DP-260911-0034", "INBOUND", 35, 35,
|
||||
BigDecimal.ZERO, BigDecimal.ZERO, "DP-260911-0034", rows);
|
||||
|
||||
assertThatThrownBy(() -> service.generate(new DropshipPalletA4PdfDtos.Request(
|
||||
DropshipPalletA4PdfDtos.TEMPLATE,
|
||||
new DropshipPalletA4PdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297")),
|
||||
"pallet.pdf", List.of(document))))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("one-page A4 capacity");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user