"""发票数据模型与 CSV 工具 定义 CSV 列结构,提供发票分类和 CSV 读写功能。 对外接口: load_csv(path) 读取支付记录 CSV save_csv(payment_records, path) 保存支付记录 CSV save_invoice_csv(payment_records, path) 保存发票级别 CSV save_application_json(applications, path) 保存出差申请单 JSON """ import csv import json from pathlib import Path from .. import get_logger log = get_logger("invoice") # 缓存目录名(相对于源文件目录) CACHE_DIR_NAME = ".invoice_cache" # CSV 列名 INVOICE_LEVEL_COLUMNS = [ "index", "invoice_type", "invoice_number", "invoice_date", "item_name", "spec_model", "total_amount", "seller_name", "departure", "arrival", "train_no", "ride_date", "seat_class", "person_name", "card_date", "card_no", "card_amount", "remark", "person_id", ] PAYMENT_RECORD_COLUMNS = [ "index", "card_date", "card_no", "card_amount", "relative_invoice_count", "invoice_detail", "remark", "_matched_invoices", "person_id", ] def _is_application_document(invoice_type: str) -> bool: """判断是否为出差事前申请单""" return invoice_type == "application" def classify_invoice_batch( invoices: list[dict[str, str]], ) -> dict[str, list[dict[str, str]]]: """按发票类型分组""" travel: list[dict[str, str]] = [] general: list[dict[str, str]] = [] application: list[dict[str, str]] = [] for inv in invoices: inv_type = inv.get("invoice_type", "general") if _is_application_document(inv_type): application.append(inv) elif inv_type in ("train", "hotel"): travel.append(inv) else: general.append(inv) return {"travel": travel, "general": general, "application": application} # ------------------------------------------------------------------ # CSV 读写工具 # ------------------------------------------------------------------ def _clean_invoice_for_json(inv: dict[str, str]) -> dict[str, str]: """清理发票字典中的内部字段,保留可序列化的字段""" clean = {} for k, v in inv.items(): if k.startswith("_"): continue clean[k] = v return clean def _load_csv( csv_path: Path, required_columns: list[str], label: str = "CSV", ) -> list[dict[str, str]] | None: """通用 CSV 读取器:按 required_columns 校验列,失败返回 None""" try: with open(csv_path, encoding="utf-8-sig", newline="") as f: reader = csv.DictReader(f) fieldnames = reader.fieldnames or [] missing = [c for c in required_columns if c not in fieldnames] if missing: log.error(f"{label} 缺少必要列: {missing}") return None return [row for row in reader] except FileNotFoundError: log.error(f"{label} 文件不存在: {csv_path.name}") return None except Exception as e: log.error(f"{label} 读取失败: {e}") return None def load_csv(csv_path: Path) -> list[dict[str, str]] | None: """读取支付记录 CSV 为 dict 列表,失败返回 None""" return _load_csv(csv_path, PAYMENT_RECORD_COLUMNS, "CSV") def load_invoice_csv(csv_path: Path) -> list[dict[str, str]] | None: """读取发票级别 CSV 为 dict 列表(每行一张发票),失败返回 None""" return _load_csv(csv_path, INVOICE_LEVEL_COLUMNS, "发票 CSV") def save_csv( payment_records: list[dict[str, str]], output_path: str | Path = "payment_records.csv", ) -> None: """将支付记录列表保存为 CSV(以支付记录为主键) 每条支付记录包含: - card_date, card_no, card_amount(支付信息) - relative_invoice_count, invoice_detail(发票聚合信息) - _matched_invoices(内部字段,序列化为 JSON 存储在 CSV 中) """ csv_path = Path(output_path) with open(csv_path, "w", encoding="utf-8", newline="") as f: writer = csv.writer(f) writer.writerow(PAYMENT_RECORD_COLUMNS) for idx, record in enumerate(payment_records, 1): matched_invoices: list[dict[str, str]] = record.get("_matched_invoices", []) # type: ignore[assignment] invoices_json = json.dumps( [_clean_invoice_for_json(inv) for inv in matched_invoices], ensure_ascii=False, ) writer.writerow( [ idx, record.get("card_date", ""), record.get("card_no", ""), record.get("card_amount", ""), record.get("relative_invoice_count", str(len(matched_invoices))), record.get("invoice_detail", ""), record.get("remark", ""), invoices_json, record.get("person_id", ""), ] ) log.info(f"支付记录 CSV 已保存: {csv_path.name}") def save_invoice_csv( payment_records: list[dict[str, str]], output_path: str | Path = "invoice_summary.csv", ) -> None: """将支付记录展平为发票级别 CSV(每行一张发票) 从 _matched_invoices 中还原每张发票,回填刷卡信息, 生成以发票为主键的 CSV,用于人工填写报销单参考。 出差事前申请单不会被写入此文件(它们有独立的 CSV)。 """ csv_path = Path(output_path) with open(csv_path, "w", encoding="utf-8", newline="") as f: writer = csv.writer(f) writer.writerow(INVOICE_LEVEL_COLUMNS) idx = 1 for record in payment_records: matched_invoices: list[dict[str, str]] = record.get("_matched_invoices", []) # type: ignore[assignment] for inv in matched_invoices: clean_inv = _clean_invoice_for_json(inv) if _is_application_document(clean_inv.get("invoice_type", "")): continue writer.writerow( [ idx, clean_inv.get("invoice_type", ""), clean_inv.get("invoice_number", ""), clean_inv.get("invoice_date", ""), clean_inv.get("item_name", ""), clean_inv.get("spec_model", ""), clean_inv.get("total_amount", ""), clean_inv.get("seller_name", ""), clean_inv.get("departure", ""), clean_inv.get("arrival", ""), clean_inv.get("train_no", ""), clean_inv.get("ride_date", ""), clean_inv.get("seat_class", ""), clean_inv.get("person_name", ""), record.get("card_date", ""), record.get("card_no", ""), record.get("card_amount", ""), record.get("remark", ""), record.get("person_id", ""), ] ) idx += 1 log.info(f"发票级别 CSV 已保存: {csv_path.name}") def save_application_json( applications: list[dict[str, str]], output_path: str | Path = "travel_applications.json", ) -> None: """将出差事前申请单列表保存为独立 JSON 文件 使用 JSON 保留完整嵌套结构(如出差人员信息的列表形式), 避免 CSV 扁平化导致的字段丢失。 """ json_path = Path(output_path) with open(json_path, "w", encoding="utf-8") as f: json.dump(applications, f, ensure_ascii=False, indent=2) log.info(f"出差申请单 JSON 已保存: {json_path.name}")