修复发票PDF单页模板版式

This commit is contained in:
andy
2026-07-17 16:03:01 +07:00
parent 7dfaea7245
commit b24bfb5882
5 changed files with 213 additions and 29 deletions

View File

@@ -225,7 +225,7 @@ SourceMessage 原文和邮件会话完整正文已迁移到登录权限体系:
- PDF 上传到阿里云 OSS返回 `pdf_url``object_key``pdf_file_name``pdf_size_bytes``duration_millis` - PDF 上传到阿里云 OSS返回 `pdf_url``object_key``pdf_file_name``pdf_size_bytes``duration_millis`
- CP2 不落库,不提供转换历史查询;如果需要自动处理邮件附件,应先进入 M008 后续持久化任务和 worker checkpoint。 - CP2 不落库,不提供转换历史查询;如果需要自动处理邮件附件,应先进入 M008 后续持久化任务和 worker checkpoint。
- M009 Manual Invoice 不调用上述调试接口,也不使用 `X-TH-Hotel-Document-Conversion-Key`;它通过登录 Bearer token、`RESERVATION_INVOICE_GENERATE` 权限和后端内部转换 Adapter 生成 PDF。 - M009 Manual Invoice 不调用上述调试接口,也不使用 `X-TH-Hotel-Document-Conversion-Key`;它通过登录 Bearer token、`RESERVATION_INVOICE_GENERATE` 权限和后端内部转换 Adapter 生成 PDF。
- M009 Manual Invoice 当前模板资源为 `server/src/main/resources/templates/reservation-invoice/proforma-invoice-v1.xlsx`,部署包必须包含该资源。 - M009 Manual Invoice 当前模板资源为 `server/src/main/resources/templates/reservation-invoice/proforma-invoice-v1.xlsx`,部署包必须包含该资源;模板为 A4 纵向单页版式,缺少字体或 LibreOffice 版本差异仍可能影响最终分页,测试环境上线后需用真实浏览器打开生成 PDF 复核
- M009 Manual Invoice 会写入 `workflow_reservation_invoice_generation`,上线前需确认 Flyway 已执行到 V22。 - M009 Manual Invoice 会写入 `workflow_reservation_invoice_generation`,上线前需确认 Flyway 已执行到 V22。
- M009 Manual Invoice 失败记录也可能保留已上传成功的 Excel / PDF object key排查或清理 OSS 生成物时应以生成记录为主,不只看 `SUCCEEDED` 状态。 - M009 Manual Invoice 失败记录也可能保留已上传成功的 Excel / PDF object key排查或清理 OSS 生成物时应以生成记录为主,不只看 `SUCCEEDED` 状态。

View File

@@ -328,9 +328,10 @@ server/src/main/resources/templates/reservation-invoice/proforma-invoice-v1.xlsx
模板字段映射原则: 模板字段映射原则:
- 模板固定文案、Logo、银行信息、税号可以先保留在模板内。 - 模板固定文案、Logo、银行信息、税号可以先保留在模板内。
- 当前 `PROFORMA_INVOICE_V1` 使用 A4 纵向单页模板,打印区域固定为 `A1:H52`,后端渲染时会再次写入纸张、缩放和打印区域,避免 LibreOffice 转 PDF 时分页漂移。
- 可动态变更但不属于单张 Invoice 的配置,后续迁移到酒店发票配置。 - 可动态变更但不属于单张 Invoice 的配置,后续迁移到酒店发票配置。
- 用户输入字段通过后端模板填充器写入指定单元格。 - 用户输入字段通过后端模板填充器写入指定单元格。
- 明细行需要支持 1 到 N 行;超过模板默认行数时,后端应复制行样式和公式。 - 第一版明细行固定支持 1 到 10 行;超过模板默认行数时,后续 checkpoint 再做复制行样式和公式。
- 金额公式由后端统一写入或由模板公式统一生成,不复制历史案例中不一致的公式。 - 金额公式由后端统一写入或由模板公式统一生成,不复制历史案例中不一致的公式。
## 9. 权限、安全和审计 ## 9. 权限、安全和审计
@@ -358,6 +359,7 @@ server/src/main/resources/templates/reservation-invoice/proforma-invoice-v1.xlsx
- 已完成:新增业务接口 `POST /api/reservation/invoices/manual-generations` - 已完成:新增业务接口 `POST /api/reservation/invoices/manual-generations`
- 已完成:新增生成记录表 `workflow_reservation_invoice_generation` 和业务审计。 - 已完成:新增生成记录表 `workflow_reservation_invoice_generation` 和业务审计。
- 已完成:根据请求 payload 填充受控 Excel 模板 `templates/reservation-invoice/proforma-invoice-v1.xlsx` - 已完成:根据请求 payload 填充受控 Excel 模板 `templates/reservation-invoice/proforma-invoice-v1.xlsx`
- 已完成:模板切换为接近酒店历史样张的 A4 单页版式,后端渲染器固定 `A1:H52` 打印区域,避免生成 PDF 被拆成多页。
- 已完成:复用平台 Excel 转 PDF Adapter 生成 PDF并上传阿里云 OSS。 - 已完成:复用平台 Excel 转 PDF Adapter 生成 PDF并上传阿里云 OSS。
- 已完成:返回 PDF URL、对象 Key、金额摘要和生成状态。 - 已完成:返回 PDF URL、对象 Key、金额摘要和生成状态。
- 已完成:失败记录保留已上传成功的 Excel / PDF object key业务审计完成后才标记 `SUCCEEDED` - 已完成:失败记录保留已上传成功的 Excel / PDF object key业务审计完成后才标记 `SUCCEEDED`

