一、开发环境

  • .NET Core 8.0

  • NPOI 2.8.0

  • Visaul Studio 2022

  • 控制台项目

二、需求介绍

  • 项目希望导出如下格式报表

Snipaste_2026-07-24_11-32-16

  • 制作模板格式如下

Snipaste_2026-07-24_11-32-26

三、个人思考

  • 直接计算数据行+标题行+头部行总行数整体下移,然后写个拷贝区域样式的工具方法拷贝头部到指定位置,随后填充数据。

四、代码实现

  • NPOI的帮助类
using NPOI.SS.UserModel;
using NPOI.XSSF.UserModel;

namespace ExcelReportDemo.Excel
{
    /// <summary>
    /// NPOI 通用工具类(静态方法,无状态)
    /// 仅提供原子操作,不包含业务逻辑
    /// </summary>
    public static class NPOIHelper
    {
        #region Workbook 加载

        /// <summary>
        /// 从文件路径加载工作簿(仅支持 .xlsx)
        /// </summary>
        public static IWorkbook LoadWorkbook(string filePath)
        {
            if (string.IsNullOrEmpty(filePath))
                throw new ArgumentNullException(nameof(filePath));

            if (!File.Exists(filePath))
                throw new FileNotFoundException($"文件不存在: {filePath}");

            using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read);
            return LoadWorkbook(fs);
        }

        /// <summary>
        /// 从流加载工作簿(仅支持 .xlsx)
        /// </summary>
        public static IWorkbook LoadWorkbook(Stream stream)
        {
            if (stream == null)
                throw new ArgumentNullException(nameof(stream));

            stream.Position = 0;
            return new XSSFWorkbook(stream);
        }

        #endregion

        #region 行操作

        /// <summary>
        /// 获取或创建行
        /// </summary>
        public static IRow GetOrCreateRow(ISheet sheet, int rowIndex)
        {
            if (sheet == null)
                throw new ArgumentNullException(nameof(sheet));

            if (rowIndex < 0)
                throw new ArgumentException("行索引不能为负数", nameof(rowIndex));

            var row = sheet.GetRow(rowIndex);
            if (row == null)
            {
                row = sheet.CreateRow(rowIndex);
            }
            return row;
        }
        #endregion

        #region 单元格操作

        /// <summary>
        /// 获取或创建单元格
        /// </summary>
        public static ICell GetOrCreateCell(IRow row, int columnIndex)
        {
            if (row == null)
                throw new ArgumentNullException(nameof(row));

            if (columnIndex < 0)
                throw new ArgumentException("列索引不能为负数", nameof(columnIndex));

            var cell = row.GetCell(columnIndex);
            if (cell == null)
            {
                cell = row.CreateCell(columnIndex);
            }
            return cell;
        }

        /// <summary>
        /// 获取或创建单元格(Sheet + 行列索引)
        /// </summary>
        public static ICell GetOrCreateCell(ISheet sheet, int rowIndex, int columnIndex)
        {
            var row = GetOrCreateRow(sheet, rowIndex);
            return GetOrCreateCell(row, columnIndex);
        }
        #endregion

        #region 单元格写入
        /// <summary>
        /// 智能设置单元格值(自动识别类型)
        /// </summary>
        public static void SetCellValue(ICell cell, object? value)
        {
            if (cell == null)
                throw new ArgumentNullException(nameof(cell));

            if (value == null || value == DBNull.Value)
            {
                cell.SetBlank();
                return;
            }

            switch (value)
            {
                case string str:
                    cell.SetCellValue(str);
                    break;
                case int i:
                    cell.SetCellValue(i);
                    break;
                case long l:
                    cell.SetCellValue(l);
                    break;
                case double d:
                    cell.SetCellValue(d);
                    break;
                case float f:
                    cell.SetCellValue(f);
                    break;
                case decimal d:
                    cell.SetCellValue((double)d);
                    break;
                case bool b:
                    cell.SetCellValue(b);
                    break;
                case DateTime dt:
                    cell.SetCellValue(dt);
                    break;
                case DateTimeOffset dto:
                    cell.SetCellValue(dto.DateTime);
                    break;
                case Enum e:
                    cell.SetCellValue(e.ToString());
                    break;
                default:
                    cell.SetCellValue(value.ToString());
                    break;
            }
        }
        #endregion

