certflow.utils.access_db 源代码

"""Access 数据库操作模块

用于连接和查询 Access 数据库(.mdb/.accdb),
独立于主 SQLite 数据库,专门处理铭牌数据。

控制方式(优先级从高到低):
1. 环境变量 CERTFLOW_ENABLE_ACCESS_EXPORT=true 强制启用
2. 配置文件 config.yaml → export.access.enabled: true
3. 以上均未设置时默认禁用
"""

from __future__ import annotations

import os
from pathlib import Path
from typing import Any

import pandas as pd
from loguru import logger


[文档] def is_access_enabled() -> bool: """检查 Access 功能是否启用 优先级:环境变量 > 配置文件 > 默认禁用 Returns: True 如果 Access 功能已启用 """ env_val = os.getenv("CERTFLOW_ENABLE_ACCESS_EXPORT") if env_val is not None: return env_val.lower() in ("true", "1", "yes") try: from certflow.config.settings import cfg return bool(cfg("export.access.enabled", False)) except Exception: return False
def _import_pyodbc() -> Any: """延迟导入 pyodbc,仅在 Access 功能启用时才加载""" if not is_access_enabled(): raise ImportError( "Access 功能已禁用" "(设置 export.access.enabled=true 或 CERTFLOW_ENABLE_ACCESS_EXPORT=true 启用)" ) import pyodbc as _pyodbc return _pyodbc
[文档] class AccessDatabase: """Access 数据库管理器 专门用于连接和查询 Access 数据库,独立于主 SQLite 数据库。 支持连接复用,避免频繁开关连接。 Examples: >>> # 推荐:使用上下文管理器(自动管理连接) >>> with get_access_db() as db: ... records = db.search(customer="宁波大隆") >>> # 或:获取全局单例(保持连接) >>> db = get_access_db() >>> records = db.search(customer="宁波大隆") """ def __init__(self, db_path: str | Path, max_retries: int = 3, keep_alive: bool = True): """初始化 Access 数据库连接 Args: db_path: Access 数据库文件路径 (.mdb 或 .accdb) max_retries: 连接失败时的最大重试次数 keep_alive: 是否保持连接(False 时每次查询后自动关闭) Raises: ImportError: Access 功能被配置禁用时抛出。 """ self.db_path = Path(db_path) self.conn = None self.cursor = None self.max_retries = max_retries self.keep_alive = keep_alive self._pyodbc = _import_pyodbc() # 延迟导入 + 配置检查 self._connect() def _connect(self) -> None: """建立数据库连接(支持重试) :raises FileNotFoundError: 数据库文件不存在时抛出 :raises ConnectionError: 所有重试均失败后抛出 """ if not self.db_path.exists(): raise FileNotFoundError(f"数据库文件不存在: {self.db_path}") conn_str = f"DRIVER={{Microsoft Access Driver (*.mdb, *.accdb)}};DBQ={self.db_path};" for attempt in range(1, self.max_retries + 1): try: self.conn = self._pyodbc.connect(conn_str, timeout=10) self.cursor = self.conn.cursor() logger.info( "Access 数据库连接成功", extra={ "db_path": str(self.db_path), "attempt": attempt, "keep_alive": self.keep_alive, }, ) return except self._pyodbc.Error as e: logger.warning( f"连接失败 (尝试 {attempt}/{self.max_retries})", extra={"db_path": str(self.db_path), "error": str(e)}, ) if attempt == self.max_retries: logger.error("数据库连接最终失败", extra={"db_path": str(self.db_path)}) raise ConnectionError(f"无法连接到数据库: {self.db_path}") from e def _ensure_connection(self) -> None: """确保连接有效,如果断开则重连 :raises pyodbc.Error: 重连失败时抛出 """ try: # 测试连接是否有效 self.cursor.execute("SELECT 1") except (self._pyodbc.Error, AttributeError): logger.warning("数据库连接已断开,尝试重新连接", extra={"db_path": str(self.db_path)}) self._connect() def _execute_query(self, sql: str, params: list | None = None) -> tuple[list, list]: """执行查询并返回列名和数据(内部方法,避免 pandas 警告) :param sql: SQL 查询语句 :param params: 查询参数列表,默认为 None :return: 包含列名列表和数据列表的元组 (columns, data) :raises pyodbc.Error: SQL 执行失败时抛出 """ self._ensure_connection() # 确保连接有效 try: if params: self.cursor.execute(sql, params) else: self.cursor.execute(sql) columns = ( [column[0] for column in self.cursor.description] if self.cursor.description else [] ) data = self.cursor.fetchall() return columns, data except self._pyodbc.Error as e: logger.error( "SQL 执行失败", extra={"sql": sql[:200], "params": params, "error": str(e)} ) raise
[文档] def query(self, sql: str, params: list | None = None) -> pd.DataFrame: """执行查询并返回 DataFrame Args: sql: SQL 查询语句。 params: 查询参数列表,默认为 None。 Returns: pd.DataFrame: 查询结果 DataFrame,无结果时返回空 DataFrame(保留列名)。 Raises: pyodbc.Error: SQL 执行失败时抛出。 """ columns, data = self._execute_query(sql, params) if not data: return pd.DataFrame(columns=columns) return pd.DataFrame.from_records(data, columns=columns)
def _safe_top_n(self, top_n: int) -> str: """安全处理 TOP N 值 :param top_n: 期望返回的记录数 :return: 经过校验和限制后的 TOP N 值(字符串格式) """ if not isinstance(top_n, int) or top_n <= 0: logger.warning(f"无效的 top_n 值: {top_n},使用默认值 10") top_n = 10 if top_n > 10000: logger.warning(f"top_n 值过大: {top_n},限制为 10000") top_n = 10000 return str(top_n)
[文档] def get_unsigned_records(self) -> pd.DataFrame: """查询未刻印的记录 Returns: pd.DataFrame: 未刻印记录的 DataFrame,按 ID 升序排列。 """ sql = "SELECT * FROM FormResumeDataTb WHERE 刻印完毕标记 = 0 ORDER BY ID" logger.debug("执行查询: 未刻印记录") return self.query(sql)
[文档] def get_signed_records(self, top_n: int = 10) -> pd.DataFrame: """查询已刻印的记录 Args: top_n: 返回的记录数,默认为 10。 Returns: pd.DataFrame: 已刻印记录的 DataFrame,按 ID 降序排列。 """ safe_top = self._safe_top_n(top_n) sql = f"SELECT TOP {safe_top} * FROM FormResumeDataTb WHERE 刻印完毕标记 = -1 ORDER BY ID DESC" logger.debug(f"执行查询: 已刻印记录 (TOP {safe_top})") return self.query(sql)
[文档] def get_next_unsigned(self) -> pd.DataFrame: """获取下一条未刻印的记录 Returns: pd.DataFrame: 按 ID 升序排列的第一条未刻印记录(TOP 1)。 """ sql = "SELECT TOP 1 * FROM FormResumeDataTb WHERE 刻印完毕标记 = 0 ORDER BY ID" logger.debug("执行查询: 下一条未刻印记录") return self.query(sql)
[文档] def get_by_id(self, record_id: int) -> pd.DataFrame: """根据ID查询记录 Args: record_id: 记录的主键 ID。 Returns: pd.DataFrame: 匹配记录的 DataFrame。 """ sql = "SELECT * FROM FormResumeDataTb WHERE ID = ?" logger.debug(f"执行查询: 根据ID查询 (ID={record_id})") return self.query(sql, [record_id])
[文档] def search( self, customer: str | None = None, project: str | None = None, model: str | None = None, sn: str | None = None, signed: bool | None = None, ) -> pd.DataFrame: """多条件搜索 Args: customer: 订货单位名称(模糊匹配),为 None 时不筛选。 project: 项目名称(模糊匹配),为 None 时不筛选。 model: 产品型号(模糊匹配),为 None 时不筛选。 sn: 出厂编号(模糊匹配),为 None 时不筛选。 signed: 刻印状态筛选,True=已刻印,False=未刻印,None=不筛选。 Returns: pd.DataFrame: 符合所有条件的记录 DataFrame,按 ID 降序排列。 """ sql = "SELECT * FROM FormResumeDataTb WHERE 1=1" params = [] if customer: sql += " AND 订货单位 LIKE ?" params.append(f"%{customer}%") if project: sql += " AND 项目名称 LIKE ?" params.append(f"%{project}%") if model: sql += " AND 产品型号 LIKE ?" params.append(f"%{model}%") if sn: sql += " AND 出厂编号 LIKE ?" params.append(f"%{sn}%") if signed is True: sql += " AND 刻印完毕标记 = -1" elif signed is False: sql += " AND 刻印完毕标记 = 0" sql += " ORDER BY ID DESC" logger.debug(f"执行搜索: 条件数={len(params)}") return self.query(sql, params)
[文档] def get_fields(self, table_name: str) -> list[str]: """获取表的字段列表 Args: table_name: 表名 Returns: 字段名列表 """ self._ensure_connection() self.cursor.execute(f"SELECT TOP 1 * FROM [{table_name}]") return [column[0] for column in self.cursor.description]
[文档] def get_customer_stats(self) -> pd.DataFrame: """按订货单位统计 Returns: pd.DataFrame: 包含订货单位和数量的 DataFrame,按数量降序排列。 """ sql = """ SELECT 订货单位, COUNT(*) as 数量 FROM FormResumeDataTb GROUP BY 订货单位 ORDER BY COUNT(*) DESC """ logger.debug("执行统计: 订货单位统计") return self.query(sql)
[文档] def get_model_stats(self) -> pd.DataFrame: """按产品型号统计 Returns: pd.DataFrame: 包含产品型号和数量的 DataFrame,按数量降序排列。 """ sql = """ SELECT 产品型号, COUNT(*) as 数量 FROM FormResumeDataTb GROUP BY 产品型号 ORDER BY COUNT(*) DESC """ logger.debug("执行统计: 产品型号统计") return self.query(sql)
[文档] def get_statistics(self) -> dict[str, Any]: """获取统计信息 Returns: dict[str, Any]: 包含以下键的统计字典: - 总记录数: int - 已刻印记录数: int - 未刻印记录数: int - 最新刻印时间: str | None - ID范围: str Raises: pyodbc.Error: 查询失败时抛出。 """ stats = {} logger.debug("开始获取统计信息") self._ensure_connection() # 确保连接有效 try: self.cursor.execute("SELECT COUNT(*) FROM FormResumeDataTb") stats["总记录数"] = self.cursor.fetchone()[0] self.cursor.execute("SELECT COUNT(*) FROM FormResumeDataTb WHERE 刻印完毕标记 = -1") stats["已刻印记录数"] = self.cursor.fetchone()[0] self.cursor.execute("SELECT COUNT(*) FROM FormResumeDataTb WHERE 刻印完毕标记 = 0") stats["未刻印记录数"] = self.cursor.fetchone()[0] self.cursor.execute( "SELECT MAX(铭牌打印时间) FROM FormResumeDataTb WHERE 刻印完毕标记 = -1" ) stats["最新刻印时间"] = self.cursor.fetchone()[0] self.cursor.execute("SELECT MIN(ID), MAX(ID) FROM FormResumeDataTb") min_id, max_id = self.cursor.fetchone() stats["ID范围"] = f"{min_id} - {max_id}" logger.info( "统计信息获取成功", extra={ "总记录数": stats["总记录数"], "已刻印": stats["已刻印记录数"], "未刻印": stats["未刻印记录数"], }, ) except self._pyodbc.Error as e: logger.error("获取统计信息失败", extra={"error": str(e)}) raise return stats
[文档] def append_signed_records_to_form(self, plan_date: str) -> int: """将已刻印记录追加到 FormResumeDataTb Args: plan_date: 计划日期 Returns: 插入的记录数 """ # 先检查是否有数据 check_sql = """ SELECT COUNT(*) FROM SignTb WHERE JiHuaRiQi = ? AND Sign_ALL = True """ self.cursor.execute(check_sql, [plan_date]) count = self.cursor.fetchone()[0] if count == 0: logger.info(f"没有找到计划日期为 {plan_date} 的已刻印记录") return 0 # 插入新记录(使用实际存在的字段) insert_sql = """ INSERT INTO FormResumeDataTb ( ID, 计划日期, 订货单位, 项目名称, 产品名称, 产品型号, 公称通径, 适用温度, 合格证数量, 合格证打印时间, 铭牌打印时间 ) SELECT SignTb.id, SignTb.JiHuaRiQi, SignTb.DingHuoDanWei, SignTb.XiangMuMingCheng, SignTb.ChanPinMingCheng, SignTb.ChanPinXingHao, SignTb.DN, SignTb.ShiYongWenDu, SignTb.hgz_ShuLiang, SignTb.hgz_PrintTime, SignTb.Sign_PrintTime FROM SignTb WHERE SignTb.JiHuaRiQi = ? AND SignTb.Sign_ALL = True AND SignTb.id NOT IN (SELECT ID FROM FormResumeDataTb) ORDER BY SignTb.ChanPinXingHao, SignTb.DN, SignTb.SN """ self.cursor.execute(insert_sql, [plan_date]) self.conn.commit() inserted = self.cursor.rowcount logger.info(f"成功插入 {inserted} 条记录到 FormResumeDataTb") return inserted
[文档] def export_to_csv(self, output_path: str, table_name: str = "FormResumeDataTb") -> pd.DataFrame: """导出表到CSV Args: output_path: CSV 输出文件路径。 table_name: 要导出的表名,默认为 "FormResumeDataTb"。 Returns: pd.DataFrame: 导出的数据 DataFrame。 """ logger.info(f"开始导出表: {table_name}") df = self.query(f"SELECT * FROM [{table_name}]") df.to_csv(output_path, index=False, encoding="utf-8-sig") logger.info(f"导出完成: {len(df)} 条记录 -> {output_path}") return df
[文档] def close(self) -> None: """关闭数据库连接 关闭游标和连接,释放资源。 Returns: None """ if self.cursor: self.cursor.close() if self.conn: self.conn.close() logger.info("Access 数据库连接已关闭", extra={"db_path": str(self.db_path)})
[文档] def __enter__(self) -> AccessDatabase: """上下文管理器入口 Returns: AccessDatabase: 当前 AccessDatabase 实例。 """ return self
[文档] def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: """上下文管理器出口,自动关闭连接 Args: exc_type: 异常类型(无异常时为 None)。 exc_val: 异常值(无异常时为 None)。 exc_tb: 异常回溯(无异常时为 None)。 """ self.close()
# ==================== 单例管理器 ====================
[文档] class AccessDatabaseManager: """Access 数据库连接管理器(单例模式) 管理全局唯一的数据库连接实例,避免频繁开关连接。 Examples: >>> manager = AccessDatabaseManager.get_instance() >>> db = manager.get_connection() >>> records = db.search(customer="宁波") """ _instance = None _connection = None def __new__(cls) -> AccessDatabaseManager: if cls._instance is None: cls._instance = super().__new__(cls) return cls._instance
[文档] @classmethod def get_instance(cls) -> AccessDatabaseManager: """获取单例实例 Returns: AccessDatabaseManager: 单例实例。 """ if cls._instance is None: cls._instance = cls() return cls._instance
[文档] def get_connection(self, db_path: str | Path | None = None) -> AccessDatabase: """获取数据库连接(复用已存在的连接) Args: db_path: 数据库路径,仅在首次连接时使用 Returns: AccessDatabase 实例 """ if self._connection is None: if db_path is None: from certflow.utils.path_utils import get_project_root db_path = get_project_root() / "data" / "SignDB.mdb" self._connection = AccessDatabase(db_path, keep_alive=True) return self._connection
[文档] def close(self) -> None: """关闭全局连接 关闭并释放全局数据库连接实例。 Returns: None """ if self._connection: self._connection.close() self._connection = None logger.info("全局数据库连接已关闭")
[文档] def __enter__(self) -> AccessDatabase: """上下文管理器入口,自动获取连接 Returns: AccessDatabase: 连接实例。 """ return self.get_connection()
[文档] def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: """上下文管理器出口,自动关闭全局连接 Args: exc_type: 异常类型(无异常时为 None)。 exc_val: 异常值(无异常时为 None)。 exc_tb: 异常回溯(无异常时为 None)。 """ self.close()
# ==================== 便捷函数 ====================
[文档] def get_access_db() -> AccessDatabase: """获取 Access 数据库连接实例(复用全局连接) 推荐使用此函数获取连接,内部会自动管理连接生命周期。 适用于需要频繁查询的场景。 Returns: AccessDatabase: 实例(全局单例)。 Raises: ImportError: Access 功能被配置禁用时抛出。 """ if not is_access_enabled(): raise ImportError( "Access 功能已禁用" "(设置 export.access.enabled=true 或 CERTFLOW_ENABLE_ACCESS_EXPORT=true 启用)" ) return AccessDatabaseManager.get_instance().get_connection()
[文档] def get_access_db_new() -> AccessDatabase: """获取新的 Access 数据库连接实例(不复用) 适用于短期使用的场景,使用后需要手动关闭或使用上下文管理器。 Returns: AccessDatabase: 新的连接实例。 Raises: ImportError: Access 功能被配置禁用时抛出。 """ if not is_access_enabled(): raise ImportError( "Access 功能已禁用" "(设置 export.access.enabled=true 或 CERTFLOW_ENABLE_ACCESS_EXPORT=true 启用)" ) from certflow.utils.path_utils import get_project_root db_path = get_project_root() / "data" / "SignDB.mdb" return AccessDatabase(str(db_path))