View File

@@ -6,8 +6,10 @@ import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.PrintSetup;
import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook; import org.apache.poi.ss.usermodel.Workbook;
@@ -24,11 +26,15 @@ public class ReservationInvoiceExcelTemplateRenderer {
private static final String TEMPLATE_PATH = "templates/reservation-invoice/proforma-invoice-v1.xlsx"; private static final String TEMPLATE_PATH = "templates/reservation-invoice/proforma-invoice-v1.xlsx";
private static final DateTimeFormatter DISPLAY_DATE_FORMATTER = DateTimeFormatter.ofPattern("dd/MM/yyyy"); private static final DateTimeFormatter DISPLAY_DATE_FORMATTER = DateTimeFormatter.ofPattern("dd/MM/yyyy");
private static final int CHARGE_START_ROW_INDEX = 22; private static final String SHEET_NAME = "Proforma Invoice";
private static final int CHARGE_START_ROW_INDEX = 25;
private static final int DEFAULT_CHARGE_ROW_COUNT = 10; private static final int DEFAULT_CHARGE_ROW_COUNT = 10;
private static final int SUBTOTAL_ROW_INDEX = 32; private static final int SUBTOTAL_ROW_INDEX = 35;
private static final int VAT_ROW_INDEX = 33; private static final int VAT_ROW_INDEX = 36;
private static final int TOTAL_ROW_INDEX = 34; private static final int TOTAL_ROW_INDEX = 37;
private static final int PRINT_LAST_ROW_INDEX = 51;
private static final int PRINT_LAST_COLUMN_INDEX = 7;
private static final short PRINT_SCALE = 55;
/** /**
* 使用受控 Excel 模板渲染 Invoice并返回 xlsx 字节。 * 使用受控 Excel 模板渲染 Invoice并返回 xlsx 字节。
@@ -39,6 +45,7 @@ public class ReservationInvoiceExcelTemplateRenderer {
fillHeader(sheet, request); fillHeader(sheet, request);
fillChargeRows(sheet, request.invoicePayload().charges()); fillChargeRows(sheet, request.invoicePayload().charges());
fillTotalFormulas(sheet); fillTotalFormulas(sheet);
applyPrintSettings(workbook, sheet);
workbook.setForceFormulaRecalculation(true); workbook.setForceFormulaRecalculation(true);
workbook.write(outputStream); workbook.write(outputStream);
return outputStream.toByteArray(); return outputStream.toByteArray();
@@ -67,21 +74,22 @@ public class ReservationInvoiceExcelTemplateRenderer {
var booking = payload.booking(); var booking = payload.booking();
var firstCharge = payload.charges().get(0); var firstCharge = payload.charges().get(0);
setText(sheet, "G5", "Date : " + DISPLAY_DATE_FORMATTER.format(document.invoiceDate())); setText(sheet, "H5", "Date : " + DISPLAY_DATE_FORMATTER.format(document.invoiceDate()));
setText(sheet, "B6", recipient.attention()); setText(sheet, "C6", recipient.attention());
setText(sheet, "B7", recipient.company()); setText(sheet, "C7", recipient.company());
setText(sheet, "B8", recipient.address()); fillAddress(sheet, recipient.address());
setText(sheet, "B12", recipient.telephone()); setText(sheet, "C12", recipient.telephone());
setText(sheet, "B13", recipient.email()); setText(sheet, "C13", recipient.email());
setText(sheet, "B14", DISPLAY_DATE_FORMATTER.format(document.bookingDate())); setText(sheet, "C14", DISPLAY_DATE_FORMATTER.format(document.bookingDate()));
setText(sheet, "G17", "Due Date : " + DISPLAY_DATE_FORMATTER.format(document.dueDate())); setText(sheet, "H19", "Due Date : " + DISPLAY_DATE_FORMATTER.format(document.dueDate()));
setText(sheet, "B18", booking.groupName()); setText(sheet, "D20", booking.groupName());
setText(sheet, "B19", DISPLAY_DATE_FORMATTER.format(booking.arrivalDate())); setText(sheet, "D22", DISPLAY_DATE_FORMATTER.format(booking.arrivalDate()));
setText(sheet, "B20", DISPLAY_DATE_FORMATTER.format(booking.departureDate())); setText(sheet, "D23", DISPLAY_DATE_FORMATTER.format(booking.departureDate()));
setText(sheet, "G18", "Room Rate : " + formatDecimal(firstCharge.rate()) setText(sheet, "H20", "Room Rate : " + formatDecimal(firstCharge.rate())
+ " /rm/n" + roomRateNoteSuffix(booking.roomRateNote())); + " /rm/n" + roomRateNoteSuffix(booking.roomRateNote()));
setText(sheet, "G19", "No.of room(s) : " + formatDecimal(totalQuantity(payload.charges()))); setText(sheet, "H21", "Extra bed : " + formatDecimal(booking.extraBedRate()));
setText(sheet, "G20", "No.of Night(s) : " + formatDecimal(firstCharge.nights())); setText(sheet, "H22", "No.of room (s) : " + formatDecimal(totalQuantity(payload.charges())));
setText(sheet, "H23", "No.of Night (s) : " + formatDecimal(firstCharge.nights()) + " NIGHTS");
} }
/** /**
@@ -93,11 +101,12 @@ public class ReservationInvoiceExcelTemplateRenderer {
ReservationInvoiceChargeRequest charge = charges.get(index); ReservationInvoiceChargeRequest charge = charges.get(index);
int rowIndex = CHARGE_START_ROW_INDEX + index; int rowIndex = CHARGE_START_ROW_INDEX + index;
Row row = row(sheet, rowIndex); Row row = row(sheet, rowIndex);
setCellText(row, 0, charge.description() + "\n" + charge.roomType()); setCellText(row, 1, charge.description());
setCellNumber(row, 3, charge.quantity()); setCellText(row, 3, charge.roomType());
setCellNumber(row, 4, charge.rate()); setCellNumber(row, 4, charge.quantity());
setCellNumber(row, 5, charge.nights()); setCellNumber(row, 5, charge.rate());
setCellFormula(row, 6, "D" + (rowIndex + 1) + "*E" + (rowIndex + 1) + "*F" + (rowIndex + 1)); setCellNumber(row, 6, charge.nights());
setCellFormula(row, 7, "E" + (rowIndex + 1) + "*F" + (rowIndex + 1) + "*G" + (rowIndex + 1));
} }
} }
@@ -108,9 +117,23 @@ public class ReservationInvoiceExcelTemplateRenderer {
Row subtotalRow = row(sheet, SUBTOTAL_ROW_INDEX); Row subtotalRow = row(sheet, SUBTOTAL_ROW_INDEX);
Row vatRow = row(sheet, VAT_ROW_INDEX); Row vatRow = row(sheet, VAT_ROW_INDEX);
Row totalRow = row(sheet, TOTAL_ROW_INDEX); Row totalRow = row(sheet, TOTAL_ROW_INDEX);
setCellFormula(subtotalRow, 6, "SUM(G23:G32)/1.07"); setCellFormula(subtotalRow, 7, "SUM(H26:H35)/1.07");
setCellFormula(vatRow, 6, "SUM(G23:G32)-G33"); setCellFormula(vatRow, 7, "SUM(H26:H35)-H36");
setCellFormula(totalRow, 6, "SUM(G23:G32)"); setCellFormula(totalRow, 7, "SUM(H26:H35)");
}
/**
* 固定 PDF 输出版式,避免模板被编辑后再次变成多页或 Letter 纸张。
*/
private void applyPrintSettings(Workbook workbook, Sheet sheet) {
int sheetIndex = workbook.getSheetIndex(sheet);
workbook.setSheetName(sheetIndex, SHEET_NAME);
workbook.setPrintArea(sheetIndex, 0, PRINT_LAST_COLUMN_INDEX, 0, PRINT_LAST_ROW_INDEX);
PrintSetup printSetup = sheet.getPrintSetup();
printSetup.setPaperSize(PrintSetup.A4_PAPERSIZE);
printSetup.setLandscape(false);
printSetup.setScale(PRINT_SCALE);
sheet.setAutobreaks(false);
} }
/** /**
@@ -119,13 +142,73 @@ public class ReservationInvoiceExcelTemplateRenderer {
private void clearChargeRows(Sheet sheet) { private void clearChargeRows(Sheet sheet) {
for (int index = 0; index < DEFAULT_CHARGE_ROW_COUNT; index++) { for (int index = 0; index < DEFAULT_CHARGE_ROW_COUNT; index++) {
Row row = row(sheet, CHARGE_START_ROW_INDEX + index); Row row = row(sheet, CHARGE_START_ROW_INDEX + index);
for (int cellIndex = 0; cellIndex <= 6; cellIndex++) { for (int cellIndex = 1; cellIndex <= 7; cellIndex++) {
Cell cell = cell(row, cellIndex); Cell cell = cell(row, cellIndex);
cell.setBlank(); cell.setBlank();
} }
} }
} }
/**
* 地址字段最多拆成三行展示,兼顾模板空间和用户手填长地址。
*/
private void fillAddress(Sheet sheet, String address) {
List<String> addressLines = splitAddressLines(address);
setText(sheet, "C8", addressLines.get(0));
setText(sheet, "C9", addressLines.get(1));
setText(sheet, "C10", addressLines.get(2));
}
/**
* 优先按换行拆分;无换行时按逗号做温和换行,避免单行地址在 PDF 中过长。
*/
private List<String> splitAddressLines(String address) {
if (address == null || address.isBlank()) {
return List.of("", "", "");
}
String normalized = address.trim();
String[] hardLines = normalized.split("\\R");
if (hardLines.length > 1) {
return firstThreeLines(List.of(hardLines));
}
List<String> lines = new ArrayList<>();
StringBuilder current = new StringBuilder();
for (String part : normalized.split(",")) {
String segment = part.trim();
if (segment.isEmpty()) {
continue;
}
String candidate = current.length() == 0 ? segment : current + ", " + segment;
if (candidate.length() > 58 && lines.size() < 2 && current.length() > 0) {
lines.add(current.toString());
current = new StringBuilder(segment);
} else {
current = new StringBuilder(candidate);
}
}
if (current.length() > 0) {
lines.add(current.toString());
}
return firstThreeLines(lines);
}
private List<String> firstThreeLines(List<String> lines) {
List<String> result = new ArrayList<>(3);
for (int index = 0; index < lines.size(); index++) {
String line = lines.get(index);
if (result.size() == 2) {
String remaining = String.join(", ", lines.subList(index, lines.size()));
result.add(remaining);
break;
}
result.add(line == null ? "" : line.trim());
}
while (result.size() < 3) {
result.add("");
}
return result;
}
private Row row(Sheet sheet, int rowIndex) { private Row row(Sheet sheet, int rowIndex) {
Row row = sheet.getRow(rowIndex); Row row = sheet.getRow(rowIndex);
return row == null ? sheet.createRow(rowIndex) : row; return row == null ? sheet.createRow(rowIndex) : row;

View File

@@ -0,0 +1,99 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import static org.assertj.core.api.Assertions.assertThat;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationInvoiceBookingRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationInvoiceChargeRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationInvoiceDocumentRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationInvoiceManualGenerationRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationInvoicePayloadRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationInvoiceRecipientRequest;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.List;
import org.apache.poi.ss.usermodel.PrintSetup;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Test;
class ReservationInvoiceExcelTemplateRendererTest {
private final ReservationInvoiceExcelTemplateRenderer renderer = new ReservationInvoiceExcelTemplateRenderer();
@Test
void shouldRenderSinglePageA4InvoiceWithExpectedCells() throws Exception {
byte[] workbookBytes = renderer.render(validRequest());
writeSampleWhenRequested(workbookBytes);
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(workbookBytes))) {
var sheet = workbook.getSheetAt(0);
assertThat(workbook.getNumberOfSheets()).isEqualTo(1);
assertThat(workbook.getPrintArea(0)).isEqualTo("'Proforma Invoice'!$A$1:$H$52");
assertThat(sheet.getPrintSetup().getPaperSize()).isEqualTo(PrintSetup.A4_PAPERSIZE);
assertThat(sheet.getPrintSetup().getScale()).isEqualTo((short) 55);
assertThat(sheet.getRow(4).getCell(7).getStringCellValue()).isEqualTo("Date : 17/07/2026");
assertThat(sheet.getRow(5).getCell(2).getStringCellValue()).isEqualTo("Khun Ann");
assertThat(sheet.getRow(6).getCell(2).getStringCellValue())
.isEqualTo("LIAN TAI TRAVEL (THAILAND) CO., LTD.");
assertThat(sheet.getRow(13).getCell(2).getStringCellValue()).isEqualTo("12/07/2026");
assertThat(sheet.getRow(18).getCell(7).getStringCellValue()).isEqualTo("Due Date : 22/07/2026");
assertThat(sheet.getRow(19).getCell(3).getStringCellValue()).isEqualTo("GRP-DEMO-0802");
assertThat(sheet.getRow(21).getCell(3).getStringCellValue()).isEqualTo("02/08/2026");
assertThat(sheet.getRow(22).getCell(3).getStringCellValue()).isEqualTo("05/08/2026");
assertThat(sheet.getRow(25).getCell(1).getStringCellValue()).isEqualTo("GRP-DEMO-0802");
assertThat(sheet.getRow(25).getCell(3).getStringCellValue()).isEqualTo("Deluxe Room");
assertThat(sheet.getRow(25).getCell(7).getCellFormula()).isEqualTo("E26*F26*G26");
assertThat(sheet.getRow(35).getCell(7).getCellFormula()).isEqualTo("SUM(H26:H35)/1.07");
assertThat(sheet.getRow(37).getCell(7).getCellFormula()).isEqualTo("SUM(H26:H35)");
}
}
private void writeSampleWhenRequested(byte[] workbookBytes) throws IOException {
if (!Boolean.getBoolean("reservation.invoice.writeSample")) {
return;
}
Path outputPath = Path.of("target/reservation-invoice-renderer/proforma-invoice-renderer-sample.xlsx");
Files.createDirectories(outputPath.getParent());
Files.write(outputPath, workbookBytes);
}
private ReservationInvoiceManualGenerationRequest validRequest() {
return new ReservationInvoiceManualGenerationRequest(
"HOTEL-TEST",
"MANUAL",
null,
null,
"PROFORMA_INVOICE_V1",
new ReservationInvoicePayloadRequest(
new ReservationInvoiceDocumentRequest(
LocalDate.of(2026, 7, 17),
LocalDate.of(2026, 7, 12),
LocalDate.of(2026, 7, 22)),
new ReservationInvoiceRecipientRequest(
"LIAN_TAI",
"LIAN_TAI_KHUN_ANN",
"LIAN TAI TRAVEL (THAILAND) CO., LTD.",
"Khun Ann",
"2/86 Rajpattana Road, Rajpattana, Sapansoong, Bangkok, TH, 10240",
"061-397-2675",
"op.liantaitravel@gmail.com"),
new ReservationInvoiceBookingRequest(
"GRP-DEMO-0802",
LocalDate.of(2026, 8, 2),
LocalDate.of(2026, 8, 5),
"includingBF",
new BigDecimal("1200")),
List.of(new ReservationInvoiceChargeRequest(
"GRP-DEMO-0802",
"Deluxe Room",
new BigDecimal("2"),
new BigDecimal("3000"),
new BigDecimal("3")))));
}
}