修复发票PDF单页模板版式
This commit is contained in:
@@ -225,7 +225,7 @@ SourceMessage 原文和邮件会话完整正文已迁移到登录权限体系:
|
||||
- PDF 上传到阿里云 OSS,返回 `pdf_url`、`object_key`、`pdf_file_name`、`pdf_size_bytes` 和 `duration_millis`。
|
||||
- CP2 不落库,不提供转换历史查询;如果需要自动处理邮件附件,应先进入 M008 后续持久化任务和 worker checkpoint。
|
||||
- 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 失败记录也可能保留已上传成功的 Excel / PDF object key;排查或清理 OSS 生成物时应以生成记录为主,不只看 `SUCCEEDED` 状态。
|
||||
|
||||
|
||||
@@ -328,9 +328,10 @@ server/src/main/resources/templates/reservation-invoice/proforma-invoice-v1.xlsx
|
||||
模板字段映射原则:
|
||||
|
||||
- 模板固定文案、Logo、银行信息、税号可以先保留在模板内。
|
||||
- 当前 `PROFORMA_INVOICE_V1` 使用 A4 纵向单页模板,打印区域固定为 `A1:H52`,后端渲染时会再次写入纸张、缩放和打印区域,避免 LibreOffice 转 PDF 时分页漂移。
|
||||
- 可动态变更但不属于单张 Invoice 的配置,后续迁移到酒店发票配置。
|
||||
- 用户输入字段通过后端模板填充器写入指定单元格。
|
||||
- 明细行需要支持 1 到 N 行;超过模板默认行数时,后端应复制行样式和公式。
|
||||
- 第一版明细行固定支持 1 到 10 行;超过模板默认行数时,后续 checkpoint 再做复制行样式和公式。
|
||||
- 金额公式由后端统一写入或由模板公式统一生成,不复制历史案例中不一致的公式。
|
||||
|
||||
## 9. 权限、安全和审计
|
||||
@@ -358,6 +359,7 @@ server/src/main/resources/templates/reservation-invoice/proforma-invoice-v1.xlsx
|
||||
- 已完成:新增业务接口 `POST /api/reservation/invoices/manual-generations`。
|
||||
- 已完成:新增生成记录表 `workflow_reservation_invoice_generation` 和业务审计。
|
||||
- 已完成:根据请求 payload 填充受控 Excel 模板 `templates/reservation-invoice/proforma-invoice-v1.xlsx`。
|
||||
- 已完成:模板切换为接近酒店历史样张的 A4 单页版式,后端渲染器固定 `A1:H52` 打印区域,避免生成 PDF 被拆成多页。
|
||||
- 已完成:复用平台 Excel 转 PDF Adapter 生成 PDF,并上传阿里云 OSS。
|
||||
- 已完成:返回 PDF URL、对象 Key、金额摘要和生成状态。
|
||||
- 已完成:失败记录保留已上传成功的 Excel / PDF object key;业务审计完成后才标记 `SUCCEEDED`。
|
||||
|
||||
@@ -6,8 +6,10 @@ import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
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.Sheet;
|
||||
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 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 SUBTOTAL_ROW_INDEX = 32;
|
||||
private static final int VAT_ROW_INDEX = 33;
|
||||
private static final int TOTAL_ROW_INDEX = 34;
|
||||
private static final int SUBTOTAL_ROW_INDEX = 35;
|
||||
private static final int VAT_ROW_INDEX = 36;
|
||||
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 字节。
|
||||
@@ -39,6 +45,7 @@ public class ReservationInvoiceExcelTemplateRenderer {
|
||||
fillHeader(sheet, request);
|
||||
fillChargeRows(sheet, request.invoicePayload().charges());
|
||||
fillTotalFormulas(sheet);
|
||||
applyPrintSettings(workbook, sheet);
|
||||
workbook.setForceFormulaRecalculation(true);
|
||||
workbook.write(outputStream);
|
||||
return outputStream.toByteArray();
|
||||
@@ -67,21 +74,22 @@ public class ReservationInvoiceExcelTemplateRenderer {
|
||||
var booking = payload.booking();
|
||||
var firstCharge = payload.charges().get(0);
|
||||
|
||||
setText(sheet, "G5", "Date : " + DISPLAY_DATE_FORMATTER.format(document.invoiceDate()));
|
||||
setText(sheet, "B6", recipient.attention());
|
||||
setText(sheet, "B7", recipient.company());
|
||||
setText(sheet, "B8", recipient.address());
|
||||
setText(sheet, "B12", recipient.telephone());
|
||||
setText(sheet, "B13", recipient.email());
|
||||
setText(sheet, "B14", DISPLAY_DATE_FORMATTER.format(document.bookingDate()));
|
||||
setText(sheet, "G17", "Due Date : " + DISPLAY_DATE_FORMATTER.format(document.dueDate()));
|
||||
setText(sheet, "B18", booking.groupName());
|
||||
setText(sheet, "B19", DISPLAY_DATE_FORMATTER.format(booking.arrivalDate()));
|
||||
setText(sheet, "B20", DISPLAY_DATE_FORMATTER.format(booking.departureDate()));
|
||||
setText(sheet, "G18", "Room Rate : " + formatDecimal(firstCharge.rate())
|
||||
setText(sheet, "H5", "Date : " + DISPLAY_DATE_FORMATTER.format(document.invoiceDate()));
|
||||
setText(sheet, "C6", recipient.attention());
|
||||
setText(sheet, "C7", recipient.company());
|
||||
fillAddress(sheet, recipient.address());
|
||||
setText(sheet, "C12", recipient.telephone());
|
||||
setText(sheet, "C13", recipient.email());
|
||||
setText(sheet, "C14", DISPLAY_DATE_FORMATTER.format(document.bookingDate()));
|
||||
setText(sheet, "H19", "Due Date : " + DISPLAY_DATE_FORMATTER.format(document.dueDate()));
|
||||
setText(sheet, "D20", booking.groupName());
|
||||
setText(sheet, "D22", DISPLAY_DATE_FORMATTER.format(booking.arrivalDate()));
|
||||
setText(sheet, "D23", DISPLAY_DATE_FORMATTER.format(booking.departureDate()));
|
||||
setText(sheet, "H20", "Room Rate : " + formatDecimal(firstCharge.rate())
|
||||
+ " /rm/n" + roomRateNoteSuffix(booking.roomRateNote()));
|
||||
setText(sheet, "G19", "No.of room(s) : " + formatDecimal(totalQuantity(payload.charges())));
|
||||
setText(sheet, "G20", "No.of Night(s) : " + formatDecimal(firstCharge.nights()));
|
||||
setText(sheet, "H21", "Extra bed : " + formatDecimal(booking.extraBedRate()));
|
||||
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);
|
||||
int rowIndex = CHARGE_START_ROW_INDEX + index;
|
||||
Row row = row(sheet, rowIndex);
|
||||
setCellText(row, 0, charge.description() + "\n" + charge.roomType());
|
||||
setCellNumber(row, 3, charge.quantity());
|
||||
setCellNumber(row, 4, charge.rate());
|
||||
setCellNumber(row, 5, charge.nights());
|
||||
setCellFormula(row, 6, "D" + (rowIndex + 1) + "*E" + (rowIndex + 1) + "*F" + (rowIndex + 1));
|
||||
setCellText(row, 1, charge.description());
|
||||
setCellText(row, 3, charge.roomType());
|
||||
setCellNumber(row, 4, charge.quantity());
|
||||
setCellNumber(row, 5, charge.rate());
|
||||
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 vatRow = row(sheet, VAT_ROW_INDEX);
|
||||
Row totalRow = row(sheet, TOTAL_ROW_INDEX);
|
||||
setCellFormula(subtotalRow, 6, "SUM(G23:G32)/1.07");
|
||||
setCellFormula(vatRow, 6, "SUM(G23:G32)-G33");
|
||||
setCellFormula(totalRow, 6, "SUM(G23:G32)");
|
||||
setCellFormula(subtotalRow, 7, "SUM(H26:H35)/1.07");
|
||||
setCellFormula(vatRow, 7, "SUM(H26:H35)-H36");
|
||||
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) {
|
||||
for (int index = 0; index < DEFAULT_CHARGE_ROW_COUNT; 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.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) {
|
||||
Row row = sheet.getRow(rowIndex);
|
||||
return row == null ? sheet.createRow(rowIndex) : row;
|
||||
|
||||
Binary file not shown.
@@ -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")))));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user