Merge test into dev and resolve conflicts

This commit is contained in:
2026-09-14 11:02:37 +08:00
342 changed files with 8454 additions and 1591 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);
@@ -12,11 +12,16 @@ public record DispatchResponse(Long id, String dispatchNo, String vehiclePlate,
int itemCount, int palletCount, int packageCount, int pickedPackageCount,
int shippedPackageCount, int remainingPackageCount, String partialCompletionReason,
LocalDateTime createdAt,
List<Item> items) {
List<Item> items, List<BolLine> bolLines) {
public record Item(Long id, Long planPalletId, Long warehousePalletId, String palletNo,
String overseasWarehouseId, String overseasWarehouseName,
String orderNo, String orderRefs, String inboundCode, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode, int packageCount,
BigDecimal volumeCbm, String sourceStage) {
}
public record BolLine(String containerNo, String palletNo, String fbaWarehouseCode,
String destinationWarehouseName, String warehouseNo, String fbaShipmentId,
String poReferenceNo, int packageCount) {
}
}
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface DispatchBolMapper {
@Select("SELECT " +
"COALESCE(MAX(NULLIF(o.container_no,'')),MAX(NULLIF(ip.container_no,''))) AS containerNo," +
"MAX(NULLIF(di.pallet_no,'')) AS palletNo," +
"COALESCE(MAX(NULLIF(l.destination_warehouse_code,'')),MAX(NULLIF(di.fba_warehouse_code,''))," +
"MAX(NULLIF(di.destination_warehouse_code,''))) AS fbaWarehouseCode," +
"COALESCE(MAX(NULLIF(l.destination_warehouse_name,'')),MAX(NULLIF(di.destination_warehouse_name,''))) AS destinationWarehouseName," +
"COALESCE(MAX(NULLIF(l.inbound_code,'')),MAX(NULLIF(di.inbound_code,''))) AS warehouseNo," +
"GROUP_CONCAT(DISTINCT NULLIF(od.shipment_no,'') ORDER BY od.shipment_no SEPARATOR ', ') AS fbaShipmentId," +
"GROUP_CONCAT(DISTINCT NULLIF(od.fba_tracking_no,'') ORDER BY od.fba_tracking_no SEPARATOR ', ') AS poReferenceNo," +
"CASE WHEN MAX(l.id) IS NULL THEN COALESCE(MAX(di.package_count),0) " +
"WHEN COALESCE(MAX(a.picked_package_count),0)>0 THEN MAX(a.picked_package_count) " +
"ELSE COALESCE(MAX(a.planned_package_count),0) END AS packageCount " +
"FROM ll_dispatch_item di " +
"LEFT JOIN ll_inbound_plan_pallet p ON p.id=di.plan_pallet_id " +
"LEFT JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=di.customer_id " +
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " +
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=di.customer_id " +
"LEFT JOIN ll_order o ON o.id=l.order_id AND o.customer_id=di.customer_id " +
"LEFT JOIN ll_order_detail od ON od.order_id=l.order_id AND od.customer_id=di.customer_id " +
"WHERE di.customer_id=#{customerId} AND di.dispatch_order_id=#{dispatchOrderId} " +
"GROUP BY di.id,l.id ORDER BY di.id,l.id")
List<DispatchResponse.BolLine> findByDispatchOrder(@Param("customerId") String customerId,
@Param("dispatchOrderId") Long dispatchOrderId);
}
@@ -0,0 +1,144 @@
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.DomesticWarehouseDirectoryClient;
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 java.util.Objects;
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_V2";
private static final int FIRST_PAGE_ROWS = 18;
private static final int CONTINUATION_PAGE_ROWS = 26;
private static final PrintPdfClient.PageSpec A4 = new PrintPdfClient.PageSpec(new BigDecimal("210"), new BigDecimal("297"));
@Resource private DispatchService dispatches;
@Resource private PrintPdfClient print;
@Resource private DomesticWarehouseDirectoryClient warehouses;
@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) {
String shipFromAddress = warehouseAddress(detail.overseasWarehouseId());
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 = pageCount(lines.size());
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("—");
int offset = 0;
for (int page = 0; page < pages; page++) {
int capacity = page == 0 ? FIRST_PAGE_ROWS : CONTINUATION_PAGE_ROWS;
int end = Math.min(lines.size(), offset + capacity);
List<PrintPdfClient.CargoRow> rows = lines.subList(offset, end).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()), shipFromAddress, 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));
offset = end;
}
}
return List.copyOf(documents);
}
private String warehouseAddress(String warehouseId) {
long id;
try {
id = Long.parseLong(warehouseId == null ? "" : warehouseId.strip());
} catch (NumberFormatException exception) {
throw new BizException(ResultCode.VALIDATION_FAILED, "排车单未关联有效的海外仓");
}
try {
var response = warehouses.activeWarehouse(id, internalToken);
if (response == null || !response.successful() || response.data() == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库主数据暂不可用,无法生成BOL");
}
List<DomesticWarehouseDirectoryClient.Address> addresses = response.data().addresses();
if (addresses == null || addresses.isEmpty()) return "—";
DomesticWarehouseDirectoryClient.Address address = addresses.stream().filter(Objects::nonNull)
.filter(value -> "OPERATING".equals(value.addressType())).findFirst()
.orElseGet(() -> addresses.stream().filter(Objects::nonNull).findFirst().orElse(null));
if (address == null) return "—";
List<String> parts = new ArrayList<>();
add(parts, address.addressLine1());
add(parts, address.addressLine2());
String city = trimmed(address.city());
String state = trimmed(address.stateProvince());
String postalCode = trimmed(address.postalCode());
String region = city == null ? state : state == null ? city : city + ", " + state;
if (postalCode != null) region = region == null ? postalCode : region + " " + postalCode;
add(parts, region);
add(parts, address.countryCode());
return parts.isEmpty() ? "—" : String.join(", ", parts);
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库主数据暂不可用,无法生成BOL");
}
}
private void add(List<String> parts, String value) {
if (value != null && !value.isBlank()) parts.add(value.strip());
}
private String trimmed(String value) {
return value == null || value.isBlank() ? null : value.strip();
}
static int pageCount(int rowCount) {
if (rowCount <= FIRST_PAGE_ROWS) return 1;
return 1 + (rowCount - FIRST_PAGE_ROWS + CONTINUATION_PAGE_ROWS - 1) / CONTINUATION_PAGE_ROWS;
}
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) {}
}
@@ -12,6 +12,7 @@ import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
import com.baiyue.lastleg.dispatch.entity.DispatchDraft;
import com.baiyue.lastleg.dispatch.entity.DispatchDraftContainer;
import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper;
@@ -52,6 +53,7 @@ public class DispatchService {
@Resource private DispatchOrderMapper orders;
@Resource private DispatchItemMapper items;
@Resource private DispatchBolMapper bolLines;
@Resource private DispatchPalletLockMapper locks;
@Resource private DispatchDraftMapper drafts;
@Resource private DispatchDraftContainerMapper draftContainers;
@@ -475,7 +477,8 @@ public class DispatchService {
public DispatchResponse detail(String customerId, String dispatchNo) {
DispatchOrder dispatch = find(customerId, dispatchNo);
return response(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
return response(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()),
bolLines.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
}
public DispatchResponse platformDetail(String dispatchNo) {
@@ -483,7 +486,8 @@ public class DispatchService {
if (dispatch == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
}
return response(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
return response(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()),
bolLines.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
}
@Transactional
@@ -593,6 +597,11 @@ public class DispatchService {
}
private DispatchResponse response(DispatchOrder order, List<DispatchItem> itemList) {
return response(order, itemList, List.of());
}
private DispatchResponse response(DispatchOrder order, List<DispatchItem> itemList,
List<DispatchResponse.BolLine> bolLineList) {
String orderNos = itemList.stream().flatMap(item -> splitOrderRefs(item.getOrderRefs(), item.getOrderNo()).stream())
.distinct().collect(Collectors.joining(","));
return new DispatchResponse(order.getId(), order.getDispatchNo(), order.getVehiclePlate(), order.getDriverName(),
@@ -609,7 +618,8 @@ public class DispatchService {
item.getWarehousePalletId(), item.getPalletNo(), item.getOverseasWarehouseId(), item.getOverseasWarehouseName(),
item.getOrderNo(), item.getOrderRefs(), item.getInboundCode(),
item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getFbaWarehouseCode(),
Objects.requireNonNullElse(item.getPackageCount(), 0), item.getVolumeCbm(), item.getSourceStage())).toList());
Objects.requireNonNullElse(item.getPackageCount(), 0), item.getVolumeCbm(), item.getSourceStage())).toList(),
bolLineList == null ? List.of() : bolLineList);
}
private static String route(List<DispatchPalletCandidate> selected) {