certflow.handlers.excel_handler 源代码

"""Excel文件基础处理器模块

提供Excel文件的读取、DataFrame清洗和列校验重命名等基础操作.
支持自定义表头行、多表头行识别等功能.

v2.1.0: 新增 openpyxl 读写封装(``load_workbook`` / ``write_excel``),
业务层应通过本 handler 间接操作 openpyxl,避免直连导致分散维护。
"""

from __future__ import annotations

import contextlib
from pathlib import Path

import pandas as pd
from loguru import logger
from openpyxl import Workbook
from openpyxl import load_workbook as _openpyxl_load_workbook

from certflow.handlers.excel_styler import ExcelStyler


[文档] class ExcelHandler: """Excel文件处理器 提供Excel文件读取、数据清洗和列名校验的静态方法集合. 支持自定义表头行、多表头行识别等高级功能. 该处理器专注于Excel文件的基础操作,不涉及业务逻辑处理. """
[文档] @staticmethod def read_excel( file_path: Path, sheet_name: str | int = 0, header_row: int | None = None, skiprows: int | None = None, usecols: str | list[int] | None = None, ) -> pd.DataFrame: """读取Excel文件 支持自定义表头行和数据起始行,提供灵活的数据读取选项. Args: file_path: Excel文件路径 sheet_name: 工作表名称或索引,默认为0(第一个工作表) header_row: 表头所在行号(0-indexed),如果指定则作为列名行 skiprows: 跳过的行数,从文件开头算起 usecols: 指定读取的列,可以是列号列表或Excel列范围字符串如"A:D" Returns: pd.DataFrame: 读取的DataFrame对象 Raises: Exception: 当文件不存在、格式错误或读取失败时抛出异常 Examples: >>> handler = ExcelHandler() >>> # 读取第一个工作表,使用第一行作为表头 >>> df = handler.read_excel(Path("data.xlsx")) >>> >>> # 读取指定工作表,使用第三行作为表头 >>> df = handler.read_excel( ... Path("data.xlsx"), ... sheet_name="Sheet1", ... header_row=2 ... ) """ try: # 构建读取参数 read_kwargs = { "sheet_name": sheet_name, "dtype": str, } if header_row is not None: read_kwargs["header"] = header_row else: read_kwargs["header"] = 0 # 默认第一行为表头 if skiprows is not None: read_kwargs["skiprows"] = skiprows if usecols is not None: read_kwargs["usecols"] = usecols df = pd.read_excel(file_path, **read_kwargs) logger.info(f"成功读取Excel: {file_path}, 行数: {len(df)}, 列数: {len(df.columns)}") if header_row is not None: logger.debug(f"使用第 {header_row + 1} 行作为表头") return df except Exception as e: logger.error(f"读取Excel失败: {e}") raise
[文档] @staticmethod def read_excel_with_multi_header( file_path: Path, sheet_name: str | int = 0, header_rows: list[int] | None = None, separator: str = "_", ) -> pd.DataFrame: """读取有多行表头的Excel文件 将多行表头合并为单行列名,适用于复杂表头结构的Excel文件. Args: file_path: Excel文件路径 sheet_name: 工作表名称或索引,默认为0 header_rows: 作为表头的行号列表(0-indexed),默认为[0, 1] separator: 多级表头连接符,默认使用下划线"_" Returns: pd.DataFrame: 处理后的DataFrame,列名为合并后的字符串 Raises: Exception: 当文件读取或表头合并失败时抛出异常 Examples: >>> handler = ExcelHandler() >>> # 合并前两行作为表头 >>> df = handler.read_excel_with_multi_header( ... Path("data.xlsx"), ... header_rows=[0, 1], ... separator="_" ... ) >>> >>> # 如果Excel表头结构为: >>> # 第1行: 基本信息 | 基本信息 | 联系方式 >>> # 第2行: 姓名 | 年龄 | 电话 >>> # 合并后列名: 基本信息_姓名, 基本信息_年龄, 联系方式_电话 """ if header_rows is None: header_rows = [0, 1] try: # 读取指定行作为表头 headers = [] for row_idx in header_rows: df_header = pd.read_excel( file_path, sheet_name=sheet_name, header=None, nrows=row_idx + 1 ) headers.append(df_header.iloc[row_idx].fillna("").astype(str)) # 合并多级表头 merged_headers = [] for i in range(len(headers[0])): parts = [h[i] for h in headers if h[i] and h[i] != "nan"] if parts: merged_headers.append(separator.join(parts)) else: merged_headers.append(f"col_{i}") # 重新读取数据,跳过表头行 skiprows = max(header_rows) + 1 df = pd.read_excel( file_path, sheet_name=sheet_name, header=None, skiprows=skiprows, dtype=str, ) # 设置合并后的列名 df.columns = merged_headers logger.info(f"成功读取多级表头Excel: {file_path}, 行数: {len(df)}") return df except Exception as e: logger.error(f"读取多级表头Excel失败: {e}") raise
[文档] @staticmethod def detect_header_row( df_raw: pd.DataFrame, keywords: list[str], max_rows: int = 10 ) -> int | None: """自动检测表头行 根据关键词在数据中搜索,找到包含最多关键词的行作为表头. Args: df_raw: 原始DataFrame(使用header=None读取) keywords: 表头关键词列表,如["产品型号", "订单号", "数量"] max_rows: 搜索的最大行数,默认为10行 Returns: Optional[int]: 表头行索引(0-indexed),如果未找到则返回None Examples: >>> handler = ExcelHandler() >>> # 读取原始数据(不指定表头) >>> df_raw = pd.read_excel("data.xlsx", header=None) >>> # 检测表头行 >>> header_row = handler.detect_header_row( ... df_raw, ... keywords=["产品型号", "订单号", "数量"], ... max_rows=10 ... ) >>> if header_row is not None: ... print(f"表头在第 {header_row + 1} 行") """ for i in range(min(max_rows, len(df_raw))): try: # 安全地转换每一行为字符串 row_values = [] for val in df_raw.iloc[i]: if pd.isna(val): row_values.append("") else: row_values.append(str(val)) row_str = " ".join(row_values) except Exception as e: logger.debug(f"第 {i + 1} 行转换失败: {e}") continue # 检查是否包含关键词 matches = sum(1 for kw in keywords if kw in row_str) if matches >= len(keywords) // 2: logger.info(f"检测到表头行: 第 {i + 1} 行, 匹配关键词数: {matches}") return i logger.warning(f"未检测到表头行,关键词: {keywords}") return None
[文档] @staticmethod def clean_dataframe(df: pd.DataFrame) -> pd.DataFrame: """清洗DataFrame 执行字符串去空格、删除全空行和重置索引等基础清洗操作. Args: df: 原始DataFrame对象 Returns: pd.DataFrame: 清洗后的DataFrame Examples: >>> handler = ExcelHandler() >>> df = pd.DataFrame({ ... "name": [" 张三 ", "李四"], ... "age": [25, None] ... }) >>> cleaned = handler.clean_dataframe(df) >>> print(cleaned["name"][0]) "张三" """ # 去除字符串空格 df = df.map(lambda x: x.strip() if isinstance(x, str) else x) # 删除全空行 before_rows = len(df) df = df.dropna(how="all") after_rows = len(df) if before_rows != after_rows: logger.debug(f"删除了 {before_rows - after_rows} 个全空行") # 重置索引 return df.reset_index(drop=True)
[文档] @staticmethod def validate_columns( df: pd.DataFrame, required_columns: dict[str, str], use_aliases: bool = True, alias_mapping: dict[str, list[str]] | None = None, ) -> pd.DataFrame: """校验并重命名列 检查DataFrame中是否包含所有必需列,并将列名按映射关系重命名. 支持列别名匹配,提高对不同Excel文件结构的兼容性. Args: df: 原始DataFrame对象 required_columns: 列名映射字典,键为原始列名,值为目标列名 use_aliases: 是否使用别名匹配,默认为True alias_mapping: 别名映射字典,格式为{目标列名: [别名列表]} Returns: pd.DataFrame: 重命名后的DataFrame Raises: ValueError: 当缺少必需列时抛出异常,并列出可用列 Examples: >>> handler = ExcelHandler() >>> df = pd.DataFrame({ ... "产品名称": ["阀门A", "阀门B"], ... "数量": [5, 10] ... }) >>> required = {"产品名称": "product_name", "数量": "quantity"} >>> renamed = handler.validate_columns(df, required) >>> print(renamed.columns.tolist()) ["product_name", "quantity"] """ # 记录原始列名 original_columns = list(df.columns) if use_aliases and alias_mapping: # 使用别名匹配 column_mapping = {} missing_columns = [] for target_col, aliases in alias_mapping.items(): # 查找匹配的列 matched = None for alias in aliases: if alias in original_columns: matched = alias break if matched: column_mapping[matched] = target_col else: # 检查是否在 required_columns 中 if target_col in required_columns.values(): missing_columns.append(aliases[0] if aliases else target_col) # 检查是否有缺失的必需列 if missing_columns: available_cols = ", ".join(original_columns) logger.error(f"可用的列: {available_cols}") raise ValueError(f"缺少必需列: {', '.join(missing_columns)}") # 执行重命名 df = df.rename(columns=column_mapping) logger.info( f"列映射成功: {list(column_mapping.keys())}{list(column_mapping.values())}" ) else: # 使用直接映射 for col in required_columns: if col not in df.columns: # 提供友好的错误信息 available_cols = ", ".join(df.columns) logger.error(f"可用的列: {available_cols}") raise ValueError(f"缺少必需列: {col}") # 重命名列 df = df.rename(columns=required_columns) return df
[文档] @staticmethod def get_column_mapping_from_config( direct_mapping: dict[str, str] | None = None, alias_mapping: dict[str, list[str]] | None = None, excel_columns: list[str] | None = None, ) -> dict[str, str]: """根据配置和Excel实际列名生成列映射 结合直接映射和别名映射,自动匹配Excel列名到目标字段. Args: direct_mapping: 直接映射字典,格式{Excel列名: 目标字段} alias_mapping: 别名映射字典,格式{目标字段: [别名列表]} excel_columns: Excel文件的实际列名列表 Returns: Dict[str, str]: 列映射字典,格式{原始列名: 目标列名} Examples: >>> handler = ExcelHandler() >>> direct = {"产品型号": "product_model"} >>> aliases = {"quantity": ["数量", "数量(件)", "数量(台)"]} >>> excel_cols = ["产品型号", "数量(件)"] >>> mapping = handler.get_column_mapping_from_config(direct, aliases, excel_cols) >>> print(mapping) {"产品型号": "product_model", "数量(件)": "quantity"} """ column_mapping = {} direct_mapping = direct_mapping or {} alias_mapping = alias_mapping or {} excel_columns = excel_columns or [] for excel_col in excel_columns: # 先检查直接映射 if excel_col in direct_mapping: column_mapping[excel_col] = direct_mapping[excel_col] continue # 使用别名查找 for target_col, aliases in alias_mapping.items(): if excel_col in aliases: column_mapping[excel_col] = target_col break logger.debug(f"生成列映射: {column_mapping}") return column_mapping
[文档] @staticmethod def get_data_start_row( file_path: Path, sheet_name: str | int = 0, header_row: int = 0, min_data_rows: int = 1, ) -> int: """获取数据起始行号 自动检测数据起始行,跳过空行和说明行. Args: file_path: Excel文件路径 sheet_name: 工作表名称或索引,默认为0 header_row: 表头行号(0-indexed) min_data_rows: 最小数据行数,用于判断是否为有效数据行 Returns: int: 数据起始行号(0-indexed),如果检测失败则返回header_row + 1 Examples: >>> handler = ExcelHandler() >>> # 假设表头在第2行(索引1),数据从第4行开始 >>> data_start = handler.get_data_start_row( ... Path("data.xlsx"), ... header_row=1, ... min_data_rows=2 ... ) >>> print(f"数据起始行: {data_start}") """ try: # 读取表头后的数据 df = pd.read_excel(file_path, sheet_name=sheet_name, header=header_row, dtype=str) # 找到第一个非空行 for i in range(min(len(df), 100)): # 最多检查100行 row = df.iloc[i] # 检查行是否有有效数据 non_empty = row.dropna().tolist() if len(non_empty) >= min_data_rows: logger.debug(f"数据起始行: {header_row + i + 1}") return header_row + i return header_row + 1 except Exception as e: logger.warning(f"检测数据起始行失败: {e}") return header_row + 1
[文档] def read_excel_with_styles( self, file_path: Path, sheet_name: str | int = 0 ) -> tuple[ pd.DataFrame, pd.DataFrame | None, pd.DataFrame | None, pd.DataFrame | None, pd.DataFrame | None, ]: """读取Excel并返回数据和样式信息 使用ExcelStyler读取Excel文件,同时获取单元格数据和样式信息. Args: file_path: Excel文件路径 sheet_name: 工作表名称或索引,默认为0 Returns: Tuple[pd.DataFrame, pd.DataFrame | None, pd.DataFrame | None, pd.DataFrame | None, pd.DataFrame | None]: (df_data, 背景色, 字体色, 批注, 状态) 五元组,后四项可能为 None """ return ExcelStyler.read_with_styles(file_path, sheet_name)
# ============================================================ # openpyxl 读写封装(v2.1.0 统一入口) # ============================================================
[文档] @staticmethod def load_workbook( file_path: Path, data_only: bool = True, read_only: bool = False, ) -> Workbook: """加载 Excel 工作簿(openpyxl 统一入口) 业务层应通过此方法间接操作 openpyxl,避免各模块直连 ``openpyxl.load_workbook`` 导致参数/错误处理分散。 Args: file_path: Excel 文件路径 data_only: 仅读取公式计算结果(默认 True) read_only: 只读模式,适合超大文件扫描(默认 False) Returns: openpyxl.Workbook 对象 Raises: FileNotFoundError: 文件不存在 Exception: openpyxl 原生异常透传 Examples: >>> handler = ExcelHandler() >>> wb = handler.load_workbook(Path("template.xlsx"), data_only=False) >>> ws = wb.active >>> ws["A1"] = "新值" >>> handler.write_excel(wb, Path("template.xlsx")) """ if not file_path.exists(): raise FileNotFoundError(f"Excel 文件不存在: {file_path}") try: wb = _openpyxl_load_workbook(str(file_path), data_only=data_only, read_only=read_only) logger.debug(f"加载工作簿成功: {file_path} (data_only={data_only})") return wb except Exception as e: logger.error(f"加载工作簿失败 [{file_path}]: {e}") raise
[文档] @staticmethod def write_excel( wb: Workbook, file_path: Path, auto_close: bool = True, ) -> None: """保存 openpyxl Workbook 到文件 Args: wb: 已修改的 openpyxl Workbook 对象 file_path: 目标文件路径 auto_close: 保存后自动关闭工作簿(默认 True) Examples: >>> handler = ExcelHandler() >>> wb = handler.load_workbook(Path("data.xlsx")) >>> ... # 修改 wb >>> handler.write_excel(wb, Path("data.xlsx")) """ try: wb.save(str(file_path)) logger.info(f"工作簿已保存: {file_path}") except Exception as e: logger.error(f"保存工作簿失败 [{file_path}]: {e}") raise finally: if auto_close: with contextlib.suppress(Exception): wb.close()