certflow.services.dict_csv_sync 源代码

"""基础字典表 DB ↔ CSV 双向同步

为「材质牌号 / 口径映射 / 型号→压力」三个管理界面提供统一的 CSV 快照同步能力,
使界面内编辑(已实时落库)与可提交的 CSV 快照保持一致:

- ``export_csv``:把当前 DB 表整表导出为 CSV(UTF-8-SIG + 表头 + LF 行尾)。
- ``import_csv``:把 CSV 内容 upsert 回 DB(按唯一键插入/更新,保留 source/usage_count)。

CSV 路径从 ``config/dictionaries.yaml`` 的 ``dictionary.<key>.csv`` 解析,
与脚本(B2/B3)共用同一份路径配置,避免漂移。

用法(在控制器内):
    self._csv_sync = DictCsvSync(
        MaterialGrade,
        csv_columns=[...], field_types={...}, unique_keys=["grade", "standard_code", "element"],

    )
    self._csv_sync.export_csv(session, path)
    self._csv_sync.import_csv(session, path)
"""

from __future__ import annotations

import csv
from pathlib import Path
from typing import Any

from sqlalchemy import inspect

# 项目根:src/certflow/services/dict_csv_sync.py → parents[3]
_PROJECT_ROOT = Path(__file__).resolve().parents[3]


[文档] def resolve_dict_csv_path(dict_key: str) -> Path | None: """从 ``config/dictionaries.yaml`` 解析 ``dictionary.<dict_key>.csv`` 的绝对路径。 Args: dict_key: 字典键名,如 ``material_grades`` / ``caliber_mappings`` / ``model_param_mappings`` Returns: Path | None: CSV 绝对路径;配置缺失或解析失败返回 None """ yaml_path = _PROJECT_ROOT / "config" / "dictionaries.yaml" if not yaml_path.exists(): return None try: import yaml data = yaml.safe_load(yaml_path.read_text(encoding="utf-8")) except Exception: # noqa: BLE001 - 配置不可用时不阻塞 UI return None rel = (data or {}).get("dictionary", {}).get(dict_key, {}).get("csv") if not rel: return None p = Path(rel) return p if p.is_absolute() else _PROJECT_ROOT / rel
[文档] class DictCsvSync: """通用字典表 CSV 双向同步器(列定义驱动,不绑定具体表)。 Attributes: model_class: SQLAlchemy 模型类 csv_columns: CSV 列序(模型字段名) field_types: 字段名 → 类型(text/int/float/bool),用于导入时类型还原 unique_keys: 唯一键字段列表(用于 upsert 定位) default_source: 导入时新行的来源标记(model/caliber 用 import) """ def __init__( self, model_class: Any, csv_columns: list[str], field_types: dict[str, str], unique_keys: list[str], default_source: str = "import", ) -> None: self.model_class = model_class self.csv_columns = csv_columns self.field_types = field_types self.unique_keys = unique_keys self.default_source = default_source # ============================================================ # 导出(DB → CSV) # ============================================================
[文档] def export_csv(self, session: Any, path: str | Path) -> int: """把当前表整表导出为 CSV 快照,返回行数。 Args: session: SQLAlchemy 会话 path: 输出 CSV 路径 """ path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) rows = session.query(self.model_class).order_by(self.model_class.id).all() with open(path, "w", newline="", encoding="utf-8-sig") as f: writer = csv.writer(f, lineterminator="\n") writer.writerow(self.csv_columns) for obj in rows: writer.writerow( [self._serialize(getattr(obj, col, None)) for col in self.csv_columns] ) return len(rows)
@staticmethod def _serialize(value: Any) -> str: """Python 值 → CSV 单元格文本(bool→true/false,None→空串)。""" if value is None: return "" if isinstance(value, bool): return "true" if value else "false" return str(value) # ============================================================ # 导入(CSV → DB,upsert) # ============================================================
[文档] def import_csv(self, session: Any, path: str | Path) -> dict[str, int]: # noqa: C901 """把 CSV 内容 upsert 回 DB,返回统计。 唯一键命中则更新非键字段(保留 source/usage_count 等);未命中则插入。 必填唯一键缺失的行跳过。 Args: session: SQLAlchemy 会话 path: 输入 CSV 路径 Returns: dict: {inserted, updated, skipped, errors} """ stats = {"inserted": 0, "updated": 0, "skipped": 0, "errors": 0} path = Path(path) if not path.exists(): stats["errors"] = -1 # 文件缺失标记 return stats with open(path, newline="", encoding="utf-8-sig") as f: reader = csv.reader(f) header_seen = False for row in reader: if not row: continue if not header_seen: header_seen = True continue record = self._coerce(row) if record is None: stats["skipped"] += 1 continue filters = {k: record[k] for k in self.unique_keys} existing = session.query(self.model_class).filter_by(**filters).first() try: if existing is None: data = dict(record) if self._has_column("source") and "source" not in data: data["source"] = self.default_source session.add(self.model_class(**data)) stats["inserted"] += 1 else: changed = False for col in self.csv_columns: if col in self.unique_keys: continue new_val = record[col] if getattr(existing, col) != new_val: setattr(existing, col, new_val) changed = True if changed: stats["updated"] += 1 else: stats["skipped"] += 1 except Exception: # noqa: BLE001 - 单行失败不中断整批 stats["errors"] += 1 try: session.commit() except Exception: # noqa: BLE001 session.rollback() stats["errors"] += 1 return stats
def _coerce(self, row: list[str]) -> dict[str, Any] | None: """CSV 行 → 记录字典(按列序 + 类型还原);必填唯一键缺失返回 None。""" record: dict[str, Any] = {} for i, col in enumerate(self.csv_columns): raw = row[i].strip() if i < len(row) else "" ft = self.field_types.get(col, "text") if ft == "bool": record[col] = raw.lower() in ("true", "1", "yes") if raw else False elif ft == "int": record[col] = int(raw) if raw else None elif ft == "float": record[col] = float(raw) if raw else None else: record[col] = raw or None for k in self.unique_keys: if not record.get(k): return None return record def _has_column(self, name: str) -> bool: """判断模型是否含指定列(避免对无 source 字段的表误写)。""" try: return name in inspect(self.model_class).columns except Exception: # noqa: BLE001 return hasattr(self.model_class, name)