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.DispatchRequests;
|
||||||
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
|
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
|
||||||
import com.baiyue.lastleg.dispatch.service.DispatchService;
|
import com.baiyue.lastleg.dispatch.service.DispatchService;
|
||||||
|
import com.baiyue.lastleg.dispatch.service.DispatchBolPrintService;
|
||||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
@@ -13,6 +14,11 @@ import jakarta.annotation.Resource;
|
|||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.http.HttpStatus;
|
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.access.prepost.PreAuthorize;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
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.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@ConditionalOnProperty(name = "lastleg.dispatch-query-api.enabled", havingValue = "true")
|
@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")
|
@Tag(name = "Tail-leg vehicle dispatch query")
|
||||||
public class DispatchQueryController {
|
public class DispatchQueryController {
|
||||||
@Resource private DispatchService service;
|
@Resource private DispatchService service;
|
||||||
|
@Resource private DispatchBolPrintService printService;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')")
|
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')")
|
||||||
@@ -48,6 +57,21 @@ public class DispatchQueryController {
|
|||||||
return ApiResponse.success(service.platformDetail(dispatchNo));
|
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) {
|
private void requirePlatform(AuthenticatedUser user) {
|
||||||
if (user == null || user.tenantId() == null) {
|
if (user == null || user.tenantId() == null) {
|
||||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
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}
|
connect-timeout: ${LASTLEG_PALLET_PHYSICAL_CONNECT_TIMEOUT:3s}
|
||||||
read-timeout: ${LASTLEG_PALLET_PHYSICAL_READ_TIMEOUT:5s}
|
read-timeout: ${LASTLEG_PALLET_PHYSICAL_READ_TIMEOUT:5s}
|
||||||
clients:
|
clients:
|
||||||
|
print:
|
||||||
|
url: ${PRINT_SERVICE_URL:}
|
||||||
domestic-warehouse:
|
domestic-warehouse:
|
||||||
url: ${DOMESTIC_WAREHOUSE_URL:}
|
url: ${DOMESTIC_WAREHOUSE_URL:}
|
||||||
cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}
|
cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}
|
||||||
|
|||||||
@@ -513,6 +513,21 @@ paths:
|
|||||||
security: [{ bearerAuth: [] }]
|
security: [{ bearerAuth: [] }]
|
||||||
parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }]
|
parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }]
|
||||||
responses: { '200': { description: 取消成功或已取消 }, '404': { description: 排车单不存在 }, '409': { description: 排车已开始执行 } }
|
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:
|
/api/platform/v1/last-leg/dispatches/candidates:
|
||||||
get:
|
get:
|
||||||
summary: 查询智能排车候选托盘
|
summary: 查询智能排车候选托盘
|
||||||
|
|||||||
+1
-1
@@ -58,7 +58,7 @@ class Phase4BrowserApiBoundaryTest {
|
|||||||
assertTrue(Arrays.asList(condition.name()).contains("lastleg.dispatch-query-api.enabled"));
|
assertTrue(Arrays.asList(condition.name()).contains("lastleg.dispatch-query-api.enabled"));
|
||||||
assertFalse(condition.matchIfMissing());
|
assertFalse(condition.matchIfMissing());
|
||||||
assertTrue(Arrays.stream(DispatchQueryController.class.getDeclaredMethods())
|
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())
|
assertFalse(Arrays.stream(DispatchQueryController.class.getDeclaredMethods())
|
||||||
.flatMap(method -> Arrays.stream(method.getAnnotations()))
|
.flatMap(method -> Arrays.stream(method.getAnnotations()))
|
||||||
.anyMatch(annotation -> Set.of("PostMapping", "PutMapping", "DeleteMapping")
|
.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.common.security.AuthenticatedUser;
|
||||||
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
|
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
|
||||||
import com.baiyue.lastleg.dispatch.service.DispatchService;
|
import com.baiyue.lastleg.dispatch.service.DispatchService;
|
||||||
|
import com.baiyue.lastleg.dispatch.service.DispatchBolPrintService;
|
||||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -25,12 +26,14 @@ import static org.mockito.Mockito.when;
|
|||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class DispatchQueryControllerTest {
|
class DispatchQueryControllerTest {
|
||||||
@Mock DispatchService service;
|
@Mock DispatchService service;
|
||||||
|
@Mock DispatchBolPrintService printService;
|
||||||
private DispatchQueryController controller;
|
private DispatchQueryController controller;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
controller = new DispatchQueryController();
|
controller = new DispatchQueryController();
|
||||||
ReflectionTestUtils.setField(controller, "service", service);
|
ReflectionTestUtils.setField(controller, "service", service);
|
||||||
|
ReflectionTestUtils.setField(controller, "printService", printService);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -57,9 +60,13 @@ class DispatchQueryControllerTest {
|
|||||||
PreAuthorize detail = DispatchQueryController.class
|
PreAuthorize detail = DispatchQueryController.class
|
||||||
.getMethod("detail", AuthenticatedUser.class, String.class)
|
.getMethod("detail", AuthenticatedUser.class, String.class)
|
||||||
.getAnnotation(PreAuthorize.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(page.value()).isEqualTo("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')");
|
||||||
assertThat(detail.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() {
|
private static AuthenticatedUser platform() {
|
||||||
|
|||||||
+3
-3
@@ -15,9 +15,9 @@
|
|||||||
| 健康状态 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/freight-a4-documents`) |
|
| 内部 PDF API | `controller/InternalPdfController.java`(`/internal/print/v1/pdfs/inbound-marks`、`/internal/print/v1/pdfs/freight-a4-documents`) |
|
||||||
| 内部令牌校验 | `controller/InternalServiceTokenGuard.java` |
|
| 内部令牌校验 | `controller/InternalServiceTokenGuard.java` |
|
||||||
| 请求与响应模型 | `dto/InboundMarkPdfDtos.java`、`dto/FreightA4PdfDtos.java` |
|
| 请求与响应模型 | `dto/InboundMarkPdfDtos.java`、`dto/FreightA4PdfDtos.java`、`dto/DispatchBolPdfDtos.java` |
|
||||||
| 固定模板与渲染 | `renderer/InboundMarkV1Renderer.java`、`renderer/FreightA4Renderer.java`、`renderer/PdfQrRenderer.java`、`renderer/PdfUnits.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/PdfGenerationLimiter.java` |
|
| 请求校验与资源保护 | `controller/PrintRequestSizeFilter.java`、`service/InboundMarkPdfService.java`、`service/FreightA4PdfService.java`、`service/DispatchBolPdfService.java`、`service/PdfGenerationLimiter.java` |
|
||||||
| PDF 参数配置 | `config/PrintPdfProperties.java` |
|
| PDF 参数配置 | `config/PrintPdfProperties.java` |
|
||||||
| 接口契约 | `src/main/resources/openapi/print-internal-v1.yaml` |
|
| 接口契约 | `src/main/resources/openapi/print-internal-v1.yaml` |
|
||||||
| 字体及授权 | `src/main/resources/fonts/NotoSansSC-Bold.ttf`、`src/main/resources/fonts/OFL.txt` |
|
| 字体及授权 | `src/main/resources/fonts/NotoSansSC-Bold.ttf`、`src/main/resources/fonts/OFL.txt` |
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
- 服务发现名:`print-service`。
|
- 服务发现名:`print-service`。
|
||||||
- 内部地址:`POST /internal/print/v1/pdfs/inbound-marks`。
|
- 内部地址:`POST /internal/print/v1/pdfs/inbound-marks`。
|
||||||
- A4 海运单据内部地址:`POST /internal/print/v1/pdfs/freight-a4-documents`。
|
- 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`。
|
- 本地直连:`http://127.0.0.1:9207`。
|
||||||
- 请求必须携带 `X-Internal-Service-Token`,值使用各服务已有的 `baiyue.security.internal-service-token` 配置。
|
- 请求必须携带 `X-Internal-Service-Token`,值使用各服务已有的 `baiyue.security.internal-service-token` 配置。
|
||||||
- 不把该接口加入 Gateway,也不允许 Admin 浏览器直接携带内部令牌访问 Print。
|
- 不把该接口加入 Gateway,也不允许 Admin 浏览器直接携带内部令牌访问 Print。
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ 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.DispatchBolPdfDtos;
|
||||||
import com.baiyue.print.dto.InboundMarkPdfDtos;
|
import com.baiyue.print.dto.InboundMarkPdfDtos;
|
||||||
import com.baiyue.print.service.FreightA4PdfService;
|
import com.baiyue.print.service.FreightA4PdfService;
|
||||||
|
import com.baiyue.print.service.DispatchBolPdfService;
|
||||||
import com.baiyue.print.service.InboundMarkPdfService;
|
import com.baiyue.print.service.InboundMarkPdfService;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
@@ -30,6 +32,7 @@ public class InternalPdfController {
|
|||||||
|
|
||||||
@Resource private InboundMarkPdfService pdfService;
|
@Resource private InboundMarkPdfService pdfService;
|
||||||
@Resource private FreightA4PdfService freightA4PdfService;
|
@Resource private FreightA4PdfService freightA4PdfService;
|
||||||
|
@Resource private DispatchBolPdfService dispatchBolPdfService;
|
||||||
@Resource private InternalServiceTokenGuard tokenGuard;
|
@Resource private InternalServiceTokenGuard tokenGuard;
|
||||||
|
|
||||||
@PostMapping(value = "/inbound-marks", consumes = MediaType.APPLICATION_JSON_VALUE)
|
@PostMapping(value = "/inbound-marks", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||||
@@ -85,4 +88,17 @@ public class InternalPdfController {
|
|||||||
|
|
||||||
return response.body(content);
|
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:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ApiError'
|
$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:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
internalServiceToken:
|
internalServiceToken:
|
||||||
@@ -186,6 +204,51 @@ components:
|
|||||||
description: One document item renders one A4 page in request order.
|
description: One document item renders one A4 page in request order.
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/FreightA4Document'
|
$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:
|
FreightA4PageSpec:
|
||||||
type: object
|
type: object
|
||||||
required: [widthMm, heightMm]
|
required: [widthMm, heightMm]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.baiyue.common.web.GlobalExceptionHandler;
|
|||||||
import com.baiyue.print.dto.FreightA4PdfDtos;
|
import com.baiyue.print.dto.FreightA4PdfDtos;
|
||||||
import com.baiyue.print.dto.InboundMarkPdfDtos;
|
import com.baiyue.print.dto.InboundMarkPdfDtos;
|
||||||
import com.baiyue.print.service.FreightA4PdfService;
|
import com.baiyue.print.service.FreightA4PdfService;
|
||||||
|
import com.baiyue.print.service.DispatchBolPdfService;
|
||||||
import com.baiyue.print.service.InboundMarkPdfService;
|
import com.baiyue.print.service.InboundMarkPdfService;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
@@ -42,6 +43,8 @@ class InternalPdfControllerTest {
|
|||||||
private InboundMarkPdfService service;
|
private InboundMarkPdfService service;
|
||||||
@MockBean
|
@MockBean
|
||||||
private FreightA4PdfService freightA4PdfService;
|
private FreightA4PdfService freightA4PdfService;
|
||||||
|
@MockBean
|
||||||
|
private DispatchBolPdfService dispatchBolPdfService;
|
||||||
@Resource
|
@Resource
|
||||||
private InternalPdfController controller;
|
private InternalPdfController controller;
|
||||||
private MockMvc mockMvc;
|
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