"""NextGen 独立名单导入、匹配和任务进度。 上传的原始名单只追加保存到 ``work/imports``;运行时只读取本模块生成的 标准 CSV,避免依赖旧工程的工作文件格式。 """ from __future__ import annotations import csv import re import shutil import xml.etree.ElementTree as ET from datetime import datetime from pathlib import Path from .paths import ROSTER, WORK ROSTER_HEADERS = ["姓名", "身份证号", "身份证后8位", "来源文件", "来源工作表", "来源行号"] NAME_KEYS = ("姓名", "name", "passengername", "passenger_name", "travelername", "traveler_name") ID_KEYS = ("身份证号", "idnumber", "idno", "identityno", "identitynumber", "certificatenumber", "certno") def roster_path(value: str = "") -> Path: candidate = Path(value) if value else ROSTER return candidate if candidate.is_absolute() else candidate.resolve() def _clean_key(value: str) -> str: return re.sub(r"[^a-z0-9\u4e00-\u9fff]+", "", value.rsplit("}", 1)[-1].lower()) def _value(values: dict[str, str], keys: tuple[str, ...]) -> str: return next((values[key] for key in keys if values.get(key)), "") def _row(values: dict[str, object], source: str, sheet: str, line: int) -> dict[str, str] | None: normalized = {_clean_key(str(key)): str(value or "").strip() for key, value in values.items()} name, identity = _value(normalized, NAME_KEYS), re.sub(r"\s+", "", _value(normalized, ID_KEYS)).upper() if not name or not re.fullmatch(r"[0-9]{17}[0-9X]", identity): return None return {"姓名": name, "身份证号": identity, "身份证后8位": identity[-8:], "来源文件": source, "来源工作表": sheet, "来源行号": str(line)} def rows_from_csv(path: Path, source_name: str = "") -> list[dict[str, str]]: with path.open("r", encoding="utf-8-sig", newline="") as handle: reader = csv.DictReader(handle) return [row for line, values in enumerate(reader, start=2) if (row := _row(values, source_name or path.name, "CSV", line))] def rows_from_xml(path: Path, source_name: str = "") -> list[dict[str, str]]: try: root = ET.parse(path).getroot() except ET.ParseError as exc: raise RuntimeError(f"XML 解析失败:{exc}") from exc result: list[dict[str, str]] = [] for line, element in enumerate(root.iter(), start=1): values: dict[str, object] = dict(element.attrib) for child in element: if len(child) == 0 and child.text: values[child.tag] = child.text if row := _row(values, source_name or path.name, element.tag.rsplit("}", 1)[-1], line): result.append(row) return result def rows_from_xlsx(path: Path, source_name: str = "") -> list[dict[str, str]]: try: from openpyxl import load_workbook except ImportError as exc: raise RuntimeError("导入 Excel 需要 openpyxl;请先在项目环境安装") from exc workbook = load_workbook(path, read_only=True, data_only=True) result: list[dict[str, str]] = [] for sheet in workbook.worksheets: rows = sheet.iter_rows(values_only=True) header_line, headers = 0, None for line, values in enumerate(rows, start=1): keys = {_clean_key(str(value or "")) for value in values} if any(key in keys for key in NAME_KEYS) and any(key in keys for key in ID_KEYS): header_line, headers = line, [str(value or "") for value in values] break if line >= 20: break if not headers: continue for line, values in enumerate(rows, start=header_line + 1): result_row = _row(dict(zip(headers, values)), source_name or path.name, sheet.title, line) if result_row: result.append(result_row) return result def import_roster(source: Path) -> Path: """保留原件并转换为 NextGen 统一名单 CSV。""" if not source.is_file(): raise RuntimeError(f"名单不存在:{source}") suffix = source.suffix.lower() if suffix not in {".csv", ".xml", ".xlsx", ".xlsm"}: raise RuntimeError("名单只支持 .csv、.xml、.xlsx、.xlsm") archive = WORK / "imports" / "roster" archive.mkdir(parents=True, exist_ok=True) safe_name = re.sub(r"[^A-Za-z0-9_.-]+", "_", source.name) or "roster" snapshot = archive / f"{datetime.now().strftime('%Y%m%d-%H%M%S-%f')}_{safe_name}" shutil.copy2(source, snapshot) rows = rows_from_csv(snapshot, source.name) if suffix == ".csv" else rows_from_xml(snapshot, source.name) if suffix == ".xml" else rows_from_xlsx(snapshot, source.name) if not rows: raise RuntimeError("没有找到有效乘车人:需要姓名和 18 位身份证号") ROSTER.parent.mkdir(parents=True, exist_ok=True) with ROSTER.open("w", encoding="utf-8-sig", newline="") as handle: writer = csv.DictWriter(handle, fieldnames=ROSTER_HEADERS) writer.writeheader(); writer.writerows(rows) return ROSTER def read_roster(value: str = "") -> list[dict[str, str]]: path = roster_path(value) if not path.exists(): raise RuntimeError(f"名单不存在:{path}") with path.open("r", encoding="utf-8-sig", newline="") as handle: rows = list(csv.DictReader(handle)) invalid = [row.get("姓名", "<未知>") for row in rows if not row.get("姓名") or not re.fullmatch(r"[0-9]{7}[0-9Xx]", str(row.get("身份证后8位", "")))] if invalid: raise RuntimeError(f"名单含无效身份证后八位:{', '.join(invalid[:5])}") return rows def normalize_name(value: str) -> str: return re.sub(r"\s+", "", (value or "").replace("*", "*")).upper() def masked_matches(masked: str, real: str) -> bool: masked, real = normalize_name(masked), normalize_name(real) if len(masked) != len(real): return False return all(a == "*" or a == b for a, b in zip(masked, real)) def match(rows: list[dict[str, str]], masked_name: str, id_first: str = "", id_last: str = "") -> list[dict[str, str]]: result = [] for person in rows: cert = (person.get("身份证号") or "").upper() if not masked_matches(masked_name, person.get("姓名", "")): continue if id_first and cert[:1] != id_first.upper(): continue if id_last and cert[-1:] != id_last.upper(): continue result.append(person) return result def progress_by_masked(task: dict, masked_name: str) -> dict | None: return next((item for item in reversed(task.setdefault("invoice", {}).setdefault("progress", [])) if item.get("masked_name") == masked_name), None) def record(task: dict, person: dict, masked_name: str, status: str, reason: str = "") -> dict: entry = {"masked_name": masked_name, "real_name": person.get("姓名", ""), "id_last8": person.get("身份证后8位", ""), "status": status, "reason": reason} task.setdefault("invoice", {}).setdefault("progress", []).append(entry) return entry