03_link_student_data.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. #!/usr/bin/env python
  2. # -*- coding: utf-8 -*-
  3. """
  4. 学生操行分历史数据迁移 - 脚本3:数据关联处理
  5. 功能说明:
  6. - 根据学号关联xg_student_msg表,获取学生ID
  7. - 根据班级ID获取班主任信息
  8. - 更新temp_conduct_score_parsed表中的关联字段
  9. 执行方式:
  10. python 03_link_student_data.py
  11. 注意事项:
  12. - 依赖脚本2已执行完成
  13. - 学号不存在的学生记录会标记为错误状态
  14. """
  15. from __future__ import annotations
  16. import argparse
  17. import logging
  18. import sys
  19. import time
  20. from datetime import datetime
  21. from typing import Any, Dict, List, Optional, Tuple
  22. import pymysql
  23. from pymysql.cursors import DictCursor
  24. from db_config import DB_CONFIG, TABLE_CONFIG, CONSTANTS
  25. # 配置日志
  26. logging.basicConfig(
  27. level=logging.INFO,
  28. format="%(asctime)s %(levelname)s %(message)s",
  29. handlers=[
  30. logging.StreamHandler(sys.stdout),
  31. logging.FileHandler("logs/03_link_data.log", mode='a', encoding='utf-8')
  32. ]
  33. )
  34. logger = logging.getLogger("link_data")
  35. class DataLinker:
  36. """数据关联处理器"""
  37. def __init__(self):
  38. self.db_config = DB_CONFIG
  39. self.temp_parsed_table = TABLE_CONFIG["temp_parsed"]
  40. self.students_table = TABLE_CONFIG["students"]
  41. self.classes_table = TABLE_CONFIG["classes"]
  42. self.batch_size = CONSTANTS["BATCH_SIZE"]
  43. self.connection = None
  44. def connect_db(self) -> None:
  45. """连接数据库"""
  46. try:
  47. self.connection = pymysql.connect(**self.db_config)
  48. logger.info("数据库连接成功")
  49. except Exception as e:
  50. logger.error(f"数据库连接失败: {e}")
  51. raise
  52. def disconnect_db(self) -> None:
  53. """断开数据库连接"""
  54. if self.connection:
  55. self.connection.close()
  56. logger.info("数据库连接已断开")
  57. def get_pending_data(self) -> List[Dict[str, Any]]:
  58. """获取待关联的数据"""
  59. try:
  60. with self.connection.cursor(DictCursor) as cursor:
  61. sql = f"""
  62. SELECT id, temp_id, student_no, academic_year, semester, month, score
  63. FROM {self.temp_parsed_table}
  64. WHERE processing_status = 'parsed'
  65. ORDER BY id
  66. LIMIT {self.batch_size}
  67. """
  68. cursor.execute(sql)
  69. return cursor.fetchall()
  70. except Exception as e:
  71. logger.error(f"获取待关联数据失败: {e}")
  72. return []
  73. def link_student_info(self, student_nos: List[str]) -> Dict[str, Dict[str, Any]]:
  74. """批量关联学生信息"""
  75. if not student_nos:
  76. return {}
  77. try:
  78. with self.connection.cursor(DictCursor) as cursor:
  79. # 构建IN查询
  80. placeholders = ','.join(['%s'] * len(student_nos))
  81. sql = f"""
  82. SELECT
  83. s.id as student_id,
  84. s.student_no,
  85. s.class_id,
  86. c.class_teacher_id
  87. FROM {self.students_table} s
  88. LEFT JOIN {self.classes_table} c ON s.class_id = c.id
  89. WHERE s.student_no IN ({placeholders})
  90. AND s.deleted = 0
  91. AND (c.id IS NULL OR c.deleted = 0)
  92. """
  93. cursor.execute(sql, student_nos)
  94. results = cursor.fetchall()
  95. # 转换为字典格式
  96. student_info = {}
  97. for row in results:
  98. student_info[row['student_no']] = {
  99. 'student_id': row['student_id'],
  100. 'class_id': row['class_id'],
  101. 'class_teacher_id': row['class_teacher_id']
  102. }
  103. return student_info
  104. except Exception as e:
  105. logger.error(f"关联学生信息失败: {e}")
  106. return {}
  107. def update_parsed_data(self, update_data: List[Dict[str, Any]]) -> tuple[int, int]:
  108. """更新解析表中的关联信息"""
  109. if not update_data:
  110. return 0, 0
  111. try:
  112. with self.connection.cursor() as cursor:
  113. # 分别处理成功和失败的数据
  114. success_updates = []
  115. error_updates = []
  116. for item in update_data:
  117. if item.get('student_id'):
  118. success_updates.append(item)
  119. else:
  120. error_updates.append(item)
  121. # 更新成功的记录
  122. success_count = 0
  123. if success_updates:
  124. sql = f"""
  125. UPDATE {self.temp_parsed_table}
  126. SET student_id = %s, class_id = %s, class_teacher_id = %s,
  127. processing_status = 'linked', update_time = NOW()
  128. WHERE id = %s
  129. """
  130. values = [(item['student_id'], item['class_id'], item['class_teacher_id'], item['id'])
  131. for item in success_updates]
  132. cursor.executemany(sql, values)
  133. success_count = len(success_updates)
  134. # 更新失败的记录
  135. error_count = 0
  136. if error_updates:
  137. sql = f"""
  138. UPDATE {self.temp_parsed_table}
  139. SET processing_status = 'error', error_msg = %s, update_time = NOW()
  140. WHERE id = %s
  141. """
  142. values = [("学号不存在或学生已被删除", item['id']) for item in error_updates]
  143. cursor.executemany(sql, values)
  144. error_count = len(error_updates)
  145. self.connection.commit()
  146. return success_count, error_count
  147. except Exception as e:
  148. logger.error(f"更新关联数据失败: {e}")
  149. self.connection.rollback()
  150. return 0, len(update_data)
  151. def process_batch(self, batch_data: List[Dict[str, Any]]) -> Dict[str, Any]:
  152. """处理一批数据"""
  153. if not batch_data:
  154. return {'processed': 0, 'linked': 0, 'not_found': 0}
  155. # 提取学号列表
  156. student_nos = list(set(row['student_no'] for row in batch_data))
  157. # 关联学生信息
  158. student_info = self.link_student_info(student_nos)
  159. # 构建更新数据
  160. update_data = []
  161. not_found_nos = set()
  162. for row in batch_data:
  163. student_no = row['student_no']
  164. info = student_info.get(student_no)
  165. if info:
  166. update_data.append({
  167. 'id': row['id'],
  168. 'student_id': info['student_id'],
  169. 'class_id': info['class_id'],
  170. 'class_teacher_id': info['class_teacher_id']
  171. })
  172. else:
  173. update_data.append({
  174. 'id': row['id'],
  175. 'student_id': None,
  176. 'class_id': None,
  177. 'class_teacher_id': None
  178. })
  179. not_found_nos.add(student_no)
  180. # 输出未找到的学号
  181. if not_found_nos:
  182. logger.warning(f"未找到以下学号的学生信息 ({len(not_found_nos)}个):")
  183. for student_no in sorted(list(not_found_nos)[:10]): # 只显示前10个
  184. logger.warning(f" {student_no}")
  185. if len(not_found_nos) > 10:
  186. logger.warning(f" ... 还有{len(not_found_nos) - 10}个")
  187. # 更新数据库
  188. linked, not_linked = self.update_parsed_data(update_data)
  189. return {
  190. 'processed': len(batch_data),
  191. 'linked': linked,
  192. 'not_found': not_linked,
  193. 'unique_students': len(student_nos),
  194. 'found_students': len(student_info)
  195. }
  196. def get_statistics(self) -> Dict[str, Any]:
  197. """获取处理统计"""
  198. try:
  199. with self.connection.cursor(DictCursor) as cursor:
  200. sql = f"""
  201. SELECT
  202. COUNT(*) as total,
  203. SUM(CASE WHEN processing_status = 'parsed' THEN 1 ELSE 0 END) as pending,
  204. SUM(CASE WHEN processing_status = 'linked' THEN 1 ELSE 0 END) as linked,
  205. SUM(CASE WHEN processing_status = 'error' THEN 1 ELSE 0 END) as error
  206. FROM {self.temp_parsed_table}
  207. """
  208. cursor.execute(sql)
  209. result = cursor.fetchone()
  210. return {
  211. 'total_records': result['total'] or 0,
  212. 'pending': result['pending'] or 0,
  213. 'linked': result['linked'] or 0,
  214. 'error': result['error'] or 0
  215. }
  216. except Exception as e:
  217. logger.error(f"获取统计信息失败: {e}")
  218. return {'total_records': 0, 'pending': 0, 'linked': 0, 'error': 0}
  219. def link_student_data_efficient(self) -> Tuple[int, int]:
  220. """高效的关联学生数据 - 使用单次UPDATE JOIN操作"""
  221. try:
  222. with self.connection.cursor() as cursor:
  223. # 使用UPDATE JOIN一次性关联所有数据
  224. sql = f"""
  225. UPDATE {self.temp_parsed_table} t
  226. LEFT JOIN {self.students_table} s ON t.student_no = s.student_no
  227. AND s.deleted = 0
  228. LEFT JOIN {self.classes_table} c ON s.class_id = c.id
  229. AND c.deleted = 0
  230. SET
  231. t.student_id = s.id,
  232. t.class_id = s.class_id,
  233. t.class_teacher_id = c.class_teacher_id,
  234. t.processing_status = CASE
  235. WHEN s.id IS NOT NULL THEN 'linked'
  236. ELSE 'error'
  237. END,
  238. t.error_msg = CASE
  239. WHEN s.id IS NULL THEN '学号不存在或学生已被删除'
  240. ELSE NULL
  241. END,
  242. t.update_time = NOW()
  243. WHERE t.processing_status = 'parsed'
  244. """
  245. affected_rows = cursor.execute(sql)
  246. self.connection.commit()
  247. # 统计成功和失败的数量
  248. success_count = 0
  249. error_count = 0
  250. if affected_rows > 0:
  251. # 获取更新后的统计
  252. with self.connection.cursor() as count_cursor:
  253. count_sql = f"""
  254. SELECT
  255. SUM(CASE WHEN processing_status = 'linked' THEN 1 ELSE 0 END) as linked,
  256. SUM(CASE WHEN processing_status = 'error' THEN 1 ELSE 0 END) as error
  257. FROM {self.temp_parsed_table}
  258. WHERE processing_status IN ('linked', 'error')
  259. """
  260. count_cursor.execute(count_sql)
  261. result = count_cursor.fetchone()
  262. success_count = result[0] or 0
  263. error_count = result[1] or 0
  264. logger.info(f"UPDATE JOIN操作完成,影响行数: {affected_rows}")
  265. return success_count, error_count
  266. except Exception as e:
  267. logger.error(f"高效关联操作失败: {e}")
  268. self.connection.rollback()
  269. return 0, 0
  270. def run(self) -> bool:
  271. """执行关联流程"""
  272. start_time = time.time()
  273. try:
  274. self.connect_db()
  275. # 显示初始统计
  276. initial_stats = self.get_statistics()
  277. logger.info("数据关联处理开始")
  278. logger.info(f"当前状态: 总计{initial_stats['total_records']}条记录,"
  279. f"待关联{initial_stats['pending']}条,已关联{initial_stats['linked']}条,"
  280. f"错误{initial_stats['error']}条")
  281. # 使用高效的UPDATE JOIN方式处理所有数据
  282. logger.info("开始执行高效关联操作...")
  283. linked, error = self.link_student_data_efficient()
  284. # 显示最终统计
  285. final_stats = self.get_statistics()
  286. logger.info("="*50)
  287. logger.info("数据关联统计结果:")
  288. logger.info(f" 关联成功: {linked}")
  289. logger.info(f" 学号未找到: {error}")
  290. logger.info(f" 最终状态:")
  291. logger.info(f" - 已关联: {final_stats['linked']}")
  292. logger.info(f" - 关联失败: {final_stats['error']}")
  293. logger.info("="*50)
  294. # 如果有未找到的学号,输出详细信息
  295. if error > 0:
  296. logger.warning(f"共有{error}条记录的学号未找到对应学生信息")
  297. logger.warning("这些记录将无法导入到正式表,请检查学生数据")
  298. return error == 0
  299. except Exception as e:
  300. logger.error(f"关联过程出现错误: {e}")
  301. return False
  302. finally:
  303. self.disconnect_db()
  304. elapsed_time = time.time() - start_time
  305. logger.info(f"关联总耗时: {elapsed_time:.1f}秒")
  306. def main():
  307. """主函数"""
  308. parser = argparse.ArgumentParser(description='学生操行分历史数据 - 数据关联处理')
  309. logger.info("="*60)
  310. logger.info("学生操行分历史数据迁移 - 脚本3:数据关联处理")
  311. logger.info("="*60)
  312. # 创建logs目录
  313. import os
  314. os.makedirs("logs", exist_ok=True)
  315. # 执行关联
  316. linker = DataLinker()
  317. success = linker.run()
  318. if success:
  319. logger.info("✅ 数据关联处理完成")
  320. sys.exit(0)
  321. else:
  322. logger.warning("⚠️ 数据关联处理完成,但存在未关联的记录")
  323. sys.exit(0) # 不算错误,只是警告
  324. if __name__ == "__main__":
  325. main()