让成交卡等报价文件真正发出后再出现,并收紧 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);
}
}
}
}