autotrain/nextgen/autotrain_next/ledger.py
2026-07-24 10:37:32 +08:00

158 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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