#!/usr/bin/env python3 """ 二期数据 ETL 脚本 将原始平台导出格式(每题一个Sheet + 15个学科独立文件) 转换为与一期相同的扁平表格式,供后续赋分/分析引擎使用 输入:二期/[整理前] 二期_课程实施+教材使用情况表/ 输出:report-admin/data/era2/ 下的3张扁平表 - era2_基础信息表.xlsx - era2_课程实施情况表.xlsx - era2_学科课程实施情况表.xlsx 一期目标格式: 基础信息表列: 题号, 题型, 题目ID, 题目内容, 字段ID, 字段名称, 字段值, 年级, 学期, 区, 学校名称, 办学性质, 学校类别, 学校等级, 地域类型, 特色类型, 等级B, 等级C 课程实施情况表列: 题号, 题型, 字段ID, 字段名称, 字段取值, 选项文字, 学科, 学期, 年级, 学校简称, 所在区, 学校类别, 学校性质, 所处地区, 学校类型, 学校类型编号 学科课程实施情况表列: 题号, 题型, 字段ID, 字段名称, 字段取值, 学科, 选项文字, 学校性质, 所在区, 学校类别, 学校特色, 学校简称, 所处地区, 学校类型, 学校类型编号 """ import pandas as pd import numpy as np from pathlib import Path from typing import Dict, List, Optional import logging import time import glob logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S", ) logger = logging.getLogger(__name__) # ===== 路径配置 ===== PROJECT_ROOT = Path(__file__).parent.parent.parent # report-admin/ DATA_ROOT = PROJECT_ROOT.parent # 20260301-邱老师-校长画像/ ERA2_RAW_DIR = DATA_ROOT / "二期" / "[整理前] 二期_课程实施+教材使用情况表" ERA2_OUTPUT_DIR = PROJECT_ROOT / "data" / "era2" def load_raw_file(filepath: Path) -> Dict: """ 加载一个二期原始 Excel 文件,返回: { 'home': DataFrame, # 首页元信息 'data_sheets': {题号: DataFrame}, # 每题的数据 'field_map': DataFrame, # 字段映射关系 'dim_map': DataFrame, # 维度映射关系 'type_stats': DataFrame, # 题型统计 } """ logger.info(f" 加载: {filepath.name}") xls = pd.ExcelFile(filepath) result = { 'home': pd.read_excel(xls, sheet_name='首页'), 'data_sheets': {}, 'field_map': None, 'dim_map': None, 'type_stats': None, } for sn in xls.sheet_names: if sn == '首页': continue elif sn == '字段映射关系': result['field_map'] = pd.read_excel(xls, sheet_name=sn) elif sn == '维度映射关系': result['dim_map'] = pd.read_excel(xls, sheet_name=sn) elif sn == '题型统计': result['type_stats'] = pd.read_excel(xls, sheet_name=sn) elif sn.isdigit(): result['data_sheets'][int(sn)] = pd.read_excel(xls, sheet_name=sn) xls.close() total_rows = sum(len(df) for df in result['data_sheets'].values()) logger.info(f" → {len(result['data_sheets'])}个题Sheet, {total_rows}行数据") return result def build_type_map(type_stats: pd.DataFrame) -> Dict[int, str]: """从题型统计构建 题号→题型 的映射""" return dict(zip(type_stats['题号'].astype(int), type_stats['题型'])) def extract_school_short_name(full_name: str) -> str: """ 从全称提取简称 '上海市延安中学' → '延安中学' '华东政法大学附属中学' → '华政附中' '华东师范大学附属天山学校' → '天山学校' 保留全称作为默认 """ # 不做过度简化,保留全称让下游配置来处理映射 return full_name def extract_district_name(region_str: str) -> str: """ 从区域维度提取区名 '上海市长宁区教育学院' → '长宁区' '上海市青浦区教师进修学院' → '青浦区' """ if pd.isna(region_str): return "" s = str(region_str) # 提取 "XX区" 部分 for suffix in ['教育学院', '教师进修学院', '教育委员会']: s = s.replace(suffix, '') s = s.replace('上海市', '') return s.strip() # ===== 1. 基础信息表 ETL ===== def transform_basic_info(raw: Dict) -> pd.DataFrame: """ 将二期基础信息表转为一期格式 一期列: 题号, 题型, 题目ID, 题目内容, 字段ID, 字段名称, 字段值, 年级, 学期, 区, 学校名称, ... 二期列: 问题id, 字段id, 字段名称, 字段取值, 维度id, 维度名称, 学科维度, 年级维度, 学期维度, 学校维度, 区域维度, 用户维度, 状态, 问卷提交时间 """ logger.info("转换基础信息表...") type_map = build_type_map(raw['type_stats']) rows = [] for q_num, df in sorted(raw['data_sheets'].items()): q_type = type_map.get(q_num, '未知') for _, row in df.iterrows(): rows.append({ '题号': q_num, '题型': q_type, '题目ID': row.get('问题id', ''), '题目内容': '', # 二期原始数据无此字段 '字段ID': row.get('字段id', ''), '字段名称': row.get('字段名称', ''), '字段值': row.get('字段取值', ''), '年级': row.get('年级维度', '不分年级'), '学期': row.get('学期维度', ''), '区': extract_district_name(row.get('区域维度', '')), '学校名称': row.get('学校维度', ''), # 以下字段二期原始数据无,留空后续由配置补充 '办学性质': '', '学校类别': '', '学校等级': '', '地域类型': '', '特色类型': '', '等级B': '', '等级C': '', }) result = pd.DataFrame(rows) # 强制所有列为字符串,避免混合类型导致 parquet 报错 result = result.astype(str) logger.info(f" 基础信息表: {len(result)}行") return result # ===== 2. 课程实施情况表 ETL ===== def transform_course_impl(raw: Dict) -> pd.DataFrame: """ 将二期课程实施情况表转为一期格式 一期列: 题号, 题型, 字段ID, 字段名称, 字段取值, 选项文字, 学科, 学期, 年级, 学校简称, 所在区, ... """ logger.info("转换课程实施情况表...") type_map = build_type_map(raw['type_stats']) rows = [] for q_num, df in sorted(raw['data_sheets'].items()): q_type = type_map.get(q_num, '未知') for _, row in df.iterrows(): rows.append({ '题号': str(q_num), '题型': q_type, '字段ID': row.get('字段id', ''), '字段名称': row.get('字段名称', ''), '字段取值': row.get('字段取值', ''), '选项文字': row.get('维度名称', ''), # 二期中维度名称对应选项文字 '学科': row.get('学科维度', '不分学科'), '学期': row.get('学期维度', ''), '年级': row.get('年级维度', '不分年级'), '学校简称': row.get('学校维度', ''), '所在区': extract_district_name(row.get('区域维度', '')), # 以下字段二期原始数据无,留空 '学校类别': '', '学校性质': '', '所处地区': '', '学校类型': '', '学校类型编号': '', }) result = pd.DataFrame(rows) result = result.astype(str) logger.info(f" 课程实施情况表: {len(result)}行") return result # ===== 3. 学科课程实施情况表 ETL ===== def transform_subject_impl(raw_files: Dict[str, Dict]) -> pd.DataFrame: """ 将二期的15个学科独立文件合并为一张表(一期格式) 一期列: 题号, 题型, 字段ID, 字段名称, 字段取值, 学科, 选项文字, 学校性质, 所在区, ... """ logger.info("转换学科课程实施情况表(合并15个学科文件)...") all_rows = [] for subject, raw in sorted(raw_files.items()): type_map = build_type_map(raw['type_stats']) subject_rows = 0 for q_num, df in sorted(raw['data_sheets'].items()): q_type = type_map.get(q_num, '未知') for _, row in df.iterrows(): all_rows.append({ '题号': str(q_num), '题型': q_type, '字段ID': row.get('字段id', ''), '字段名称': row.get('字段名称', ''), '字段取值': row.get('字段取值', ''), '学科': subject, '选项文字': row.get('维度名称', ''), '学校性质': '', '所在区': extract_district_name(row.get('区域维度', '')), '学校类别': '', '学校特色': '', '学校简称': row.get('学校维度', ''), '所处地区': '', '学校类型': '', '学校类型编号': '', }) subject_rows += 1 logger.info(f" {subject}: {subject_rows}行") result = pd.DataFrame(all_rows) result = result.astype(str) logger.info(f" 学科课程实施情况表合计: {len(result)}行") return result # ===== 主流程 ===== def main(): start = time.time() print("=" * 70) print("📊 二期数据 ETL 转换") print(f" 输入: {ERA2_RAW_DIR}") print(f" 输出: {ERA2_OUTPUT_DIR}") print("=" * 70) # 检查输入目录 if not ERA2_RAW_DIR.exists(): logger.error(f"❌ 输入目录不存在: {ERA2_RAW_DIR}") return # 创建输出目录 ERA2_OUTPUT_DIR.mkdir(parents=True, exist_ok=True) # ===== 1. 基础信息表 ===== print("\n[1/3] 基础信息表") raw_basic = load_raw_file(ERA2_RAW_DIR / "第二期_学校基础信息表.xlsx") df_basic = transform_basic_info(raw_basic) out_basic = ERA2_OUTPUT_DIR / "era2_基础信息表.xlsx" out_basic_pq = ERA2_OUTPUT_DIR / "era2_基础信息表.parquet" df_basic.to_parquet(out_basic_pq, index=False) logger.info(f" ✅ 保存 → {out_basic_pq}") # ===== 2. 课程实施情况表 ===== print("\n[2/3] 课程实施情况表") raw_course = load_raw_file(ERA2_RAW_DIR / "第二期_学校课程实施情况表.xlsx") df_course = transform_course_impl(raw_course) out_course_pq = ERA2_OUTPUT_DIR / "era2_课程实施情况表.parquet" df_course.to_parquet(out_course_pq, index=False) logger.info(f" ✅ 保存 → {out_course_pq}") # ===== 3. 学科课程实施情况表 ===== print("\n[3/3] 学科课程实施情况表(15个学科文件)") subject_files = sorted(ERA2_RAW_DIR.glob("第二期_*学科课程实施情况表.xlsx")) raw_subjects = {} for f in subject_files: subject_name = f.name.replace("第二期_", "").replace("学科课程实施情况表.xlsx", "") raw_subjects[subject_name] = load_raw_file(f) df_subject = transform_subject_impl(raw_subjects) # 学科表超过Excel行数上限(1,048,576),保存为parquet + 按区分片xlsx out_subject_parquet = ERA2_OUTPUT_DIR / "era2_学科课程实施情况表.parquet" df_subject.to_parquet(out_subject_parquet, index=False) logger.info(f" ✅ 保存 (parquet) → {out_subject_parquet}") # 注:如需xlsx格式可按区分片,但parquet格式已满足分析需求 # ===== 汇总 ===== elapsed = time.time() - start print(f"\n{'=' * 70}") print(f"✅ ETL 转换完成! 耗时 {elapsed:.1f}s") print(f" 基础信息表: {len(df_basic):>8,}行") print(f" 课程实施情况表: {len(df_course):>8,}行") print(f" 学科课程实施情况表: {len(df_subject):>8,}行") print(f" 学校数量: {df_course['学校简称'].nunique()}") print(f" 区域数量: {df_course['所在区'].nunique()}") print(f" 学科数量: {df_subject['学科'].nunique()}") print(f" 输出目录: {ERA2_OUTPUT_DIR}") print(f"{'=' * 70}") # 保存元信息 meta = { '基础信息表行数': len(df_basic), '课程实施情况表行数': len(df_course), '学科课程实施情况表行数': len(df_subject), '学校列表': sorted(df_course['学校简称'].unique().tolist()), '区域列表': sorted(df_course['所在区'].unique().tolist()), '学科列表': sorted(df_subject['学科'].unique().tolist()), '课程实施题数': raw_course['type_stats']['题号'].max(), '各学科题数': {s: r['type_stats']['题号'].max() for s, r in raw_subjects.items()}, } import json meta_path = ERA2_OUTPUT_DIR / "etl_meta.json" with open(meta_path, 'w', encoding='utf-8') as f: json.dump(meta, f, ensure_ascii=False, indent=2, default=str) logger.info(f" 元信息 → {meta_path}") if __name__ == "__main__": main()