158 lines
6.9 KiB
Python
158 lines
6.9 KiB
Python
"""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
|