        #region 保存

        /// <summary>
        /// 保存工作簿到文件
        /// </summary>
        public static void SaveWorkbook(IWorkbook workbook, string filePath)
        {
            if (workbook == null)
                throw new ArgumentNullException(nameof(workbook));

            if (string.IsNullOrEmpty(filePath))
                throw new ArgumentNullException(nameof(filePath));

            // 自动补全 .xlsx 扩展名
            if (!filePath.EndsWith(".xlsx", StringComparison.OrdinalIgnoreCase) &&
                !filePath.EndsWith(".xlsm", StringComparison.OrdinalIgnoreCase))
            {
                filePath += ".xlsx";
            }

            // 确保目录存在
            string? directory = Path.GetDirectoryName(filePath);
            if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
            {
                Directory.CreateDirectory(directory);
            }

            using var fs = new FileStream(filePath, FileMode.Create, FileAccess.Write);
            workbook.Write(fs);
        }
        #endregion
    }
}
  • 区域拷贝帮助类

注意: 这里示例Excel的表头比较简单,帮助类不能覆盖全部需求,如果需要其他特殊复制需自行扩展。

using NPOI.SS.UserModel;
using NPOI.SS.Util;

namespace ExcelReportDemo.Excel
{
    /// <summary>
    /// Excel 行复制器
    /// 支持复制行数据、样式、合并区域
    /// </summary>
    public class ExcelRowCopier
    {
        /// <summary>
        /// 初始化行复制器
        /// </summary>
        public ExcelRowCopier()
        {
        }

        #region 公开方法

        /// <summary>
        /// 复制行范围到指定位置
        /// </summary>
        /// <param name="sourceSheet">源工作表</param>
        /// <param name="targetSheet">目标工作表</param>
        /// <param name="startSourceRow">源起始行索引(从0开始)</param>
        /// <param name="endSourceRow">源结束行索引(从0开始)</param>
        /// <param name="startTargetRow">目标起始行索引(从0开始)</param>
        /// <param name="includeMergedRegions">是否包含合并区域,默认 true</param>
        /// <exception cref="ArgumentNullException">源或目标 Sheet 为 null</exception>
        /// <exception cref="ArgumentException">行索引参数无效</exception>
        public static void CopyRowRange(
            ISheet sourceSheet,
            ISheet targetSheet,
            int startSourceRow,
            int endSourceRow,
            int startTargetRow,
            bool includeMergedRegions = true)
        {
            ValidateParameters(sourceSheet, targetSheet, startSourceRow, endSourceRow, startTargetRow);

            int rowOffset = startTargetRow - startSourceRow;

            // 1. 复制行数据
            CopyRows(sourceSheet, targetSheet, startSourceRow, endSourceRow, rowOffset);

            // 2. 复制合并区域
            if (includeMergedRegions)
            {
                CopyMergedRegions(sourceSheet, targetSheet, startSourceRow, endSourceRow, rowOffset);
            }
        }

        #endregion

        #region 核心复制方法

        /// <summary>
        /// 复制多行数据
        /// </summary>
        private static void CopyRows(ISheet sourceSheet, ISheet targetSheet, int startSourceRow, int endSourceRow, int rowOffset)
        {
            for (int rowIndex = startSourceRow; rowIndex <= endSourceRow; rowIndex++)
            {
                IRow sourceRow = sourceSheet.GetRow(rowIndex);
                if (sourceRow == null)
                {
                    continue;
                }

                int targetRowIndex = rowIndex + rowOffset;
                IRow targetRow = targetSheet.GetRow(targetRowIndex) ?? targetSheet.CreateRow(targetRowIndex);

                CopyRow(sourceRow, targetRow);
            }
        }

