feat(file-print): support ocean booking documents

This commit is contained in:
2026-08-31 19:30:37 +08:00
parent 01385c4bc7
commit 47ce84d122
17 changed files with 418 additions and 49 deletions
@@ -68,13 +68,7 @@ public class InternalPdfController {
@Valid @RequestBody FreightA4PdfDtos.Request request,
HttpServletRequest servletRequest) {
tokenGuard.verify(internalToken);
String requestId = CorrelationContext.requestId(servletRequest);
FreightA4PdfDtos.Request effectiveRequest = request;
if (request.downloadFileName() == null && requestId != null && !requestId.isBlank()) {
effectiveRequest = new FreightA4PdfDtos.Request(request.templateCode(), request.pageSpec(),
"freight-a4-documents-" + requestId + ".pdf", request.documents());
}
FreightA4PdfService.GeneratedPdf pdf = freightA4PdfService.generate(effectiveRequest);
FreightA4PdfService.GeneratedPdf pdf = freightA4PdfService.generate(request);
byte[] content = pdf.content();
ContentDisposition disposition = ContentDisposition.attachment()
@@ -27,7 +27,25 @@ public class FreightA4PdfService {
private static final int MAX_FILE_NAME_CODE_POINTS = 120;
private static final int MAX_FIELD_KEY_CODE_POINTS = 80;
private static final int MAX_FIELD_VALUE_CODE_POINTS = 4000;
private static final int MAX_CARGO_ROWS = 50;
private static final Set<String> SHIPPING_FIELDS = Set.of(
"toCompany", "issuerNameCn", "issuerNameEn", "issuerAddress", "issuerPostalCode",
"issuerTel", "issuerFax", "shipper", "consignee", "notifyParty", "picName", "picTel",
"picFax", "documentName", "documentTel", "documentFax", "jobNumber", "etdDate",
"serviceTerm", "freightTerm", "apCode", "apName", "cargoQuantity", "transshipment",
"partialShipment", "oceanVesselVoyage", "portOfLoading", "portOfDischarge",
"placeOfDelivery", "totalPackagesInWords", "remark", "carrier", "bookingAgent", "printDate");
private static final Set<String> SHIPPING_REQUIRED_FIELDS = Set.of(
"toCompany", "shipper", "consignee", "notifyParty", "jobNumber", "cargoQuantity",
"portOfLoading", "portOfDischarge", "placeOfDelivery", "totalPackagesInWords");
private static final Set<String> BILL_FIELDS = Set.of(
"shipper", "consignee", "notifyParty", "bookingNumber", "ensAmsCutoffDate",
"customsCutoffDate", "expectedDepartureDate", "oceanVesselVoyage", "portOfLoading",
"portOfDischarge", "placeOfDelivery", "finalDestination", "serviceTerm", "freightTerm",
"shipperLoadCountSeal", "containerSpec", "shippedOnBoard", "draftText",
"totalPackagesInWords", "containerSummary", "notes", "issuePlace", "issueDate");
private static final Set<String> BILL_REQUIRED_FIELDS = Set.of(
"shipper", "consignee", "notifyParty", "bookingNumber", "portOfLoading",
"portOfDischarge", "placeOfDelivery", "serviceTerm", "freightTerm", "totalPackagesInWords");
private static final BigDecimal A4_WIDTH_MM = new BigDecimal("210");
private static final BigDecimal A4_HEIGHT_MM = new BigDecimal("297");
private static final Set<String> SUPPORTED_TEMPLATES = Set.of(
@@ -99,35 +117,45 @@ public class FreightA4PdfService {
List<FreightA4PdfDtos.Document> documents = new ArrayList<>(request.documents().size());
for (int index = 0; index < request.documents().size(); index++) {
documents.add(normalizeDocument(request.documents().get(index), index));
documents.add(normalizeDocument(templateCode, request.documents().get(index), index));
}
return new NormalizedRequest(templateCode, normalizeFileName(request.downloadFileName()),
return new NormalizedRequest(templateCode,
normalizeFileName(request.downloadFileName(), templateCode, documents.get(0)),
List.copyOf(documents));
}
private FreightA4PdfDtos.Document normalizeDocument(FreightA4PdfDtos.Document document, int index) {
private FreightA4PdfDtos.Document normalizeDocument(String templateCode,
FreightA4PdfDtos.Document document, int index) {
if (document == null) {
invalid("documents[" + index + "] is required");
}
Map<String, String> fields = new LinkedHashMap<>();
if (document.fields() != null) {
Set<String> allowed = FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE.equals(templateCode)
? SHIPPING_FIELDS : BILL_FIELDS;
for (Map.Entry<String, String> entry : document.fields().entrySet()) {
String key = normalizeRequiredLine(entry.getKey(), field(index, "fields key"));
if (key.codePointCount(0, key.length()) > MAX_FIELD_KEY_CODE_POINTS) {
invalid(field(index, "fields." + key) + " key is too long");
}
if (!allowed.contains(key)) {
invalid(field(index, "fields." + key) + " is not supported by " + templateCode);
}
fields.put(key, normalizeOptionalBlock(entry.getValue(), field(index, "fields." + key)));
}
}
List<FreightA4PdfDtos.CargoRow> rows = new ArrayList<>();
if (document.cargoRows() != null) {
if (document.cargoRows().size() > MAX_CARGO_ROWS) {
invalid(field(index, "cargoRows") + " must not contain more than " + MAX_CARGO_ROWS + " items");
}
for (int rowIndex = 0; rowIndex < document.cargoRows().size(); rowIndex++) {
rows.add(normalizeCargoRow(document.cargoRows().get(rowIndex), index, rowIndex));
Set<String> required = FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE.equals(templateCode)
? SHIPPING_REQUIRED_FIELDS : BILL_REQUIRED_FIELDS;
for (String key : required) {
if (!fields.containsKey(key) || fields.get(key).isBlank()) {
invalid(field(index, "fields." + key) + " is required by " + templateCode);
}
}
List<FreightA4PdfDtos.CargoRow> rows = new ArrayList<>();
if (document.cargoRows() == null || document.cargoRows().size() != 1) {
invalid(field(index, "cargoRows") + " must contain exactly one item");
}
rows.add(normalizeCargoRow(document.cargoRows().get(0), index, 0));
return new FreightA4PdfDtos.Document(Map.copyOf(fields), List.copyOf(rows));
}
@@ -138,19 +166,22 @@ public class FreightA4PdfService {
}
String prefix = "documents[" + documentIndex + "].cargoRows[" + rowIndex + "].";
return new FreightA4PdfDtos.CargoRow(
normalizeOptionalBlock(row.marksAndNos(), prefix + "marksAndNos"),
normalizeOptionalBlock(row.quantity(), prefix + "quantity"),
normalizeOptionalBlock(row.packaging(), prefix + "packaging"),
normalizeOptionalBlock(row.descriptionOfGoods(), prefix + "descriptionOfGoods"),
normalizeOptionalBlock(row.grossWeight(), prefix + "grossWeight"),
normalizeOptionalBlock(row.measurement(), prefix + "measurement"));
normalizeRequiredBlock(row.marksAndNos(), prefix + "marksAndNos"),
normalizeRequiredBlock(row.quantity(), prefix + "quantity"),
normalizeRequiredBlock(row.packaging(), prefix + "packaging"),
normalizeRequiredBlock(row.descriptionOfGoods(), prefix + "descriptionOfGoods"),
normalizeRequiredBlock(row.grossWeight(), prefix + "grossWeight"),
normalizeRequiredBlock(row.measurement(), prefix + "measurement"));
}
private String normalizeFileName(String value) {
if (value == null) {
return "freight-a4-documents.pdf";
private String normalizeFileName(String value, String templateCode, FreightA4PdfDtos.Document firstDocument) {
String candidate = value;
if (candidate == null) {
candidate = FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE.equals(templateCode)
? firstDocument.field("jobNumber") + "-出口货物托运单.pdf"
: firstDocument.field("bookingNumber") + "-提单确认件.pdf";
}
String normalized = normalizeRequiredLine(value, "downloadFileName");
String normalized = normalizeRequiredLine(candidate, "downloadFileName");
if (normalized.codePointCount(0, normalized.length()) > MAX_FILE_NAME_CODE_POINTS) {
invalid("downloadFileName must not exceed 120 characters");
}
@@ -165,6 +196,12 @@ public class FreightA4PdfService {
return normalized;
}
private String normalizeRequiredBlock(String value, String field) {
String normalized = normalizeOptionalBlock(value, field);
if (normalized.isBlank()) invalid(field + " must not be blank");
return normalized;
}
private String normalizeRequiredLine(String value, String field) {
String normalized = normalizeOptionalBlock(value, field);
if (normalized.isBlank()) {
@@ -173,6 +173,7 @@ components:
type: string
maxLength: 120
example: BYSE2607044-托运书.pdf
description: 未传时托运书为{jobNumber}-出口货物托运单.pdf,提单确认件为{bookingNumber}-提单确认件.pdf
documents:
type: array
minItems: 1
@@ -192,21 +193,77 @@ components:
enum: [297]
FreightA4Document:
type: object
required: [fields, cargoRows]
x-template-contracts:
SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1:
required: [toCompany, shipper, consignee, notifyParty, jobNumber, cargoQuantity, portOfLoading, portOfDischarge, placeOfDelivery, totalPackagesInWords]
optional: [issuerNameCn, issuerNameEn, issuerAddress, issuerPostalCode, issuerTel, issuerFax, picName, picTel, picFax, documentName, documentTel, documentFax, etdDate, serviceTerm, freightTerm, apCode, apName, transshipment, partialShipment, oceanVesselVoyage, remark, carrier, bookingAgent, printDate]
BILL_OF_LADING_CONFIRMATION_A4_V1:
required: [shipper, consignee, notifyParty, bookingNumber, portOfLoading, portOfDischarge, placeOfDelivery, serviceTerm, freightTerm, totalPackagesInWords]
optional: [ensAmsCutoffDate, customsCutoffDate, expectedDepartureDate, oceanVesselVoyage, finalDestination, shipperLoadCountSeal, containerSpec, shippedOnBoard, draftText, containerSummary, notes, issuePlace, issueDate]
properties:
fields:
type: object
additionalProperties:
type: string
maxLength: 4000
description: Template field map. Known keys are documented in the print module README.
additionalProperties: false
description: 只能提交所选模板登记的键;未知键和缺少必填键返回A0422。
properties:
toCompany: {$ref: '#/components/schemas/FreightField'}
issuerNameCn: {$ref: '#/components/schemas/FreightField'}
issuerNameEn: {$ref: '#/components/schemas/FreightField'}
issuerAddress: {$ref: '#/components/schemas/FreightField'}
issuerPostalCode: {$ref: '#/components/schemas/FreightField'}
issuerTel: {$ref: '#/components/schemas/FreightField'}
issuerFax: {$ref: '#/components/schemas/FreightField'}
shipper: {$ref: '#/components/schemas/FreightField'}
consignee: {$ref: '#/components/schemas/FreightField'}
notifyParty: {$ref: '#/components/schemas/FreightField'}
picName: {$ref: '#/components/schemas/FreightField'}
picTel: {$ref: '#/components/schemas/FreightField'}
picFax: {$ref: '#/components/schemas/FreightField'}
documentName: {$ref: '#/components/schemas/FreightField'}
documentTel: {$ref: '#/components/schemas/FreightField'}
documentFax: {$ref: '#/components/schemas/FreightField'}
jobNumber: {$ref: '#/components/schemas/FreightField'}
etdDate: {$ref: '#/components/schemas/FreightField'}
serviceTerm: {$ref: '#/components/schemas/FreightField'}
freightTerm: {$ref: '#/components/schemas/FreightField'}
apCode: {$ref: '#/components/schemas/FreightField'}
apName: {$ref: '#/components/schemas/FreightField'}
cargoQuantity: {$ref: '#/components/schemas/FreightField'}
transshipment: {$ref: '#/components/schemas/FreightField'}
partialShipment: {$ref: '#/components/schemas/FreightField'}
oceanVesselVoyage: {$ref: '#/components/schemas/FreightField'}
portOfLoading: {$ref: '#/components/schemas/FreightField'}
portOfDischarge: {$ref: '#/components/schemas/FreightField'}
placeOfDelivery: {$ref: '#/components/schemas/FreightField'}
totalPackagesInWords: {$ref: '#/components/schemas/FreightField'}
remark: {$ref: '#/components/schemas/FreightField'}
carrier: {$ref: '#/components/schemas/FreightField'}
bookingAgent: {$ref: '#/components/schemas/FreightField'}
printDate: {$ref: '#/components/schemas/FreightField'}
bookingNumber: {$ref: '#/components/schemas/FreightField'}
ensAmsCutoffDate: {$ref: '#/components/schemas/FreightField'}
customsCutoffDate: {$ref: '#/components/schemas/FreightField'}
expectedDepartureDate: {$ref: '#/components/schemas/FreightField'}
finalDestination: {$ref: '#/components/schemas/FreightField'}
shipperLoadCountSeal: {$ref: '#/components/schemas/FreightField'}
containerSpec: {$ref: '#/components/schemas/FreightField'}
shippedOnBoard: {$ref: '#/components/schemas/FreightField'}
draftText: {$ref: '#/components/schemas/FreightField'}
containerSummary: {$ref: '#/components/schemas/FreightField'}
notes: {$ref: '#/components/schemas/FreightField'}
issuePlace: {$ref: '#/components/schemas/FreightField'}
issueDate: {$ref: '#/components/schemas/FreightField'}
cargoRows:
type: array
minItems: 1
maxItems: 1
description: V1 templates render at most one cargo row. Callers must not send additional rows.
description: V1模板固定恰好一行货物,六个字段均必填且非空。
items:
$ref: '#/components/schemas/FreightA4CargoRow'
FreightA4CargoRow:
type: object
required: [marksAndNos, quantity, packaging, descriptionOfGoods, grossWeight, measurement]
properties:
marksAndNos:
type: string
@@ -220,6 +277,9 @@ components:
type: string
measurement:
type: string
FreightField:
type: string
maxLength: 4000
ApiError:
type: object
required: [code, message]