Files
hzdyjs/学校学生操行历史数据迁移/01_import_excel_to_temp.py
T
2026-01-04 10:00:08 +08:00

368 lines
13 KiB
Python

#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
学生操行分历史数据迁移 - 脚本1:Excel数据导入临时表
功能说明:
- 读取操行.xls文件
- 数据清洗和验证
- 导入到temp_conduct_score_import临时表
执行方式:
python 01_import_excel_to_temp.py
注意事项:
- 执行前确保Excel文件存在
- 执行前确保临时表已创建
- 支持断点续传(已导入的数据会跳过)
"""
from __future__ import annotations
import argparse
import logging
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
import pandas as pd
import pymysql
from pymysql.cursors import DictCursor
from db_config import DB_CONFIG, TABLE_CONFIG, CONSTANTS
# 配置日志
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler("logs/01_import_excel.log", mode='a', encoding='utf-8')
]
)
logger = logging.getLogger("import_excel")
class ExcelImporter:
"""Excel导入处理器"""
def __init__(self, excel_file: str):
self.excel_file = excel_file
self.db_config = DB_CONFIG
self.temp_table = TABLE_CONFIG["temp_import"]
self.batch_size = CONSTANTS["BATCH_SIZE"]
self.connection = None
def connect_db(self) -> None:
"""连接数据库"""
try:
self.connection = pymysql.connect(**self.db_config)
logger.info("数据库连接成功")
except Exception as e:
logger.error(f"数据库连接失败: {e}")
raise
def disconnect_db(self) -> None:
"""断开数据库连接"""
if self.connection:
self.connection.close()
logger.info("数据库连接已断开")
def read_excel_data(self) -> pd.DataFrame:
"""读取Excel文件"""
try:
logger.info(f"开始读取Excel文件: {self.excel_file}")
# 检查文件是否存在
if not Path(self.excel_file).exists():
raise FileNotFoundError(f"Excel文件不存在: {self.excel_file}")
# 读取Excel文件
df = pd.read_excel(self.excel_file)
logger.info(f"Excel文件读取成功,共{len(df)}行数据")
logger.info(f"列名: {list(df.columns)}")
return df
except Exception as e:
logger.error(f"读取Excel文件失败: {e}")
raise
def validate_data(self, df: pd.DataFrame) -> pd.DataFrame:
"""数据验证和清洗"""
logger.info("开始数据验证和清洗")
# 检查必需列
required_columns = ['学号', '学年', '学期', '平时分数']
missing_columns = [col for col in required_columns if col not in df.columns]
if missing_columns:
raise ValueError(f"Excel文件缺少必需列: {missing_columns}")
# 数据清洗
original_count = len(df)
# 删除空行(学号为空的行)
df = df.dropna(subset=['学号'])
logger.info(f"删除空学号行: {original_count - len(df)}行")
# 去除学号前后的空格
df.loc[:, '学号'] = df['学号'].astype(str).str.strip()
# 删除重复的学号+学年+学期记录(保留第一条)
before_dedupe = len(df)
df = df.drop_duplicates(subset=['学号', '学年', '学期'], keep='first')
logger.info(f"删除重复记录: {before_dedupe - len(df)}行")
# 验证学年格式
df.loc[:, 'academic_year_valid'] = df['学年'].astype(str).str.match(r'^\d{4}-\d{4}$')
invalid_years = df[~df['academic_year_valid']]
if len(invalid_years) > 0:
logger.warning(f"发现{len(invalid_years)}条学年格式不正确的记录:")
for idx, row in invalid_years.head(5).iterrows():
logger.warning(f" 行{idx+2}: 学号={row['学号']}, 学年={row['学年']}")
# 验证学期格式
valid_semesters = ['第一学期', '第二学期']
df.loc[:, 'semester_valid'] = df['学期'].isin(valid_semesters)
invalid_semesters = df[~df['semester_valid']]
if len(invalid_semesters) > 0:
logger.warning(f"发现{len(invalid_semesters)}条学期格式不正确的记录:")
for idx, row in invalid_semesters.head(5).iterrows():
logger.warning(f" 行{idx+2}: 学号={row['学号']}, 学期={row['学期']}")
# 验证平时分数格式(非空且包含冒号)
df.loc[:, 'monthly_scores_valid'] = df['平时分数'].astype(str).str.contains(':')
invalid_scores = df[~df['monthly_scores_valid']]
if len(invalid_scores) > 0:
logger.warning(f"发现{len(invalid_scores)}条平时分数格式不正确的记录:")
for idx, row in invalid_scores.head(5).iterrows():
logger.warning(f" 行{idx+2}: 学号={row['学号']}, 平时分数={row['平时分数']}")
# 验证数值范围
df.loc[:, 'final_score_valid'] = True
final_scores = pd.to_numeric(df['最终分'], errors='coerce')
# 检查最终分范围 (decimal(5,2) 最大999.99)
invalid_final_scores = final_scores[(final_scores < 0) | (final_scores > 999.99)]
if len(invalid_final_scores) > 0:
df.loc[:, 'final_score_valid'] = ~final_scores.isin(invalid_final_scores)
logger.warning(f"发现{len(invalid_final_scores)}条最终分数值超出范围(0-999.99):")
for idx in invalid_final_scores.head(5).index:
logger.warning(f" 行{idx+2}: 学号={df.loc[idx, '学号']}, 最终分={final_scores.loc[idx]}")
# 统计验证结果
valid_count = len(df[df['academic_year_valid'] & df['semester_valid'] & df['monthly_scores_valid'] & df['final_score_valid']])
logger.info(f"数据验证完成: 总计{len(df)}行,验证通过{valid_count}行")
# 处理超出范围的数值
df.loc[:, '最终分'] = final_scores.clip(0, 999.99) # 将超出范围的值截断到有效范围
# 清理临时列
df = df.drop(columns=['academic_year_valid', 'semester_valid', 'monthly_scores_valid', 'final_score_valid'])
return df
def check_existing_data(self, student_nos: List[str]) -> set:
"""检查已存在的学号"""
try:
with self.connection.cursor() as cursor:
# 分批查询已存在的学号
existing_nos = set()
batch_size = 1000
for i in range(0, len(student_nos), batch_size):
batch = student_nos[i:i+batch_size]
placeholders = ','.join(['%s'] * len(batch))
sql = f"""
SELECT DISTINCT student_no
FROM {self.temp_table}
WHERE student_no IN ({placeholders})
"""
cursor.execute(sql, batch)
results = cursor.fetchall()
existing_nos.update([row[0] for row in results])
logger.info(f"检查到{len(existing_nos)}个学号已存在,将跳过导入")
return existing_nos
except Exception as e:
logger.error(f"检查已存在数据失败: {e}")
return set()
def insert_batch(self, batch_data: List[Dict[str, Any]]) -> tuple[int, int]:
"""批量插入数据"""
if not batch_data:
return 0, 0
try:
with self.connection.cursor() as cursor:
sql = """
INSERT INTO {table} (
student_no, student_name, gender, ethnicity,
academic_year_str, semester_str, monthly_scores,
final_score, grade_level, department, major,
class_name, grade, import_status
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'pending'
)
""".format(table=self.temp_table)
values = []
for row in batch_data:
# 处理NaN值和数值范围,转换为None或合适的值
def safe_value(key, default='', is_numeric=False):
val = row.get(key, default)
if pd.isna(val):
return None
if is_numeric and isinstance(val, (int, float)):
# 对于数值字段,确保在有效范围内
if key == '最终分':
return max(0, min(999.99, float(val)))
return val
values.append((
safe_value('学号', ''),
safe_value('姓名', ''),
safe_value('性别', ''),
safe_value('民族', ''),
safe_value('学年', ''),
safe_value('学期', ''),
safe_value('平时分数', ''),
safe_value('最终分', is_numeric=True), # 数值字段,NaN转换为None
safe_value('等级', ''),
safe_value('院系', ''),
safe_value('专业', ''),
safe_value('班级', ''),
safe_value('年级', '')
))
cursor.executemany(sql, values)
self.connection.commit()
return len(batch_data), 0
except Exception as e:
logger.error(f"批量插入失败: {e}")
self.connection.rollback()
return 0, len(batch_data)
def import_data(self, df: pd.DataFrame) -> Dict[str, Any]:
"""导入数据到临时表"""
logger.info("开始导入数据到临时表")
# 获取所有学号
student_nos = df['学号'].tolist()
# 检查已存在的数据
existing_nos = self.check_existing_data(student_nos)
# 过滤掉已存在的数据
df_to_import = df[~df['学号'].isin(existing_nos)]
skipped_count = len(df) - len(df_to_import)
logger.info(f"准备导入{len(df_to_import)}行数据,跳过{skipped_count}行已存在数据")
if len(df_to_import) == 0:
logger.info("没有需要导入的新数据")
return {
'total': len(df),
'skipped': skipped_count,
'imported': 0,
'failed': 0
}
# 批量导入
total_imported = 0
total_failed = 0
for i in range(0, len(df_to_import), self.batch_size):
batch_df = df_to_import.iloc[i:i+self.batch_size]
batch_data = batch_df.to_dict('records')
imported, failed = self.insert_batch(batch_data)
total_imported += imported
total_failed += failed
logger.info(f"处理进度: {min(i+self.batch_size, len(df_to_import))}/{len(df_to_import)} "
f"(成功:{total_imported}, 失败:{total_failed})")
result = {
'total': len(df),
'skipped': skipped_count,
'imported': total_imported,
'failed': total_failed
}
logger.info(f"数据导入完成: {result}")
return result
def run(self) -> bool:
"""执行导入流程"""
start_time = time.time()
try:
# 连接数据库
self.connect_db()
# 读取Excel数据
df = self.read_excel_data()
# 数据验证和清洗
df = self.validate_data(df)
# 导入数据
result = self.import_data(df)
# 输出统计信息
logger.info("="*50)
logger.info("导入统计结果:")
logger.info(f" 总记录数: {result['total']}")
logger.info(f" 跳过已存在: {result['skipped']}")
logger.info(f" 成功导入: {result['imported']}")
logger.info(f" 导入失败: {result['failed']}")
logger.info(f"导入总耗时: {elapsed_time:.1f}秒")
logger.info("="*50)
return result['failed'] == 0
except Exception as e:
logger.error(f"导入过程出现错误: {e}")
return False
finally:
self.disconnect_db()
elapsed_time = time.time() - start_time
logger.info(f"导入总耗时: {elapsed_time:.1f}秒")
def main():
"""主函数"""
parser = argparse.ArgumentParser(description='学生操行分历史数据 - Excel导入临时表')
parser.add_argument('--excel', default=CONSTANTS["EXCEL_FILE"],
help=f'Excel文件路径 (默认: {CONSTANTS["EXCEL_FILE"]})')
args = parser.parse_args()
logger.info("="*60)
logger.info("学生操行分历史数据迁移 - 脚本1:Excel导入临时表")
logger.info("="*60)
# 创建logs目录
Path("logs").mkdir(exist_ok=True)
# 执行导入
importer = ExcelImporter(args.excel)
success = importer.run()
if success:
logger.info("✅ Excel数据导入完成")
sys.exit(0)
else:
logger.error("❌ Excel数据导入失败")
sys.exit(1)
if __name__ == "__main__":
main()