feat: generate dispatch BOL PDFs

This commit is contained in:
2026-09-11 10:09:06 +08:00
parent 81f4f9163c
commit 220d159cec
16 changed files with 484 additions and 4 deletions
@@ -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);
@@ -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) {}
}