fix: keep pallet labels on A4 templates
This commit is contained in:
+2
-1
@@ -5,7 +5,7 @@
|
|||||||
- 本模块是无数据库的无状态 PDF 渲染服务;调用方传入完整 `labels[]` 或 `documents[]`,模块在内存中生成一次性多页 PDF 并同步返回。
|
- 本模块是无数据库的无状态 PDF 渲染服务;调用方传入完整 `labels[]` 或 `documents[]`,模块在内存中生成一次性多页 PDF 并同步返回。
|
||||||
- 不保存模板、任务、历史或文件,不连接数据库、File 服务或打印机,不反查或修正业务数据。
|
- 不保存模板、任务、历史或文件,不连接数据库、File 服务或打印机,不反查或修正业务数据。
|
||||||
- `INBOUND_MARK_V1` 箱唛不接收或展示 SKU(包括一件代发箱唛),二维码仅编码稳定箱码;SKU 明细由一件代发 A4 托盘信息单展示,不能重新加入箱唛版面。
|
- `INBOUND_MARK_V1` 箱唛不接收或展示 SKU(包括一件代发箱唛),二维码仅编码稳定箱码;SKU 明细由一件代发 A4 托盘信息单展示,不能重新加入箱唛版面。
|
||||||
- 当前支持 `INBOUND_MARK_V1`(100×100 mm)、`FBA_PALLET_A4_LANDSCAPE_V1`(297×210 mm),以及纵向 A4 的 `DROPSHIP_PALLET_A4_V2`、`SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1`、`BILL_OF_LADING_CONFIRMATION_A4_V1`、`DISPATCH_BILL_OF_LADING_A4_V2`。FBA 模板由国内和海外统一使用,每托一页,只展示 SCK8 等 FBA 仓码、完整托盘号的 Code 128 条码和完整托盘号;141 等内部编号保留在托盘号中。一件代发每托一页,展示托盘 Code 128、目的仓、最多 10 行入仓码/客户昵称和最多 34 行 SKU,不展示状态、重量或体积。新尺寸或布局必须使用新模板编码。
|
- 当前只有箱唛 `INBOUND_MARK_V1` 使用 100×100 mm;托盘标不得复用该模板。横向 A4 托盘模板包括 `FBA_PALLET_A4_LANDSCAPE_V1` 与 `PALLET_A4_LANDSCAPE_V1`,纵向 A4 包括 `DROPSHIP_PALLET_A4_V2`,其他 A4 单据为 `SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1`、`BILL_OF_LADING_CONFIRMATION_A4_V1`、`DISPATCH_BILL_OF_LADING_A4_V2`。FBA 模板由国内和海外统一使用,每托一页,只展示 SCK8 等 FBA 仓码、完整托盘号的 Code 128 条码和完整托盘号;141 等内部编号保留在托盘号中。一件代发展示托盘 Code 128、目的仓、每页最多 10 行入仓码/客户昵称和 34 行 SKU,超量由调用方按同一托盘自动续页,不展示状态、重量或体积。新尺寸或布局必须使用新模板编码。
|
||||||
|
|
||||||
## 快速索引
|
## 快速索引
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 调用方接入指南 | `README.md` |
|
| 调用方接入指南 | `README.md` |
|
||||||
| 国内/海外统一 FBA 托盘标 | `dto/FbaPalletPdfDtos.java`、`service/FbaPalletPdfService.java`、`renderer/FbaPalletRenderer.java`;内部 `/internal/print/v1/pdfs/fba-pallet-labels`,回归 `src/test/java/com/baiyue/print/service/FbaPalletPdfServiceTest.java` |
|
| 国内/海外统一 FBA 托盘标 | `dto/FbaPalletPdfDtos.java`、`service/FbaPalletPdfService.java`、`renderer/FbaPalletRenderer.java`;内部 `/internal/print/v1/pdfs/fba-pallet-labels`,回归 `src/test/java/com/baiyue/print/service/FbaPalletPdfServiceTest.java` |
|
||||||
|
| 非 FBA 通用托盘标 | `dto/GenericPalletPdfDtos.java`、`service/GenericPalletPdfService.java`、`renderer/GenericPalletRenderer.java`;内部 `/internal/print/v1/pdfs/pallet-a4-labels` |
|
||||||
| 应用入口 | `src/main/java/com/baiyue/print/PrintServiceApplication.java` |
|
| 应用入口 | `src/main/java/com/baiyue/print/PrintServiceApplication.java` |
|
||||||
| 健康状态 API | `controller/PrintHealthController.java`(`/api/platform/v1/print/status`) |
|
| 健康状态 API | `controller/PrintHealthController.java`(`/api/platform/v1/print/status`) |
|
||||||
| 内部 PDF API | `controller/InternalPdfController.java`(`/internal/print/v1/pdfs/inbound-marks`、`/internal/print/v1/pdfs/dropship-pallet-a4-documents`、`/internal/print/v1/pdfs/freight-a4-documents`) |
|
| 内部 PDF API | `controller/InternalPdfController.java`(`/internal/print/v1/pdfs/inbound-marks`、`/internal/print/v1/pdfs/dropship-pallet-a4-documents`、`/internal/print/v1/pdfs/freight-a4-documents`) |
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import jakarta.annotation.Resource;
|
|||||||
import com.baiyue.common.web.CorrelationContext;
|
import com.baiyue.common.web.CorrelationContext;
|
||||||
import com.baiyue.print.dto.FreightA4PdfDtos;
|
import com.baiyue.print.dto.FreightA4PdfDtos;
|
||||||
import com.baiyue.print.dto.FbaPalletPdfDtos;
|
import com.baiyue.print.dto.FbaPalletPdfDtos;
|
||||||
|
import com.baiyue.print.dto.GenericPalletPdfDtos;
|
||||||
import com.baiyue.print.service.FbaPalletPdfService;
|
import com.baiyue.print.service.FbaPalletPdfService;
|
||||||
|
import com.baiyue.print.service.GenericPalletPdfService;
|
||||||
import com.baiyue.print.dto.DispatchBolPdfDtos;
|
import com.baiyue.print.dto.DispatchBolPdfDtos;
|
||||||
import com.baiyue.print.dto.DropshipPalletA4PdfDtos;
|
import com.baiyue.print.dto.DropshipPalletA4PdfDtos;
|
||||||
import com.baiyue.print.dto.InboundMarkPdfDtos;
|
import com.baiyue.print.dto.InboundMarkPdfDtos;
|
||||||
@@ -39,6 +41,7 @@ public class InternalPdfController {
|
|||||||
@Resource private DispatchBolPdfService dispatchBolPdfService;
|
@Resource private DispatchBolPdfService dispatchBolPdfService;
|
||||||
@Resource private DropshipPalletA4PdfService dropshipPalletA4PdfService;
|
@Resource private DropshipPalletA4PdfService dropshipPalletA4PdfService;
|
||||||
@Resource private FbaPalletPdfService fbaPalletPdfService;
|
@Resource private FbaPalletPdfService fbaPalletPdfService;
|
||||||
|
@Resource private GenericPalletPdfService genericPalletPdfService;
|
||||||
@Resource private InternalServiceTokenGuard tokenGuard;
|
@Resource private InternalServiceTokenGuard tokenGuard;
|
||||||
|
|
||||||
@PostMapping(value = "/fba-pallet-labels", consumes = MediaType.APPLICATION_JSON_VALUE)
|
@PostMapping(value = "/fba-pallet-labels", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||||
@@ -54,6 +57,19 @@ public class InternalPdfController {
|
|||||||
.contentType(MediaType.APPLICATION_PDF).contentLength(content.length).body(content);
|
.contentType(MediaType.APPLICATION_PDF).contentLength(content.length).body(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/pallet-a4-labels", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||||
|
public ResponseEntity<byte[]> generatePalletA4Labels(
|
||||||
|
@RequestHeader(value = INTERNAL_TOKEN_HEADER, required = false) String internalToken,
|
||||||
|
@Valid @RequestBody GenericPalletPdfDtos.Request request) {
|
||||||
|
tokenGuard.verify(internalToken);
|
||||||
|
GenericPalletPdfService.GeneratedPdf pdf = genericPalletPdfService.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);
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping(value = "/inbound-marks", consumes = MediaType.APPLICATION_JSON_VALUE)
|
@PostMapping(value = "/inbound-marks", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||||
public ResponseEntity<byte[]> generateInboundMarks(
|
public ResponseEntity<byte[]> generateInboundMarks(
|
||||||
@RequestHeader(value = INTERNAL_TOKEN_HEADER, required = false) String internalToken,
|
@RequestHeader(value = INTERNAL_TOKEN_HEADER, required = false) String internalToken,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ public class PrintRequestSizeFilter extends OncePerRequestFilter {
|
|||||||
private static final java.util.Set<String> PATHS = java.util.Set.of(
|
private static final java.util.Set<String> PATHS = java.util.Set.of(
|
||||||
"/internal/print/v1/pdfs/inbound-marks",
|
"/internal/print/v1/pdfs/inbound-marks",
|
||||||
"/internal/print/v1/pdfs/fba-pallet-labels",
|
"/internal/print/v1/pdfs/fba-pallet-labels",
|
||||||
|
"/internal/print/v1/pdfs/pallet-a4-labels",
|
||||||
"/internal/print/v1/pdfs/dropship-pallet-a4-documents",
|
"/internal/print/v1/pdfs/dropship-pallet-a4-documents",
|
||||||
"/internal/print/v1/pdfs/freight-a4-documents");
|
"/internal/print/v1/pdfs/freight-a4-documents");
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.baiyue.print.dto;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class GenericPalletPdfDtos {
|
||||||
|
public static final String TEMPLATE = "PALLET_A4_LANDSCAPE_V1";
|
||||||
|
private GenericPalletPdfDtos() {}
|
||||||
|
|
||||||
|
public record Request(String templateCode, PageSpec pageSpec, String downloadFileName, List<Label> labels) {}
|
||||||
|
public record PageSpec(BigDecimal widthMm, BigDecimal heightMm) {}
|
||||||
|
public record Label(String heading, String reference, String palletCode) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.baiyue.print.renderer;
|
||||||
|
|
||||||
|
import com.baiyue.print.dto.GenericPalletPdfDtos;
|
||||||
|
import com.google.zxing.common.BitMatrix;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
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 GenericPalletRenderer {
|
||||||
|
@Resource private PdfBarcodeRenderer barcodeRenderer;
|
||||||
|
|
||||||
|
public void renderPage(PDDocument pdf, PDFont font, GenericPalletPdfDtos.Label label, BitMatrix barcode)
|
||||||
|
throws IOException {
|
||||||
|
PDPage page = new PDPage(new PDRectangle(PdfUnits.mmToPt(297), PdfUnits.mmToPt(210)));
|
||||||
|
pdf.addPage(page);
|
||||||
|
PdfUnits units = new PdfUnits(210);
|
||||||
|
try (PDPageContentStream stream = new PDPageContentStream(pdf, page)) {
|
||||||
|
centered(stream, units, font, label.heading(), 80, 43);
|
||||||
|
centered(stream, units, font, label.reference(), 30, 76);
|
||||||
|
barcodeRenderer.draw(stream, barcode, PdfUnits.mmToPt(33.5), units.topDownMmToPdfY(145),
|
||||||
|
PdfUnits.mmToPt(230), PdfUnits.mmToPt(34));
|
||||||
|
centered(stream, units, font, label.palletCode(), 58, 175);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void centered(PDPageContentStream stream, PdfUnits units, PDFont font, String value,
|
||||||
|
float preferredSize, double centerY) throws IOException {
|
||||||
|
float unitWidth = font.getStringWidth(value) / 1000;
|
||||||
|
float size = Math.min(preferredSize, PdfUnits.mmToPt(267) / unitWidth);
|
||||||
|
stream.beginText();
|
||||||
|
stream.setFont(font, size);
|
||||||
|
stream.newLineAtOffset((PdfUnits.mmToPt(297) - unitWidth * size) / 2,
|
||||||
|
units.centeredTextBaseline(font, size, centerY));
|
||||||
|
stream.showText(value);
|
||||||
|
stream.endText();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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.GenericPalletPdfDtos;
|
||||||
|
import com.baiyue.print.renderer.GenericPalletRenderer;
|
||||||
|
import com.baiyue.print.renderer.PdfBarcodeRenderer;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Locale;
|
||||||
|
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 GenericPalletPdfService {
|
||||||
|
@Resource private PrintPdfProperties properties;
|
||||||
|
@Resource private PdfGenerationLimiter limiter;
|
||||||
|
@Resource private GenericPalletRenderer renderer;
|
||||||
|
@Resource private PdfBarcodeRenderer barcodeRenderer;
|
||||||
|
|
||||||
|
public GeneratedPdf generate(GenericPalletPdfDtos.Request request) {
|
||||||
|
validate(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 (var label : request.labels()) {
|
||||||
|
renderer.renderPage(pdf, font, label, barcodeRenderer.encode(label.palletCode()));
|
||||||
|
}
|
||||||
|
pdf.save(output);
|
||||||
|
byte[] content = output.toByteArray();
|
||||||
|
if (content.length > properties.getMaxOutputBytes()) invalid("生成的 PDF 超过输出大小上限");
|
||||||
|
return new GeneratedPdf(content, request.downloadFileName(), request.labels().size());
|
||||||
|
} catch (IOException exception) {
|
||||||
|
throw new BizException(ResultCode.INTERNAL_ERROR, "PDF 生成失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validate(GenericPalletPdfDtos.Request request) {
|
||||||
|
if (request == null || !GenericPalletPdfDtos.TEMPLATE.equals(request.templateCode())) invalid("templateCode is not supported");
|
||||||
|
if (request.pageSpec() == null || request.pageSpec().widthMm() == null || request.pageSpec().heightMm() == null
|
||||||
|
|| request.pageSpec().widthMm().compareTo(new BigDecimal("297")) != 0
|
||||||
|
|| request.pageSpec().heightMm().compareTo(new BigDecimal("210")) != 0) invalid("pageSpec must be landscape A4 (297 x 210 mm)");
|
||||||
|
String fileName = request.downloadFileName();
|
||||||
|
if (fileName == null || fileName.isBlank() || fileName.length() > 120
|
||||||
|
|| !fileName.toLowerCase(Locale.ROOT).endsWith(".pdf")
|
||||||
|
|| fileName.matches(".*[\\\\/:*?\"<>|].*")
|
||||||
|
|| fileName.codePoints().anyMatch(Character::isISOControl)) invalid("downloadFileName is invalid");
|
||||||
|
if (request.labels() == null || request.labels().isEmpty() || request.labels().size() > properties.getMaxPages()) invalid("labels count is invalid");
|
||||||
|
for (var label : request.labels()) {
|
||||||
|
if (label == null || !text(label.heading(), 80) || !text(label.reference(), 80)) invalid("heading and reference are required");
|
||||||
|
if (label.palletCode() == null || label.palletCode().isBlank()
|
||||||
|
|| !label.palletCode().equals(label.palletCode().strip())
|
||||||
|
|| !label.palletCode().matches("[\\x20-\\x7E]{1,80}")) invalid("palletCode must contain 1-80 printable ASCII characters without surrounding spaces");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean text(String value, int max) {
|
||||||
|
return value != null && !value.isBlank() && value.codePointCount(0, value.length()) <= max
|
||||||
|
&& value.codePoints().noneMatch(Character::isISOControl);
|
||||||
|
}
|
||||||
|
private void invalid(String message) { throw new BizException(ResultCode.VALIDATION_FAILED, message); }
|
||||||
|
public record GeneratedPdf(byte[] content, String fileName, int pageCount) {}
|
||||||
|
}
|
||||||
@@ -4,6 +4,24 @@ info:
|
|||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
description: Internal stateless PDF rendering API. This path must not be exposed through Gateway.
|
description: Internal stateless PDF rendering API. This path must not be exposed through Gateway.
|
||||||
paths:
|
paths:
|
||||||
|
/internal/print/v1/pdfs/pallet-a4-labels:
|
||||||
|
post:
|
||||||
|
summary: Generate non-FBA pallet labels as landscape A4 pages
|
||||||
|
operationId: generatePalletA4Labels
|
||||||
|
security: [{ internalServiceToken: [] }]
|
||||||
|
parameters: [{ $ref: '#/components/parameters/RequestId' }]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content: { application/json: { schema: { $ref: '#/components/schemas/GenericPalletPdfRequest' } } }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: PDF generation succeeded, or a business error was returned as ApiResponse JSON.
|
||||||
|
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' } } }
|
||||||
/internal/print/v1/pdfs/fba-pallet-labels:
|
/internal/print/v1/pdfs/fba-pallet-labels:
|
||||||
post:
|
post:
|
||||||
summary: Generate shared domestic and overseas FBA pallet labels as landscape A4 pages
|
summary: Generate shared domestic and overseas FBA pallet labels as landscape A4 pages
|
||||||
@@ -176,6 +194,29 @@ components:
|
|||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
schemas:
|
schemas:
|
||||||
|
GenericPalletPdfRequest:
|
||||||
|
type: object
|
||||||
|
required: [templateCode, pageSpec, downloadFileName, labels]
|
||||||
|
properties:
|
||||||
|
templateCode: { type: string, enum: [PALLET_A4_LANDSCAPE_V1] }
|
||||||
|
pageSpec:
|
||||||
|
type: object
|
||||||
|
required: [widthMm, heightMm]
|
||||||
|
properties:
|
||||||
|
widthMm: { type: number, enum: [297] }
|
||||||
|
heightMm: { type: number, enum: [210] }
|
||||||
|
downloadFileName: { type: string, minLength: 4, maxLength: 120 }
|
||||||
|
labels:
|
||||||
|
type: array
|
||||||
|
minItems: 1
|
||||||
|
maxItems: 2000
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required: [heading, reference, palletCode]
|
||||||
|
properties:
|
||||||
|
heading: { type: string, minLength: 1, maxLength: 80 }
|
||||||
|
reference: { type: string, minLength: 1, maxLength: 80 }
|
||||||
|
palletCode: { type: string, minLength: 1, maxLength: 80, pattern: '^[\x20-\x7E]+$' }
|
||||||
FbaPalletPdfRequest:
|
FbaPalletPdfRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [templateCode, pageSpec, downloadFileName, labels]
|
required: [templateCode, pageSpec, downloadFileName, labels]
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import com.baiyue.print.service.DispatchBolPdfService;
|
|||||||
import com.baiyue.print.service.DropshipPalletA4PdfService;
|
import com.baiyue.print.service.DropshipPalletA4PdfService;
|
||||||
import com.baiyue.print.service.InboundMarkPdfService;
|
import com.baiyue.print.service.InboundMarkPdfService;
|
||||||
import com.baiyue.print.service.FbaPalletPdfService;
|
import com.baiyue.print.service.FbaPalletPdfService;
|
||||||
|
import com.baiyue.print.service.GenericPalletPdfService;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -52,6 +53,8 @@ class InternalPdfControllerTest {
|
|||||||
private DropshipPalletA4PdfService dropshipPalletA4PdfService;
|
private DropshipPalletA4PdfService dropshipPalletA4PdfService;
|
||||||
@MockBean
|
@MockBean
|
||||||
private FbaPalletPdfService fbaPalletPdfService;
|
private FbaPalletPdfService fbaPalletPdfService;
|
||||||
|
@MockBean
|
||||||
|
private GenericPalletPdfService genericPalletPdfService;
|
||||||
@Resource
|
@Resource
|
||||||
private InternalPdfController controller;
|
private InternalPdfController controller;
|
||||||
private MockMvc mockMvc;
|
private MockMvc mockMvc;
|
||||||
@@ -232,12 +235,14 @@ class InternalPdfControllerTest {
|
|||||||
.contains("/internal/print/v1/pdfs/inbound-marks:")
|
.contains("/internal/print/v1/pdfs/inbound-marks:")
|
||||||
.contains("/internal/print/v1/pdfs/freight-a4-documents:")
|
.contains("/internal/print/v1/pdfs/freight-a4-documents:")
|
||||||
.contains("/internal/print/v1/pdfs/dropship-pallet-a4-documents:")
|
.contains("/internal/print/v1/pdfs/dropship-pallet-a4-documents:")
|
||||||
|
.contains("/internal/print/v1/pdfs/pallet-a4-labels:")
|
||||||
.contains("application/pdf:")
|
.contains("application/pdf:")
|
||||||
.contains("application/json:")
|
.contains("application/json:")
|
||||||
.contains("name: X-Internal-Service-Token")
|
.contains("name: X-Internal-Service-Token")
|
||||||
.contains("SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1:")
|
.contains("SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1:")
|
||||||
.contains("BILL_OF_LADING_CONFIRMATION_A4_V1:")
|
.contains("BILL_OF_LADING_CONFIRMATION_A4_V1:")
|
||||||
.contains("DROPSHIP_PALLET_A4_V2")
|
.contains("DROPSHIP_PALLET_A4_V2")
|
||||||
|
.contains("PALLET_A4_LANDSCAPE_V1")
|
||||||
.contains("additionalProperties: false")
|
.contains("additionalProperties: false")
|
||||||
.contains("minItems: 1")
|
.contains("minItems: 1")
|
||||||
.contains("required: [marksAndNos, quantity, packaging, descriptionOfGoods, grossWeight, measurement]")
|
.contains("required: [marksAndNos, quantity, packaging, descriptionOfGoods, grossWeight, measurement]")
|
||||||
|
|||||||
@@ -73,6 +73,16 @@ class PrintRequestSizeFilterTest {
|
|||||||
assertTooLarge(response);
|
assertTooLarge(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void capsGenericPalletRequestsBeforeDeserialization() throws Exception {
|
||||||
|
var response = new MockHttpServletResponse();
|
||||||
|
AtomicReference<Boolean> called = new AtomicReference<>(false);
|
||||||
|
filter.doFilter(request("12345678901", true, "/internal/print/v1/pdfs/pallet-a4-labels"),
|
||||||
|
response, (ignoredRequest, ignoredResponse) -> called.set(true));
|
||||||
|
assertThat(called).hasValue(false);
|
||||||
|
assertTooLarge(response);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsOversizedDeclaredContentLengthWithoutCallingDownstream() throws Exception {
|
void rejectsOversizedDeclaredContentLengthWithoutCallingDownstream() throws Exception {
|
||||||
MockHttpServletRequest request = new MockHttpServletRequest() {
|
MockHttpServletRequest request = new MockHttpServletRequest() {
|
||||||
|
|||||||
+43
@@ -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.GenericPalletPdfDtos;
|
||||||
|
import com.baiyue.print.renderer.GenericPalletRenderer;
|
||||||
|
import com.baiyue.print.renderer.PdfBarcodeRenderer;
|
||||||
|
import com.baiyue.print.renderer.PdfUnits;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
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 = {GenericPalletPdfService.class, PrintPdfProperties.class,
|
||||||
|
PdfGenerationLimiter.class, GenericPalletRenderer.class, PdfBarcodeRenderer.class})
|
||||||
|
class GenericPalletPdfServiceGenerationTest {
|
||||||
|
@jakarta.annotation.Resource private GenericPalletPdfService service;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void generatesLandscapeA4WithoutUsingTheCartonMarkTemplate() throws Exception {
|
||||||
|
var generated = service.generate(new GenericPalletPdfDtos.Request(
|
||||||
|
GenericPalletPdfDtos.TEMPLATE,
|
||||||
|
new GenericPalletPdfDtos.PageSpec(new BigDecimal("297"), new BigDecimal("210")),
|
||||||
|
"pallet.pdf", List.of(new GenericPalletPdfDtos.Label(
|
||||||
|
"Los Angeles Warehouse", "Transfer WTR-1", "PALLET-001"))));
|
||||||
|
Files.createDirectories(Path.of("target"));
|
||||||
|
Files.write(Path.of("target", "generic-pallet-a4-preview.pdf"), generated.content());
|
||||||
|
|
||||||
|
try (PDDocument pdf = Loader.loadPDF(generated.content())) {
|
||||||
|
assertThat(pdf.getNumberOfPages()).isEqualTo(1);
|
||||||
|
assertThat(pdf.getPage(0).getMediaBox().getWidth()).isCloseTo(PdfUnits.mmToPt(297), org.assertj.core.data.Offset.offset(.002f));
|
||||||
|
assertThat(pdf.getPage(0).getMediaBox().getHeight()).isCloseTo(PdfUnits.mmToPt(210), org.assertj.core.data.Offset.offset(.002f));
|
||||||
|
assertThat(new PDFTextStripper().getText(pdf)).contains(
|
||||||
|
"Los Angeles Warehouse", "Transfer WTR-1", "PALLET-001");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
| 托盘解析与业务绑定 | `controller/InternalWarehousePalletController.java`、`dto/WarehousePalletResolveResponse.java`、`entity/WarehousePalletBindingEntity.java` |
|
| 托盘解析与业务绑定 | `controller/InternalWarehousePalletController.java`、`dto/WarehousePalletResolveResponse.java`、`entity/WarehousePalletBindingEntity.java` |
|
||||||
| 柜级入库计划批量托盘 | `controller/InternalWarehousePalletBatchController.java`、`service/WarehousePalletBatchService.java`、`warehouse-pallet-batch-internal-v1.yaml`;以稳定批次键幂等执行 `ensure-reserved → confirm`,权威托盘码已存在且资源可预留时必须复用,未提供或不存在时才创建;Lastleg 本地计划事务失败时仅补偿仍为 `RESERVED` 的未确认资源,已 `CONFIRMED` 批次不得回退,同柜重放不得产生第二批资源 |
|
| 柜级入库计划批量托盘 | `controller/InternalWarehousePalletBatchController.java`、`service/WarehousePalletBatchService.java`、`warehouse-pallet-batch-internal-v1.yaml`;以稳定批次键幂等执行 `ensure-reserved → confirm`,权威托盘码已存在且资源可预留时必须复用,未提供或不存在时才创建;Lastleg 本地计划事务失败时仅补偿仍为 `RESERVED` 的未确认资源,已 `CONFIRMED` 批次不得回退,同柜重放不得产生第二批资源 |
|
||||||
| 仓储资源服务 | `service/WarehouseResourceService.java`、`WarehouseResourceLockService.java` |
|
| 仓储资源服务 | `service/WarehouseResourceService.java`、`WarehouseResourceLockService.java` |
|
||||||
| 单张托盘标签 PDF | `controller/PlatformWarehouseResourceController.java` 的 `/pallets/{id}/label`、`service/PalletLabelPrintService.java`、`client/PrintPdfClient.java`;契约 `src/main/resources/openapi/pallet-label-v1.yaml`。有 FBA 仓码的托盘与国内统一调用 Print `/internal/print/v1/pdfs/fba-pallet-labels`、`FBA_PALLET_A4_LANDSCAPE_V1`:297×210 mm,顶部 SCK8 等仓码,完整托盘号作为 Code 128 与底部文字,141 等内部编号原样保留;其他托盘使用 `INBOUND_MARK_V1`。模板只在 Print 维护;沿用托盘列表权限与海外 Gateway 的 `warehouse-admin/**` 路由。`baiyue.clients.print.url` 对应 `PRINT_SERVICE_URL`,海外 Compose 必须与 BOL 一样显式注入国内 Print 地址,不能依赖海外 Nacos 发现国内服务;Feign 及熔断包装异常统一返回 `S0503` |
|
| 单张托盘标签 PDF | `controller/PlatformWarehouseResourceController.java` 的 `/pallets/{id}/label`、`service/PalletLabelPrintService.java`、`client/PrintPdfClient.java`;契约 `src/main/resources/openapi/pallet-label-v1.yaml`。有 FBA 仓码的托盘与国内统一调用 Print `/internal/print/v1/pdfs/fba-pallet-labels`、`FBA_PALLET_A4_LANDSCAPE_V1`;其他托盘调用 `/internal/print/v1/pdfs/pallet-a4-labels`、`PALLET_A4_LANDSCAPE_V1`。两者均为 297×210 mm A4 横向,托盘完整编码作为 Code 128 与底部文字,托盘标不得回退到 100×100 mm 箱唛模板。模板只在 Print 维护;沿用托盘列表权限与海外 Gateway 的 `warehouse-admin/**` 路由。`baiyue.clients.print.url` 对应 `PRINT_SERVICE_URL`,海外 Compose 必须与 BOL 一样显式注入国内 Print 地址,不能依赖海外 Nacos 发现国内服务;Feign 及熔断包装异常统一返回 `S0503` |
|
||||||
| 内部服务令牌 | `security/InternalServiceTokenGuard.java` |
|
| 内部服务令牌 | `security/InternalServiceTokenGuard.java` |
|
||||||
| 数据库基线与中文注释契约 | `src/main/resources/db/warehouse.sql`、`src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java` |
|
| 数据库基线与中文注释契约 | `src/main/resources/db/warehouse.sql`、`src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java` |
|
||||||
| 测试 | `src/test/java/com/baiyue/warehouse/` |
|
| 测试 | `src/test/java/com/baiyue/warehouse/` |
|
||||||
|
|||||||
+6
-5
@@ -21,13 +21,14 @@ public interface PrintPdfClient {
|
|||||||
record FbaPalletRequest(String templateCode, PageSpec pageSpec, String downloadFileName, List<FbaPalletLabel> labels) {}
|
record FbaPalletRequest(String templateCode, PageSpec pageSpec, String downloadFileName, List<FbaPalletLabel> labels) {}
|
||||||
record FbaPalletLabel(String fbaWarehouseCode, String palletCode) {}
|
record FbaPalletLabel(String fbaWarehouseCode, String palletCode) {}
|
||||||
|
|
||||||
@PostMapping(value = "/inbound-marks", consumes = "application/json")
|
@PostMapping(value = "/pallet-a4-labels", consumes = "application/json")
|
||||||
ResponseEntity<byte[]> generateInboundMarks(
|
ResponseEntity<byte[]> generatePalletA4Labels(
|
||||||
@RequestHeader("X-Internal-Service-Token") String token,
|
@RequestHeader("X-Internal-Service-Token") String token,
|
||||||
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
|
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
|
||||||
@RequestBody Request request);
|
@RequestBody PalletA4Request request);
|
||||||
|
|
||||||
|
record PalletA4Request(String templateCode, PageSpec pageSpec, String downloadFileName, List<PalletA4Label> labels) {}
|
||||||
|
record PalletA4Label(String heading, String reference, String palletCode) {}
|
||||||
|
|
||||||
record Request(String templateCode, PageSpec pageSpec, String downloadFileName, List<Label> labels) {}
|
|
||||||
record PageSpec(BigDecimal widthMm, BigDecimal heightMm) {}
|
record PageSpec(BigDecimal widthMm, BigDecimal heightMm) {}
|
||||||
record Label(String topCode, String warehouseText, String cartonText, String routeText, String qrContent) {}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-7
@@ -39,7 +39,7 @@ public class PalletLabelPrintService {
|
|||||||
String code = pallet.getPalletCode();
|
String code = pallet.getPalletCode();
|
||||||
String fbaCode = pallet.getFbaWarehouseCode();
|
String fbaCode = pallet.getFbaWarehouseCode();
|
||||||
boolean fba = fbaCode != null && !fbaCode.isBlank();
|
boolean fba = fbaCode != null && !fbaCode.isBlank();
|
||||||
int maxCodeLength = fba ? 80 : 34;
|
int maxCodeLength = 80;
|
||||||
// Preserve the authoritative pallet identity on both the barcode and visible text.
|
// Preserve the authoritative pallet identity on both the barcode and visible text.
|
||||||
if (code == null || !code.matches("[\\x20-\\x7E]{1," + maxCodeLength + "}") || code.isBlank()
|
if (code == null || !code.matches("[\\x20-\\x7E]{1," + maxCodeLength + "}") || code.isBlank()
|
||||||
|| !code.equals(code.strip())) {
|
|| !code.equals(code.strip())) {
|
||||||
@@ -47,17 +47,14 @@ public class PalletLabelPrintService {
|
|||||||
}
|
}
|
||||||
var warehouse = warehouses.required(warehouseId);
|
var warehouse = warehouses.required(warehouseId);
|
||||||
String fileName = code.replaceAll("[\\\\/:*?\"<>|]", "-") + "-托盘码.pdf";
|
String fileName = code.replaceAll("[\\\\/:*?\"<>|]", "-") + "-托盘码.pdf";
|
||||||
var label = new PrintPdfClient.Label(code,
|
|
||||||
fbaCode == null || fbaCode.isBlank() ? "目的仓待定" : fbaCode,
|
|
||||||
reference, warehouse.warehouseName(), code);
|
|
||||||
var request = new PrintPdfClient.Request("INBOUND_MARK_V1",
|
|
||||||
new PrintPdfClient.PageSpec(new BigDecimal("100"), new BigDecimal("100")), fileName, List.of(label));
|
|
||||||
try {
|
try {
|
||||||
var response = fba
|
var response = fba
|
||||||
? print.generateFbaPalletLabels(internalToken, requestId, new PrintPdfClient.FbaPalletRequest(
|
? print.generateFbaPalletLabels(internalToken, requestId, new PrintPdfClient.FbaPalletRequest(
|
||||||
"FBA_PALLET_A4_LANDSCAPE_V1", new PrintPdfClient.PageSpec(new BigDecimal("297"), new BigDecimal("210")),
|
"FBA_PALLET_A4_LANDSCAPE_V1", new PrintPdfClient.PageSpec(new BigDecimal("297"), new BigDecimal("210")),
|
||||||
fileName, List.of(new PrintPdfClient.FbaPalletLabel(fbaCode, code))))
|
fileName, List.of(new PrintPdfClient.FbaPalletLabel(fbaCode, code))))
|
||||||
: print.generateInboundMarks(internalToken, requestId, request);
|
: print.generatePalletA4Labels(internalToken, requestId, new PrintPdfClient.PalletA4Request(
|
||||||
|
"PALLET_A4_LANDSCAPE_V1", new PrintPdfClient.PageSpec(new BigDecimal("297"), new BigDecimal("210")),
|
||||||
|
fileName, List.of(new PrintPdfClient.PalletA4Label(warehouse.warehouseName(), reference, code))));
|
||||||
byte[] body = response == null ? null : response.getBody();
|
byte[] body = response == null ? null : response.getBody();
|
||||||
MediaType type = response == null ? null : response.getHeaders().getContentType();
|
MediaType type = response == null ? null : response.getHeaders().getContentType();
|
||||||
if (response == null || !response.getStatusCode().is2xxSuccessful() || body == null || body.length == 0
|
if (response == null || !response.getStatusCode().is2xxSuccessful() || body == null || body.length == 0
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ paths:
|
|||||||
get:
|
get:
|
||||||
operationId: printOverseasPalletLabel
|
operationId: printOverseasPalletLabel
|
||||||
summary: 下载指定托盘的一页标签 PDF
|
summary: 下载指定托盘的一页标签 PDF
|
||||||
description: 平台公共资源,要求 platform:overseas-warehouse:pallet:list。校验仓库和托盘归属后,有 FBA 仓码的托盘统一调用 Print FBA_PALLET_A4_LANDSCAPE_V1(297×210mm),顶部显示 SCK8 等 FBA 仓码,托盘完整编码同时作为 Code 128 内容和底部文字;内部编号如 141 原样保留。FBA 编码限 1–80 位可打印 ASCII,禁止首尾空格。不创建或修改托盘。其他托盘继续使用其现有 INBOUND_MARK_V1(100×100mm,编码限 1–34 位)。
|
description: 平台公共资源,要求 platform:overseas-warehouse:pallet:list。校验仓库和托盘归属后,有 FBA 仓码的托盘统一调用 Print FBA_PALLET_A4_LANDSCAPE_V1(297×210mm),顶部显示 SCK8 等 FBA 仓码;其他托盘调用 PALLET_A4_LANDSCAPE_V1(297×210mm),展示仓库名称和业务参考。托盘完整编码同时作为 Code 128 内容和底部文字,限 1–80 位可打印 ASCII 且禁止首尾空格。不创建或修改托盘,不复用 100×100mm 箱唛模板。
|
||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
parameters:
|
parameters:
|
||||||
|
|||||||
+13
-12
@@ -56,7 +56,6 @@ class PalletLabelPrintServiceTest {
|
|||||||
assertThat(response.getBody()).isEqualTo(pdf);
|
assertThat(response.getBody()).isEqualTo(pdf);
|
||||||
verify(resources).pallet(0L, 200L, 9L);
|
verify(resources).pallet(0L, 200L, 9L);
|
||||||
verifyNoMoreInteractions(resources);
|
verifyNoMoreInteractions(resources);
|
||||||
verify(print, never()).generateInboundMarks(any(), any(), any());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -64,18 +63,20 @@ class PalletLabelPrintServiceTest {
|
|||||||
pallet.setPalletCode("ABCD1234567-ABE3-003");
|
pallet.setPalletCode("ABCD1234567-ABE3-003");
|
||||||
pallet.setFbaWarehouseCode(null);
|
pallet.setFbaWarehouseCode(null);
|
||||||
byte[] pdf = "%PDF-transfer-label".getBytes(java.nio.charset.StandardCharsets.US_ASCII);
|
byte[] pdf = "%PDF-transfer-label".getBytes(java.nio.charset.StandardCharsets.US_ASCII);
|
||||||
when(print.generateInboundMarks(anyString(), anyString(), any()))
|
when(print.generatePalletA4Labels(anyString(), anyString(), any()))
|
||||||
.thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).body(pdf));
|
.thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).body(pdf));
|
||||||
|
|
||||||
service.printTransfer(200L, 9L, "WTR-20260916-1", "request-transfer");
|
service.printTransfer(200L, 9L, "WTR-20260916-1", "request-transfer");
|
||||||
|
|
||||||
var request = ArgumentCaptor.forClass(PrintPdfClient.Request.class);
|
var request = ArgumentCaptor.forClass(PrintPdfClient.PalletA4Request.class);
|
||||||
verify(print).generateInboundMarks(eq("test-token"), eq("request-transfer"), request.capture());
|
verify(print).generatePalletA4Labels(eq("test-token"), eq("request-transfer"), request.capture());
|
||||||
|
assertThat(request.getValue().templateCode()).isEqualTo("PALLET_A4_LANDSCAPE_V1");
|
||||||
|
assertThat(request.getValue().pageSpec().widthMm()).isEqualByComparingTo("297");
|
||||||
|
assertThat(request.getValue().pageSpec().heightMm()).isEqualByComparingTo("210");
|
||||||
assertThat(request.getValue().labels()).singleElement().satisfies(label -> {
|
assertThat(request.getValue().labels()).singleElement().satisfies(label -> {
|
||||||
assertThat(label.topCode()).isEqualTo("ABCD1234567-ABE3-003");
|
assertThat(label.heading()).isEqualTo("洛杉矶仓");
|
||||||
assertThat(label.cartonText()).isEqualTo("转运单 WTR-20260916-1");
|
assertThat(label.reference()).isEqualTo("转运单 WTR-20260916-1");
|
||||||
assertThat(label.warehouseText()).isEqualTo("目的仓待定");
|
assertThat(label.palletCode()).isEqualTo("ABCD1234567-ABE3-003");
|
||||||
assertThat(label.routeText()).isEqualTo("洛杉矶仓");
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,12 +121,12 @@ class PalletLabelPrintServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void keepsNonFbaLabelsOnTheirOwnTemplate() {
|
void keepsNonFbaLabelsOnTheirOwnTemplate() {
|
||||||
pallet.setFbaWarehouseCode(null);
|
pallet.setFbaWarehouseCode(null);
|
||||||
when(print.generateInboundMarks(anyString(), anyString(), any()))
|
when(print.generatePalletA4Labels(anyString(), anyString(), any()))
|
||||||
.thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).body("%PDF-other".getBytes()));
|
.thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).body("%PDF-other".getBytes()));
|
||||||
service.print(200L, 9L, "request-1");
|
service.print(200L, 9L, "request-1");
|
||||||
var captor = ArgumentCaptor.forClass(PrintPdfClient.Request.class);
|
var captor = ArgumentCaptor.forClass(PrintPdfClient.PalletA4Request.class);
|
||||||
verify(print).generateInboundMarks(anyString(), anyString(), captor.capture());
|
verify(print).generatePalletA4Labels(anyString(), anyString(), captor.capture());
|
||||||
assertThat(captor.getValue().templateCode()).isEqualTo("INBOUND_MARK_V1");
|
assertThat(captor.getValue().templateCode()).isEqualTo("PALLET_A4_LANDSCAPE_V1");
|
||||||
verify(print, never()).generateFbaPalletLabels(any(), any(), any());
|
verify(print, never()).generateFbaPalletLabels(any(), any(), any());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user