        /// <summary>
        /// 复制单行(值 + 样式)
        /// </summary>
        private static void CopyRow(IRow sourceRow, IRow targetRow)
        {
            // 复制行属性
            targetRow.Height = sourceRow.Height;
            targetRow.ZeroHeight = sourceRow.ZeroHeight;

            targetRow.RowStyle = sourceRow.RowStyle;

            // 遍历所有单元格
            for (int columnIndex = 0; columnIndex < sourceRow.LastCellNum; columnIndex++)
            {
                ICell sourceCell = sourceRow.GetCell(columnIndex);
                if (sourceCell == null)
                {
                    continue;
                }

                ICell targetCell = targetRow.GetCell(columnIndex) ?? targetRow.CreateCell(columnIndex);
                CopyCellValue(sourceCell, targetCell);
                CopyCellStyle(sourceCell, targetCell);
            }
        }

        /// <summary>
        /// 复制单元格值(支持常用数据类型,处理 null 值)
        /// </summary>
        private static void CopyCellValue(ICell sourceCell, ICell targetCell)
        {
            try
            {
                switch (sourceCell.CellType)
                {
                    case CellType.String:
                        targetCell.SetCellValue(sourceCell.StringCellValue ?? string.Empty);
                        break;

                    case CellType.Numeric:
                        if (DateUtil.IsCellDateFormatted(sourceCell))
                        {
                            // 处理可空日期类型
                            DateTime? dateValue = sourceCell.DateCellValue;
                            if (dateValue.HasValue && dateValue.Value != DateTime.MinValue)
                            {
                                targetCell.SetCellValue(dateValue.Value);
                            }
                            else
                            {
                                targetCell.SetBlank();
                            }
                        }
                        else
                        {
                            targetCell.SetCellValue(sourceCell.NumericCellValue);
                        }
                        break;

                    case CellType.Boolean:
                        targetCell.SetCellValue(sourceCell.BooleanCellValue);
                        break;

                    case CellType.Formula:
                        // 复制公式(注意:公式的结果不会复制,只复制公式本身)
                        targetCell.SetCellFormula(sourceCell.CellFormula);
                        break;

                    case CellType.Blank:
                        targetCell.SetBlank();
                        break;

                    case CellType.Error:
                        targetCell.SetCellErrorValue(sourceCell.ErrorCellValue);
                        break;

                    default:
                        // 未知类型,设置为空白
                        targetCell.SetBlank();
                        break;
                }
            }
            catch
            {
                // 如果复制失败,设置为空白,避免整个复制流程中断
                targetCell.SetBlank();
            }
        }

        private static void CopyCellStyle(ICell sourceCell, ICell targetCell)
        {
            targetCell.CellStyle = sourceCell.CellStyle;
        }

        #endregion

        #region 合并区域复制

        /// <summary>
        /// 复制合并区域
        /// </summary>
        private static void CopyMergedRegions(
            ISheet sourceSheet,
            ISheet targetSheet,
            int startSourceRow,
            int endSourceRow,
            int rowOffset)
        {
            int mergedRegionCount = sourceSheet.NumMergedRegions;

            for (int regionIndex = 0; regionIndex < mergedRegionCount; regionIndex++)
            {
                CellRangeAddress region = sourceSheet.GetMergedRegion(regionIndex);

                // 判断合并区域是否在复制范围内
                if (!IsRegionInRange(region, startSourceRow, endSourceRow))
                {
                    continue;
                }

                CellRangeAddress newRegion = new(
                    region.FirstRow + rowOffset,
                    region.LastRow + rowOffset,
                    region.FirstColumn,
                    region.LastColumn
                );

                targetSheet.AddMergedRegion(newRegion);
            }
        }

