01-import_history.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. 导入学校历史学生和老师数据到 MySQL 表
  5. 用法:
  6. python import_history.py
  7. 依赖:
  8. pip install -r requirements.txt
  9. 说明:
  10. - 从同目录下的 db_config.py 读取 DB_CONFIG
  11. - 将 `student.xls` 导入到表 `his_xssj`
  12. - 将 `teacher.xlsx` 导入到表 `his_lssj`
  13. - 自动创建表(所有列使用 TEXT 类型),按批次写入以节省内存
  14. """
  15. from __future__ import annotations
  16. import os
  17. import sys
  18. import math
  19. import argparse
  20. import importlib.util
  21. from typing import List, Dict, Any
  22. import pandas as pd
  23. from sqlalchemy import create_engine, types as sql_types
  24. # Optional, used for xls/xlsx low-level reading
  25. try:
  26. import xlrd # for .xls
  27. except Exception:
  28. xlrd = None
  29. try:
  30. import openpyxl # for .xlsx
  31. except Exception:
  32. openpyxl = None
  33. def load_db_config_from_file(config_path: str) -> dict:
  34. spec = importlib.util.spec_from_file_location("db_config", config_path)
  35. module = importlib.util.module_from_spec(spec)
  36. spec.loader.exec_module(module) # type: ignore
  37. if not hasattr(module, "DB_CONFIG"):
  38. raise RuntimeError(f"No DB_CONFIG found in {config_path}")
  39. return getattr(module, "DB_CONFIG")
  40. def sanitize_columns(header: List[str]) -> List[str]:
  41. seen: Dict[str, int] = {}
  42. out: List[str] = []
  43. for i, col in enumerate(header):
  44. if col is None:
  45. name = f"col_{i+1}"
  46. else:
  47. name = str(col).strip()
  48. if name == "":
  49. name = f"col_{i+1}"
  50. # replace problematic characters
  51. name = name.replace(" ", "_").replace("\n", "_").replace("\r", "_")
  52. # ensure starts with letter or underscore
  53. if not name[0].isalpha() and name[0] != "_":
  54. name = f"c_{name}"
  55. base = name
  56. cnt = seen.get(base, 0)
  57. if cnt:
  58. name = f"{base}_{cnt}"
  59. seen[base] = cnt + 1
  60. out.append(name)
  61. return out
  62. def df_to_mysql_append(engine, df: pd.DataFrame, table_name: str, first_write: bool):
  63. # ensure all columns are strings (TEXT)
  64. df = df.where(pd.notnull(df), None)
  65. if first_write:
  66. # create table with TEXT columns by writing empty dataframe with dtype mapping
  67. empty = df.head(0)
  68. dtype_map = {col: sql_types.Text() for col in empty.columns}
  69. empty.to_sql(name=table_name, con=engine, index=False, if_exists="replace", dtype=dtype_map)
  70. # then append the first actual df
  71. df.to_sql(name=table_name, con=engine, index=False, if_exists="append", chunksize=1000, method="multi")
  72. else:
  73. df.to_sql(name=table_name, con=engine, index=False, if_exists="append", chunksize=1000, method="multi")
  74. def process_xls(filepath: str, engine, table_name: str, chunk_size: int = 2000):
  75. if xlrd is None:
  76. raise RuntimeError("Missing xlrd library to read .xls files. Install from requirements.")
  77. book = xlrd.open_workbook(filepath, formatting_info=False)
  78. sheet = book.sheet_by_index(0)
  79. header = [str(c).strip() for c in sheet.row_values(0)]
  80. header = sanitize_columns(header)
  81. total = sheet.nrows - 1
  82. print(f"读取 .xls 文件 {filepath}, 行数(不含表头): {total}")
  83. batch: List[Dict[str, Any]] = []
  84. first_write = True
  85. for r in range(1, sheet.nrows):
  86. row = sheet.row_values(r)
  87. # convert nan floats to None
  88. row = [None if (isinstance(x, float) and math.isnan(x)) else x for x in row]
  89. row_dict = {col: row[idx] if idx < len(row) else None for idx, col in enumerate(header)}
  90. batch.append(row_dict)
  91. if len(batch) >= chunk_size:
  92. df = pd.DataFrame(batch)
  93. df_to_mysql_append(engine, df, table_name, first_write)
  94. first_write = False
  95. batch = []
  96. print(f"已写入 {chunk_size} 行到 {table_name}")
  97. if batch:
  98. df = pd.DataFrame(batch)
  99. df_to_mysql_append(engine, df, table_name, first_write)
  100. print(f"写入剩余 {len(batch)} 行到 {table_name}")
  101. def process_xlsx(filepath: str, engine, table_name: str, chunk_size: int = 2000):
  102. if openpyxl is None:
  103. raise RuntimeError("Missing openpyxl library to read .xlsx files. Install from requirements.")
  104. wb = openpyxl.load_workbook(filename=filepath, read_only=True, data_only=True)
  105. sheet = wb.active
  106. rows = sheet.iter_rows(values_only=True)
  107. try:
  108. header_row = next(rows)
  109. except StopIteration:
  110. print(f"{filepath} 是空文件")
  111. return
  112. header = [str(c).strip() if c is not None else "" for c in header_row]
  113. header = sanitize_columns(header)
  114. batch: List[Dict[str, Any]] = []
  115. first_write = True
  116. written = 0
  117. for row in rows:
  118. row = [None if (isinstance(x, float) and math.isnan(x)) else x for x in row]
  119. row_dict = {col: row[idx] if idx < len(row) else None for idx, col in enumerate(header)}
  120. batch.append(row_dict)
  121. if len(batch) >= chunk_size:
  122. df = pd.DataFrame(batch)
  123. df_to_mysql_append(engine, df, table_name, first_write)
  124. first_write = False
  125. written += len(batch)
  126. batch = []
  127. print(f"已写入 {written} 行到 {table_name}")
  128. if batch:
  129. df = pd.DataFrame(batch)
  130. df_to_mysql_append(engine, df, table_name, first_write)
  131. written += len(batch)
  132. print(f"写入剩余 {len(batch)} 行到 {table_name},总计写入 {written} 行")
  133. def process_file(filepath: str, engine, table_name: str, chunk_size: int = 2000):
  134. ext = os.path.splitext(filepath)[1].lower()
  135. if ext == ".xls":
  136. process_xls(filepath, engine, table_name, chunk_size=chunk_size)
  137. elif ext in (".xlsx",):
  138. process_xlsx(filepath, engine, table_name, chunk_size=chunk_size)
  139. else:
  140. # try pandas fallback (may be memory heavy)
  141. print(f"未知扩展名 {ext},尝试使用 pandas 直接读取(可能占用大量内存)")
  142. df = pd.read_excel(filepath, engine="openpyxl" if ext == ".xlsx" else None)
  143. df.columns = sanitize_columns(list(df.columns))
  144. df_to_mysql_append(engine, df, table_name, first_write=True)
  145. def build_engine_from_config(db_config: dict):
  146. user = db_config.get("user")
  147. password = db_config.get("password")
  148. host = db_config.get("host", "localhost")
  149. port = db_config.get("port", 3306)
  150. database = db_config.get("database")
  151. charset = db_config.get("charset", "utf8mb4")
  152. uri = f"mysql+pymysql://{user}:{password}@{host}:{port}/{database}?charset={charset}"
  153. engine = create_engine(uri, pool_pre_ping=True)
  154. return engine
  155. def main(args):
  156. base_dir = os.path.dirname(os.path.abspath(__file__))
  157. config_path = os.path.join(base_dir, "db_config.py")
  158. if not os.path.exists(config_path):
  159. print(f"找不到配置文件: {config_path}")
  160. sys.exit(1)
  161. db_config = load_db_config_from_file(config_path)
  162. engine = build_engine_from_config(db_config)
  163. # 在每次导入前询问是否清空目标表(或使用 --yes 自动确认)
  164. should_truncate = False
  165. if args and getattr(args, "yes", False):
  166. should_truncate = True
  167. else:
  168. resp = input("是否在导入前清空目标表 `his_xssj` 和 `his_lssj`?(yes/no) [no]: ").strip().lower()
  169. if resp in ("y", "yes"):
  170. should_truncate = True
  171. if should_truncate:
  172. print("尝试清空目标表: his_xssj, his_lssj ...")
  173. try:
  174. with engine.begin() as conn:
  175. # 关闭外键检查以确保能够 truncate 有外键依赖的表
  176. conn.exec_driver_sql("SET FOREIGN_KEY_CHECKS=0;")
  177. conn.exec_driver_sql("TRUNCATE TABLE `his_xssj`;")
  178. conn.exec_driver_sql("TRUNCATE TABLE `his_lssj`;")
  179. conn.exec_driver_sql("SET FOREIGN_KEY_CHECKS=1;")
  180. print("目标表已清空(如果表存在且有权限)")
  181. except Exception as e:
  182. print(f"清空表时发生异常(可能表不存在或无权限),继续执行导入。错误:{e}")
  183. else:
  184. print("跳过清空表,继续导入(若需要清空请重新运行并使用 --yes 参数)。")
  185. student_file = os.path.join(base_dir, "student1222.xls")
  186. teacher_file = os.path.join(base_dir, "teacher.xlsx")
  187. if not os.path.exists(student_file):
  188. print(f"找不到学生文件: {student_file}")
  189. else:
  190. print("开始导入学生数据到表 his_xssj ...")
  191. process_file(student_file, engine, "his_xssj", chunk_size=2000)
  192. print("学生数据导入完成 -> his_xssj")
  193. if not os.path.exists(teacher_file):
  194. print(f"找不到老师文件: {teacher_file}")
  195. else:
  196. print("开始导入老师数据到表 his_lssj ...")
  197. process_file(teacher_file, engine, "his_lssj", chunk_size=2000)
  198. print("老师数据导入完成 -> his_lssj")
  199. print("全部导入完成。")
  200. if __name__ == "__main__":
  201. parser = argparse.ArgumentParser(description="导入历史学生/老师数据或生成创建表SQL")
  202. parser.add_argument("--dump-create-sql", action="store_true", help="只读取表头并生成 CREATE TABLE SQL 文件(不写入数据库)")
  203. parser.add_argument("--student-file", type=str, default=None, help="学生文件路径,默认同目录下 student.xls")
  204. parser.add_argument("--teacher-file", type=str, default=None, help="老师文件路径,默认同目录下 teacher.xlsx")
  205. parser.add_argument("--yes", action="store_true", help="在导入前自动确认清空目标表(跳过交互式确认)")
  206. args = parser.parse_args()
  207. if args.dump_create_sql:
  208. # generate CREATE TABLE SQL based on excel headers
  209. def generate_create_table_sql(table_name: str, cols: List[str]) -> str:
  210. # base fields required by project
  211. lines: List[str] = []
  212. lines.append(f"CREATE TABLE `{table_name}` (")
  213. lines.append(" `id` BIGINT(20) NOT NULL AUTO_INCREMENT,")
  214. lines.append(" `tenant_id` BIGINT(20) DEFAULT NULL,")
  215. lines.append(" `deleted` TINYINT(1) NOT NULL DEFAULT 0,")
  216. lines.append(" `create_by` BIGINT(20) DEFAULT NULL,")
  217. lines.append(" `create_time` DATETIME DEFAULT NULL,")
  218. lines.append(" `update_by` BIGINT(20) DEFAULT NULL,")
  219. lines.append(" `update_time` DATETIME DEFAULT NULL,")
  220. # add excel columns as TEXT
  221. for col in cols:
  222. lines.append(f" `{col}` TEXT DEFAULT NULL,")
  223. lines.append(" PRIMARY KEY (`id`)")
  224. lines.append(") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;")
  225. return "\n".join(lines)
  226. base_dir = os.path.dirname(os.path.abspath(__file__))
  227. student_path = args.student_file or os.path.join(base_dir, "student1222.xls")
  228. teacher_path = args.teacher_file or os.path.join(base_dir, "teacher.xlsx")
  229. for path, table in ((student_path, "his_xssj"), (teacher_path, "his_lssj")):
  230. if not os.path.exists(path):
  231. print(f"跳过,找不到文件: {path}")
  232. continue
  233. ext = os.path.splitext(path)[1].lower()
  234. try:
  235. # read header only
  236. if ext == ".xls":
  237. df = pd.read_excel(path, nrows=0, engine="xlrd")
  238. else:
  239. df = pd.read_excel(path, nrows=0, engine="openpyxl")
  240. except Exception as e:
  241. # fallback
  242. print(f"读取表头失败 ({path}): {e}")
  243. continue
  244. raw_cols = list(df.columns)
  245. safe_cols = sanitize_columns([str(c) for c in raw_cols])
  246. sql = generate_create_table_sql(table, safe_cols)
  247. out_file = os.path.join(base_dir, f"{table}_create.sql")
  248. with open(out_file, "w", encoding="utf-8") as fh:
  249. fh.write(sql)
  250. print(f"已生成 {out_file},包含 {len(safe_cols)} 列(不含基础字段)")
  251. else:
  252. main(args)