05_validate_and_cleanup.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. #!/usr/bin/env python
  2. # -*- coding: utf-8 -*-
  3. """
  4. 学生操行分历史数据迁移 - 脚本5:验证与清理
  5. 功能说明:
  6. - 验证迁移结果的完整性和正确性
  7. - 生成详细的迁移报告
  8. - 可选择清理临时表和日志
  9. 执行方式:
  10. python 05_validate_and_cleanup.py [--cleanup]
  11. 注意事项:
  12. - 默认只做验证,不清理数据
  13. - 使用--cleanup参数才会清理临时表
  14. """
  15. from __future__ import annotations
  16. import argparse
  17. import csv
  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 pymysql
  25. from pymysql.cursors import DictCursor
  26. from db_config import DB_CONFIG, TABLE_CONFIG, CONSTANTS
  27. # 配置日志
  28. logging.basicConfig(
  29. level=logging.INFO,
  30. format="%(asctime)s %(levelname)s %(message)s",
  31. handlers=[
  32. logging.StreamHandler(sys.stdout),
  33. logging.FileHandler("logs/05_validate_cleanup.log", mode='a', encoding='utf-8')
  34. ]
  35. )
  36. logger = logging.getLogger("validate_cleanup")
  37. class MigrationValidator:
  38. """迁移验证和清理处理器"""
  39. def __init__(self, cleanup: bool = False):
  40. self.db_config = DB_CONFIG
  41. self.temp_import_table = TABLE_CONFIG["temp_import"]
  42. self.temp_parsed_table = TABLE_CONFIG["temp_parsed"]
  43. self.production_table = TABLE_CONFIG["production"]
  44. self.students_table = TABLE_CONFIG["students"]
  45. self.classes_table = TABLE_CONFIG["classes"]
  46. self.cleanup = cleanup
  47. self.connection = None
  48. self.report_data = {}
  49. def connect_db(self) -> None:
  50. """连接数据库"""
  51. try:
  52. self.connection = pymysql.connect(**self.db_config)
  53. logger.info("数据库连接成功")
  54. except Exception as e:
  55. logger.error(f"数据库连接失败: {e}")
  56. raise
  57. def disconnect_db(self) -> None:
  58. """断开数据库连接"""
  59. if self.connection:
  60. self.connection.close()
  61. logger.info("数据库连接已断开")
  62. def get_migration_statistics(self) -> Dict[str, Any]:
  63. """获取迁移统计数据"""
  64. try:
  65. with self.connection.cursor(DictCursor) as cursor:
  66. stats = {}
  67. # Excel导入表统计
  68. sql_import = f"""
  69. SELECT
  70. COUNT(*) as total_import,
  71. SUM(CASE WHEN import_status = 'pending' THEN 1 ELSE 0 END) as pending_import,
  72. SUM(CASE WHEN import_status = 'parsed' THEN 1 ELSE 0 END) as parsed_import,
  73. SUM(CASE WHEN import_status = 'error' THEN 1 ELSE 0 END) as error_import
  74. FROM {self.temp_import_table}
  75. """
  76. cursor.execute(sql_import)
  77. stats.update(cursor.fetchone())
  78. # 解析表统计
  79. sql_parsed = f"""
  80. SELECT
  81. COUNT(*) as total_parsed,
  82. SUM(CASE WHEN processing_status = 'parsed' THEN 1 ELSE 0 END) as pending_parsed,
  83. SUM(CASE WHEN processing_status = 'linked' THEN 1 ELSE 0 END) as linked_parsed,
  84. SUM(CASE WHEN processing_status = 'imported' THEN 1 ELSE 0 END) as imported_parsed,
  85. SUM(CASE WHEN processing_status = 'duplicate' THEN 1 ELSE 0 END) as duplicate_parsed,
  86. SUM(CASE WHEN processing_status = 'error' THEN 1 ELSE 0 END) as error_parsed
  87. FROM {self.temp_parsed_table}
  88. """
  89. cursor.execute(sql_parsed)
  90. stats.update(cursor.fetchone())
  91. # 正式表统计
  92. sql_production = f"""
  93. SELECT COUNT(*) as total_production
  94. FROM {self.production_table}
  95. WHERE deleted = 0
  96. """
  97. cursor.execute(sql_production)
  98. production_result = cursor.fetchone()
  99. stats['total_production'] = production_result['total_production']
  100. # 数据质量检查
  101. sql_quality = f"""
  102. SELECT
  103. COUNT(*) as invalid_scores,
  104. SUM(CASE WHEN score < 0 OR score > 100 THEN 1 ELSE 0 END) as out_of_range_scores
  105. FROM {self.production_table}
  106. WHERE deleted = 0
  107. """
  108. cursor.execute(sql_quality)
  109. quality_result = cursor.fetchone()
  110. stats.update(quality_result)
  111. return stats
  112. except Exception as e:
  113. logger.error(f"获取统计数据失败: {e}")
  114. return {}
  115. def validate_data_integrity(self) -> Dict[str, Any]:
  116. """验证数据完整性"""
  117. issues = []
  118. try:
  119. with self.connection.cursor(DictCursor) as cursor:
  120. # 检查孤立记录(学生不存在)
  121. sql_orphan_students = f"""
  122. SELECT COUNT(*) as count
  123. FROM {self.production_table} cs
  124. LEFT JOIN {self.students_table} s ON cs.student_id = s.id
  125. WHERE cs.deleted = 0 AND (s.id IS NULL OR s.deleted = 1)
  126. """
  127. cursor.execute(sql_orphan_students)
  128. orphan_students = cursor.fetchone()['count']
  129. if orphan_students > 0:
  130. issues.append(f"发现{orphan_students}条学生ID不存在的记录")
  131. # 检查孤立记录(班级不存在)
  132. sql_orphan_classes = f"""
  133. SELECT COUNT(*) as count
  134. FROM {self.production_table} cs
  135. LEFT JOIN {self.classes_table} c ON cs.class_id = c.id
  136. WHERE cs.deleted = 0 AND cs.class_id IS NOT NULL AND (c.id IS NULL OR c.deleted = 1)
  137. """
  138. cursor.execute(sql_orphan_classes)
  139. orphan_classes = cursor.fetchone()['count']
  140. if orphan_classes > 0:
  141. issues.append(f"发现{orphan_classes}条班级ID不存在的记录")
  142. # 检查重复记录
  143. sql_duplicates = f"""
  144. SELECT COUNT(*) as count
  145. FROM (
  146. SELECT student_id, academic_year, semester, month, COUNT(*) as cnt
  147. FROM {self.production_table}
  148. WHERE deleted = 0
  149. GROUP BY student_id, academic_year, semester, month
  150. HAVING cnt > 1
  151. ) t
  152. """
  153. cursor.execute(sql_duplicates)
  154. duplicates = cursor.fetchone()['count']
  155. if duplicates > 0:
  156. issues.append(f"发现{duplicates}组重复记录(同一学生同月有多条记录)")
  157. # 检查异常分数
  158. sql_invalid_scores = f"""
  159. SELECT COUNT(*) as count
  160. FROM {self.production_table}
  161. WHERE deleted = 0 AND (score < 0 OR score > 100 OR score IS NULL)
  162. """
  163. cursor.execute(sql_invalid_scores)
  164. invalid_scores = cursor.fetchone()['count']
  165. if invalid_scores > 0:
  166. issues.append(f"发现{invalid_scores}条分数不在0-100范围内的记录")
  167. return {
  168. 'issues': issues,
  169. 'orphan_students': orphan_students,
  170. 'orphan_classes': orphan_classes,
  171. 'duplicates': duplicates,
  172. 'invalid_scores': invalid_scores
  173. }
  174. except Exception as e:
  175. logger.error(f"数据完整性验证失败: {e}")
  176. return {'issues': [f"验证过程出错: {str(e)}"], 'error': True}
  177. def generate_detailed_report(self) -> str:
  178. """生成详细报告"""
  179. stats = self.get_migration_statistics()
  180. integrity = self.validate_data_integrity()
  181. report_lines = []
  182. report_lines.append("="*80)
  183. report_lines.append("学生操行分历史数据迁移报告")
  184. report_lines.append("="*80)
  185. report_lines.append(f"生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
  186. report_lines.append("")
  187. # 总体统计
  188. report_lines.append("📊 总体统计")
  189. report_lines.append("-" * 40)
  190. report_lines.append(f"Excel原始记录: {stats.get('total_import', 0)}")
  191. report_lines.append(f"解析后月度记录: {stats.get('total_parsed', 0)}")
  192. report_lines.append(f"正式表总记录: {stats.get('total_production', 0)}")
  193. report_lines.append("")
  194. # 各阶段处理情况
  195. report_lines.append("🔄 处理阶段详情")
  196. report_lines.append("-" * 40)
  197. report_lines.append(f"Excel导入 - 待处理: {stats.get('pending_import', 0)}")
  198. report_lines.append(f"Excel导入 - 已解析: {stats.get('parsed_import', 0)}")
  199. report_lines.append(f"Excel导入 - 解析失败: {stats.get('error_import', 0)}")
  200. report_lines.append("")
  201. report_lines.append(f"数据解析 - 待关联: {stats.get('pending_parsed', 0)}")
  202. report_lines.append(f"数据解析 - 已关联: {stats.get('linked_parsed', 0)}")
  203. report_lines.append(f"数据解析 - 已导入: {stats.get('imported_parsed', 0)}")
  204. report_lines.append(f"数据解析 - 重复跳过: {stats.get('duplicate_parsed', 0)}")
  205. report_lines.append(f"数据解析 - 处理失败: {stats.get('error_parsed', 0)}")
  206. report_lines.append("")
  207. # 数据质量检查
  208. report_lines.append("✅ 数据质量检查")
  209. report_lines.append("-" * 40)
  210. report_lines.append(f"分数范围异常: {stats.get('out_of_range_scores', 0)}")
  211. report_lines.append("")
  212. # 数据完整性验证
  213. report_lines.append("🔍 数据完整性验证")
  214. report_lines.append("-" * 40)
  215. if integrity.get('issues'):
  216. for issue in integrity['issues']:
  217. report_lines.append(f"❌ {issue}")
  218. else:
  219. report_lines.append("✅ 所有完整性检查通过")
  220. report_lines.append("")
  221. report_lines.append(f"孤立学生记录: {integrity.get('orphan_students', 0)}")
  222. report_lines.append(f"孤立班级记录: {integrity.get('orphan_classes', 0)}")
  223. report_lines.append(f"重复记录组数: {integrity.get('duplicates', 0)}")
  224. report_lines.append(f"无效分数记录: {integrity.get('invalid_scores', 0)}")
  225. report_lines.append("")
  226. # 计算成功率
  227. total_import = stats.get('total_import', 0)
  228. total_production = stats.get('total_production', 0)
  229. success_rate = (total_production / total_import * 100) if total_import > 0 else 0
  230. report_lines.append("📈 迁移成功率")
  231. report_lines.append("-" * 40)
  232. report_lines.append(".1f")
  233. report_lines.append("")
  234. # 结论
  235. report_lines.append("🎯 迁移结论")
  236. report_lines.append("-" * 40)
  237. if not integrity.get('issues') and success_rate >= 95:
  238. report_lines.append("✅ 迁移成功:数据完整,质量良好")
  239. elif success_rate >= 80:
  240. report_lines.append("⚠️ 迁移基本成功:存在少量问题,建议检查")
  241. else:
  242. report_lines.append("❌ 迁移失败:存在严重问题,需要重新处理")
  243. report_lines.append("="*80)
  244. return "\n".join(report_lines)
  245. def export_error_details(self) -> None:
  246. """导出错误详情到CSV文件"""
  247. try:
  248. with self.connection.cursor(DictCursor) as cursor:
  249. # 导出解析失败的记录
  250. sql_parse_errors = f"""
  251. SELECT ti.student_no, ti.academic_year_str, ti.semester_str,
  252. ti.monthly_scores, ti.error_msg as import_error
  253. FROM {self.temp_import_table} ti
  254. WHERE ti.import_status = 'error'
  255. """
  256. cursor.execute(sql_parse_errors)
  257. parse_errors = cursor.fetchall()
  258. if parse_errors:
  259. with open('migration_parse_errors.csv', 'w', newline='', encoding='utf-8-sig') as f:
  260. if parse_errors:
  261. writer = csv.DictWriter(f, fieldnames=parse_errors[0].keys())
  262. writer.writeheader()
  263. writer.writerows(parse_errors)
  264. logger.info(f"导出{len(parse_errors)}条解析错误记录到 migration_parse_errors.csv")
  265. # 导出关联失败的记录
  266. sql_link_errors = f"""
  267. SELECT tp.student_no, tp.academic_year, tp.semester, tp.month,
  268. tp.score, tp.error_msg as link_error
  269. FROM {self.temp_parsed_table} tp
  270. WHERE tp.processing_status = 'error'
  271. """
  272. cursor.execute(sql_link_errors)
  273. link_errors = cursor.fetchall()
  274. if link_errors:
  275. with open('migration_link_errors.csv', 'w', newline='', encoding='utf-8-sig') as f:
  276. if link_errors:
  277. writer = csv.DictWriter(f, fieldnames=link_errors[0].keys())
  278. writer.writeheader()
  279. writer.writerows(link_errors)
  280. logger.info(f"导出{len(link_errors)}条关联错误记录到 migration_link_errors.csv")
  281. except Exception as e:
  282. logger.error(f"导出错误详情失败: {e}")
  283. def cleanup_temp_tables(self) -> bool:
  284. """清理临时表"""
  285. if not self.cleanup:
  286. logger.info("跳过清理操作(使用--cleanup参数启用)")
  287. return True
  288. try:
  289. logger.warning("开始清理临时表数据...")
  290. with self.connection.cursor() as cursor:
  291. # 删除临时表
  292. cursor.execute(f"DROP TABLE IF EXISTS {self.temp_parsed_table}")
  293. cursor.execute(f"DROP TABLE IF EXISTS {self.temp_import_table}")
  294. # 可选:删除迁移日志表
  295. cursor.execute("DROP TABLE IF EXISTS temp_conduct_score_migration_log")
  296. self.connection.commit()
  297. logger.info("临时表清理完成")
  298. return True
  299. except Exception as e:
  300. logger.error(f"清理临时表失败: {e}")
  301. self.connection.rollback()
  302. return False
  303. def run(self) -> bool:
  304. """执行验证和清理流程"""
  305. start_time = time.time()
  306. try:
  307. self.connect_db()
  308. logger.info("开始迁移验证和清理")
  309. # 生成报告
  310. report = self.generate_detailed_report()
  311. print("\n" + report + "\n")
  312. # 保存报告到文件
  313. report_file = f"migration_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
  314. with open(report_file, 'w', encoding='utf-8') as f:
  315. f.write(report)
  316. logger.info(f"详细报告已保存到: {report_file}")
  317. # 导出错误详情
  318. self.export_error_details()
  319. # 清理临时表(如果指定了cleanup参数)
  320. cleanup_success = self.cleanup_temp_tables()
  321. return cleanup_success
  322. except Exception as e:
  323. logger.error(f"验证和清理过程出现错误: {e}")
  324. return False
  325. finally:
  326. self.disconnect_db()
  327. elapsed_time = time.time() - start_time
  328. logger.info(f"验证总耗时: {elapsed_time:.1f}秒")
  329. def main():
  330. """主函数"""
  331. parser = argparse.ArgumentParser(description='学生操行分历史数据 - 验证与清理')
  332. parser.add_argument('--cleanup', action='store_true',
  333. help='清理临时表数据(默认只验证不清理)')
  334. args = parser.parse_args()
  335. logger.info("="*60)
  336. logger.info("学生操行分历史数据迁移 - 脚本5:验证与清理")
  337. if args.cleanup:
  338. logger.warning("⚠️ 已启用清理模式,将删除临时表数据")
  339. else:
  340. logger.info("ℹ️ 验证模式:只检查数据,不清理临时表")
  341. logger.info("="*60)
  342. # 创建logs目录
  343. import os
  344. os.makedirs("logs", exist_ok=True)
  345. # 执行验证和清理
  346. validator = MigrationValidator(cleanup=args.cleanup)
  347. success = validator.run()
  348. if success:
  349. if args.cleanup:
  350. logger.info("✅ 验证和清理处理完成")
  351. else:
  352. logger.info("✅ 验证处理完成")
  353. sys.exit(0)
  354. else:
  355. logger.error("❌ 验证和清理处理失败")
  356. sys.exit(1)
  357. if __name__ == "__main__":
  358. main()