        /// <summary>
        /// 检查合并区域是否在指定行范围内(完全包含)
        /// </summary>
        private static bool IsRegionInRange(CellRangeAddress region, int startRow, int endRow)
        {
            // 完全在范围之上
            if (region.LastRow < startRow)
            {
                return false;
            }

            // 完全在范围之下
            if (region.FirstRow > endRow)
            {
                return false;
            }

            // 只处理完全在范围内的,部分在范围内的跳过
            return region.FirstRow >= startRow && region.LastRow <= endRow;
        }

        #endregion

        #region 参数验证

        /// <summary>
        /// 验证输入参数
        /// </summary>
        private static void ValidateParameters(ISheet sourceSheet, ISheet targetSheet, int startSourceRow, int endSourceRow, int startTargetRow)
        {
            ArgumentNullException.ThrowIfNull(sourceSheet);
            ArgumentNullException.ThrowIfNull(targetSheet);

            if (startSourceRow < 0)
                throw new ArgumentException("起始行索引不能为负数", nameof(startSourceRow));

            if (endSourceRow < startSourceRow)
                throw new ArgumentException("结束行索引不能小于起始行索引", nameof(endSourceRow));

            if (startTargetRow < 0)
                throw new ArgumentException("目标起始行索引不能为负数", nameof(startTargetRow));

            // 检查源行是否存在
            var firstSourceRow = sourceSheet.GetRow(startSourceRow);
            ArgumentNullException.ThrowIfNull(firstSourceRow, nameof(startSourceRow));
        }

        #endregion
    }
}
  • 示例实体
namespace ExcelReportDemo.Models
{
    public class ReportEntity
    {
        public int ProductTypeOrder { get; set; }

        public string YearMonth { get; set; } = string.Empty;

        public string ProductTypeNo { get; set; } = string.Empty;

        public string ProductTypeName { get; set; } = string.Empty;

        public string CustTypeNo { get; set; } = string.Empty;

        public string CustTypeName { get; set; } = string.Empty;

        public int CustTypeOrder { get; set; }

        // ========== 总量 ==========
        public int TotalQty { get; set; }
        public int StandardQty { get; set; }
        public decimal StandardRate { get; set; }
        public int CustomizedQty { get; set; }
        public decimal CustomizedRate { get; set; }
        // ========== 线上汇总 ==========
        public int OnlineQty { get; set; }
        public decimal OnlineRate { get; set; }
        // ========== 线上-淘宝 ==========
        public int TBStandardQty { get; set; }
        public decimal TBStandardRate { get; set; }
        public int TBCustomizedQty { get; set; }
        public decimal TBCustomizedRate { get; set; }

        // ========== 线上-拼多多 ==========
        public int PDDStandardQty { get; set; }
        public decimal PDDStandardRate { get; set; }
        public int PDDCustomizedQty { get; set; }
        public decimal PDDCustomizedRate { get; set; }
        // ========== 线下汇总 ==========
        public int OfflineQty { get; set; }
        public decimal OfflineRate { get; set; }
        // ========== 线下-门店 ==========
        public int StoreStandardQty { get; set; }
        public decimal StoreStandardRate { get; set; }
        public int StoreCustomizedQty { get; set; }
        public decimal StoreCustomizedRate { get; set; }

        // ========== 线下-工厂 ==========
        public int FactoryStandardQty { get; set; }
        public decimal FactoryStandardRate { get; set; }
        public int FactoryCustomizedQty { get; set; }
        public decimal FactoryCustomizedRate { get; set; }
    }

    public record ProductTypeGroupKey(string ProductTypeNo, string ProductTypeName, int ProductTypeOrder);

    public record CustomerTypeGroupKey(string CustTypeNo, string CustTypeName, int CustTypeOrder);

    /// <summary>
    /// 产品类型分组
    /// </summary>
    public class ProductTypeGroup
    {
        public string ProductTypeNo { get; set; } = string.Empty;
        public string ProductTypeName { get; set; } = string.Empty;
        public int ProductTypeOrder { get; set; }
        public List<ReportEntity> Records { get; set; } = [];
    }

