让成交卡等报价文件真正发出后再出现,并收紧 PDF 顶部留白,避免抬头被裁或空一截。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-15 10:36:55 +08:00
co-authored by Cursor
parent cb3bf5238b
commit a7a8487eb3
22 changed files with 1630 additions and 126 deletions
@@ -191,6 +191,11 @@ public final class QuoteTemplateExcelSupport {
writeFeeRows(wb, mapping, facts, yellow);
// 后台常未配 fee_rows:按「空运费」等名称写入金额列,SUM 才能加算
writeFeeAmountsByName(wb, facts, yellow);
// 模板「预估总价」是 SUM。POI 写完数字不会重算,缓存仍是 0。
// 企微/WPS 预览常不重算,销售会看到汇总为 0。这里把公式缓存刷成当前值。
refreshFormulas(wb);
// 转 PDF 前把纸张改成 A4 居中,避免顶部裁字、整页偏左。
applyQuotePrintLayout(wb);
Files.createDirectories(targetXlsx.getParent());
try (OutputStream out = Files.newOutputStream(targetXlsx)) {
wb.write(out);
@@ -252,7 +257,62 @@ public final class QuoteTemplateExcelSupport {
if (cell == null) {
cell = row.createCell(col);
}
cell.setCellValue(value == null ? "" : value);
writeNumericOrText(cell, value);
}
/**
* 金额/数量/单价写成数字,SUM 才能加算;名称等仍写文本。
*/
private static void writeNumericOrText(Cell cell, String value) {
if (value == null) {
cell.setCellValue("");
return;
}
String norm = stripMoneyPrefix(value);
if (StringUtils.isBlank(norm) || "-".equals(norm)) {
cell.setCellValue(value);
return;
}
try {
cell.setCellValue(Double.parseDouble(norm));
} catch (NumberFormatException e) {
cell.setCellValue(value);
}
}
/**
* 回填后重算公式,把 SUM 等缓存从 0 刷成真实合计。
* 失败不抛:出单仍要发出,只记日志。
*/
static void refreshFormulas(Workbook wb) {
if (wb == null) {
return;
}
FormulaEvaluator ev = wb.getCreationHelper().createFormulaEvaluator();
for (int s = 0; s < wb.getNumberOfSheets(); s++) {
Sheet sheet = wb.getSheetAt(s);
if (sheet == null) {
continue;
}
for (Row row : sheet) {
if (row == null) {
continue;
}
for (Cell cell : row) {
if (cell == null || cell.getCellType() != CellType.FORMULA) {
continue;
}
try {
ev.evaluateFormulaCell(cell);
} catch (Exception e) {
log.warn("报价公式未重算 {}!{}: {}",
sheet.getSheetName(),
new CellReference(cell).formatAsString(),
e.getMessage());
}
}
}
}
}
private static void clearCell(Workbook wb, String cellRef) {
@@ -493,6 +553,23 @@ public final class QuoteTemplateExcelSupport {
return fuzzy;
}
/**
* 费用明细表头原字,回填系统字段时不得覆盖。
*/
static boolean isFeeTableHeader(String text) {
if (StringUtils.isBlank(text)) {
return false;
}
String t = text.trim();
return "项目".equals(t) || "费用名称".equals(t) || "费用名".equals(t)
|| "单位".equals(t) || "單位".equals(t)
|| "币别".equals(t) || "币種".equals(t) || "币种".equals(t)
|| "单价".equals(t) || "單價".equals(t)
|| "数量".equals(t) || "數量".equals(t)
|| "金额".equals(t) || "金額".equals(t)
|| "备注".equals(t) || "備註".equals(t);
}
private static String readCellText(Cell cell) {
if (cell == null) {
return "";
@@ -585,6 +662,10 @@ public final class QuoteTemplateExcelSupport {
String text = value == null ? "" : value;
// 标签格「报价编号:」只换冒号后的值,避免整格被工单号顶掉看起来像残缺
String existing = readCellText(cell);
// 「币别」右侧常被误映到「单价」表头,不能把 CNY 盖掉单价二字
if (isFeeTableHeader(existing)) {
return;
}
int colon = Math.max(existing.indexOf(':'), existing.indexOf(':'));
if (colon >= 0 && colon == existing.length() - 1) {
text = existing + text;
@@ -599,13 +680,78 @@ public final class QuoteTemplateExcelSupport {
copy.setDataFormat(wb.createDataFormat().getFormat("@"));
cell.setCellStyle(copy);
}
// 工单号、日期略加宽,避免列太窄只露出半截
if (dest[1] >= 0 && dest[1] < 20 && text.length() >= 8) {
org.apache.poi.ss.usermodel.Sheet sh = sheet;
int col = dest[1];
double width = sh.getColumnWidth(col) / 256.0;
if (width > 0 && width < text.length() + 2) {
sh.setColumnWidth(col, (int) Math.min(40, text.length() + 4) * 256);
// 不再临时加宽列:会把打印缩放打乱,转 PDF 后整页偏左。
}
/**
* 报价单打印版心:A4、水平居中、按页宽缩放。
* <p>
* 空运模板常是 62% 缩放且不居中。LibreOffice 按这个出 PDF,
* 公司名会被裁掉上半截,正文整页靠左。出单时统一改过来。
* 只改打印属性,不改单元格里的业务数字。
*/
static void applyQuotePrintLayout(Workbook wb) {
if (wb == null) {
return;
}
for (int s = 0; s < wb.getNumberOfSheets(); s++) {
Sheet sheet = wb.getSheetAt(s);
if (sheet == null) {
continue;
}
sheet.setMargin(Sheet.LeftMargin, 0.5);
sheet.setMargin(Sheet.RightMargin, 0.5);
// 上边距适中:太小企微会裁公司名,太大纸顶空一截
sheet.setMargin(Sheet.TopMargin, 0.75);
sheet.setMargin(Sheet.BottomMargin, 0.6);
sheet.setMargin(Sheet.HeaderMargin, 0.1);
sheet.setMargin(Sheet.FooterMargin, 0.2);
sheet.setHorizontallyCenter(true);
sheet.setVerticallyCenter(false);
sheet.setAutobreaks(true);
sheet.setFitToPage(true);
PrintSetup ps = sheet.getPrintSetup();
ps.setPaperSize(PrintSetup.A4_PAPERSIZE);
ps.setLandscape(false);
ps.setFitWidth((short) 1);
ps.setFitHeight((short) 0);
ensureHeaderRowHeight(sheet);
}
}
/**
* 抬头合并格(公司中英文)行高太矮时加高。
* 现网空运模板 A1:H2 合计只有 42 磅,两行字会裁顶。
*/
static void ensureHeaderRowHeight(Sheet sheet) {
if (sheet == null) {
return;
}
for (int i = 0; i < sheet.getNumMergedRegions(); i++) {
org.apache.poi.ss.util.CellRangeAddress range = sheet.getMergedRegion(i);
if (range.getFirstRow() != 0 || range.getLastRow() < 1) {
continue;
}
Row first = sheet.getRow(range.getFirstRow());
Cell cell = first == null ? null : first.getCell(range.getFirstColumn());
String text = readCellText(cell);
if (!text.contains("公司") && text.toLowerCase().indexOf("company") < 0) {
continue;
}
// 模板末尾常带空行,字会被顶到合并格上沿,转 PDF 再被裁一刀
if (cell != null && StringUtils.isNotBlank(text)) {
cell.setCellValue(text.replaceAll("[\\r\\n]+$", ""));
}
int rows = range.getLastRow() - range.getFirstRow() + 1;
float each = Math.max(32f, 64f / rows);
for (int r = range.getFirstRow(); r <= range.getLastRow(); r++) {
Row row = sheet.getRow(r);
if (row == null) {
row = sheet.createRow(r);
}
if (row.getHeightInPoints() < each) {
row.setHeightInPoints(each);
}
}
}
}
@@ -2,6 +2,8 @@ package org.jeecg.modules.inquiry.template;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
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;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
@@ -12,6 +14,8 @@ import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import org.apache.poi.ss.usermodel.CellType;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -153,4 +157,91 @@ public class QuoteTemplateFillTest {
Files.deleteIfExists(out);
}
}
/**
* 空运模板「预估总价」是 SUM。回填后必须把公式缓存刷成合计,
* 否则企微/WPS 预览仍显示模板里的 0。
*/
@Test
public void refreshSumCacheAfterWritingFeeAmount() throws Exception {
Path src = Files.createTempFile("quote-sum-src", ".xlsx");
Path out = Files.createTempFile("quote-sum-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("Sheet2");
org.apache.poi.ss.usermodel.Row head = sheet.createRow(15);
head.createCell(1).setCellValue("费用名称");
head.createCell(2).setCellValue("单位");
head.createCell(3).setCellValue("币别");
head.createCell(4).setCellValue("单价");
head.createCell(5).setCellValue("数量");
head.createCell(6).setCellValue("金额");
head.createCell(7).setCellValue("备注");
sheet.createRow(16).createCell(1).setCellValue("空运费");
org.apache.poi.ss.usermodel.Row total = sheet.createRow(22);
total.createCell(0).setCellValue("预估总价");
total.createCell(6).setCellFormula("SUM(G17:G22)");
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject mapping = new JSONObject();
JSONObject fields = new JSONObject();
// 测服空运模板常把「币别」右侧误映到单价表头,回填时不能盖掉「单价」
fields.put("currency", "Sheet2!E16");
mapping.put("fields", fields);
JSONObject facts = new JSONObject(true);
facts.put("currency", "CNY");
facts.put("total_amount", "14669.60");
facts.put("fee_rows", QuoteTemplateExcelSupport.parseFeeRows(
"[{\"name\":\"空运费\",\"amount\":\"14669.60\",\"currency\":\"CNY\","
+ "\"unit\":\"KG\",\"quantity\":\"1333.6\",\"unit_price\":\"11.0\"}]"));
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
try (Workbook formulas = new XSSFWorkbook(Files.newInputStream(out))) {
org.apache.poi.ss.usermodel.Cell sum = formulas.getSheetAt(0).getRow(22).getCell(6);
assertEquals(CellType.FORMULA, sum.getCellType());
assertEquals("SUM(G17:G22)", sum.getCellFormula());
// 公式格的数字缓存 = 企微预览看到的值;没刷缓存会是 0
assertEquals(14669.60, sum.getNumericCellValue(), 0.01);
assertEquals("单价", formulas.getSheetAt(0).getRow(15).getCell(4).getStringCellValue());
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
/**
* 出单后必须 A4 水平居中,抬头行高够放下中英文公司名。
* 否则转 PDF 会顶部裁字、整页偏左。
*/
@Test
public void applyPrintLayoutCentersA4AndRaisesHeader() throws Exception {
Path src = Files.createTempFile("quote-layout-src", ".xlsx");
Path out = Files.createTempFile("quote-layout-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("Sheet2");
sheet.setHorizontallyCenter(false);
sheet.getPrintSetup().setScale((short) 62);
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(0, 1, 0, 7));
Row r0 = sheet.createRow(0);
r0.setHeightInPoints(21f);
r0.createCell(0).setCellValue("深圳市友通达国际货运代理有限公司\nShenzhen Youtongda International Freight Forwarding Co.LTD");
sheet.createRow(1).setHeightInPoints(21f);
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
QuoteTemplateExcelSupport.fillWorkbook(src, out, new JSONObject(), new JSONObject());
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
assertTrue(sheet.getHorizontallyCenter());
assertEquals(PrintSetup.A4_PAPERSIZE, sheet.getPrintSetup().getPaperSize());
assertEquals(1, sheet.getPrintSetup().getFitWidth());
assertEquals(0.75, sheet.getMargin(Sheet.TopMargin), 0.01);
assertTrue(sheet.getRow(0).getHeightInPoints() >= 32f);
assertTrue(sheet.getRow(1).getHeightInPoints() >= 32f);
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
}