feat: generate dispatch BOL PDFs
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
package com.baiyue.lastleg.client;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
@FeignClient(name = "print-service", contextId = "lastlegPrintPdfClient",
|
||||
url = "${baiyue.clients.print.url:}", path = "/internal/print/v1/pdfs")
|
||||
public interface PrintPdfClient {
|
||||
@PostMapping(value = "/dispatch-bol-documents", consumes = "application/json")
|
||||
ResponseEntity<byte[]> generateDispatchBol(
|
||||
@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
|
||||
@RequestBody Request request);
|
||||
|
||||
record Request(String templateCode, PageSpec pageSpec, String downloadFileName, List<Document> documents) {}
|
||||
record PageSpec(BigDecimal widthMm, BigDecimal heightMm) {}
|
||||
record Document(String shipFromName, String shipFromAddress, String carrierName, String isaNumber,
|
||||
String appointmentTime, String trailerNumber, String sealNumber, String fbaWarehouseCode,
|
||||
String shipToName, String shipToAddress, String dispatchNo, String fbaShipmentIds,
|
||||
String poReferenceNos, int totalPackages, int pageNumber, int pageCount, List<CargoRow> cargoRows) {}
|
||||
record CargoRow(String containerNo, String palletNo, String fbaWarehouseCode, String warehouseNo,
|
||||
String fbaShipmentId, String poReferenceNo, int packageCount) {}
|
||||
}
|
||||
+24
@@ -6,6 +6,7 @@ import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
|
||||
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
|
||||
import com.baiyue.lastleg.dispatch.service.DispatchService;
|
||||
import com.baiyue.lastleg.dispatch.service.DispatchBolPrintService;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@@ -13,6 +14,11 @@ import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -20,6 +26,8 @@ import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
@RestController
|
||||
@ConditionalOnProperty(name = "lastleg.dispatch-query-api.enabled", havingValue = "true")
|
||||
@@ -27,6 +35,7 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
@Tag(name = "Tail-leg vehicle dispatch query")
|
||||
public class DispatchQueryController {
|
||||
@Resource private DispatchService service;
|
||||
@Resource private DispatchBolPrintService printService;
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')")
|
||||
@@ -48,6 +57,21 @@ public class DispatchQueryController {
|
||||
return ApiResponse.success(service.platformDetail(dispatchNo));
|
||||
}
|
||||
|
||||
@GetMapping(value = "/{dispatchNo}/prints/bol", produces = MediaType.APPLICATION_PDF_VALUE)
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')")
|
||||
@Operation(summary = "Generate dispatch BOL as an A4 PDF")
|
||||
public ResponseEntity<byte[]> printBol(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String dispatchNo, HttpServletRequest request) {
|
||||
requirePlatform(user);
|
||||
DispatchBolPrintService.GeneratedPdf pdf = printService.generate(dispatchNo, request.getHeader("X-Request-Id"));
|
||||
ResponseEntity.BodyBuilder response = ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF)
|
||||
.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");
|
||||
if (pdf.pageCount() != null) response.header("X-Print-Page-Count", pdf.pageCount());
|
||||
return response.contentLength(pdf.content().length).body(pdf.content());
|
||||
}
|
||||
|
||||
private void requirePlatform(AuthenticatedUser user) {
|
||||
if (user == null || user.tenantId() == null) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.client.PrintPdfClient;
|
||||
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
|
||||
import feign.FeignException;
|
||||
import jakarta.annotation.Resource;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class DispatchBolPrintService {
|
||||
private static final String TEMPLATE = "DISPATCH_BILL_OF_LADING_A4_V1";
|
||||
private static final int ROWS_PER_PAGE = 12;
|
||||
private static final PrintPdfClient.PageSpec A4 = new PrintPdfClient.PageSpec(new BigDecimal("210"), new BigDecimal("297"));
|
||||
|
||||
@Resource private DispatchService dispatches;
|
||||
@Resource private PrintPdfClient print;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||
|
||||
public GeneratedPdf generate(String dispatchNo, String requestId) {
|
||||
DispatchResponse detail = dispatches.platformDetail(dispatchNo);
|
||||
if (detail.bolLines() == null || detail.bolLines().isEmpty()) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "排车单没有可打印的BOL明细");
|
||||
}
|
||||
List<PrintPdfClient.Document> documents = documents(detail);
|
||||
String fileName = safeFilePart(detail.dispatchNo()) + "-BOL.pdf";
|
||||
try {
|
||||
ResponseEntity<byte[]> response = print.generateDispatchBol(internalToken, requestId,
|
||||
new PrintPdfClient.Request(TEMPLATE, A4, fileName, documents));
|
||||
byte[] content = response.getBody();
|
||||
MediaType contentType = response.getHeaders().getContentType();
|
||||
if (!response.getStatusCode().is2xxSuccessful() || content == null || content.length == 0
|
||||
|| contentType == null || !MediaType.APPLICATION_PDF.isCompatibleWith(contentType)) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务未返回有效PDF");
|
||||
}
|
||||
return new GeneratedPdf(content, fileName, response.getHeaders().getFirst("X-Print-Page-Count"));
|
||||
} catch (FeignException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用");
|
||||
}
|
||||
}
|
||||
|
||||
private List<PrintPdfClient.Document> documents(DispatchResponse detail) {
|
||||
Map<String, List<DispatchResponse.BolLine>> groups = new LinkedHashMap<>();
|
||||
for (DispatchResponse.BolLine line : detail.bolLines()) groups.computeIfAbsent(text(line.fbaWarehouseCode()), ignored -> new ArrayList<>()).add(line);
|
||||
List<PrintPdfClient.Document> documents = new ArrayList<>();
|
||||
for (Map.Entry<String, List<DispatchResponse.BolLine>> group : groups.entrySet()) {
|
||||
List<DispatchResponse.BolLine> lines = group.getValue();
|
||||
int pages = (lines.size() + ROWS_PER_PAGE - 1) / ROWS_PER_PAGE;
|
||||
int total = lines.stream().mapToInt(DispatchResponse.BolLine::packageCount).sum();
|
||||
String destination = lines.stream().map(DispatchResponse.BolLine::destinationWarehouseName).filter(v -> v != null && !v.isBlank()).findFirst().orElse("—");
|
||||
for (int page = 0; page < pages; page++) {
|
||||
List<PrintPdfClient.CargoRow> rows = lines.subList(page * ROWS_PER_PAGE, Math.min(lines.size(), (page + 1) * ROWS_PER_PAGE)).stream()
|
||||
.map(line -> new PrintPdfClient.CargoRow(text(line.containerNo()), text(line.palletNo()), text(line.fbaWarehouseCode()),
|
||||
text(line.warehouseNo()), text(line.fbaShipmentId()), text(line.poReferenceNo()), line.packageCount())).toList();
|
||||
documents.add(new PrintPdfClient.Document(text(detail.overseasWarehouseName()), "—", text(detail.carrierName()),
|
||||
text(detail.fbaAppointmentNo()), dateTime(detail.estimatedArrivalAt()), text(detail.vehiclePlate()), "—",
|
||||
group.getKey(), destination, "—", text(detail.dispatchNo()), unique(lines, true), unique(lines, false),
|
||||
total, page + 1, pages, rows));
|
||||
}
|
||||
}
|
||||
return List.copyOf(documents);
|
||||
}
|
||||
|
||||
private String unique(List<DispatchResponse.BolLine> lines, boolean shipment) {
|
||||
LinkedHashSet<String> values = new LinkedHashSet<>();
|
||||
for (DispatchResponse.BolLine line : lines) {
|
||||
String raw = shipment ? line.fbaShipmentId() : line.poReferenceNo();
|
||||
if (raw != null) for (String value : raw.split(",")) if (!value.isBlank()) values.add(value.strip());
|
||||
}
|
||||
return values.isEmpty() ? "—" : String.join(", ", values);
|
||||
}
|
||||
private String dateTime(java.time.LocalDateTime value) { return value == null ? "—" : value.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); }
|
||||
private String text(String value) { return value == null || value.isBlank() ? "—" : value.strip(); }
|
||||
private String safeFilePart(String value) { return text(value).replaceAll("[\\\\/:*?\"<>|]", "-"); }
|
||||
|
||||
public record GeneratedPdf(byte[] content, String fileName, String pageCount) {}
|
||||
}
|
||||
@@ -67,6 +67,8 @@ baiyue:
|
||||
connect-timeout: ${LASTLEG_PALLET_PHYSICAL_CONNECT_TIMEOUT:3s}
|
||||
read-timeout: ${LASTLEG_PALLET_PHYSICAL_READ_TIMEOUT:5s}
|
||||
clients:
|
||||
print:
|
||||
url: ${PRINT_SERVICE_URL:}
|
||||
domestic-warehouse:
|
||||
url: ${DOMESTIC_WAREHOUSE_URL:}
|
||||
cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}
|
||||
|
||||
@@ -513,6 +513,21 @@ paths:
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }]
|
||||
responses: { '200': { description: 取消成功或已取消 }, '404': { description: 排车单不存在 }, '409': { description: 排车已开始执行 } }
|
||||
/api/platform/v1/last-leg/dispatches/{dispatchNo}/prints/bol:
|
||||
get:
|
||||
summary: 生成排车 BOL PDF
|
||||
description: 权限 platform:overseas-warehouse:dispatch:list;服务端按目的 FBA 仓分组并分页生成固定 A4 PDF,浏览器不直接访问内部打印服务。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }]
|
||||
responses:
|
||||
'200':
|
||||
description: BOL PDF
|
||||
headers:
|
||||
Content-Disposition: { schema: { type: string } }
|
||||
X-Print-Page-Count: { schema: { type: integer, minimum: 1 } }
|
||||
content: { application/pdf: { schema: { type: string, format: binary } } }
|
||||
'404': { description: 排车单不存在 }
|
||||
'422': { description: 排车单没有可打印的 BOL 明细 }
|
||||
/api/platform/v1/last-leg/dispatches/candidates:
|
||||
get:
|
||||
summary: 查询智能排车候选托盘
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ class Phase4BrowserApiBoundaryTest {
|
||||
assertTrue(Arrays.asList(condition.name()).contains("lastleg.dispatch-query-api.enabled"));
|
||||
assertFalse(condition.matchIfMissing());
|
||||
assertTrue(Arrays.stream(DispatchQueryController.class.getDeclaredMethods())
|
||||
.filter(method -> method.isAnnotationPresent(GetMapping.class)).count() == 2);
|
||||
.filter(method -> method.isAnnotationPresent(GetMapping.class)).count() == 3);
|
||||
assertFalse(Arrays.stream(DispatchQueryController.class.getDeclaredMethods())
|
||||
.flatMap(method -> Arrays.stream(method.getAnnotations()))
|
||||
.anyMatch(annotation -> Set.of("PostMapping", "PutMapping", "DeleteMapping")
|
||||
|
||||
+7
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
|
||||
import com.baiyue.lastleg.dispatch.service.DispatchService;
|
||||
import com.baiyue.lastleg.dispatch.service.DispatchBolPrintService;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -25,12 +26,14 @@ import static org.mockito.Mockito.when;
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DispatchQueryControllerTest {
|
||||
@Mock DispatchService service;
|
||||
@Mock DispatchBolPrintService printService;
|
||||
private DispatchQueryController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
controller = new DispatchQueryController();
|
||||
ReflectionTestUtils.setField(controller, "service", service);
|
||||
ReflectionTestUtils.setField(controller, "printService", printService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -57,9 +60,13 @@ class DispatchQueryControllerTest {
|
||||
PreAuthorize detail = DispatchQueryController.class
|
||||
.getMethod("detail", AuthenticatedUser.class, String.class)
|
||||
.getAnnotation(PreAuthorize.class);
|
||||
PreAuthorize print = DispatchQueryController.class
|
||||
.getMethod("printBol", AuthenticatedUser.class, String.class, jakarta.servlet.http.HttpServletRequest.class)
|
||||
.getAnnotation(PreAuthorize.class);
|
||||
|
||||
assertThat(page.value()).isEqualTo("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')");
|
||||
assertThat(detail.value()).isEqualTo("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')");
|
||||
assertThat(print.value()).isEqualTo("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')");
|
||||
}
|
||||
|
||||
private static AuthenticatedUser platform() {
|
||||
|
||||
+3
-3
@@ -15,9 +15,9 @@
|
||||
| 健康状态 API | `controller/PrintHealthController.java`(`/api/platform/v1/print/status`) |
|
||||
| 内部 PDF API | `controller/InternalPdfController.java`(`/internal/print/v1/pdfs/inbound-marks`、`/internal/print/v1/pdfs/freight-a4-documents`) |
|
||||
| 内部令牌校验 | `controller/InternalServiceTokenGuard.java` |
|
||||
| 请求与响应模型 | `dto/InboundMarkPdfDtos.java`、`dto/FreightA4PdfDtos.java` |
|
||||
| 固定模板与渲染 | `renderer/InboundMarkV1Renderer.java`、`renderer/FreightA4Renderer.java`、`renderer/PdfQrRenderer.java`、`renderer/PdfUnits.java` |
|
||||
| 请求校验与资源保护 | `controller/PrintRequestSizeFilter.java`、`service/InboundMarkPdfService.java`、`service/FreightA4PdfService.java`、`service/PdfGenerationLimiter.java` |
|
||||
| 请求与响应模型 | `dto/InboundMarkPdfDtos.java`、`dto/FreightA4PdfDtos.java`、`dto/DispatchBolPdfDtos.java` |
|
||||
| 固定模板与渲染 | `renderer/InboundMarkV1Renderer.java`、`renderer/FreightA4Renderer.java`、`renderer/DispatchBolRenderer.java`、`renderer/PdfQrRenderer.java`、`renderer/PdfUnits.java` |
|
||||
| 请求校验与资源保护 | `controller/PrintRequestSizeFilter.java`、`service/InboundMarkPdfService.java`、`service/FreightA4PdfService.java`、`service/DispatchBolPdfService.java`、`service/PdfGenerationLimiter.java` |
|
||||
| PDF 参数配置 | `config/PrintPdfProperties.java` |
|
||||
| 接口契约 | `src/main/resources/openapi/print-internal-v1.yaml` |
|
||||
| 字体及授权 | `src/main/resources/fonts/NotoSansSC-Bold.ttf`、`src/main/resources/fonts/OFL.txt` |
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
- 服务发现名:`print-service`。
|
||||
- 内部地址:`POST /internal/print/v1/pdfs/inbound-marks`。
|
||||
- A4 海运单据内部地址:`POST /internal/print/v1/pdfs/freight-a4-documents`。
|
||||
- 海外仓排车 BOL 内部地址:`POST /internal/print/v1/pdfs/dispatch-bol-documents`;模板固定为 `DISPATCH_BILL_OF_LADING_A4_V1`,每个 document 对应一张 A4、包含 1-12 行明细。
|
||||
- 本地直连:`http://127.0.0.1:9207`。
|
||||
- 请求必须携带 `X-Internal-Service-Token`,值使用各服务已有的 `baiyue.security.internal-service-token` 配置。
|
||||
- 不把该接口加入 Gateway,也不允许 Admin 浏览器直接携带内部令牌访问 Print。
|
||||
|
||||
@@ -4,8 +4,10 @@ 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.InboundMarkPdfDtos;
|
||||
import com.baiyue.print.service.FreightA4PdfService;
|
||||
import com.baiyue.print.service.DispatchBolPdfService;
|
||||
import com.baiyue.print.service.InboundMarkPdfService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
@@ -30,6 +32,7 @@ public class InternalPdfController {
|
||||
|
||||
@Resource private InboundMarkPdfService pdfService;
|
||||
@Resource private FreightA4PdfService freightA4PdfService;
|
||||
@Resource private DispatchBolPdfService dispatchBolPdfService;
|
||||
@Resource private InternalServiceTokenGuard tokenGuard;
|
||||
|
||||
@PostMapping(value = "/inbound-marks", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@@ -85,4 +88,17 @@ public class InternalPdfController {
|
||||
|
||||
return response.body(content);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/dispatch-bol-documents", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<byte[]> generateDispatchBolDocuments(
|
||||
@RequestHeader(value = INTERNAL_TOKEN_HEADER, required = false) String internalToken,
|
||||
@Valid @RequestBody DispatchBolPdfDtos.Request request) {
|
||||
tokenGuard.verify(internalToken);
|
||||
DispatchBolPdfService.GeneratedPdf pdf = dispatchBolPdfService.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,24 @@
|
||||
package com.baiyue.print.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/** A4 vehicle-dispatch bill of lading request contract. */
|
||||
public final class DispatchBolPdfDtos {
|
||||
public static final String TEMPLATE = "DISPATCH_BILL_OF_LADING_A4_V1";
|
||||
|
||||
private DispatchBolPdfDtos() {}
|
||||
|
||||
public record Request(String templateCode, PageSpec pageSpec, String downloadFileName,
|
||||
List<Document> documents) {}
|
||||
public record PageSpec(BigDecimal widthMm, BigDecimal heightMm) {}
|
||||
public record Document(String shipFromName, String shipFromAddress, String carrierName,
|
||||
String isaNumber, String appointmentTime, String trailerNumber,
|
||||
String sealNumber, String fbaWarehouseCode, String shipToName,
|
||||
String shipToAddress, String dispatchNo, String fbaShipmentIds,
|
||||
String poReferenceNos, int totalPackages, int pageNumber, int pageCount,
|
||||
List<CargoRow> cargoRows) {}
|
||||
public record CargoRow(String containerNo, String palletNo, String fbaWarehouseCode,
|
||||
String warehouseNo, String fbaShipmentId, String poReferenceNo,
|
||||
int packageCount) {}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.baiyue.print.renderer;
|
||||
|
||||
import com.baiyue.print.dto.DispatchBolPdfDtos;
|
||||
import java.awt.Color;
|
||||
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 DispatchBolRenderer {
|
||||
private static final float[] COLS = {8, 31, 58, 84, 119, 160, 195, 202};
|
||||
private static final String[] HEADERS = {"Container #", "Pallet #", "FBA Warehouse #", "Warehouse No #", "FBA Shipment ID", "PO / Reference #", "CTNS"};
|
||||
|
||||
public void renderPage(PDDocument document, PDFont font, DispatchBolPdfDtos.Document data) throws IOException {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
document.addPage(page);
|
||||
PdfUnits units = new PdfUnits(297);
|
||||
try (PDPageContentStream stream = new PDPageContentStream(document, page)) {
|
||||
stream.setLineWidth(PdfUnits.mmToPt(.35));
|
||||
centered(stream, units, font, "BILL OF LADING", 17, 105, 13);
|
||||
block(stream, units, font, data, 18, 202, 18, 82);
|
||||
table(stream, units, font, data, 92);
|
||||
line(stream, units, 12, 244, 198, 244);
|
||||
text(stream, units, font, "总箱数 / Total CTNS:", 8, 12, 241);
|
||||
right(stream, units, font, Integer.toString(data.totalPackages()), 12, 198, 241);
|
||||
if (data.pageCount() > 1) right(stream, units, font, data.pageNumber() + " / " + data.pageCount(), 7, 198, 251);
|
||||
signature(stream, units, font, 24, "Driver signature");
|
||||
signature(stream, units, font, 93, "Warehouse signature");
|
||||
signature(stream, units, font, 160, "Date");
|
||||
}
|
||||
}
|
||||
|
||||
private void block(PDPageContentStream s, PdfUnits u, PDFont f, DispatchBolPdfDtos.Document d,
|
||||
double left, double right, double top, double bottom) throws IOException {
|
||||
line(s,u,left,top,right,top); line(s,u,left,bottom,right,bottom); line(s,u,left,top,left,bottom); line(s,u,right,top,right,bottom);
|
||||
line(s,u,105,top,105,bottom); line(s,u,left,51,right,51);
|
||||
blackHeader(s,u,f,"SHIP FROM",left,top,105,24); blackHeader(s,u,f,"CARRIER",105,top,right,24);
|
||||
blackHeader(s,u,f,"SHIP TO",left,51,105,57); blackHeader(s,u,f,"REFERENCE",105,51,right,57);
|
||||
text(s,u,f,d.shipFromName(),10,left+2,30); text(s,u,f,"Address: " + d.shipFromAddress(),8,left+2,37);
|
||||
text(s,u,f,"Carrier Name: " + d.carrierName(),8,107,29); text(s,u,f,"ISA #: " + d.isaNumber(),8,107,34);
|
||||
text(s,u,f,"Appointment time: " + d.appointmentTime(),8,107,39); text(s,u,f,"Trailer #: " + d.trailerNumber(),8,107,44);
|
||||
text(s,u,f,"Seal #: " + d.sealNumber(),8,107,49);
|
||||
text(s,u,f,"Amazon FBA Warehouse",8,left+2,63); text(s,u,f,"FC Code: " + d.fbaWarehouseCode(),8,left+2,69);
|
||||
text(s,u,f,"Name: " + d.shipToName(),8,left+2,75); text(s,u,f,"Address: " + d.shipToAddress(),8,left+2,80);
|
||||
text(s,u,f,"BOL / Dispatch #: " + d.dispatchNo(),8,107,63); text(s,u,f,"FBA Shipment ID: " + d.fbaShipmentIds(),8,107,70);
|
||||
text(s,u,f,"PO / Reference #: " + d.poReferenceNos(),8,107,77);
|
||||
}
|
||||
|
||||
private void table(PDPageContentStream s, PdfUnits u, PDFont f, DispatchBolPdfDtos.Document d, double top) throws IOException {
|
||||
double headerBottom=101, rowHeight=9, bottom=headerBottom+12*rowHeight;
|
||||
for(float x:COLS) line(s,u,x,top,x,bottom);
|
||||
line(s,u,COLS[0],top,COLS[COLS.length-1],top); line(s,u,COLS[0],headerBottom,COLS[COLS.length-1],headerBottom);
|
||||
for(int i=1;i<=12;i++) line(s,u,COLS[0],headerBottom+i*rowHeight,COLS[COLS.length-1],headerBottom+i*rowHeight);
|
||||
for(int i=0;i<HEADERS.length;i++) centeredCell(s,u,f,HEADERS[i],5.3f,COLS[i],COLS[i+1],97.5);
|
||||
for(int r=0;r<d.cargoRows().size();r++){
|
||||
DispatchBolPdfDtos.CargoRow row=d.cargoRows().get(r); String[] values={row.containerNo(),row.palletNo(),row.fbaWarehouseCode(),row.warehouseNo(),row.fbaShipmentId(),row.poReferenceNo(),Integer.toString(row.packageCount())};
|
||||
for(int c=0;c<values.length;c++) centeredCell(s,u,f,values[c],5.5f,COLS[c]+.7,COLS[c+1]-.7,107+r*rowHeight);
|
||||
}
|
||||
}
|
||||
|
||||
private void blackHeader(PDPageContentStream s,PdfUnits u,PDFont f,String label,double x1,double y1,double x2,double y2)throws IOException{
|
||||
s.setNonStrokingColor(Color.BLACK); s.addRect(PdfUnits.mmToPt(x1),u.topDownMmToPdfY(y2),PdfUnits.mmToPt(x2-x1),PdfUnits.mmToPt(y2-y1)); s.fill();
|
||||
s.setNonStrokingColor(Color.WHITE); text(s,u,f,label,6.5f,x1+2,y2-1.7); s.setNonStrokingColor(Color.BLACK);
|
||||
}
|
||||
private void signature(PDPageContentStream s,PdfUnits u,PDFont f,double x,String label)throws IOException{line(s,u,x,266,x+52,266); centeredCell(s,u,f,label,6.5f,x,x+52,274);}
|
||||
private void centered(PDPageContentStream s,PdfUnits u,PDFont f,String value,float size,double centerX,double y)throws IOException{float w=f.getStringWidth(value)/1000*size;text(s,u,f,value,size,centerX-w/PdfUnits.mmToPt(1)/2,y);}
|
||||
private void centeredCell(PDPageContentStream s,PdfUnits u,PDFont f,String value,float size,double x1,double x2,double y)throws IOException{String fit=fit(f,value,size,PdfUnits.mmToPt(x2-x1));float w=f.getStringWidth(fit)/1000*size;text(s,u,f,fit,size,x1+(x2-x1-w/PdfUnits.mmToPt(1))/2,y);}
|
||||
private void right(PDPageContentStream s,PdfUnits u,PDFont f,String value,float size,double x,double y)throws IOException{float w=f.getStringWidth(value)/1000*size;text(s,u,f,value,size,x-w/PdfUnits.mmToPt(1),y);}
|
||||
private String fit(PDFont f,String v,float size,float max)throws IOException{String value=v==null?"—":v;if(f.getStringWidth(value)/1000*size<=max)return value;String ell="…";int end=value.length();while(end>0&&f.getStringWidth(value.substring(0,end)+ell)/1000*size>max)end--;return end==0?ell:value.substring(0,end)+ell;}
|
||||
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,90 @@
|
||||
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.DispatchBolPdfDtos;
|
||||
import com.baiyue.print.renderer.DispatchBolRenderer;
|
||||
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 DispatchBolPdfService {
|
||||
private static final BigDecimal A4_WIDTH = new BigDecimal("210");
|
||||
private static final BigDecimal A4_HEIGHT = new BigDecimal("297");
|
||||
private static final Set<Character> FORBIDDEN_FILE_CHARS = Set.of('/', '\\', ':', '*', '?', '"', '<', '>', '|');
|
||||
private static final int MAX_ROWS_PER_PAGE = 12;
|
||||
|
||||
@Resource private PrintPdfProperties properties;
|
||||
@Resource private PdfGenerationLimiter limiter;
|
||||
@Resource private DispatchBolRenderer renderer;
|
||||
|
||||
public GeneratedPdf generate(DispatchBolPdfDtos.Request request) {
|
||||
DispatchBolPdfDtos.Request normalized = normalize(request);
|
||||
try (PdfGenerationLimiter.Permit ignored = limiter.acquire();
|
||||
PDDocument pdf = new PDDocument(); InputStream fontStream = openFont();
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
|
||||
PDType0Font font = PDType0Font.load(pdf, fontStream, true);
|
||||
for (DispatchBolPdfDtos.Document document : normalized.documents()) renderer.renderPage(pdf, font, document);
|
||||
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 InputStream openFont() throws IOException {
|
||||
return new ClassPathResource(properties.getFontResource()).getInputStream();
|
||||
}
|
||||
|
||||
private DispatchBolPdfDtos.Request normalize(DispatchBolPdfDtos.Request request) {
|
||||
if (request == null || !DispatchBolPdfDtos.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(c -> FORBIDDEN_FILE_CHARS.contains((char) c))) invalid("downloadFileName is invalid");
|
||||
if (request.documents() == null || request.documents().isEmpty() || request.documents().size() > properties.getMaxPages()) invalid("documents count is invalid");
|
||||
List<DispatchBolPdfDtos.Document> documents = new ArrayList<>();
|
||||
for (int i = 0; i < request.documents().size(); i++) documents.add(normalizeDocument(request.documents().get(i), i));
|
||||
return new DispatchBolPdfDtos.Request(DispatchBolPdfDtos.TEMPLATE, request.pageSpec(), fileName, List.copyOf(documents));
|
||||
}
|
||||
|
||||
private DispatchBolPdfDtos.Document normalizeDocument(DispatchBolPdfDtos.Document value, int index) {
|
||||
if (value == null || value.cargoRows() == null || value.cargoRows().isEmpty() || value.cargoRows().size() > MAX_ROWS_PER_PAGE) invalid("documents[" + index + "].cargoRows must contain 1 to 12 items");
|
||||
if (value.totalPackages() < 0 || value.pageNumber() < 1 || value.pageCount() < value.pageNumber()) invalid("documents[" + index + "] page metadata is invalid");
|
||||
List<DispatchBolPdfDtos.CargoRow> rows = new ArrayList<>();
|
||||
for (DispatchBolPdfDtos.CargoRow row : value.cargoRows()) {
|
||||
if (row == null || row.packageCount() < 0) invalid("cargo row is invalid");
|
||||
rows.add(new DispatchBolPdfDtos.CargoRow(text(row.containerNo()), text(row.palletNo()), text(row.fbaWarehouseCode()),
|
||||
text(row.warehouseNo()), text(row.fbaShipmentId()), text(row.poReferenceNo()), row.packageCount()));
|
||||
}
|
||||
return new DispatchBolPdfDtos.Document(text(value.shipFromName()), text(value.shipFromAddress()), text(value.carrierName()),
|
||||
text(value.isaNumber()), text(value.appointmentTime()), text(value.trailerNumber()), text(value.sealNumber()),
|
||||
text(value.fbaWarehouseCode()), text(value.shipToName()), text(value.shipToAddress()), text(value.dispatchNo()),
|
||||
text(value.fbaShipmentIds()), text(value.poReferenceNos()), value.totalPackages(), value.pageNumber(), value.pageCount(), List.copyOf(rows));
|
||||
}
|
||||
|
||||
private String text(String value) {
|
||||
String normalized = line(value);
|
||||
if (normalized.codePointCount(0, normalized.length()) > 300 || normalized.codePoints().anyMatch(c -> Character.isISOControl(c))) invalid("document text is invalid");
|
||||
return normalized.isBlank() ? "—" : 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) {}
|
||||
}
|
||||
@@ -100,6 +100,24 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ApiError'
|
||||
/internal/print/v1/pdfs/dispatch-bol-documents:
|
||||
post:
|
||||
summary: Generate vehicle-dispatch BOL documents as fixed A4 PDF pages
|
||||
operationId: generateDispatchBolPdf
|
||||
security: [{ internalServiceToken: [] }]
|
||||
parameters: [{ $ref: '#/components/parameters/RequestId' }]
|
||||
requestBody:
|
||||
required: true
|
||||
content: { application/json: { schema: { $ref: '#/components/schemas/DispatchBolPdfRequest' } } }
|
||||
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' } }
|
||||
components:
|
||||
securitySchemes:
|
||||
internalServiceToken:
|
||||
@@ -186,6 +204,51 @@ components:
|
||||
description: One document item renders one A4 page in request order.
|
||||
items:
|
||||
$ref: '#/components/schemas/FreightA4Document'
|
||||
DispatchBolPdfRequest:
|
||||
type: object
|
||||
required: [templateCode, pageSpec, downloadFileName, documents]
|
||||
properties:
|
||||
templateCode: { type: string, enum: [DISPATCH_BILL_OF_LADING_A4_V1] }
|
||||
pageSpec: { $ref: '#/components/schemas/FreightA4PageSpec' }
|
||||
downloadFileName: { type: string, maxLength: 120 }
|
||||
documents:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 2000
|
||||
description: One item renders one A4 page; the caller groups and chunks BOL rows in stable order.
|
||||
items: { $ref: '#/components/schemas/DispatchBolDocument' }
|
||||
DispatchBolDocument:
|
||||
type: object
|
||||
required: [shipFromName, carrierName, fbaWarehouseCode, shipToName, dispatchNo, totalPackages, pageNumber, pageCount, cargoRows]
|
||||
properties:
|
||||
shipFromName: { type: string }
|
||||
shipFromAddress: { type: string }
|
||||
carrierName: { type: string }
|
||||
isaNumber: { type: string }
|
||||
appointmentTime: { type: string }
|
||||
trailerNumber: { type: string }
|
||||
sealNumber: { type: string }
|
||||
fbaWarehouseCode: { type: string }
|
||||
shipToName: { type: string }
|
||||
shipToAddress: { type: string }
|
||||
dispatchNo: { type: string }
|
||||
fbaShipmentIds: { type: string }
|
||||
poReferenceNos: { type: string }
|
||||
totalPackages: { type: integer, minimum: 0 }
|
||||
pageNumber: { type: integer, minimum: 1 }
|
||||
pageCount: { type: integer, minimum: 1 }
|
||||
cargoRows: { type: array, minItems: 1, maxItems: 12, items: { $ref: '#/components/schemas/DispatchBolCargoRow' } }
|
||||
DispatchBolCargoRow:
|
||||
type: object
|
||||
required: [containerNo, palletNo, fbaWarehouseCode, warehouseNo, fbaShipmentId, poReferenceNo, packageCount]
|
||||
properties:
|
||||
containerNo: { type: string }
|
||||
palletNo: { type: string }
|
||||
fbaWarehouseCode: { type: string }
|
||||
warehouseNo: { type: string }
|
||||
fbaShipmentId: { type: string }
|
||||
poReferenceNo: { type: string }
|
||||
packageCount: { type: integer, minimum: 0 }
|
||||
FreightA4PageSpec:
|
||||
type: object
|
||||
required: [widthMm, heightMm]
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.baiyue.common.web.GlobalExceptionHandler;
|
||||
import com.baiyue.print.dto.FreightA4PdfDtos;
|
||||
import com.baiyue.print.dto.InboundMarkPdfDtos;
|
||||
import com.baiyue.print.service.FreightA4PdfService;
|
||||
import com.baiyue.print.service.DispatchBolPdfService;
|
||||
import com.baiyue.print.service.InboundMarkPdfService;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -42,6 +43,8 @@ class InternalPdfControllerTest {
|
||||
private InboundMarkPdfService service;
|
||||
@MockBean
|
||||
private FreightA4PdfService freightA4PdfService;
|
||||
@MockBean
|
||||
private DispatchBolPdfService dispatchBolPdfService;
|
||||
@Resource
|
||||
private InternalPdfController controller;
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@@ -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.DispatchBolPdfDtos;
|
||||
import com.baiyue.print.renderer.DispatchBolRenderer;
|
||||
import com.baiyue.print.renderer.PdfUnits;
|
||||
import jakarta.annotation.Resource;
|
||||
import java.math.BigDecimal;
|
||||
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 = {DispatchBolPdfService.class, PrintPdfProperties.class,
|
||||
PdfGenerationLimiter.class, DispatchBolRenderer.class})
|
||||
class DispatchBolPdfServiceGenerationTest {
|
||||
@Resource private DispatchBolPdfService service;
|
||||
|
||||
@Test
|
||||
void generatesStableA4BolWithEmbeddedChineseFontAndBlackHeaders() throws Exception {
|
||||
DispatchBolPdfDtos.Document page = new DispatchBolPdfDtos.Document(
|
||||
"洛杉矶仓", "—", "五月开发验收车队", "ISA-446646", "2026-09-11 18:39:37",
|
||||
"DEV-83222028", "—", "ABE4", "ABE4", "—", "DS-WH-20260911-692865",
|
||||
"FBA-SHIP-1", "PO-1", 4, 1, 1,
|
||||
List.of(new DispatchBolPdfDtos.CargoRow("TSTU2186171", "TSTU2186171-126-001", "ABE4",
|
||||
"NYEXX2609110181", "FBA-SHIP-1", "PO-1", 4)));
|
||||
var generated = service.generate(new DispatchBolPdfDtos.Request(DispatchBolPdfDtos.TEMPLATE,
|
||||
new DispatchBolPdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297")),
|
||||
"DS-WH-20260911-692865-BOL.pdf", List.of(page)));
|
||||
|
||||
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("BILL OF LADING", "洛杉矶仓", "DS-WH-20260911-692865", "TSTU2186171", "Total CTNS:", "4");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user