01_import_excel_to_temp.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. #!/usr/bin/env python
  2. # -*- coding: utf-8 -*-
  3. """
  4. 学生操行分历史数据迁移 - 脚本1:Excel数据导入临时表
  5. 功能说明:
  6. - 读取操行.xls文件
  7. - 数据清洗和验证
  8. - 导入到temp_conduct_score_import临时表
  9. 执行方式:
  10. python 01_import_excel_to_temp.py
  11. 注意事项:
  12. - 执行前确保Excel文件存在
  13. - 执行前确保临时表已创建
  14. - 支持断点续传(已导入的数据会跳过)
  15. """
  16. from __future__ import annotations
  17. import argparse
  18. import logging
  19. import sys
  20. import time
  21. from datetime import datetime
  22. from pathlib import Path
  23. from typing import Any, Dict, List, Optional
  24. import pandas as pd
  25. import pymysql
  26. from pymysql.cursors import DictCursor
  27. from db_config import DB_CONFIG, TABLE_CONFIG, CONSTANTS
  28. # 配置日志
  29. logging.basicConfig(
  30. level=logging.INFO,
  31. format="%(asctime)s %(levelname)s %(message)s",
  32. handlers=[
  33. logging.StreamHandler(sys.stdout),
  34. logging.FileHandler("logs/01_import_excel.log", mode='a', encoding='utf-8')
  35. ]
  36. )
  37. logger = logging.getLogger("import_excel")
  38. class ExcelImporter:
  39. """Excel导入处理器"""
  40. def __init__(self, excel_file: str):
  41. self.excel_file = excel_file
  42. self.db_config = DB_CONFIG
  43. self.temp_table = TABLE_CONFIG["temp_import"]
  44. self.batch_size = CONSTANTS["BATCH_SIZE"]
  45. self.connection = None
  46. def connect_db(self) -> None:
  47. """连接数据库"""
  48. try:
  49. self.connection = pymysql.connect(**self.db_config)
  50. logger.info("数据库连接成功")
  51. except Exception as e:
  52. logger.error(f"数据库连接失败: {e}")
  53. raise
  54. def disconnect_db(self) -> None:
  55. """断开数据库连接"""
  56. if self.connection:
  57. self.connection.close()
  58. logger.info("数据库连接已断开")
  59. def read_excel_data(self) -> pd.DataFrame:
  60. """读取Excel文件"""
  61. try:
  62. logger.info(f"开始读取Excel文件: {self.excel_file}")
  63. # 检查文件是否存在
  64. if not Path(self.excel_file).exists():
  65. raise FileNotFoundError(f"Excel文件不存在: {self.excel_file}")
  66. # 读取Excel文件
  67. df = pd.read_excel(self.excel_file)
  68. logger.info(f"Excel文件读取成功,共{len(df)}行数据")
  69. logger.info(f"列名: {list(df.columns)}")
  70. return df
  71. except Exception as e:
  72. logger.error(f"读取Excel文件失败: {e}")
  73. raise
  74. def validate_data(self, df: pd.DataFrame) -> pd.DataFrame:
  75. """数据验证和清洗"""
  76. logger.info("开始数据验证和清洗")
  77. # 检查必需列
  78. required_columns = ['学号', '学年', '学期', '平时分数']
  79. missing_columns = [col for col in required_columns if col not in df.columns]
  80. if missing_columns:
  81. raise ValueError(f"Excel文件缺少必需列: {missing_columns}")
  82. # 数据清洗
  83. original_count = len(df)
  84. # 删除空行(学号为空的行)
  85. df = df.dropna(subset=['学号'])
  86. logger.info(f"删除空学号行: {original_count - len(df)}行")
  87. # 去除学号前后的空格
  88. df.loc[:, '学号'] = df['学号'].astype(str).str.strip()
  89. # 删除重复的学号+学年+学期记录(保留第一条)
  90. before_dedupe = len(df)
  91. df = df.drop_duplicates(subset=['学号', '学年', '学期'], keep='first')
  92. logger.info(f"删除重复记录: {before_dedupe - len(df)}行")
  93. # 验证学年格式
  94. df.loc[:, 'academic_year_valid'] = df['学年'].astype(str).str.match(r'^\d{4}-\d{4}$')
  95. invalid_years = df[~df['academic_year_valid']]
  96. if len(invalid_years) > 0:
  97. logger.warning(f"发现{len(invalid_years)}条学年格式不正确的记录:")
  98. for idx, row in invalid_years.head(5).iterrows():
  99. logger.warning(f" 行{idx+2}: 学号={row['学号']}, 学年={row['学年']}")
  100. # 验证学期格式
  101. valid_semesters = ['第一学期', '第二学期']
  102. df.loc[:, 'semester_valid'] = df['学期'].isin(valid_semesters)
  103. invalid_semesters = df[~df['semester_valid']]
  104. if len(invalid_semesters) > 0:
  105. logger.warning(f"发现{len(invalid_semesters)}条学期格式不正确的记录:")
  106. for idx, row in invalid_semesters.head(5).iterrows():
  107. logger.warning(f" 行{idx+2}: 学号={row['学号']}, 学期={row['学期']}")
  108. # 验证平时分数格式(非空且包含冒号)
  109. df.loc[:, 'monthly_scores_valid'] = df['平时分数'].astype(str).str.contains(':')
  110. invalid_scores = df[~df['monthly_scores_valid']]
  111. if len(invalid_scores) > 0:
  112. logger.warning(f"发现{len(invalid_scores)}条平时分数格式不正确的记录:")
  113. for idx, row in invalid_scores.head(5).iterrows():
  114. logger.warning(f" 行{idx+2}: 学号={row['学号']}, 平时分数={row['平时分数']}")
  115. # 验证数值范围
  116. df.loc[:, 'final_score_valid'] = True
  117. final_scores = pd.to_numeric(df['最终分'], errors='coerce')
  118. # 检查最终分范围 (decimal(5,2) 最大999.99)
  119. invalid_final_scores = final_scores[(final_scores < 0) | (final_scores > 999.99)]
  120. if len(invalid_final_scores) > 0:
  121. df.loc[:, 'final_score_valid'] = ~final_scores.isin(invalid_final_scores)
  122. logger.warning(f"发现{len(invalid_final_scores)}条最终分数值超出范围(0-999.99):")
  123. for idx in invalid_final_scores.head(5).index:
  124. logger.warning(f" 行{idx+2}: 学号={df.loc[idx, '学号']}, 最终分={final_scores.loc[idx]}")
  125. # 统计验证结果
  126. valid_count = len(df[df['academic_year_valid'] & df['semester_valid'] & df['monthly_scores_valid'] & df['final_score_valid']])
  127. logger.info(f"数据验证完成: 总计{len(df)}行,验证通过{valid_count}行")
  128. # 处理超出范围的数值
  129. df.loc[:, '最终分'] = final_scores.clip(0, 999.99) # 将超出范围的值截断到有效范围
  130. # 清理临时列
  131. df = df.drop(columns=['academic_year_valid', 'semester_valid', 'monthly_scores_valid', 'final_score_valid'])
  132. return df
  133. def check_existing_data(self, student_nos: List[str]) -> set:
  134. """检查已存在的学号"""
  135. try:
  136. with self.connection.cursor() as cursor:
  137. # 分批查询已存在的学号
  138. existing_nos = set()
  139. batch_size = 1000
  140. for i in range(0, len(student_nos), batch_size):
  141. batch = student_nos[i:i+batch_size]
  142. placeholders = ','.join(['%s'] * len(batch))
  143. sql = f"""
  144. SELECT DISTINCT student_no
  145. FROM {self.temp_table}
  146. WHERE student_no IN ({placeholders})
  147. """
  148. cursor.execute(sql, batch)
  149. results = cursor.fetchall()
  150. existing_nos.update([row[0] for row in results])
  151. logger.info(f"检查到{len(existing_nos)}个学号已存在,将跳过导入")
  152. return existing_nos
  153. except Exception as e:
  154. logger.error(f"检查已存在数据失败: {e}")
  155. return set()
  156. def insert_batch(self, batch_data: List[Dict[str, Any]]) -> tuple[int, int]:
  157. """批量插入数据"""
  158. if not batch_data:
  159. return 0, 0
  160. try:
  161. with self.connection.cursor() as cursor:
  162. sql = """
  163. INSERT INTO {table} (
  164. student_no, student_name, gender, ethnicity,
  165. academic_year_str, semester_str, monthly_scores,
  166. final_score, grade_level, department, major,
  167. class_name, grade, import_status
  168. ) VALUES (
  169. %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'pending'
  170. )
  171. """.format(table=self.temp_table)
  172. values = []
  173. for row in batch_data:
  174. # 处理NaN值和数值范围,转换为None或合适的值
  175. def safe_value(key, default='', is_numeric=False):
  176. val = row.get(key, default)
  177. if pd.isna(val):
  178. return None
  179. if is_numeric and isinstance(val, (int, float)):
  180. # 对于数值字段,确保在有效范围内
  181. if key == '最终分':
  182. return max(0, min(999.99, float(val)))
  183. return val
  184. values.append((
  185. safe_value('学号', ''),
  186. safe_value('姓名', ''),
  187. safe_value('性别', ''),
  188. safe_value('民族', ''),
  189. safe_value('学年', ''),
  190. safe_value('学期', ''),
  191. safe_value('平时分数', ''),
  192. safe_value('最终分', is_numeric=True), # 数值字段,NaN转换为None
  193. safe_value('等级', ''),
  194. safe_value('院系', ''),
  195. safe_value('专业', ''),
  196. safe_value('班级', ''),
  197. safe_value('年级', '')
  198. ))
  199. cursor.executemany(sql, values)
  200. self.connection.commit()
  201. return len(batch_data), 0
  202. except Exception as e:
  203. logger.error(f"批量插入失败: {e}")
  204. self.connection.rollback()
  205. return 0, len(batch_data)
  206. def import_data(self, df: pd.DataFrame) -> Dict[str, Any]:
  207. """导入数据到临时表"""
  208. logger.info("开始导入数据到临时表")
  209. # 获取所有学号
  210. student_nos = df['学号'].tolist()
  211. # 检查已存在的数据
  212. existing_nos = self.check_existing_data(student_nos)
  213. # 过滤掉已存在的数据
  214. df_to_import = df[~df['学号'].isin(existing_nos)]
  215. skipped_count = len(df) - len(df_to_import)
  216. logger.info(f"准备导入{len(df_to_import)}行数据,跳过{skipped_count}行已存在数据")
  217. if len(df_to_import) == 0:
  218. logger.info("没有需要导入的新数据")
  219. return {
  220. 'total': len(df),
  221. 'skipped': skipped_count,
  222. 'imported': 0,
  223. 'failed': 0
  224. }
  225. # 批量导入
  226. total_imported = 0
  227. total_failed = 0
  228. for i in range(0, len(df_to_import), self.batch_size):
  229. batch_df = df_to_import.iloc[i:i+self.batch_size]
  230. batch_data = batch_df.to_dict('records')
  231. imported, failed = self.insert_batch(batch_data)
  232. total_imported += imported
  233. total_failed += failed
  234. logger.info(f"处理进度: {min(i+self.batch_size, len(df_to_import))}/{len(df_to_import)} "
  235. f"(成功:{total_imported}, 失败:{total_failed})")
  236. result = {
  237. 'total': len(df),
  238. 'skipped': skipped_count,
  239. 'imported': total_imported,
  240. 'failed': total_failed
  241. }
  242. logger.info(f"数据导入完成: {result}")
  243. return result
  244. def run(self) -> bool:
  245. """执行导入流程"""
  246. start_time = time.time()
  247. try:
  248. # 连接数据库
  249. self.connect_db()
  250. # 读取Excel数据
  251. df = self.read_excel_data()
  252. # 数据验证和清洗
  253. df = self.validate_data(df)
  254. # 导入数据
  255. result = self.import_data(df)
  256. # 输出统计信息
  257. logger.info("="*50)
  258. logger.info("导入统计结果:")
  259. logger.info(f" 总记录数: {result['total']}")
  260. logger.info(f" 跳过已存在: {result['skipped']}")
  261. logger.info(f" 成功导入: {result['imported']}")
  262. logger.info(f" 导入失败: {result['failed']}")
  263. logger.info(f"导入总耗时: {elapsed_time:.1f}秒")
  264. logger.info("="*50)
  265. return result['failed'] == 0
  266. except Exception as e:
  267. logger.error(f"导入过程出现错误: {e}")
  268. return False
  269. finally:
  270. self.disconnect_db()
  271. elapsed_time = time.time() - start_time
  272. logger.info(f"导入总耗时: {elapsed_time:.1f}秒")
  273. def main():
  274. """主函数"""
  275. parser = argparse.ArgumentParser(description='学生操行分历史数据 - Excel导入临时表')
  276. parser.add_argument('--excel', default=CONSTANTS["EXCEL_FILE"],
  277. help=f'Excel文件路径 (默认: {CONSTANTS["EXCEL_FILE"]})')
  278. args = parser.parse_args()
  279. logger.info("="*60)
  280. logger.info("学生操行分历史数据迁移 - 脚本1:Excel导入临时表")
  281. logger.info("="*60)
  282. # 创建logs目录
  283. Path("logs").mkdir(exist_ok=True)
  284. # 执行导入
  285. importer = ExcelImporter(args.excel)
  286. success = importer.run()
  287. if success:
  288. logger.info("✅ Excel数据导入完成")
  289. sys.exit(0)
  290. else:
  291. logger.error("❌ Excel数据导入失败")
  292. sys.exit(1)
  293. if __name__ == "__main__":
  294. main()