diff --git a/docs/project/go-live-notes.md b/docs/project/go-live-notes.md index 9f485a7..4f7893f 100644 --- a/docs/project/go-live-notes.md +++ b/docs/project/go-live-notes.md @@ -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` 状态。 diff --git a/docs/project/requirements/M009-manual-invoice-generation-v1.md b/docs/project/requirements/M009-manual-invoice-generation-v1.md index f02678d..c287d5d 100644 --- a/docs/project/requirements/M009-manual-invoice-generation-v1.md +++ b/docs/project/requirements/M009-manual-invoice-generation-v1.md @@ -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`。 diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationInvoiceExcelTemplateRenderer.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationInvoiceExcelTemplateRenderer.java index c6f9ec2..c39db84 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationInvoiceExcelTemplateRenderer.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationInvoiceExcelTemplateRenderer.java @@ -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 addressLines = splitAddressLines(address); + setText(sheet, "C8", addressLines.get(0)); + setText(sheet, "C9", addressLines.get(1)); + setText(sheet, "C10", addressLines.get(2)); + } + + /** + * 优先按换行拆分;无换行时按逗号做温和换行,避免单行地址在 PDF 中过长。 + */ + private List 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 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 firstThreeLines(List lines) { + List 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; diff --git a/server/src/main/resources/templates/reservation-invoice/proforma-invoice-v1.xlsx b/server/src/main/resources/templates/reservation-invoice/proforma-invoice-v1.xlsx index 44000f8..44fba14 100644 Binary files a/server/src/main/resources/templates/reservation-invoice/proforma-invoice-v1.xlsx and b/server/src/main/resources/templates/reservation-invoice/proforma-invoice-v1.xlsx differ diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationInvoiceExcelTemplateRendererTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationInvoiceExcelTemplateRendererTest.java new file mode 100644 index 0000000..b8bed8f --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationInvoiceExcelTemplateRendererTest.java @@ -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"))))); + } +}