    public class CustomerTypeGroup
    {
        public string CustTypeNo { get; set; } = string.Empty;
        public string CustTypeName { get; set; } = string.Empty;
        public int CustTypeOrder { get; set; }
        public List<ReportEntity> Records { get; set; } = [];
    }
}
  • 属性映射器
using ExcelReportDemo.Models;
using System.Linq.Expressions;
using System.Reflection;
namespace ExcelReportDemo.Excel
{
    /// <summary>
    /// Excel 列映射配置
    /// </summary>
    public static class ReportColumnMapping
    {
        /// <summary>
        /// 列号 → (字段名, 表头名称)
        /// </summary>
    public static readonly SortedDictionary<int, (string Field, string Header)> Columns =
        new()
        {
            [0] = (nameof(ReportEntity.YearMonth), "统计年月"),
            [1] = (nameof(ReportEntity.TotalQty), "订单总数"),
            [2] = (nameof(ReportEntity.StandardQty), "标准订单数"),
            [3] = (nameof(ReportEntity.StandardRate), "标准订单占比(%)"),
            [4] = (nameof(ReportEntity.CustomizedQty), "定制订单数"),
            [5] = (nameof(ReportEntity.CustomizedRate), "定制订单占比(%)"),
            [6] = (nameof(ReportEntity.OnlineQty), "线上订单数"),
            [7] = (nameof(ReportEntity.OnlineRate), "线上订单占比(%)"),
            [8] = (nameof(ReportEntity.TBStandardQty), "淘宝-标准订单数"),
            [9] = (nameof(ReportEntity.TBStandardRate), "淘宝-标准订单占比(%)"),
            [10] = (nameof(ReportEntity.TBCustomizedQty), "淘宝-定制订单数"),
            [11] = (nameof(ReportEntity.TBCustomizedRate), "淘宝-定制订单占比(%)"),
            [12] = (nameof(ReportEntity.PDDStandardQty), "拼多多-标准订单数"),
            [13] = (nameof(ReportEntity.PDDStandardRate), "拼多多-标准订单占比(%)"),
            [14] = (nameof(ReportEntity.PDDCustomizedQty), "拼多多-定制订单数"),
            [15] = (nameof(ReportEntity.PDDCustomizedRate), "拼多多-定制订单占比(%)"),
            [16] = (nameof(ReportEntity.OfflineQty), "线下订单数"),
            [17] = (nameof(ReportEntity.OfflineRate), "线下订单占比(%)"),
            [18] = (nameof(ReportEntity.StoreStandardQty), "门店-标准订单数"),
            [19] = (nameof(ReportEntity.StoreStandardRate), "门店-标准订单占比(%)"),
            [20] = (nameof(ReportEntity.StoreCustomizedQty), "门店-定制订单数"),
            [21] = (nameof(ReportEntity.StoreCustomizedRate), "门店-定制订单占比(%)"),
            [22] = (nameof(ReportEntity.FactoryStandardQty), "工厂-标准订单数"),
            [23] = (nameof(ReportEntity.FactoryStandardRate), "工厂-标准订单占比(%)"),
            [24] = (nameof(ReportEntity.FactoryCustomizedQty), "工厂-定制订单数"),
            [25] = (nameof(ReportEntity.FactoryCustomizedRate), "工厂-定制订单占比(%)"),
        };


        /// <summary>
        /// 字段名 → 编译后的属性访问器缓存
        /// </summary>
        private static readonly Dictionary<string, Func<ReportEntity, object?>> _accessorCache;

        static ReportColumnMapping()
        {
            _accessorCache = typeof(ReportEntity)
                .GetProperties()
                .ToDictionary(p => p.Name, CompileGetter);
        }

        /// <summary>
        /// 根据字段名获取编译后的访问器
        /// </summary>
        public static Func<ReportEntity, object?>? GetAccessor(string fieldName)
        {
            _accessorCache.TryGetValue(fieldName, out var accessor);
            return accessor;
        }

        private static Func<ReportEntity, object?> CompileGetter(PropertyInfo property)
        {
            var param = Expression.Parameter(typeof(ReportEntity), "e");
            var cast = Expression.Convert(Expression.Property(param, property), typeof(object));
            return Expression.Lambda<Func<ReportEntity, object?>>(cast, param).Compile();
        }
    }
}
  • 主流程
using ExcelReportDemo.Excel;
using ExcelReportDemo.Models;
using NPOI.SS.UserModel;

try
{
    // 1. 定义路径
    var projectRoot = Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"..\..\..\"));
    var sourceTemplate = Path.Combine(projectRoot, "Template", "OrderRatioATemplate.xlsx");
    var targetTemplate = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Template", "OrderRatioATemplate.xlsx");
    var exportDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Reports");

    // 2. 准备模板和输出目录
    var targetTemplateDir = Path.GetDirectoryName(targetTemplate);
    if (string.IsNullOrWhiteSpace(targetTemplateDir)) return;

    Directory.CreateDirectory(targetTemplateDir);
    if (!File.Exists(targetTemplate) || File.GetLastWriteTime(sourceTemplate) > File.GetLastWriteTime(targetTemplate))
    {
        File.Copy(sourceTemplate, targetTemplate, true);
        Console.WriteLine($"模板已复制到: {targetTemplate}");
    }
    Directory.CreateDirectory(exportDir);

    // 3. 模拟取数
    List<ReportEntity> data =
    [
        new() { ProductTypeNo = "P001", ProductTypeName = "电子产品", ProductTypeOrder = 1,
                CustTypeNo = "C001", CustTypeName = "企业客户", CustTypeOrder = 1,
                YearMonth = "2024-01", TotalQty = 100 },
        new() { ProductTypeNo = "P001", ProductTypeName = "电子产品", ProductTypeOrder = 1,
                CustTypeNo = "C001", CustTypeName = "企业客户", CustTypeOrder = 1,
                YearMonth = "2024-02", TotalQty = 120 },
        new() { ProductTypeNo = "P001", ProductTypeName = "电子产品", ProductTypeOrder = 1,
                CustTypeNo = "C002", CustTypeName = "个人客户", CustTypeOrder = 2,
                YearMonth = "2024-01", TotalQty = 50 },
        new() { ProductTypeNo = "P002", ProductTypeName = "家居用品", ProductTypeOrder = 2,
                CustTypeNo = "C001", CustTypeName = "企业客户", CustTypeOrder = 1,
                YearMonth = "2024-01", TotalQty = 80 },
    ];

    // 4. 按产品类型分组
    List<ProductTypeGroup> productTypeGroups = [.. data
        .GroupBy(r => new ProductTypeGroupKey(r.ProductTypeNo, r.ProductTypeName, r.ProductTypeOrder))
        .Select(g => new ProductTypeGroup
        {
            ProductTypeNo = g.Key.ProductTypeNo,
            ProductTypeName = g.Key.ProductTypeName,
            ProductTypeOrder = g.Key.ProductTypeOrder,
            Records = [.. g]
        })
        .OrderBy(g => g.ProductTypeOrder)];

    // 5. 常量定义
    const int templateSheetIndex = 0;
    const string titleSuffix = "标准/定制订单比例(根据订单数量统计)";
    const int blankRowOffset = 2;
    const int headerRowCount = 5;
    const int dataStartRow = headerRowCount;

    // 6. 加载模板
    using var workbook = NPOIHelper.LoadWorkbook(targetTemplate);

    var exportUser = "测试";
    var exportDate = DateTime.Now.ToString("yyyy/MM/dd");

    // 7. 遍历产品类型分组
    foreach (var productGroup in productTypeGroups)
    {
        // 7.1 克隆 Sheet 并命名
        ISheet sheet = workbook.CloneSheet(templateSheetIndex);
        workbook.SetSheetName(workbook.GetSheetIndex(sheet), productGroup.ProductTypeName);

        // 7.2 按客户类型分组
        List<CustomerTypeGroup> customerTypeGroups = [.. productGroup.Records
            .GroupBy(r => new CustomerTypeGroupKey(r.CustTypeNo, r.CustTypeName, r.CustTypeOrder))
            .Select(g => new CustomerTypeGroup
            {
                CustTypeNo = g.Key.CustTypeNo,
                CustTypeName = g.Key.CustTypeName,
                CustTypeOrder = g.Key.CustTypeOrder,
                Records = [.. g.OrderBy(r => r.YearMonth)]
            })
            .OrderBy(g => g.CustTypeOrder)];

        // 7.3 计算所需行数
        int totalDataRows = customerTypeGroups.Sum(g => g.Records.Count);
        int totalGroups = customerTypeGroups.Count;
        int totalHeaderRows = totalGroups * headerRowCount;
        int totalBlankRows = (totalGroups - 1) * blankRowOffset;
        int totalRowsNeeded = totalHeaderRows + totalDataRows + totalBlankRows;

        // 7.4 设置导出信息
        var exportUserCell = NPOIHelper.GetOrCreateCell(sheet, 6, 1);
        var exportDateCell = NPOIHelper.GetOrCreateCell(sheet, 7, 1);
        exportUserCell.SetCellValue(exportUser);
        exportDateCell.SetCellValue(exportDate);

        // 7.5 清理原有数据并整体下移行(偏移 = 总所需行数 - 模板已有的表头行数)
        int rowsToShift = totalRowsNeeded - headerRowCount;
        sheet.ShiftRows(dataStartRow, sheet.LastRowNum, rowsToShift, true, false);

        // 7.6 填充各客户类型数据
        int currentRowIndex = 0;
        bool isFirstGroup = true;

        foreach (var customerGroup in customerTypeGroups)
        {
            int recordCount = customerGroup.Records.Count;

            // 非第一个分组时复制标题行
            if (!isFirstGroup)
            {
                ExcelRowCopier.CopyRowRange(sheet, sheet, 0, headerRowCount - 1, currentRowIndex);
            }

            // 设置标题
            var title = $"{customerGroup.CustTypeName}{productGroup.ProductTypeName}{titleSuffix}";
            var titleCell = NPOIHelper.GetOrCreateCell(sheet, currentRowIndex, 0);
            titleCell.SetCellValue(title);

            // 填充数据行
            int dataStartRowIndex = currentRowIndex + headerRowCount;
            for (int recordIndex = 0; recordIndex < recordCount; recordIndex++)
            {
                var dataRecord = customerGroup.Records[recordIndex];
                int targetRowIndex = dataStartRowIndex + recordIndex;
                FillDataRow(targetRowIndex, sheet, dataRecord, recordIndex);
            }

            // 更新下一个标题行的起始位置
            currentRowIndex = currentRowIndex + headerRowCount + recordCount + blankRowOffset;
            isFirstGroup = false;
        }
    }

    // 8. 移除模板 Sheet 并保存
    workbook.RemoveSheetAt(0);
    var exportFilePath = Path.Combine(exportDir, $"Report_{DateTime.Now:yyyyMMdd_HHmmss}.xlsx");
    NPOIHelper.SaveWorkbook(workbook, exportFilePath);
    Console.WriteLine($"报表已导出至: {exportFilePath}");
}
catch (Exception ex)
{
    Console.WriteLine($"导出失败: {ex.Message}");
}

// 数据行填充方法
static void FillDataRow(int rowIndex, ISheet sheet, ReportEntity data, int recordIndex)
{
    var row = NPOIHelper.GetOrCreateRow(sheet, rowIndex);

    foreach (var kvp in ReportColumnMapping.Columns)
    {
        int colIndex = kvp.Key;
        var (field, _) = kvp.Value;
        var value = ReportColumnMapping.GetAccessor(field)!(data);
        NPOIHelper.SetCellValue(NPOIHelper.GetOrCreateCell(row, colIndex), value);
    }
}