zlwu 10 tháng trước cách đây
mục cha
commit
fca7096e9f
30 tập tin đã thay đổi với 4038 bổ sung và 8 xóa
  1. 2 2
      backend/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysUserServiceImpl.java
  2. 4 4
      backend/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml
  3. 6 0
      backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/controller/XgClassMsgController.java
  4. 31 2
      web/src/views/xg/xsjcxx/studentMsg/studentMsg/index.vue
  5. 285 0
      学校历史数据迁移/01-import_history.py
  6. 325 0
      学校历史数据迁移/02-update_sys_dept.py
  7. 292 0
      学校历史数据迁移/03-insert_sys_user.py
  8. 225 0
      学校历史数据迁移/04-01-dedupe_sys_dept.py
  9. 211 0
      学校历史数据迁移/04-02-reorder_dept_order_num.py
  10. 294 0
      学校历史数据迁移/04-insert_majors.py
  11. 201 0
      学校历史数据迁移/05-sort_majors_by_dept_category.py
  12. 854 0
      学校历史数据迁移/06-insert_classes.py
  13. 119 0
      学校历史数据迁移/07-reorder_classes_by_grade.py
  14. 549 0
      学校历史数据迁移/08-insert_students_from_his.py
  15. BIN
      学校历史数据迁移/__pycache__/db_config.cpython-312.pyc
  16. 111 0
      学校历史数据迁移/compare_student_headers.py
  17. 26 0
      学校历史数据迁移/db_config.py
  18. 15 0
      学校历史数据迁移/his_lssj_create.sql
  19. 56 0
      学校历史数据迁移/his_xssj_create.sql
  20. 1 0
      学校历史数据迁移/logs/06_insert_classes_skipped_majors.jsonl
  21. 7 0
      学校历史数据迁移/requirements.txt
  22. BIN
      学校历史数据迁移/student1127.xls
  23. BIN
      学校历史数据迁移/student1222.xls
  24. 45 0
      学校历史数据迁移/sys_dept.sql
  25. 50 0
      学校历史数据迁移/sys_user.sql
  26. 30 0
      学校历史数据迁移/sys_user_role.sql
  27. BIN
      学校历史数据迁移/teacher.xlsx
  28. 98 0
      学校历史数据迁移/xg_class_msg.sql
  29. 51 0
      学校历史数据迁移/xg_class_teacher_counselor_rel.sql
  30. 150 0
      学校历史数据迁移/xg_student_msg.sql

+ 2 - 2
backend/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysUserServiceImpl.java

@@ -64,14 +64,14 @@ public class SysUserServiceImpl implements ISysUserService, UserService {
 
     @Override
     public TableDataInfo<SysUserVo> selectPageUserList(SysUserBo user, PageQuery pageQuery) {
-        pageQuery.withDefaultOrder("u.user_name");
+        pageQuery.withDefaultOrder("u.user_name", "asc");
         Page<SysUserVo> page = baseMapper.selectPageUserList(pageQuery.build(), this.buildQueryWrapper(user));
         return TableDataInfo.build(page);
     }
 
     @Override
     public TableDataInfo<SysUserVo> selectPageUserListByOr(SysUserBo user, PageQuery pageQuery) {
-        pageQuery.withDefaultOrder("u.user_name");
+        pageQuery.withDefaultOrder("u.user_name", "asc");
         Page<SysUserVo> page = baseMapper.selectPageUserList(pageQuery.build(), this.buildQueryWrapperByOr(user));
         return TableDataInfo.build(page);
     }

+ 4 - 4
backend/ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml

@@ -24,7 +24,7 @@
         ${ew.getCustomSqlSegment}
         <choose>
             <when test="ew != null and @org.dromara.common.core.utils.StringUtils@containsAnyIgnoreCase(ew.getCustomSqlSegment, 'order by')">
-                , u.user_name asc, u.create_time desc
+                , u.create_time desc
             </when>
             <otherwise>
                 order by u.user_name asc, u.create_time desc
@@ -45,7 +45,7 @@
         ${ew.getCustomSqlSegment}
         <choose>
             <when test="ew != null and @org.dromara.common.core.utils.StringUtils@containsAnyIgnoreCase(ew.getCustomSqlSegment, 'order by')">
-                , u.user_name asc, u.create_time desc
+                , u.create_time desc
             </when>
             <otherwise>
                 order by u.user_name asc, u.create_time desc
@@ -63,7 +63,7 @@
         ${ew.getCustomSqlSegment}
         <choose>
             <when test="ew != null and @org.dromara.common.core.utils.StringUtils@containsAnyIgnoreCase(ew.getCustomSqlSegment, 'order by')">
-                , u.user_name asc, u.create_time desc
+                , u.create_time desc
             </when>
             <otherwise>
                 order by u.user_name asc, u.create_time desc
@@ -80,7 +80,7 @@
         ${ew.getCustomSqlSegment}
         <choose>
             <when test="ew != null and @org.dromara.common.core.utils.StringUtils@containsAnyIgnoreCase(ew.getCustomSqlSegment, 'order by')">
-                , u.user_name asc, u.create_time desc
+                , u.create_time desc
             </when>
             <otherwise>
                 order by u.user_name asc, u.create_time desc

+ 6 - 0
backend/ruoyi-modules/ruoyi-xg/src/main/java/org/dromara/xg/controller/XgClassMsgController.java

@@ -45,6 +45,12 @@ public class XgClassMsgController extends BaseController {
     @SaCheckPermission("classMsg:classMsg:list")
     @GetMapping("/list")
     public TableDataInfo<XgClassMsgVo> list(XgClassMsgBo bo, PageQuery pageQuery) {
+        // Disable the framework default ordering (create_time desc) so the service's
+        // wrapper-defined order (grade, collegeName, majorName, classStatus) takes
+        // effect.
+        if (pageQuery != null) {
+            pageQuery.disableDefaultOrder();
+        }
         return xgClassMsgService.queryPageList(bo, pageQuery);
     }
 

+ 31 - 2
web/src/views/xg/xsjcxx/studentMsg/studentMsg/index.vue

@@ -2014,6 +2014,27 @@ const cloneDeptTreeNodes = (nodes: any[]): any[] => {
   });
 };
 
+/** 规范化部门树节点(确保 label / originalLabel / disabled 存在),递归处理 */
+const processDeptTreeData = (nodes: any[]): any[] => {
+  return (nodes || []).map((node) => {
+    const processed: any = { ...node };
+
+    // 保留原始 label,兼容不同接口字段(label / name)
+    processed.originalLabel = processed.originalLabel ?? processed.label ?? processed.name ?? '';
+    processed.label = processed.label ?? processed.name ?? processed.originalLabel;
+
+    // 确保 disabled 字段存在,避免某些节点被意外隐藏/不可选
+    processed.disabled = processed.disabled ?? false;
+
+    if (Array.isArray(processed.children) && processed.children.length > 0) {
+      processed.children = processDeptTreeData(processed.children);
+    } else {
+      processed.children = [];
+    }
+    return processed;
+  });
+};
+
 /** 增强树节点(补充班级状态) */
 const enhanceDeptTreeNodes = (nodes: any[]): any[] => {
   return (nodes || []).map((node) => {
@@ -2042,11 +2063,17 @@ const filterDeptTreeByStatus = (nodes: any[], status?: string): any[] => {
       } else {
         cloned.children = [];
       }
+      // 如果开启了状态过滤,仅保留符合状态的班级节点
       if (targetStatus && isClassNode(cloned)) {
         if (cloned.classStatus !== targetStatus) {
           return null;
         }
       }
+
+      // 当开启状态过滤且节点不是班级节点且没有子节点时,过滤掉该节点(和 stuRoster 行为一致)
+      if (targetStatus && !isClassNode(cloned) && (!cloned.children || cloned.children.length === 0)) {
+        return null;
+      }
       return cloned;
     })
     .filter((item) => item !== null) as any[];
@@ -2142,8 +2169,10 @@ const extractDeptTreeData = (payload: any): any[] => {
 
 /** 构建部门树 */
 const rebuildDeptTree = async (treeData: any[], resetSelection = true) => {
+  // 先规范化树结构,保证每个节点都有 label / disabled / children 字段
+  const processed = processDeptTreeData(treeData);
   await ensureClassStatusMap();
-  const enhanced = enhanceDeptTreeNodes(treeData);
+  const enhanced = enhanceDeptTreeNodes(processed);
   rawDeptOptions.value = enhanced;
   applyClassStatusFilter(!resetSelection);
   if (resetSelection) {
@@ -3215,7 +3244,7 @@ onMounted(() => {
 
 /* 部门树组件样式 */
 .dept-tree {
-  height: 600px;
+  height: calc(100vh - 280px); /* 与 stuRoster 左侧树一致,随视窗高度自适应 */
   overflow-y: auto;
   overflow-x: auto;
   width: 330px; /* 固定宽度,超出时显示滚动条 */

+ 285 - 0
学校历史数据迁移/01-import_history.py

@@ -0,0 +1,285 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+导入学校历史学生和老师数据到 MySQL 表
+
+用法:
+    python import_history.py
+
+依赖:
+    pip install -r requirements.txt
+
+说明:
+    - 从同目录下的 db_config.py 读取 DB_CONFIG
+    - 将 `student.xls` 导入到表 `his_xssj`
+    - 将 `teacher.xlsx` 导入到表 `his_lssj`
+    - 自动创建表(所有列使用 TEXT 类型),按批次写入以节省内存
+"""
+from __future__ import annotations
+import os
+import sys
+import math
+import argparse
+import importlib.util
+from typing import List, Dict, Any
+
+import pandas as pd
+from sqlalchemy import create_engine, types as sql_types
+
+# Optional, used for xls/xlsx low-level reading
+try:
+    import xlrd  # for .xls
+except Exception:
+    xlrd = None
+
+try:
+    import openpyxl  # for .xlsx
+except Exception:
+    openpyxl = None
+
+
+def load_db_config_from_file(config_path: str) -> dict:
+    spec = importlib.util.spec_from_file_location("db_config", config_path)
+    module = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(module)  # type: ignore
+    if not hasattr(module, "DB_CONFIG"):
+        raise RuntimeError(f"No DB_CONFIG found in {config_path}")
+    return getattr(module, "DB_CONFIG")
+
+
+def sanitize_columns(header: List[str]) -> List[str]:
+    seen: Dict[str, int] = {}
+    out: List[str] = []
+    for i, col in enumerate(header):
+        if col is None:
+            name = f"col_{i+1}"
+        else:
+            name = str(col).strip()
+            if name == "":
+                name = f"col_{i+1}"
+        # replace problematic characters
+        name = name.replace(" ", "_").replace("\n", "_").replace("\r", "_")
+        # ensure starts with letter or underscore
+        if not name[0].isalpha() and name[0] != "_":
+            name = f"c_{name}"
+        base = name
+        cnt = seen.get(base, 0)
+        if cnt:
+            name = f"{base}_{cnt}"
+        seen[base] = cnt + 1
+        out.append(name)
+    return out
+
+
+def df_to_mysql_append(engine, df: pd.DataFrame, table_name: str, first_write: bool):
+    # ensure all columns are strings (TEXT)
+    df = df.where(pd.notnull(df), None)
+    if first_write:
+        # create table with TEXT columns by writing empty dataframe with dtype mapping
+        empty = df.head(0)
+        dtype_map = {col: sql_types.Text() for col in empty.columns}
+        empty.to_sql(name=table_name, con=engine, index=False, if_exists="replace", dtype=dtype_map)
+        # then append the first actual df
+        df.to_sql(name=table_name, con=engine, index=False, if_exists="append", chunksize=1000, method="multi")
+    else:
+        df.to_sql(name=table_name, con=engine, index=False, if_exists="append", chunksize=1000, method="multi")
+
+
+def process_xls(filepath: str, engine, table_name: str, chunk_size: int = 2000):
+    if xlrd is None:
+        raise RuntimeError("Missing xlrd library to read .xls files. Install from requirements.")
+    book = xlrd.open_workbook(filepath, formatting_info=False)
+    sheet = book.sheet_by_index(0)
+    header = [str(c).strip() for c in sheet.row_values(0)]
+    header = sanitize_columns(header)
+    total = sheet.nrows - 1
+    print(f"读取 .xls 文件 {filepath}, 行数(不含表头): {total}")
+    batch: List[Dict[str, Any]] = []
+    first_write = True
+    for r in range(1, sheet.nrows):
+        row = sheet.row_values(r)
+        # convert nan floats to None
+        row = [None if (isinstance(x, float) and math.isnan(x)) else x for x in row]
+        row_dict = {col: row[idx] if idx < len(row) else None for idx, col in enumerate(header)}
+        batch.append(row_dict)
+        if len(batch) >= chunk_size:
+            df = pd.DataFrame(batch)
+            df_to_mysql_append(engine, df, table_name, first_write)
+            first_write = False
+            batch = []
+            print(f"已写入 {chunk_size} 行到 {table_name}")
+    if batch:
+        df = pd.DataFrame(batch)
+        df_to_mysql_append(engine, df, table_name, first_write)
+        print(f"写入剩余 {len(batch)} 行到 {table_name}")
+
+
+def process_xlsx(filepath: str, engine, table_name: str, chunk_size: int = 2000):
+    if openpyxl is None:
+        raise RuntimeError("Missing openpyxl library to read .xlsx files. Install from requirements.")
+    wb = openpyxl.load_workbook(filename=filepath, read_only=True, data_only=True)
+    sheet = wb.active
+    rows = sheet.iter_rows(values_only=True)
+    try:
+        header_row = next(rows)
+    except StopIteration:
+        print(f"{filepath} 是空文件")
+        return
+    header = [str(c).strip() if c is not None else "" for c in header_row]
+    header = sanitize_columns(header)
+    batch: List[Dict[str, Any]] = []
+    first_write = True
+    written = 0
+    for row in rows:
+        row = [None if (isinstance(x, float) and math.isnan(x)) else x for x in row]
+        row_dict = {col: row[idx] if idx < len(row) else None for idx, col in enumerate(header)}
+        batch.append(row_dict)
+        if len(batch) >= chunk_size:
+            df = pd.DataFrame(batch)
+            df_to_mysql_append(engine, df, table_name, first_write)
+            first_write = False
+            written += len(batch)
+            batch = []
+            print(f"已写入 {written} 行到 {table_name}")
+    if batch:
+        df = pd.DataFrame(batch)
+        df_to_mysql_append(engine, df, table_name, first_write)
+        written += len(batch)
+        print(f"写入剩余 {len(batch)} 行到 {table_name},总计写入 {written} 行")
+
+
+def process_file(filepath: str, engine, table_name: str, chunk_size: int = 2000):
+    ext = os.path.splitext(filepath)[1].lower()
+    if ext == ".xls":
+        process_xls(filepath, engine, table_name, chunk_size=chunk_size)
+    elif ext in (".xlsx",):
+        process_xlsx(filepath, engine, table_name, chunk_size=chunk_size)
+    else:
+        # try pandas fallback (may be memory heavy)
+        print(f"未知扩展名 {ext},尝试使用 pandas 直接读取(可能占用大量内存)")
+        df = pd.read_excel(filepath, engine="openpyxl" if ext == ".xlsx" else None)
+        df.columns = sanitize_columns(list(df.columns))
+        df_to_mysql_append(engine, df, table_name, first_write=True)
+
+
+def build_engine_from_config(db_config: dict):
+    user = db_config.get("user")
+    password = db_config.get("password")
+    host = db_config.get("host", "localhost")
+    port = db_config.get("port", 3306)
+    database = db_config.get("database")
+    charset = db_config.get("charset", "utf8mb4")
+    uri = f"mysql+pymysql://{user}:{password}@{host}:{port}/{database}?charset={charset}"
+    engine = create_engine(uri, pool_pre_ping=True)
+    return engine
+
+
+def main(args):
+    base_dir = os.path.dirname(os.path.abspath(__file__))
+    config_path = os.path.join(base_dir, "db_config.py")
+    if not os.path.exists(config_path):
+        print(f"找不到配置文件: {config_path}")
+        sys.exit(1)
+    db_config = load_db_config_from_file(config_path)
+    engine = build_engine_from_config(db_config)
+    # 在每次导入前询问是否清空目标表(或使用 --yes 自动确认)
+    should_truncate = False
+    if args and getattr(args, "yes", False):
+        should_truncate = True
+    else:
+        resp = input("是否在导入前清空目标表 `his_xssj` 和 `his_lssj`?(yes/no) [no]: ").strip().lower()
+        if resp in ("y", "yes"):
+            should_truncate = True
+
+    if should_truncate:
+        print("尝试清空目标表: his_xssj, his_lssj ...")
+        try:
+            with engine.begin() as conn:
+                # 关闭外键检查以确保能够 truncate 有外键依赖的表
+                conn.exec_driver_sql("SET FOREIGN_KEY_CHECKS=0;")
+                conn.exec_driver_sql("TRUNCATE TABLE `his_xssj`;")
+                conn.exec_driver_sql("TRUNCATE TABLE `his_lssj`;")
+                conn.exec_driver_sql("SET FOREIGN_KEY_CHECKS=1;")
+            print("目标表已清空(如果表存在且有权限)")
+        except Exception as e:
+            print(f"清空表时发生异常(可能表不存在或无权限),继续执行导入。错误:{e}")
+    else:
+        print("跳过清空表,继续导入(若需要清空请重新运行并使用 --yes 参数)。")
+
+    student_file = os.path.join(base_dir, "student1222.xls")
+    teacher_file = os.path.join(base_dir, "teacher.xlsx")
+    if not os.path.exists(student_file):
+        print(f"找不到学生文件: {student_file}")
+    else:
+        print("开始导入学生数据到表 his_xssj ...")
+        process_file(student_file, engine, "his_xssj", chunk_size=2000)
+        print("学生数据导入完成 -> his_xssj")
+
+    if not os.path.exists(teacher_file):
+        print(f"找不到老师文件: {teacher_file}")
+    else:
+        print("开始导入老师数据到表 his_lssj ...")
+        process_file(teacher_file, engine, "his_lssj", chunk_size=2000)
+        print("老师数据导入完成 -> his_lssj")
+
+    print("全部导入完成。")
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(description="导入历史学生/老师数据或生成创建表SQL")
+    parser.add_argument("--dump-create-sql", action="store_true", help="只读取表头并生成 CREATE TABLE SQL 文件(不写入数据库)")
+    parser.add_argument("--student-file", type=str, default=None, help="学生文件路径,默认同目录下 student.xls")
+    parser.add_argument("--teacher-file", type=str, default=None, help="老师文件路径,默认同目录下 teacher.xlsx")
+    parser.add_argument("--yes", action="store_true", help="在导入前自动确认清空目标表(跳过交互式确认)")
+    args = parser.parse_args()
+
+    if args.dump_create_sql:
+        # generate CREATE TABLE SQL based on excel headers
+        def generate_create_table_sql(table_name: str, cols: List[str]) -> str:
+            # base fields required by project
+            lines: List[str] = []
+            lines.append(f"CREATE TABLE `{table_name}` (")
+            lines.append("  `id` BIGINT(20) NOT NULL AUTO_INCREMENT,")
+            lines.append("  `tenant_id` BIGINT(20) DEFAULT NULL,")
+            lines.append("  `deleted` TINYINT(1) NOT NULL DEFAULT 0,")
+            lines.append("  `create_by` BIGINT(20) DEFAULT NULL,")
+            lines.append("  `create_time` DATETIME DEFAULT NULL,")
+            lines.append("  `update_by` BIGINT(20) DEFAULT NULL,")
+            lines.append("  `update_time` DATETIME DEFAULT NULL,")
+            # add excel columns as TEXT
+            for col in cols:
+                lines.append(f"  `{col}` TEXT DEFAULT NULL,")
+            lines.append("  PRIMARY KEY (`id`)")
+            lines.append(") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;")
+            return "\n".join(lines)
+
+        base_dir = os.path.dirname(os.path.abspath(__file__))
+        student_path = args.student_file or os.path.join(base_dir, "student1222.xls")
+        teacher_path = args.teacher_file or os.path.join(base_dir, "teacher.xlsx")
+
+        for path, table in ((student_path, "his_xssj"), (teacher_path, "his_lssj")):
+            if not os.path.exists(path):
+                print(f"跳过,找不到文件: {path}")
+                continue
+            ext = os.path.splitext(path)[1].lower()
+            try:
+                # read header only
+                if ext == ".xls":
+                    df = pd.read_excel(path, nrows=0, engine="xlrd")
+                else:
+                    df = pd.read_excel(path, nrows=0, engine="openpyxl")
+            except Exception as e:
+                # fallback
+                print(f"读取表头失败 ({path}): {e}")
+                continue
+            raw_cols = list(df.columns)
+            safe_cols = sanitize_columns([str(c) for c in raw_cols])
+            sql = generate_create_table_sql(table, safe_cols)
+            out_file = os.path.join(base_dir, f"{table}_create.sql")
+            with open(out_file, "w", encoding="utf-8") as fh:
+                fh.write(sql)
+            print(f"已生成 {out_file},包含 {len(safe_cols)} 列(不含基础字段)")
+    else:
+        main(args)
+
+

+ 325 - 0
学校历史数据迁移/02-update_sys_dept.py

@@ -0,0 +1,325 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+更新 sys_dept 表:
+1) 打印 sys_dept 表结构供学习
+2) 可删除 sys_dept 中除 dept_id 为 100/101/102 外的所有记录(需确认或使用 --yes)
+3) 从 teacher.xlsx 中读取部门列,按规则插入到 sys_dept (部分学院放到 parent_id=101,其余放到 102)
+
+用法:
+  python update_sys_dept.py           # 交互式执行(会提示确认删除)
+  python update_sys_dept.py --yes     # 自动确认删除
+  python update_sys_dept.py --teacher-file /path/to/teacher.xlsx
+
+注意:
+  - 本脚本会尝试读取 `学校历史数据迁移/db_config.py` 中的 DB_CONFIG 连接数据库
+  - 插入时会尽量推断并填充常用字段(ancestors, parent_id, dept_name, order_num, status, del_flag, create_time)
+"""
+from __future__ import annotations
+import os
+import sys
+import argparse
+import datetime
+from typing import List, Dict, Any, Optional
+import pandas as pd
+from sqlalchemy import create_engine, text
+import importlib.util
+
+
+def load_db_config_from_file(config_path: str) -> dict:
+    spec = importlib.util.spec_from_file_location("db_config", config_path)
+    module = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(module)  # type: ignore
+    if not hasattr(module, "DB_CONFIG"):
+        raise RuntimeError(f"No DB_CONFIG found in {config_path}")
+    return getattr(module, "DB_CONFIG")
+
+
+def build_engine_from_config(db_config: dict):
+    user = db_config.get("user")
+    password = db_config.get("password")
+    host = db_config.get("host", "localhost")
+    port = db_config.get("port", 3306)
+    database = db_config.get("database")
+    charset = db_config.get("charset", "utf8mb4")
+    uri = f"mysql+pymysql://{user}:{password}@{host}:{port}/{database}?charset={charset}"
+    engine = create_engine(uri, pool_pre_ping=True)
+    return engine
+
+
+def inspect_table(engine, table_name: str) -> List[Dict[str, Any]]:
+    q = text(
+        "SELECT COLUMN_NAME, IS_NULLABLE, COLUMN_DEFAULT, DATA_TYPE "
+        "FROM information_schema.columns "
+        "WHERE table_schema = :schema AND table_name = :table ORDER BY ORDINAL_POSITION"
+    )
+    schema = engine.url.database
+    with engine.connect() as conn:
+        result = conn.execute(q, {"schema": schema, "table": table_name})
+        # Use mappings() so each row is a mapping of column_name->value
+        rows = result.mappings().all()
+    return [dict(r) for r in rows]
+
+
+def find_dept_column(df: pd.DataFrame) -> Optional[str]:
+    candidates = []
+    for col in df.columns:
+        name = str(col).strip()
+        low = name.lower()
+        if any(k in low for k in ("部门", "学院", "系", "组织", "院", "department", "dept", "office", "unit")):
+            candidates.append(name)
+    if candidates:
+        return candidates[0]
+    # fallback: ask user by listing columns
+    return None
+
+
+def parent_has_children(engine, parent_id: int) -> bool:
+    """
+    检查 parent_id 下是否已有子部门记录(用于决定是否跳过该 parent 的批量插入)
+    """
+    q = text("SELECT 1 FROM sys_dept WHERE parent_id = :pid LIMIT 1")
+    with engine.connect() as conn:
+        r = conn.execute(q, {"pid": parent_id}).fetchone()
+    return bool(r)
+
+
+def read_teacher_departments_from_db(engine, table_name: str = "his_lssj") -> List[str]:
+    """
+    从数据库表(默认 his_lssj)读取去重的部门名称(列名 '部门')。
+    返回去重、排序后的部门名称列表。
+    """
+    print(f"从数据库表 `{table_name}` 读取部门列(列名假定为 '部门')")
+    q = text(f"SELECT DISTINCT `部门` AS dept_name FROM `{table_name}` WHERE `部门` IS NOT NULL AND TRIM(`部门`) != ''")
+    with engine.connect() as conn:
+        rows = conn.execute(q).mappings().all()
+    depts = sorted({str(r.get("dept_name")).strip() for r in rows if r.get("dept_name") is not None})
+    print(f"发现 {len(depts)} 个去重部门名(示例前20):")
+    for item in depts[:20]:
+        print("  -", item)
+    return depts
+
+
+def get_parent_info(engine, parent_ids: List[int]) -> Dict[int, Dict[str, Any]]:
+    q = text("SELECT * FROM sys_dept WHERE dept_id IN :ids")
+    with engine.connect() as conn:
+        result = conn.execute(q, {"ids": tuple(parent_ids)})
+        rows = result.mappings().all()
+    out: Dict[int, Dict[str, Any]] = {}
+    for r in rows:
+        d = dict(r)
+        out[int(d.get("dept_id"))] = d
+    return out
+
+
+def delete_except(engine, keep_ids: List[int], yes: bool):
+    if not yes:
+        resp = input(f"将删除 sys_dept 表中除 dept_id IN {keep_ids} 外的所有记录,确认继续?(yes/no): ").strip().lower()
+        if resp not in ("y", "yes"):
+            print("取消操作。")
+            return False
+    q = text("DELETE FROM sys_dept WHERE dept_id NOT IN :keep")
+    with engine.begin() as conn:
+        conn.execute(q, {"keep": tuple(keep_ids)})
+    print("删除完成。")
+    return True
+
+
+def insert_departments(engine, dept_names: List[str], parent_mapping: Dict[str, int], parent_info: Dict[int, Dict[str, Any]]):
+    """
+    插入时遵循以下规则以保持与现有数据一致性:
+    - dept_id: 若表中已有较大数值 id(如 2025xxxxxxxxxxxx),则以 max(dept_id)+1 继续;否则以当前时间 `%Y%m%d%H%M%S` + 4位序号生成
+    - tenant_id: 写为 '000000'
+    - order_num: 在同一 parent_id 下取 max(order_num)+1
+    - ancestors: 从 parent_info 推断,若父项无 ancestors 则使用 "0,{parent_id}"
+    - 其他字段填充 create_by/update_by/create_dept 等默认值以匹配样例
+    """
+    inserted = 0
+    skipped = 0
+    now_dt = datetime.datetime.now()
+    now_str = now_dt.strftime("%Y-%m-%d %H:%M:%S")
+    timestamp_prefix = now_dt.strftime("%Y%m%d%H%M%S")
+
+    # helper to generate unique dept_id
+    def generate_dept_id(conn, local_counter_start=1):
+        # get current max dept_id
+        r = conn.execute(text("SELECT MAX(dept_id) as m FROM sys_dept")).fetchone()
+        max_id = int(r[0]) if r and r[0] is not None else 0
+        if max_id > 10**14:
+            return max_id + 1
+        # else use timestamp-based id plus 4-digit counter
+        counter = local_counter_start
+        while True:
+            candidate = int(f"{timestamp_prefix}{counter:04d}")
+            exists = conn.execute(text("SELECT 1 FROM sys_dept WHERE dept_id = :did LIMIT 1"), {"did": candidate}).fetchone()
+            if not exists:
+                return candidate
+            counter += 1
+
+    # Cache per-parent decision to avoid repeated checks: 'skip' or 'insert'
+    parent_decision: Dict[int, str] = {}
+    with engine.begin() as conn:
+        # fetch column info once
+        col_info = inspect_table(engine, "sys_dept")
+        col_names = [c["COLUMN_NAME"] for c in col_info]
+        # prepare xy counter for parent_id = 101 based on existing dept_category values
+        xy_counter = 1
+        try:
+            rows = conn.execute(text("SELECT dept_category FROM sys_dept WHERE parent_id = :pid AND dept_category LIKE 'xy-%'"), {"pid": 101}).mappings().all()
+            nums = []
+            for r in rows:
+                val = r.get("dept_category")
+                if not val:
+                    continue
+                parts = str(val).split("-")
+                if len(parts) >= 2:
+                    try:
+                        nums.append(int(parts[-1]))
+                    except Exception:
+                        continue
+            if nums:
+                xy_counter = max(nums) + 1
+        except Exception:
+            # ignore and keep default
+            xy_counter = 1
+
+        for name in dept_names:
+            pname = name.strip()
+            if not pname:
+                continue
+            parent_id = parent_mapping.get(pname, None)
+            if parent_id is None:
+                parent_id = 102
+            # If we've decided to skip inserting for this parent, continue
+            if parent_id in parent_decision and parent_decision[parent_id] == "skip":
+                skipped += 1
+                continue
+
+            # First time encountering this parent: if it already has children, skip inserting any under it
+            if parent_id not in parent_decision:
+                try:
+                    has_children = conn.execute(text("SELECT 1 FROM sys_dept WHERE parent_id = :pid LIMIT 1"), {"pid": parent_id}).fetchone()
+                    if has_children:
+                        parent_decision[parent_id] = "skip"
+                        logger = None  # no logger in this module-level function; just mark skip
+                        skipped += 1
+                        continue
+                    else:
+                        parent_decision[parent_id] = "insert"
+                except Exception:
+                    parent_decision[parent_id] = "insert"
+            # skip if same dept_name and parent exists
+            exists_q = text("SELECT count(1) as c FROM sys_dept WHERE dept_name = :name AND parent_id = :pid")
+            res = conn.execute(exists_q, {"name": pname, "pid": parent_id}).fetchone()
+            if res and int(res[0]) > 0:
+                skipped += 1
+                continue
+
+            parent = parent_info.get(parent_id, {})
+            parent_ancestors = parent.get("ancestors") if parent else None
+            if parent_ancestors:
+                ancestors = f"{parent_ancestors},{parent_id}"
+            else:
+                # ensure root '0' present
+                if parent_id == 0:
+                    ancestors = "0"
+                else:
+                    ancestors = f"0,{parent_id}"
+
+            # compute next order_num under this parent
+            r = conn.execute(text("SELECT COALESCE(MAX(order_num),0) as m FROM sys_dept WHERE parent_id = :pid"), {"pid": parent_id}).fetchone()
+            next_order = int(r[0]) + 1 if r and r[0] is not None else 1
+
+            # generate unique dept_id
+            new_dept_id = generate_dept_id(conn)
+
+            # prepare insert dict with default fields matching provided samples
+            # set dept_category to 'xy-序号' for entries under parent_id == 101
+            dept_category_val = None
+            if parent_id == 101:
+                dept_category_val = f"xy-{xy_counter}"
+                xy_counter += 1
+
+            insert_dict: Dict[str, Any] = {
+                "dept_id": new_dept_id,
+                "tenant_id": "000000",
+                "parent_id": parent_id,
+                "ancestors": ancestors,
+                "dept_name": pname,
+                "dept_category": dept_category_val,
+                "order_num": next_order,
+                "leader": None,
+                "phone": None,
+                "email": None,
+                "status": "0",
+                "del_flag": "0",
+                "create_dept": 0,
+                "create_by": 1,
+                "create_time": now_str,
+                "update_by": 1,
+                "update_time": now_str,
+            }
+
+            # only keep keys that exist in the actual table
+            use_cols = {k: v for k, v in insert_dict.items() if k in col_names}
+            if "dept_name" not in use_cols:
+                raise RuntimeError("目标表不包含 dept_name 列,无法继续")
+
+            cols_part = ", ".join(f"`{k}`" for k in use_cols.keys())
+            vals_part = ", ".join(f":{k}" for k in use_cols.keys())
+            ins_q = text(f"INSERT INTO sys_dept ({cols_part}) VALUES ({vals_part})")
+            conn.execute(ins_q, use_cols)
+            inserted += 1
+
+    print(f"插入完成:新增 {inserted} 条,跳过已存在 {skipped} 条。")
+
+
+def main():
+    base_dir = os.path.dirname(os.path.abspath(__file__))
+    config_path = os.path.join(base_dir, "db_config.py")
+    if not os.path.exists(config_path):
+        print(f"找不到配置文件: {config_path}")
+        sys.exit(1)
+    db_config = load_db_config_from_file(config_path)
+    engine = build_engine_from_config(db_config)
+
+    # 1. 学习表结构
+    print("正在读取 sys_dept 表结构...")
+    cols = inspect_table(engine, "sys_dept")
+    for c in cols:
+        print(f"  - {c['COLUMN_NAME']}  type={c['DATA_TYPE']}  nullable={c['IS_NULLABLE']}  default={c['COLUMN_DEFAULT']}")
+
+    # 2. 删除除指定 dept_id 外的记录
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--yes", action="store_true", help="自动确认删除")
+    parser.add_argument("--teacher-file", type=str, default=os.path.join(base_dir, "teacher.xlsx"))
+    args = parser.parse_args()
+
+    keep_ids = [100, 101, 102]
+    ok = delete_except(engine, keep_ids, args.yes)
+    if not ok:
+        print("用户取消,退出。")
+        return
+
+    # 3. 从 his_lssj 表导入部门(更快)
+    dept_names = read_teacher_departments_from_db(engine, table_name="his_lssj")
+
+    # mapping: specific colleges -> parent 101, others -> 102
+    top_level_101 = {
+        "人文学院", "餐饮休闲学院", "生态园林学院", "数字信息学院",
+        "医药健康学院", "智能控制学院", "智能制造学院"
+    }
+    # build mapping for exact names
+    parent_mapping = {name: 101 for name in top_level_101}
+
+    # get parent info
+    parent_info = get_parent_info(engine, [101, 102])
+    if 101 not in parent_info or 102 not in parent_info:
+        print("警告:未能在 sys_dept 中找到 parent_id 为 101 或 102 的记录,请确认这两个父组织是否存在。")
+    insert_departments(engine, dept_names, parent_mapping, parent_info)
+
+
+if __name__ == "__main__":
+    main()
+
+

+ 292 - 0
学校历史数据迁移/03-insert_sys_user.py

@@ -0,0 +1,292 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+03-插入老师数据到 sys_user 表
+
+功能:
+- 保留 user_id = 1 的用户,其他用户的 del_flag 都改成 '1'(虚拟删除,可交互确认)
+- 从 teacher.xlsx 读取教师信息,插入到 sys_user
+  - user_name: 工号
+  - nick_name: 姓名
+  - phonenumber: 手机号(取前11位数字)
+  - sex: 男->'0', 女->'1'(其他为空)
+  - password: 使用固定哈希值
+  - account_type: '1'
+  - dept_id: 通过部门名称关联 sys_dept.dept_id(找不到会写 NULL 并打印警告)
+  - 跳过已存在相同 user_name 的记录(可选择覆盖)
+
+用法:
+  python 03-insert_sys_user.py           # 交互式确认虚拟删除
+  python 03-insert_sys_user.py --yes     # 自动确认虚拟删除
+  python 03-insert_sys_user.py --teacher-file /path/to/teacher.xlsx
+  python 03-insert_sys_user.py --dry-run  # 仅打印将执行的操作,不写库
+"""
+from __future__ import annotations
+import os
+import sys
+import re
+import argparse
+import datetime
+from typing import List, Dict, Any, Optional
+import pandas as pd
+from sqlalchemy import create_engine, text
+import importlib.util
+
+
+def load_db_config_from_file(config_path: str) -> dict:
+    spec = importlib.util.spec_from_file_location("db_config", config_path)
+    module = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(module)  # type: ignore
+    if not hasattr(module, "DB_CONFIG"):
+        raise RuntimeError(f"No DB_CONFIG found in {config_path}")
+    return getattr(module, "DB_CONFIG")
+
+
+def build_engine_from_config(db_config: dict):
+    user = db_config.get("user")
+    password = db_config.get("password")
+    host = db_config.get("host", "localhost")
+    port = db_config.get("port", 3306)
+    database = db_config.get("database")
+    charset = db_config.get("charset", "utf8mb4")
+    uri = f"mysql+pymysql://{user}:{password}@{host}:{port}/{database}?charset={charset}"
+    engine = create_engine(uri, pool_pre_ping=True)
+    return engine
+
+
+def find_column(df: pd.DataFrame, keywords: List[str]) -> Optional[str]:
+    for col in df.columns:
+        name = str(col).strip()
+        low = name.lower()
+        for k in keywords:
+            if k in low:
+                return name
+    return None
+
+
+def read_teacher_df_from_db(engine, table_name: str = "his_lssj") -> pd.DataFrame:
+    """
+    从数据库表 his_lssj 读取教师数据,返回 pandas DataFrame(列名使用表中的列名)。
+    假定表中包含列:`工号`, `姓名`, `部门`, `性别`, `手机号码`
+    """
+    print(f"从数据库表 `{table_name}` 读取教师数据(假定列:工号, 姓名, 部门, 性别, 手机号码)")
+    q = f"SELECT `工号`, `姓名`, `部门`, `性别`, `手机号码` FROM `{table_name}`"
+    df = pd.read_sql_query(q, con=engine)
+    return df
+
+
+def normalize_phone(raw: Optional[str]) -> Optional[str]:
+    if raw is None:
+        return None
+    s = str(raw)
+    digits = "".join(re.findall(r"\d", s))
+    return digits[:11] if digits else None
+
+
+def map_sex(raw: Optional[str]) -> Optional[str]:
+    if raw is None:
+        return None
+    s = str(raw).strip()
+    if s in ("男", "M", "m", "male", "Male"):
+        return "0"
+    if s in ("女", "F", "f", "female", "Female"):
+        return "1"
+    return None
+
+
+def soft_delete_other_users(engine, yes: bool, dry_run: bool = False):
+    # Renamed behavior: perform physical DELETE except user_id = 1
+    if not yes:
+        resp = input("将删除 sys_user 中除 user_id=1 外的所有用户,确认?(yes/no) [no]: ").strip().lower()
+        if resp not in ("y", "yes"):
+            print("取消删除步骤。")
+            return
+    q_count = text("SELECT COUNT(1) as c FROM sys_user WHERE user_id != 1")
+    with engine.connect() as conn:
+        cnt = conn.execute(q_count).scalar()
+    print(f"将被删除的用户数:{cnt}")
+    if dry_run:
+        print("dry-run 模式,跳过删除。")
+        return
+    q = text("DELETE FROM sys_user WHERE user_id != 1")
+    with engine.begin() as conn:
+        conn.execute(q)
+    print("已删除其他用户(保留 user_id = 1)。")
+
+
+def generate_user_id(conn):
+    r = conn.execute(text("SELECT MAX(user_id) as m FROM sys_user")).fetchone()
+    max_id = int(r[0]) if r and r[0] is not None else 0
+    return max_id + 1
+
+
+def get_dept_id(conn, dept_name: str) -> Optional[int]:
+    if dept_name is None or str(dept_name).strip() == "":
+        return None
+    r = conn.execute(text("SELECT dept_id FROM sys_dept WHERE dept_name = :name LIMIT 1"), {"name": dept_name.strip()}).fetchone()
+    return int(r[0]) if r and r[0] is not None else None
+
+
+def user_exists(conn, user_name: str, dept_id: Optional[int]) -> bool:
+    # skip only if both user_name and dept_id match an existing record
+    q = text(
+        "SELECT 1 FROM sys_user WHERE user_name = :u AND "
+        "((dept_id = :did) OR (dept_id IS NULL AND :did IS NULL)) LIMIT 1"
+    )
+    r = conn.execute(q, {"u": user_name, "did": dept_id}).fetchone()
+    return bool(r)
+
+
+def dept_has_users(conn, dept_id: int) -> bool:
+    """检查指定 dept_id 下是否已有用户存在(用于决定是否跳过该部门的批量插入)"""
+    if dept_id is None:
+        return False
+    r = conn.execute(text("SELECT 1 FROM sys_user WHERE dept_id = :did LIMIT 1"), {"did": dept_id}).fetchone()
+    return bool(r)
+
+
+def insert_user(conn, data: Dict[str, Any]):
+    cols = ", ".join(f"`{k}`" for k in data.keys())
+    vals = ", ".join(f":{k}" for k in data.keys())
+    ins = text(f"INSERT INTO sys_user ({cols}) VALUES ({vals})")
+    conn.execute(ins, data)
+
+
+def user_has_role(conn, user_id: int, role_id: int) -> bool:
+    r = conn.execute(text("SELECT 1 FROM sys_user_role WHERE user_id = :uid AND role_id = :rid LIMIT 1"), {"uid": user_id, "rid": role_id}).fetchone()
+    return bool(r)
+
+
+def insert_user_role(conn, user_id: int, role_id: int):
+    ins = text("INSERT INTO sys_user_role (`user_id`, `role_id`) VALUES (:uid, :rid)")
+    conn.execute(ins, {"uid": user_id, "rid": role_id})
+
+
+def main():
+    base_dir = os.path.dirname(os.path.abspath(__file__))
+    config_path = os.path.join(base_dir, "db_config.py")
+    if not os.path.exists(config_path):
+        print(f"找不到配置文件: {config_path}")
+        sys.exit(1)
+    db_config = load_db_config_from_file(config_path)
+    engine = build_engine_from_config(db_config)
+
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--yes", action="store_true", help="自动确认虚拟删除现有用户(除 user_id=1)")
+    parser.add_argument("--teacher-file", type=str, default=os.path.join(base_dir, "teacher.xlsx"))
+    parser.add_argument("--dry-run", action="store_true", help="只打印将执行的操作,不写入数据库")
+    args = parser.parse_args()
+
+    # 1) 虚拟删除其他用户
+    soft_delete_other_users(engine, args.yes, dry_run=args.dry_run)
+
+    # 2) 从 his_lssj 表读取教师数据(更快)
+    df = read_teacher_df_from_db(engine, table_name="his_lssj")
+    # 直接使用约定列名(表中创建时已用这些列名)
+    col_workno = "工号"
+    col_name = "姓名"
+    col_phone = "手机号码"
+    col_dept = "部门"
+    col_sex = "性别"
+    # ensure columns exist
+    for c in (col_workno, col_name):
+        if c not in df.columns:
+            print(f"必需列 {c} 在表 his_lssj 中未找到,请检查表结构或使用 Excel 源。")
+            return
+
+    password_hash = "$2a$10$FGldU5BGGq9ONuBmYhxLweFhmbkE45keMKASNZpjETXawE1oGbtZy"
+    now_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+    # iterate rows and insert
+    inserted = 0
+    skipped = 0
+    warnings = 0
+    # cache per-dept decision to avoid repeated checks: dept_id -> 'skip'|'insert'
+    dept_decision: Dict[Optional[int], str] = {}
+    with engine.begin() as conn:
+        for idx, row in df.iterrows():
+            user_name_val = str(row.get(col_workno)).strip() if row.get(col_workno) is not None else ""
+            if not user_name_val:
+                skipped += 1
+                continue
+
+            # determine department first so existence check can use dept_id
+            dept_raw = str(row.get(col_dept)).strip() if col_dept and row.get(col_dept) is not None else ""
+            dept_id = get_dept_id(conn, dept_raw) if dept_raw else None
+            if dept_raw and not dept_id:
+                print(f"警告: 第 {idx+1} 行,部门 '{dept_raw}' 在 sys_dept 中未找到,dept_id 将设为 NULL。")
+                warnings += 1
+            # If we've decided to skip this department because it already has users, skip adding users under it
+            if dept_id in dept_decision and dept_decision[dept_id] == "skip":
+                skipped += 1
+                continue
+
+            # First time encountering this dept: if dept already has users, mark skip
+            if dept_id not in dept_decision:
+                try:
+                    if dept_has_users(conn, dept_id):
+                        dept_decision[dept_id] = "skip"
+                        skipped += 1
+                        continue
+                    else:
+                        dept_decision[dept_id] = "insert"
+                except Exception:
+                    dept_decision[dept_id] = "insert"
+
+            if user_exists(conn, user_name_val, dept_id):
+                skipped += 1
+                continue
+
+            nick = str(row.get(col_name)).strip() if row.get(col_name) is not None else ""
+            raw_phone = row.get(col_phone) if col_phone else None
+            phone = normalize_phone(raw_phone)
+            sex_val = map_sex(row.get(col_sex)) if col_sex else None
+
+            # generate user_id
+            new_user_id = generate_user_id(conn)
+
+            user_record = {
+                "user_id": new_user_id,
+                "tenant_id": "000000",
+                "dept_id": dept_id,
+                "user_name": user_name_val,
+                "nick_name": nick or user_name_val,
+                "user_type": "sys_user",
+                "email": None,
+                "phonenumber": phone,
+                "sex": sex_val or "0",
+                "avatar": None,
+                "password": password_hash,
+                "status": "0",
+                "del_flag": "0",
+                "login_ip": "",
+                "login_date": None,
+                "create_dept": 103,
+                "create_by": 1,
+                "create_time": now_str,
+                "update_by": 1,
+                "update_time": now_str,
+                "remark": None,
+                "account_type": "1",
+            }
+
+            if args.dry_run:
+                print("DRY:", user_record)
+                inserted += 1
+                continue
+
+            insert_user(conn, user_record)
+            # assign roles to the new user
+            role_ids = [1938120323613728770, 4]
+            for rid in role_ids:
+                if not user_has_role(conn, new_user_id, rid):
+                    insert_user_role(conn, new_user_id, rid)
+            inserted += 1
+
+    print(f"完成:插入 {inserted} 条,跳过 {skipped} 条,警告 {warnings} 条。")
+
+
+if __name__ == "__main__":
+    main()
+
+

+ 225 - 0
学校历史数据迁移/04-01-dedupe_sys_dept.py

@@ -0,0 +1,225 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+04-01-dedupe_sys_dept.py
+
+目的:
+  在 sys_dept 表中,对于同一学院(parent_id)下的相同专业名称(dept_name)只保留一条记录,删除重复记录。
+
+行为:
+  - 默认 dry-run:打印将要执行的 SQL 操作,不做修改。
+  - 传入 --apply 会执行修改(在事务中)。
+  - 对于重复记录,会保留 dept_id 最小的那条作为主记录(keep_id),
+    并将重复记录的子部门(parent_id 指向被删除 id) 统一改为 keep_id,
+    同时将 sys_user 表中引用的 dept_id 更新为 keep_id。
+  - 会打印处理摘要。
+
+使用:
+  python3 04-01-dedupe_sys_dept.py --host ... --user ... --password ... --database ... [--apply]
+
+注意:
+  在运行前请先备份数据库。对非数字或特殊 dept_name 不做额外过滤,脚本基于 parent_id + dept_name 进行分组。
+"""
+
+from __future__ import annotations
+
+import argparse
+import getpass
+import logging
+import sys
+import os
+import importlib.util
+from typing import List, Tuple
+
+import pymysql
+from pymysql.connections import Connection
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
+logger = logging.getLogger("dedupe_sys_dept")
+
+
+def parse_args():
+    """
+    Parse arguments. If a local db_config.py exists in the same directory and defines DB_CONFIG dict,
+    its values are used as defaults (same behavior as 04-insert_majors.py).
+    """
+    # try load local db_config.py for defaults
+    local_db = load_local_db_config()
+    host_default = local_db.get("host", os.getenv("DB_HOST", "localhost"))
+    port_default = int(local_db.get("port", os.getenv("DB_PORT", 3306)))
+    user_default = local_db.get("user", os.getenv("DB_USER", "root"))
+    password_default = local_db.get("password", os.getenv("DB_PASSWORD", ""))
+    database_default = local_db.get("database", os.getenv("DB_NAME", "hzdyjs_xg"))
+
+    p = argparse.ArgumentParser(description="Deduplicate sys_dept entries by (parent_id, dept_name)")
+    p.add_argument("--host", default=host_default, help="DB host")
+    p.add_argument("--port", type=int, default=port_default, help="DB port")
+    p.add_argument("--user", default=user_default, help="DB user")
+    p.add_argument("--password", default=password_default, help="DB password (if omitted, prompted)")
+    p.add_argument("--database", default=database_default, help="DB name")
+    p.add_argument("--charset", default="utf8mb4", help="DB charset")
+    p.add_argument("--apply", action="store_true", help="Actually apply deletions and updates. Default is dry-run.")
+    p.add_argument("--limit", type=int, default=0, help="If >0, limit number of groups processed (for testing)")
+    return p.parse_args()
+
+
+def load_local_db_config() -> dict:
+    """
+    Attempt to load DB_CONFIG dict from db_config.py in the current script directory.
+    Returns empty dict if not present or on error.
+    """
+    cfg_path = os.path.join(os.path.dirname(__file__), "db_config.py")
+    if not os.path.exists(cfg_path):
+        return {}
+    try:
+        spec = importlib.util.spec_from_file_location("local_db_config", cfg_path)
+        module = importlib.util.module_from_spec(spec)
+        spec.loader.exec_module(module)  # type: ignore
+        return getattr(module, "DB_CONFIG", {}) or {}
+    except Exception as e:
+        logger.warning("加载本地 db_config.py 失败: %s", e)
+        return {}
+
+
+def get_db_conn(args) -> Connection:
+    """
+    Return a pymysql connection. Naming aligned with 04-insert_majors.py (`get_db_conn`).
+    """
+    pwd = args.password or getpass.getpass("DB password: ")
+    return pymysql.connect(
+        host=args.host,
+        port=args.port,
+        user=args.user,
+        password=pwd,
+        database=args.database,
+        charset=args.charset,
+        cursorclass=pymysql.cursors.DictCursor,
+        autocommit=False,
+    )
+
+
+def find_duplicate_groups(conn: Connection, limit: int = 0) -> List[Tuple[int, str, List[int]]]:
+    """
+    返回列表,每项为 (parent_id, dept_name, [dept_id1, dept_id2, ...]),且 dept_id 列表长度 >=2
+    """
+    sql = """
+    SELECT parent_id, dept_name, GROUP_CONCAT(dept_id ORDER BY dept_id ASC) AS ids, COUNT(*) AS cnt
+    FROM sys_dept
+    GROUP BY parent_id, dept_name
+    HAVING cnt > 1
+    ORDER BY parent_id, dept_name
+    """
+    if limit and limit > 0:
+        sql = sql + f" LIMIT {limit}"
+    with conn.cursor() as cur:
+        cur.execute(sql)
+        rows = cur.fetchall()
+    groups = []
+    for r in rows:
+        ids = [int(x) for x in str(r["ids"]).split(",") if x.strip()]
+        groups.append((r["parent_id"], r["dept_name"], ids))
+    return groups
+
+
+def reparent_children_and_users(conn: Connection, keep_id: int, delete_ids: List[int], dry_run: bool):
+    if not delete_ids:
+        return
+    # 1) Reparent child depts whose parent_id in delete_ids -> set parent_id = keep_id
+    sql_children = "SELECT dept_id FROM sys_dept WHERE parent_id IN ({ids})".format(
+        ids=",".join(str(i) for i in delete_ids)
+    )
+    with conn.cursor() as cur:
+        cur.execute(sql_children)
+        children = cur.fetchall()
+    if children:
+        children_ids = [r["dept_id"] for r in children]
+        logger.info("  Found %d child departments to reparent to %s: %s", len(children_ids), keep_id, children_ids)
+        if not dry_run:
+            sql_upd = "UPDATE sys_dept SET parent_id = %s WHERE parent_id IN ({ids})".format(
+                ids=",".join(str(i) for i in delete_ids)
+            )
+            with conn.cursor() as cur:
+                cur.execute(sql_upd, (keep_id,))
+    else:
+        logger.debug("  No child departments referencing deleted ids.")
+
+    # 2) Update sys_user.dept_id referencing deleted ids -> keep_id
+    sql_users = "SELECT count(*) as cnt FROM sys_user WHERE dept_id IN ({ids})".format(
+        ids=",".join(str(i) for i in delete_ids)
+    )
+    with conn.cursor() as cur:
+        cur.execute(sql_users)
+        cnt_row = cur.fetchone()
+        user_cnt = int(cnt_row["cnt"])
+    if user_cnt > 0:
+        logger.info("  Found %d users referencing deleted dept ids -> will update to %s", user_cnt, keep_id)
+        if not dry_run:
+            sql_upd_users = "UPDATE sys_user SET dept_id = %s WHERE dept_id IN ({ids})".format(
+                ids=",".join(str(i) for i in delete_ids)
+            )
+            with conn.cursor() as cur:
+                cur.execute(sql_upd_users, (keep_id,))
+
+
+def delete_dept_ids(conn: Connection, delete_ids: List[int], dry_run: bool):
+    if not delete_ids:
+        return
+    logger.info("  Will delete dept ids: %s", delete_ids)
+    if dry_run:
+        return
+    sql = "DELETE FROM sys_dept WHERE dept_id IN ({ids})".format(ids=",".join(str(i) for i in delete_ids))
+    with conn.cursor() as cur:
+        cur.execute(sql)
+
+
+def process_groups(conn: Connection, groups, apply: bool):
+    dry_run = not apply
+    summary = {"groups": 0, "deleted_ids_total": 0}
+    for parent_id, dept_name, ids in groups:
+        keep_id = min(ids)
+        delete_ids = [i for i in ids if i != keep_id]
+        summary["groups"] += 1
+        summary["deleted_ids_total"] += len(delete_ids)
+        logger.info("Group parent_id=%s dept_name=%r => keep=%s delete=%s", parent_id, dept_name, keep_id, delete_ids)
+        # Reparent children and users first
+        reparent_children_and_users(conn, keep_id, delete_ids, dry_run=dry_run)
+        # Then delete
+        delete_dept_ids(conn, delete_ids, dry_run=dry_run)
+    return summary
+
+
+def main():
+    args = parse_args()
+    conn = get_db_conn(args)
+    try:
+        groups = find_duplicate_groups(conn, limit=args.limit)
+        if not groups:
+            logger.info("No duplicate groups found.")
+            return 0
+        logger.info("Found %d duplicate groups (parent_id, dept_name with count>1).", len(groups))
+        for idx, (parent_id, dept_name, ids) in enumerate(groups, start=1):
+            logger.info(" %d) parent_id=%s dept_name=%r ids=%s", idx, parent_id, dept_name, ids)
+
+        if args.apply:
+            logger.info("Applying changes inside a transaction...")
+            try:
+                summary = process_groups(conn, groups, apply=True)
+                conn.commit()
+                logger.info("Committed. Processed %d groups, total deleted ids %d", summary["groups"], summary["deleted_ids_total"])
+            except Exception as e:
+                conn.rollback()
+                logger.exception("Error during apply, rolled back. Error: %s", e)
+                return 2
+        else:
+            logger.info("Dry-run mode (no changes will be made). Showing what would be executed:")
+            _ = process_groups(conn, groups, apply=False)
+            logger.info("Dry-run finished. Re-run with --apply to execute.")
+        return 0
+    finally:
+        conn.close()
+
+
+if __name__ == "__main__":
+    sys.exit(main())
+
+

+ 211 - 0
学校历史数据迁移/04-02-reorder_dept_order_num.py

@@ -0,0 +1,211 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+04-02-reorder_dept_order_num.py
+
+目的:
+  将 `sys_dept` 表中,同一个学院(parent_id)下的专业记录(可通过 dept_category 判断)按照 `dept_name` 升序重新设置 `order_num`(从 1 开始递增)。
+
+行为:
+  - 默认 dry-run:打印将要执行的 UPDATE 语句,不做修改。
+  - 传入 --apply 会在事务中执行更新。
+  - 支持通过 --major-only 控制是否仅对 dept_category LIKE 'zy-%' 的记录进行排序(默认 True)。
+  - 支持 --limit 控制处理的 parent_id 数量(用于测试)。
+
+使用:
+  python3 04-02-reorder_dept_order_num.py --host ... --user ... --database ... [--apply]
+"""
+
+from __future__ import annotations
+
+import argparse
+import getpass
+import importlib.util
+import os
+import sys
+import logging
+from typing import List, Tuple, Dict
+
+import pymysql
+from pymysql.connections import Connection
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
+logger = logging.getLogger("reorder_dept_order_num")
+
+
+def load_local_db_config() -> dict:
+    cfg_path = os.path.join(os.path.dirname(__file__), "db_config.py")
+    if not os.path.exists(cfg_path):
+        return {}
+    try:
+        spec = importlib.util.spec_from_file_location("local_db_config", cfg_path)
+        module = importlib.util.module_from_spec(spec)
+        spec.loader.exec_module(module)  # type: ignore
+        return getattr(module, "DB_CONFIG", {}) or {}
+    except Exception as e:
+        logger.warning("加载本地 db_config.py 失败: %s", e)
+        return {}
+
+
+def parse_args():
+    local_db = load_local_db_config()
+    host_default = local_db.get("host", os.getenv("DB_HOST", "localhost"))
+    port_default = int(local_db.get("port", os.getenv("DB_PORT", 3306)))
+    user_default = local_db.get("user", os.getenv("DB_USER", "root"))
+    password_default = local_db.get("password", os.getenv("DB_PASSWORD", ""))
+    database_default = local_db.get("database", os.getenv("DB_NAME", "hzdyjs_xg"))
+
+    p = argparse.ArgumentParser(description="Reorder sys_dept.order_num by dept_name under the same parent_id")
+    p.add_argument("--host", default=host_default)
+    p.add_argument("--port", type=int, default=port_default)
+    p.add_argument("--user", default=user_default)
+    p.add_argument("--password", default=password_default)
+    p.add_argument("--database", default=database_default)
+    p.add_argument("--charset", default="utf8mb4")
+    p.add_argument("--apply", action="store_true", help="Apply changes (default dry-run)")
+    p.add_argument("--limit", type=int, default=0, help="Limit number of parent groups to process (for testing)")
+    p.add_argument("--major-only", action="store_true", default=True, help="Only reorder records where dept_category LIKE 'zy-%' (default True)")
+    return p.parse_args()
+
+
+def get_db_conn(args) -> Connection:
+    pwd = args.password or getpass.getpass("DB password: ")
+    return pymysql.connect(
+        host=args.host,
+        port=args.port,
+        user=args.user,
+        password=pwd,
+        database=args.database,
+        charset=args.charset,
+        cursorclass=pymysql.cursors.DictCursor,
+        autocommit=False,
+    )
+
+
+def find_parent_ids(conn: Connection, major_only: bool = True, limit: int = 0) -> List[int]:
+    """
+    Find parent_id values that have child departments to reorder.
+    If major_only is True, only consider children with dept_category LIKE 'zy-%'.
+    """
+    if major_only:
+        sql = """
+        SELECT parent_id
+        FROM sys_dept
+        WHERE dept_category LIKE 'zy-%%' AND del_flag = '0'
+        GROUP BY parent_id
+        ORDER BY parent_id
+        """
+    else:
+        sql = """
+        SELECT parent_id
+        FROM sys_dept
+        WHERE parent_id IS NOT NULL AND del_flag = '0'
+        GROUP BY parent_id
+        ORDER BY parent_id
+        """
+    if limit and limit > 0:
+        sql = sql + f" LIMIT {limit}"
+    with conn.cursor() as cur:
+        cur.execute(sql)
+        rows = cur.fetchall()
+    return [r["parent_id"] for r in rows if r["parent_id"] is not None]
+
+
+def fetch_children(conn: Connection, parent_id: int, major_only: bool = True) -> List[Dict]:
+    """
+    Fetch child departments for a parent_id, ordered by dept_name asc then dept_id asc.
+    """
+    if major_only:
+        sql = """
+        SELECT dept_id, dept_name, order_num
+        FROM sys_dept
+        WHERE parent_id = %s AND dept_category LIKE 'zy-%%' AND del_flag = '0'
+        ORDER BY dept_name ASC, dept_id ASC
+        """
+    else:
+        sql = """
+        SELECT dept_id, dept_name, order_num
+        FROM sys_dept
+        WHERE parent_id = %s AND del_flag = '0'
+        ORDER BY dept_name ASC, dept_id ASC
+        """
+    with conn.cursor() as cur:
+        cur.execute(sql, (parent_id,))
+        return cur.fetchall()
+
+
+def apply_updates(conn: Connection, updates: List[Tuple[int, int]], dry_run: bool):
+    """
+    updates: list of (dept_id, new_order_num)
+    """
+    if not updates:
+        return
+    logger.info("  %d updates to apply", len(updates))
+    for dept_id, new_order in updates:
+        sql = "UPDATE sys_dept SET order_num = %s WHERE dept_id = %s"
+        logger.info("    %s -- params: (%s, %s)", sql, new_order, dept_id)
+        if not dry_run:
+            with conn.cursor() as cur:
+                cur.execute(sql, (new_order, dept_id))
+
+
+def process(conn: Connection, parent_ids: List[int], major_only: bool, apply: bool):
+    dry_run = not apply
+    summary = {"parents": 0, "updates": 0}
+    for parent_id in parent_ids:
+        children = fetch_children(conn, parent_id, major_only=major_only)
+        if not children:
+            logger.debug("Parent %s has no children to reorder", parent_id)
+            continue
+        updates = []
+        for idx, child in enumerate(children, start=1):
+            dept_id = child["dept_id"]
+            current_order = child.get("order_num") or 0
+            if current_order != idx:
+                updates.append((dept_id, idx))
+        if updates:
+            logger.info("Parent %s: will update %d rows", parent_id, len(updates))
+            apply_updates(conn, updates, dry_run=dry_run)
+            summary["updates"] += len(updates)
+        else:
+            logger.info("Parent %s: no updates needed", parent_id)
+        summary["parents"] += 1
+    return summary
+
+
+def main():
+    args = parse_args()
+    conn = get_db_conn(args)
+    try:
+        parent_ids = find_parent_ids(conn, major_only=args.major_only, limit=args.limit)
+        if not parent_ids:
+            logger.info("No parent groups found to process.")
+            return 0
+        logger.info("Found %d parent groups to process (limit=%s, major_only=%s).", len(parent_ids), args.limit, args.major_only)
+        # show parents
+        for pid in parent_ids:
+            logger.info("  parent_id=%s", pid)
+
+        if args.apply:
+            logger.info("Applying updates inside a transaction...")
+            try:
+                summary = process(conn, parent_ids, args.major_only, apply=True)
+                conn.commit()
+                logger.info("Committed. Processed %d parents, total updates %d", summary["parents"], summary["updates"])
+            except Exception as e:
+                conn.rollback()
+                logger.exception("Error during apply, rolled back. Error: %s", e)
+                return 2
+        else:
+            logger.info("Dry-run mode: showing planned updates (no DB changes).")
+            summary = process(conn, parent_ids, args.major_only, apply=False)
+            logger.info("Dry-run finished. Processed %d parents, planned updates %d. Re-run with --apply to execute.", summary["parents"], summary["updates"])
+        return 0
+    finally:
+        conn.close()
+
+
+if __name__ == "__main__":
+    sys.exit(main())
+
+

+ 294 - 0
学校历史数据迁移/04-insert_majors.py

@@ -0,0 +1,294 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+04 - 专业信息入库脚本
+
+说明:
+- 从 `his_xssj` 表读取学院编号、专业编号、专业名称(去重)
+- 根据学院编号映射到学院名称,然后在 `sys_dept`(或目标表)中查找父节点 parent_id
+- 若专业在目标表中不存在,则生成 INSERT 语句(默认 dry-run,仅打印)
+- 可使用 --execute 开关执行插入
+
+用法示例:
+  python3 "学校历史数据迁移/04-insert_majors.py" --host localhost --user root --password secret --database hzdyjs_xg
+  python3 "学校历史数据迁移/04-insert_majors.py" --execute --host ...  (实际写入)
+
+注意:
+- 请提前安装依赖: pip install pymysql
+"""
+from __future__ import annotations
+
+import argparse
+import datetime
+import logging
+import os
+import sys
+import importlib.util
+from typing import Dict, List, Optional, Tuple
+
+import pymysql
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
+logger = logging.getLogger("insert_majors")
+
+
+COLLEGE_MAP: Dict[str, str] = {
+    "14": "智能控制学院",
+    "15": "智能制造学院",
+    "19": "人文学院",
+    "18": "餐饮休闲学院",
+    "28": "数字信息学院",
+    "16": "医药健康学院",
+    "17": "生态园林学院",
+}
+
+
+def load_local_db_config() -> Dict:
+    """
+    尝试从当前脚本目录的 db_config.py 加载 DB_CONFIG 字典并返回(失败则返回空字典)
+    """
+    cfg_path = os.path.join(os.path.dirname(__file__), "db_config.py")
+    if not os.path.exists(cfg_path):
+        return {}
+    try:
+        spec = importlib.util.spec_from_file_location("local_db_config", cfg_path)
+        module = importlib.util.module_from_spec(spec)
+        spec.loader.exec_module(module)  # type: ignore
+        return getattr(module, "DB_CONFIG", {}) or {}
+    except Exception as e:
+        logger.warning("加载本地 db_config.py 失败: %s", e)
+        return {}
+
+
+def get_db_conn(host: str, port: int, user: str, password: str, database: str):
+    """建立并返回 pymysql 连接(默认 autocommit False)"""
+    return pymysql.connect(
+        host=host,
+        port=port,
+        user=user,
+        password=password,
+        database=database,
+        charset="utf8mb4",
+        cursorclass=pymysql.cursors.DictCursor,
+        autocommit=False,
+    )
+
+
+def college_has_majors(conn, parent_id: int, target_table: str) -> bool:
+    """
+    检查指定 parent_id(学院)下是否已有专业类记录(dept_category 以 'zy-' 开头或任意子节点)
+    若存在任一子记录则返回 True。
+    """
+    # note: percent signs must be escaped (%%) because PyMySQL uses Python %-formatting during mogrify
+    # Only consider existing records that look like majors (dept_category starting with 'zy-').
+    # Previously the condition included "OR dept_category IS NOT NULL" which caused false positives
+    # and made the script skip entire colleges when any child had a non-null dept_category.
+    sql = f"SELECT 1 FROM {target_table} WHERE parent_id = %s AND dept_category LIKE 'zy-%%' LIMIT 1"
+    with conn.cursor() as cur:
+        cur.execute(sql, (parent_id,))
+        return cur.fetchone() is not None
+
+
+def fetch_distinct_majors(conn) -> List[Tuple[str, str, str]]:
+    """
+    从 his_xssj 表读取 distinct (college_code, major_code, major_name)
+    假设表名为 his_xssj,字段名可调整如 xybm(学院编码)、zybm(专业编码)、zymc(专业名称)
+    """
+    # use Chinese column names present in his_xssj table
+    sql = """
+SELECT DISTINCT
+    COALESCE(`学院编号`, '') AS college_code,
+    COALESCE(`专业编号`, '') AS major_code,
+    COALESCE(`专业名称`, '') AS major_name
+FROM his_xssj
+WHERE `专业编号` IS NOT NULL AND `专业编号` <> '' AND `专业名称` IS NOT NULL AND `专业名称` <> ''
+ORDER BY college_code, major_code;
+"""
+    with conn.cursor() as cur:
+        cur.execute(sql)
+        rows = cur.fetchall()
+    return [(r["college_code"], r["major_code"], r["major_name"]) for r in rows]
+
+
+def find_parent_dept(conn, college_name: str) -> Optional[Dict]:
+    """
+    根据学院名称查找 sys_dept 中的父节点(按 dept_name 精确匹配)
+    返回整行记录(字典)或 None
+    """
+    sql = "SELECT * FROM sys_dept WHERE dept_name = %s LIMIT 1"
+    with conn.cursor() as cur:
+        cur.execute(sql, (college_name,))
+        return cur.fetchone()
+
+
+def exists_major(conn, parent_id: int, major_code: str, major_name: str, target_table: str) -> bool:
+    """
+    判断目标表中是否已存在该专业(通过 dept_category 或 dept_name 与 parent_id 共同判断)
+    """
+    # Consider a major existing only if BOTH dept_category and dept_name match under the same parent_id
+    sql = f"SELECT 1 FROM {target_table} WHERE parent_id = %s AND dept_category = %s AND dept_name = %s LIMIT 1"
+    with conn.cursor() as cur:
+        cur.execute(sql, (parent_id, f"zy-{major_code}", major_name))
+        return cur.fetchone() is not None
+
+
+def gen_dept_id(counter: int) -> int:
+    """
+    生成一个大整数 dept_id:
+    使用当前时间 (YYYYMMDDHHMMSS) + 3-digit counter 保证唯一性(可根据需要替换为雪花ID)
+    """
+    ts = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
+    return int(f"{ts}{counter:03d}")
+
+
+def insert_major(conn, target_table: str, parent_row: Dict, major_code: str, major_name: str, dept_id: int):
+    """
+    插入一条 sys_dept 记录到 target_table
+    - parent_row: 父节点整行(包含 dept_id, ancestors)
+    """
+    now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+    parent_id = parent_row["dept_id"]
+    parent_ancestors = parent_row.get("ancestors") or ""
+    if parent_ancestors:
+        ancestors = f"{parent_ancestors},{parent_id}"
+    else:
+        ancestors = str(parent_id)
+
+    dept_category = f"zy-{major_code}"
+    sql = f"""
+INSERT INTO {target_table}
+    (dept_id, tenant_id, parent_id, ancestors, dept_name, dept_category, order_num, leader, phone, email, status, del_flag, create_dept, create_by, create_time, update_by, update_time)
+VALUES
+    (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+"""
+    params = (
+        dept_id,
+        "000000",  # tenant_id
+        parent_id,
+        ancestors,
+        major_name,
+        dept_category,
+        1,  # order_num
+        None,  # leader
+        None,  # phone
+        None,  # email
+        "0",  # status
+        "0",  # del_flag
+        0,  # create_dept
+        1,  # create_by
+        now,
+        1,  # update_by
+        now,
+    )
+    with conn.cursor() as cur:
+        cur.execute(sql, params)
+
+
+def main(argv: Optional[List[str]] = None):
+    # 尝试从同目录下的 db_config.py 读取默认数据库配置
+    local_db = load_local_db_config()
+    host_default = local_db.get("host", os.getenv("DB_HOST", "localhost"))
+    port_default = int(local_db.get("port", os.getenv("DB_PORT", 3306)))
+    user_default = local_db.get("user", os.getenv("DB_USER", "root"))
+    password_default = local_db.get("password", os.getenv("DB_PASSWORD", ""))
+    database_default = local_db.get("database", os.getenv("DB_NAME", "hzdyjs_xg"))
+    target_table_default = os.getenv("TARGET_TABLE", "sys_dept")
+
+    parser = argparse.ArgumentParser(description="从 his_xssj 导入专业到 sys_dept")
+    parser.add_argument("--host", default=host_default)
+    parser.add_argument("--port", type=int, default=port_default)
+    parser.add_argument("--user", default=user_default)
+    parser.add_argument("--password", default=password_default)
+    parser.add_argument("--database", default=database_default)
+    parser.add_argument("--target-table", default=target_table_default)
+    parser.add_argument("--dry-run", action="store_true", default=False, help="仅打印将要执行的 SQL(默认执行写库)")
+    parser.add_argument("--execute", action="store_true", default=True, help="执行插入(会在事务中执行并提交,默认开启)")
+    parser.add_argument("--limit", type=int, default=0, help="仅处理前 N 条(用于测试)")
+    args = parser.parse_args(argv)
+
+    conn = get_db_conn(args.host, args.port, args.user, args.password, args.database)
+    try:
+        majors = fetch_distinct_majors(conn)
+        logger.info("从 his_xssj 获取到 %d 条去重专业记录", len(majors))
+
+        counter = 1
+        inserted = 0
+        skipped = 0
+        sql_statements: List[str] = []
+        # 记录对每个学院的整体决定('skip' or 'insert'),避免对同一学院重复查询
+        college_decision: Dict[str, str] = {}
+
+        for idx, (college_code, major_code, major_name) in enumerate(majors, start=1):
+            if args.limit and inserted >= args.limit:
+                break
+
+            college_name = COLLEGE_MAP.get(college_code)
+            if not college_name:
+                logger.warning("未在 COLLEGE_MAP 中找到学院编码 %s,跳过: %s", college_code, major_name)
+                skipped += 1
+                continue
+
+            parent_row = find_parent_dept(conn, college_name)
+            if not parent_row:
+                logger.warning("未在 sys_dept 中找到学院父节点(dept_name=%s),请先确认父节点存在,跳过: %s", college_name, major_name)
+                skipped += 1
+                continue
+
+            # 判重:在同一父节点下,若 dept_category 和 dept_name 同时匹配则视为已存在并跳过
+            if exists_major(conn, parent_row["dept_id"], major_code, major_name, args.target_table):
+                logger.info("专业已存在,跳过: %s (%s) under %s", major_name, major_code, college_name)
+                skipped += 1
+                continue
+
+            dept_id = gen_dept_id(counter)
+            counter += 1
+
+            # Prepare SQL string for logging / dry-run
+            now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+            parent_id = parent_row["dept_id"]
+            parent_ancestors = parent_row.get("ancestors") or ""
+            ancestors = f"{parent_ancestors},{parent_id}" if parent_ancestors else str(parent_id)
+            dept_category = f"zy-{major_code}"
+            # escape single quotes in major_name for SQL literal
+            safe_major_name = str(major_name).replace("'", "''")
+            # ancestors is numeric-comma separated; safe to include directly
+            insert_sql = (
+                "INSERT INTO {table} (dept_id, tenant_id, parent_id, ancestors, dept_name, dept_category, order_num, leader, phone, email, status, del_flag, create_dept, create_by, create_time, update_by, update_time) "
+                "VALUES ({did}, '000000', {pid}, '{ancs}', '{dname}', '{dcat}', 1, NULL, NULL, NULL, '0', '0', 0, 1, '{now}', 1, '{now}');"
+            ).format(
+                table=args.target_table,
+                did=dept_id,
+                pid=parent_id,
+                ancs=ancestors,
+                dname=safe_major_name,
+                dcat=dept_category,
+                now=now,
+            )
+            sql_statements.append(insert_sql)
+
+            logger.info("准备插入专业: %s (%s) parent=%s new_dept_id=%s", major_name, major_code, college_name, dept_id)
+
+            if args.execute:
+                insert_major(conn, args.target_table, parent_row, major_code, major_name, dept_id)
+                conn.commit()
+                inserted += 1
+            else:
+                skipped += 1
+
+        # 输出 dry-run 的 SQL
+        if sql_statements:
+            logger.info("共准备 %d 条 INSERT 语句 (dry-run=%s, execute=%s)", len(sql_statements), args.dry_run, args.execute)
+            for s in sql_statements:
+                print(s)
+        else:
+            logger.info("没有需要插入的专业记录")
+
+        logger.info("完成: 插入 %d 条, 跳过 %d 条", inserted, skipped)
+    finally:
+        conn.close()
+
+
+if __name__ == "__main__":
+    main()
+
+

+ 201 - 0
学校历史数据迁移/05-sort_majors_by_dept_category.py

@@ -0,0 +1,201 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+05 - 按 dept_category 为同一学院下的专业排序并更新 order_num
+
+说明:
+- 在目标表(默认 `sys_dept`)中查找所有 dept_category 以 "zy-" 开头的专业记录
+- 按照 `parent_id`(学院)分组,对于每个组按照 `dept_category` 排序
+- 为排序后的记录按序设置 `order_num`(从 1 开始递增)
+- 支持 dry-run(仅打印 SQL)和 --execute(真正更新数据库)
+
+用法示例:
+  python3 "学校历史数据迁移/05-sort_majors_by_dept_category.py" --host localhost --user root --password secret --database hzdyjs_xg --dry-run
+  python3 "学校历史数据迁移/05-sort_majors_by_dept_category.py" --execute --host ...
+
+依赖:
+  pip install pymysql
+"""
+from __future__ import annotations
+
+import argparse
+import importlib.util
+import os
+import logging
+from typing import Dict, List, Optional
+import pymysql
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
+logger = logging.getLogger("sort_majors_order_num")
+
+
+def load_local_db_config() -> Dict:
+    """
+    尝试从当前脚本目录的 db_config.py 加载 DB_CONFIG 字典并返回(失败则返回空字典)
+    """
+    cfg_path = os.path.join(os.path.dirname(__file__), "db_config.py")
+    if not os.path.exists(cfg_path):
+        return {}
+    try:
+        spec = importlib.util.spec_from_file_location("local_db_config", cfg_path)
+        module = importlib.util.module_from_spec(spec)
+        spec.loader.exec_module(module)  # type: ignore
+        return getattr(module, "DB_CONFIG", {}) or {}
+    except Exception as e:
+        logger.warning("加载本地 db_config.py 失败: %s", e)
+        return {}
+
+
+def get_db_conn(host: str, port: int, user: str, password: str, database: str):
+    """建立并返回 pymysql 连接(默认 autocommit False)"""
+    return pymysql.connect(
+        host=host,
+        port=port,
+        user=user,
+        password=password,
+        database=database,
+        charset="utf8mb4",
+        cursorclass=pymysql.cursors.DictCursor,
+        autocommit=False,
+    )
+
+
+def fetch_parent_ids_with_majors(conn, target_table: str) -> List[int]:
+    """
+    获取在目标表中存在以 'zy-' 开头 dept_category 的 parent_id 列表
+    """
+    # escape percent for PyMySQL mogrify/formatting
+    sql = f"SELECT DISTINCT parent_id FROM {target_table} WHERE dept_category LIKE 'zy-%%' AND parent_id IS NOT NULL"
+    with conn.cursor() as cur:
+        cur.execute(sql)
+        rows = cur.fetchall()
+    return [int(r["parent_id"]) for r in rows]
+
+
+def fetch_majors_by_parent(conn, target_table: str, parent_id: int) -> List[Dict]:
+    """
+    获取指定 parent_id 下的专业记录,按 dept_category 排序(字典列表)
+    """
+    # escape percent for PyMySQL mogrify/formatting
+    sql = f"SELECT dept_id, dept_name, dept_category, order_num FROM {target_table} WHERE parent_id = %s AND dept_category LIKE 'zy-%%' ORDER BY dept_category ASC, dept_id ASC"
+    with conn.cursor() as cur:
+        cur.execute(sql, (parent_id,))
+        rows = cur.fetchall()
+    return rows
+
+
+def generate_update_statements(parent_id: int, majors: List[Dict]) -> List[str]:
+    """
+    根据传入的 majors 列表生成 UPDATE 语句(字符串),从 1 开始递增 order_num
+    """
+    statements: List[str] = []
+    for idx, row in enumerate(majors, start=1):
+        dept_id = row["dept_id"]
+        stmt = f"UPDATE {{table}} SET order_num = {idx} WHERE dept_id = {dept_id} AND parent_id = {parent_id};"
+        statements.append(stmt)
+    return statements
+
+
+def apply_updates(conn, target_table: str, updates: List[Dict], execute: bool):
+    """
+    执行更新操作。updates 为包含 (dept_id, new_order_num) 的字典列表
+    如果 execute=False,只打印 SQL(dry-run)。
+    """
+    if not updates:
+        return
+    with conn.cursor() as cur:
+        for u in updates:
+            sql = f"UPDATE {target_table} SET order_num = %s WHERE dept_id = %s"
+            params = (u["order_num"], u["dept_id"])
+            if execute:
+                cur.execute(sql, params)
+            else:
+                # 打印替换后的 SQL,便于 dry-run 审核
+                print(cur.mogrify(sql, params).decode() if hasattr(cur, "mogrify") else sql)
+    if execute:
+        conn.commit()
+
+
+def main(argv: Optional[List[str]] = None):
+    local_db = load_local_db_config()
+    host_default = local_db.get("host", os.getenv("DB_HOST", "localhost"))
+    port_default = int(local_db.get("port", os.getenv("DB_PORT", 3306)))
+    user_default = local_db.get("user", os.getenv("DB_USER", "root"))
+    password_default = local_db.get("password", os.getenv("DB_PASSWORD", ""))
+    database_default = local_db.get("database", os.getenv("DB_NAME", "hzdyjs_xg"))
+    target_table_default = os.getenv("TARGET_TABLE", "sys_dept")
+
+    parser = argparse.ArgumentParser(description="按 dept_category 对同一学院的专业排序并更新 order_num")
+    parser.add_argument("--host", default=host_default)
+    parser.add_argument("--port", type=int, default=port_default)
+    parser.add_argument("--user", default=user_default)
+    parser.add_argument("--password", default=password_default)
+    parser.add_argument("--database", default=database_default)
+    parser.add_argument("--target-table", default=target_table_default)
+    parser.add_argument("--dry-run", action="store_true", default=False, help="仅打印将要执行的 SQL(默认不开启)")
+    parser.add_argument("--execute", action="store_true", default=False, help="真正执行更新(需谨慎开启)")
+    parser.add_argument("--parent-id", type=int, default=0, help="可选:仅处理指定 parent_id(学院)")
+    args = parser.parse_args(argv)
+
+    conn = get_db_conn(args.host, args.port, args.user, args.password, args.database)
+    try:
+        target_table = args.target_table
+        execute_mode = args.execute
+        dry_run = args.dry_run and not args.execute
+
+        parent_ids: List[int]
+        if args.parent_id:
+            parent_ids = [args.parent_id]
+        else:
+            parent_ids = fetch_parent_ids_with_majors(conn, target_table)
+
+        logger.info("将处理 %d 个学院(parent_id)", len(parent_ids))
+        total_updates = 0
+        for parent_id in parent_ids:
+            majors = fetch_majors_by_parent(conn, target_table, parent_id)
+            if not majors:
+                continue
+            # 生成更新计划
+            updates = []
+            for idx, row in enumerate(majors, start=1):
+                # 仅当现有 order_num 与目标不同才更新
+                current_order = row.get("order_num") or 0
+                if current_order != idx:
+                    updates.append({"dept_id": row["dept_id"], "order_num": idx, "dept_category": row.get("dept_category"), "dept_name": row.get("dept_name")})
+
+            if not updates:
+                logger.info("parent_id=%s: 无需更新(已按 dept_category 排序)", parent_id)
+                continue
+
+            # 打印变更预览
+            logger.info("parent_id=%s: 将更新 %d 条记录(按 dept_category 排序)", parent_id, len(updates))
+            for u in updates:
+                print(f"-- parent_id={parent_id} dept_id={u['dept_id']} dept_category={u.get('dept_category')} dept_name={u.get('dept_name')} => order_num={u['order_num']}")
+
+            # 执行或打印 SQL
+            if execute_mode:
+                apply_updates(conn, target_table, updates, execute=True)
+                total_updates += len(updates)
+                logger.info("parent_id=%s: 已提交 %d 条更新", parent_id, len(updates))
+            else:
+                # dry-run: 打印 SQL 语句
+                for u in updates:
+                    sql = f"UPDATE {target_table} SET order_num = %s WHERE dept_id = %s;"
+                    params = (u["order_num"], u["dept_id"])
+                    # Using simple safe formatting for printing
+                    try:
+                        with conn.cursor() as cur:
+                            print(cur.mogrify(sql, params).decode() if hasattr(cur, "mogrify") else sql % params)
+                    except Exception:
+                        # Fallback
+                        print(sql.replace("%s", "{}").format(*params))
+
+        logger.info("完成: 总计更新 %d 条记录(execute=%s, dry-run=%s)", total_updates, execute_mode, dry_run)
+    finally:
+        conn.close()
+
+
+if __name__ == "__main__":
+    main()
+
+

+ 854 - 0
学校历史数据迁移/06-insert_classes.py

@@ -0,0 +1,854 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""
+06 - 班级数据入库
+
+从 his_xssj 表将班级数据导入学工表:
+ - sys_dept
+ - xg_class_msg
+ - xg_class_teacher_counselor_rel
+
+脚本特性:
+ - 列名映射可配置(见 COLUMN_MAP)
+ - 学院编号映射(见 COLLEGE_CODE_TO_NAME)
+ - 对班级存在性检测:使用 sys_dept(dept_name, dept_category)
+ - 使用时间戳+序号生成 dept_id 与 class id(与现有脚本保持一致)
+ - 对班级中学生状态判定 class_status 与 is_graduation
+ - 对班主任通过 sys_user.nick_name 进行唯一性解析
+
+使用方法:
+ - 在运行前确认 `db_config.py` 中 `DB_CONFIG` 可用
+ - 如需先预览请设置 DRY_RUN = True
+"""
+from __future__ import annotations
+import time
+from datetime import datetime
+from typing import Dict, Any, List, Optional, Tuple
+import pymysql
+from db_config import DB_CONFIG
+import os
+import json
+
+# 可根据 his_xssj 表模型调整映射(使用真实中文列名)
+COLUMN_MAP: Dict[str, str] = {
+    "class_code": "班级编号",          # 班级编号
+    "class_name": "班级名称",          # 班级名称
+    "major_code": "专业编号",          # 专业编号
+    "major_name": "专业名称",          # 专业名称
+    "college_code": "学院编号",        # 学院编号
+    "enrollment_year": "入学年份",     # 入学年份(文本,需要转换为 int)
+    "grade": "所属年级",               # 所属年级
+    "school_system": "学制编码",       # 学制编码(文本,需要转换为 int)
+    "class_type": "层次编号",          # 层次编号
+    "training_type": "学生类别",       # 学生类别/培养方式
+    "class_teacher_name": "班主任",    # 班主任姓名
+    "student_status": "学生状态",      # 学生状态(判定是否为'在读')
+    "enrollment_date": "入学日期"      # 入学日期(可为空)
+}
+
+# 学院编号对应关系(题主给定)
+COLLEGE_CODE_TO_NAME: Dict[str, str] = {
+    "14": "智能控制学院",
+    "15": "智能制造学院",
+    "19": "人文学院",
+    "18": "餐饮休闲学院",
+    "28": "数字信息学院",
+    "16": "医药健康学院",
+    "17": "生态园林学院"
+}
+
+def extract_distinct_classes_to_his_bjsj(connection, dry_run: bool = False) -> int:
+    """
+    (已移除写入逻辑)该函数保留为占位:如果需要从脚本内写入 his_bjsj,请使用外部迁移或重新启用写入逻辑。
+    当前实现仅返回 0 或在 dry_run 模式下打印示例,不执行对 his_bjsj 的 INSERT/DELETE 操作。
+    """
+    select_sql = """
+    SELECT DISTINCT
+      `入学年份` AS enrollment_year,
+      `入学日期` AS enrollment_date,
+      `教学系部` AS teaching_dept,
+      `班级名称` AS class_name,
+      `班主任` AS class_teacher,
+      `学生类别` AS student_type,
+      `学院编号` AS college_code,
+      `专业编号` AS major_code,
+      `专业名称` AS major_name,
+      `层次编号` AS level_code,
+      `报读层次` AS level_name,
+      `所属年级` AS grade,
+      `班级编号` AS class_code,
+      `学制编码` AS school_system
+    FROM his_xssj
+    """
+    with connection.cursor() as cursor:
+        cursor.execute(select_sql)
+        rows = cursor.fetchall()
+        if dry_run:
+            print(f"[DRY_RUN] 将从 his_xssj 提取 {len(rows)} 条去重班级记录(示例前 5 条)")
+            for r in rows[:5]:
+                print(r)
+        return 0
+
+def get_connection():
+    return pymysql.connect(cursorclass=pymysql.cursors.DictCursor, **DB_CONFIG)
+
+
+def fetch_distinct_classes(connection) -> List[Dict[str, Any]]:
+    """
+    从 his_xssj 中取出按班级分组的基础信息(去重后的班级列表)
+    """
+    cols = COLUMN_MAP
+    sql = f"""
+    SELECT
+        `{cols['class_code']}` AS class_code,
+        `{cols['class_name']}` AS class_name,
+        `{cols['major_code']}` AS major_code,
+        `{cols['major_name']}` AS major_name,
+        `{cols['college_code']}` AS college_code,
+        `{cols['enrollment_year']}` AS enrollment_year,
+        `{cols['grade']}` AS grade,
+        `{cols['school_system']}` AS school_system,
+        `{cols['class_type']}` AS class_type,
+        `{cols['training_type']}` AS training_type,
+        `{cols['class_teacher_name']}` AS class_teacher_name,
+        `{cols['enrollment_date']}` AS enrollment_date
+    FROM his_xssj
+    GROUP BY class_code, class_name, major_code, major_name, college_code, enrollment_year, grade, school_system, class_type, training_type, class_teacher_name, enrollment_date
+    ORDER BY enrollment_year ASC, class_name ASC
+    """
+    with connection.cursor() as cursor:
+        cursor.execute(sql)
+        return cursor.fetchall()
+
+
+def fetch_classes_from_his_bjsj(connection) -> List[Dict[str, Any]]:
+    """
+    从 his_bjsj 中读取班级数据,作为后续插入学工表的唯一数据源。
+    返回的字段与原来从 his_xssj 聚合出的字段结构一致(class_code, class_name, major_code, major_name, college_code, enrollment_year, grade, school_system, class_type, training_type, class_teacher_name, enrollment_date)。
+    注意:his_bjsj 表需事先存在且已由 extract_distinct_classes_to_his_bjsj 填充。
+    """
+    sql = """
+    SELECT
+        class_code AS class_code,
+        class_name AS class_name,
+        major_code AS major_code,
+        major_name AS major_name,
+        college_code AS college_code,
+        enrollment_year AS enrollment_year,
+        grade AS grade,
+        school_system AS school_system,
+        level_code AS class_type,
+        student_type AS training_type,
+        class_teacher AS class_teacher_name,
+        enrollment_date AS enrollment_date
+    FROM his_bjsj
+    ORDER BY enrollment_year ASC, class_name ASC
+    """
+    with connection.cursor() as cursor:
+        cursor.execute(sql)
+        return cursor.fetchall()
+
+
+def class_has_active_student(connection, class_code: str) -> bool:
+    """
+    判断班级内是否存在至少一名学生状态为 '在读'
+    """
+    cols = COLUMN_MAP
+    sql = f"""
+    SELECT COUNT(1) AS cnt FROM his_xssj
+    WHERE `{cols['class_code']}` = %s AND `{cols['student_status']}` = '在读'
+    """
+    with connection.cursor() as cursor:
+        cursor.execute(sql, (class_code,))
+        row = cursor.fetchone()
+        return (row and row.get("cnt", 0) > 0)
+
+
+def resolve_major_dept(connection, major_name: str, major_code: str, college_code: Optional[str] = None) -> Optional[Dict[str, Any]]:
+    """
+    根据规则查找专业部门,优先尝试精确匹配 (dept_category = 'zy-'+major_code AND dept_name = major_name)。
+    若未命中且提供了 college_code,则:
+      1) 通过 dept_category = 'xy-'+college_code 在 sys_dept 查到学院 parent_id;
+      2) 在该学院的子节点中(parent_id = 学院dept_id,且 dept_category LIKE 'zy-%')按 dept_name 精确匹配 major_name。
+    返回 dict 包含 dept_id, ancestors, parent_id 等或 None。
+    """
+    # 1) 精确匹配(保留原有逻辑)
+    if major_name is not None and major_code is not None and str(major_code).strip() != "":
+        sql = """
+        SELECT dept_id, parent_id, ancestors, dept_name, dept_category
+        FROM sys_dept
+        WHERE dept_name = %s AND dept_category = %s
+        LIMIT 1
+        """
+        with connection.cursor() as cursor:
+            cursor.execute(sql, (major_name, f"zy-{major_code}"))
+            row = cursor.fetchone()
+            if row:
+                return row
+
+    # 2) 若未匹配到,且提供 college_code,则根据 his_bjsj 的 college_code 关联学院,再在学院下按名称查找专业
+    if college_code:
+        try:
+            sql_college = "SELECT dept_id FROM sys_dept WHERE dept_category = %s LIMIT 1"
+            with connection.cursor() as cursor:
+                cursor.execute(sql_college, (f"xy-{college_code}",))
+                college_row = cursor.fetchone()
+            if college_row and college_row.get("dept_id") is not None:
+                college_dept_id = college_row["dept_id"]
+                sql_child = """
+                SELECT dept_id, parent_id, ancestors, dept_name, dept_category
+                FROM sys_dept
+                WHERE parent_id = %s AND dept_category LIKE %s AND dept_name = %s
+                LIMIT 1
+                """
+                with connection.cursor() as cursor:
+                    cursor.execute(sql_child, (college_dept_id, "zy-%", major_name))
+                    child = cursor.fetchone()
+                    if child:
+                        return child
+        except Exception:
+            # 不阻塞主流程,继续回退为 None
+            pass
+
+    return None
+
+
+def resolve_college_dept(connection, college_code: str) -> Optional[Dict[str, Any]]:
+    """
+    通过学院编号查找学院在 sys_dept 中的 dept_id(dept_category = 'xy-' + code)
+    """
+    sql = """
+    SELECT dept_id, dept_name
+    FROM sys_dept
+    WHERE dept_category = %s
+    LIMIT 1
+    """
+    with connection.cursor() as cursor:
+        cursor.execute(sql, (f"xy-{college_code}",))
+        return cursor.fetchone()
+
+
+def resolve_teacher_user(connection, teacher_name: str) -> Optional[int]:
+    """
+    通过 sys_user.nick_name 查找教师 user_id
+    - 唯一命中返回 user_id
+    - 多于或少于 1 条返回 None
+    """
+    if not teacher_name:
+        return None
+    sql = """
+    SELECT user_id FROM sys_user WHERE nick_name = %s
+    """
+    with connection.cursor() as cursor:
+        cursor.execute(sql, (teacher_name,))
+        rows = cursor.fetchall()
+        if len(rows) == 1:
+            return rows[0].get("user_id")
+        return None
+
+
+def find_similar_majors(connection, major_name: str, limit: int = 5) -> List[Dict[str, Any]]:
+    """
+    在 sys_dept 中查找与 major_name 相似的专业(dept_category LIKE 'zy-%')作为候选建议。
+    返回最多 limit 条候选,元素包含 dept_id 与 dept_name。
+    """
+    if not major_name:
+        return []
+    sql = """
+    SELECT dept_id, dept_name, dept_category
+    FROM sys_dept
+    WHERE dept_category LIKE %s AND dept_name LIKE %s
+    LIMIT %s
+    """
+    with connection.cursor() as cursor:
+        cursor.execute(sql, ('zy-%', f"%{major_name}%", limit))
+        return cursor.fetchall()
+
+
+LOG_DIR = "logs"
+SKIP_LOG_FILE = os.path.join(LOG_DIR, "06_insert_classes_skipped_majors.jsonl")
+
+
+def ensure_log_dir():
+    if not os.path.exists(LOG_DIR):
+        os.makedirs(LOG_DIR, exist_ok=True)
+
+
+def log_skipped_major(connection, row: Dict[str, Any]):
+    """
+    将未找到父专业(major)导致跳过的班级信息记录到日志文件(JSONL),并返回候选建议列表。
+    """
+    ensure_log_dir()
+    suggestions = find_similar_majors(connection, row.get("major_name") or "")
+    # 尝试获取 his_xssj 中该班级的所有原始记录(完整行),以便人工核对
+    his_rows: List[Dict[str, Any]] = []
+    class_code = row.get("class_code")
+    his_class_col = COLUMN_MAP.get("class_code")
+    try:
+        with connection.cursor() as c:
+            if his_class_col and class_code is not None:
+                sql = f"SELECT * FROM his_xssj WHERE `{his_class_col}` = %s"
+                c.execute(sql, (class_code,))
+                his_rows = c.fetchall()
+    except Exception:
+        his_rows = []
+
+    entry = {
+        "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+        "class_code": class_code,
+        "class_name": row.get("class_name"),
+        "major_code": row.get("major_code"),
+        "major_name": row.get("major_name"),
+        "college_code": row.get("college_code"),
+        "row": row,
+        "his_rows_count": len(his_rows),
+        "his_rows": his_rows,
+        "suggestions": suggestions
+    }
+    try:
+        with open(SKIP_LOG_FILE, "a", encoding="utf-8") as fh:
+            fh.write(json.dumps(entry, ensure_ascii=False, default=str) + "\n")
+    except Exception:
+        # 写日志失败不应阻塞主流程
+        pass
+    return suggestions
+
+
+def get_parent_ancestors(connection, parent_id: int) -> str:
+    sql = "SELECT ancestors FROM sys_dept WHERE dept_id = %s LIMIT 1"
+    with connection.cursor() as cursor:
+        cursor.execute(sql, (parent_id,))
+        row = cursor.fetchone()
+        if row and row.get("ancestors"):
+            return f"{row['ancestors']},{parent_id}"
+        return f"0,100,101,{parent_id}"
+
+
+def get_max_order_num(connection, parent_id: int) -> int:
+    sql = "SELECT MAX(order_num) AS max_order FROM sys_dept WHERE parent_id = %s"
+    with connection.cursor() as cursor:
+        cursor.execute(sql, (parent_id,))
+        row = cursor.fetchone()
+        return int(row["max_order"]) if row and row["max_order"] is not None else 0
+
+
+def generate_id(base_timestamp: str, index: int) -> int:
+    """
+    生成 dept_id / class id: timestamp + 四位序号(与原脚本保持一致)
+    """
+    return int(f"{base_timestamp}{str(index).zfill(4)}")
+
+
+def parse_date_to_mysql(value: Any) -> Optional[str]:
+    """
+    将各种可能的日期/时间值规范化为 MySQL DATETIME 字符串 'YYYY-MM-DD HH:MM:SS'。
+    - 支持 datetime 对象直接格式化
+    - 支持常见字符串格式,如 'YYYY-MM-DD HH:MM:SS', 'YYYY/MM/DD HH:MM:SS', 'M/D/YYYY H:M:S', 'YYYY-MM-DD', 'M/D/YYYY'
+    - 若无法解析或为 None,返回 None
+    """
+    if value is None:
+        return None
+    if isinstance(value, datetime):
+        return value.strftime("%Y-%m-%d %H:%M:%S")
+    if not isinstance(value, str):
+        try:
+            # 尝试将数字型视为时间戳(秒)
+            ts = float(value)
+            dt = datetime.fromtimestamp(ts)
+            return dt.strftime("%Y-%m-%d %H:%M:%S")
+        except Exception:
+            return None
+
+    s = value.strip()
+    if s == "":
+        return None
+
+    patterns = [
+        "%Y-%m-%d %H:%M:%S",
+        "%Y/%m/%d %H:%M:%S",
+        "%m/%d/%Y %H:%M:%S",
+        "%m/%d/%y %H:%M:%S",
+        "%Y-%m-%d",
+        "%Y/%m/%d",
+        "%m/%d/%Y",
+        "%m/%d/%y",
+    ]
+    for p in patterns:
+        try:
+            dt = datetime.strptime(s, p)
+            # 如果没有时分秒,补 00:00:00
+            return dt.strftime("%Y-%m-%d %H:%M:%S")
+        except Exception:
+            continue
+    # 兜底:尝试解析含有空格并用 '/' 或 '-' 的混合格式(例如 '9/1/2009 0:0:0' 已被覆盖上面,但保留此处以防)
+    try:
+        parts = s.split()
+        date_part = parts[0]
+        time_part = parts[1] if len(parts) > 1 else "0:0:0"
+        md = date_part.replace("-", "/")
+        combined = f"{md} {time_part}"
+        dt = datetime.strptime(combined, "%m/%d/%Y %H:%M:%S")
+        return dt.strftime("%Y-%m-%d %H:%M:%S")
+    except Exception:
+        return None
+
+
+def insert_sys_dept(cursor, dept_id: int, tenant_id: str, parent_id: int, ancestors: str, dept_name: str, dept_category: str, order_num: int, current_time: datetime):
+    sql = """
+    INSERT INTO sys_dept (
+        dept_id, tenant_id, parent_id, ancestors, dept_name, dept_category,
+        order_num, leader, phone, email, status, del_flag,
+        create_dept, create_by, create_time, update_by, update_time
+    ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
+    """
+    params = (
+        dept_id, tenant_id, parent_id, ancestors, dept_name, dept_category,
+        order_num, None, None, None, '0', '0', 0, 1, current_time, 1, current_time
+    )
+    cursor.execute(sql, params)
+
+
+def insert_xg_class_msg(cursor, class_data: Dict[str, Any]):
+    sql = """
+    INSERT INTO xg_class_msg (
+        id, tenant_id, create_dept, create_by, create_time,
+        update_by, update_time, deleted,
+        college_id, college_name, major_id, major_name,
+        class_code, class_name, class_type, grade, enrollment_year,
+        graduation_year, training_type, degree_type,
+        counselor_id, counselor_name, class_teacher_id, class_teacher_name,
+        class_advisor_id, class_advisor_name,
+        class_monitor_id, class_monitor_name,
+        deputy_monitor_id, deputy_monitor_name,
+        class_status, is_graduation, graduation_date, school_system
+    ) VALUES (
+        %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s
+    )
+    """
+    params = (
+        class_data.get("id"),
+        class_data.get("tenant_id", "000000"),
+        class_data.get("create_dept", 0),
+        class_data.get("create_by", 1),
+        class_data.get("create_time"),
+        class_data.get("update_by", 1),
+        class_data.get("update_time"),
+        class_data.get("deleted", 0),
+        class_data.get("college_id"),
+        class_data.get("college_name"),
+        class_data.get("major_id"),
+        class_data.get("major_name"),
+        class_data.get("class_code"),
+        class_data.get("class_name"),
+        class_data.get("class_type"),
+        class_data.get("grade"),
+        class_data.get("enrollment_year"),
+        class_data.get("graduation_year"),
+        class_data.get("training_type"),
+        None,  # degree_type
+        None, None,
+        class_data.get("class_teacher_id"),
+        class_data.get("class_teacher_name"),
+        None, None, None, None, None, None,
+        class_data.get("class_status"),
+        class_data.get("is_graduation"),
+        class_data.get("graduation_date"),
+        class_data.get("school_system")
+    )
+    cursor.execute(sql, params)
+
+
+def insert_teacher_rel(cursor, rel_data: Dict[str, Any]):
+    sql = """
+    INSERT INTO xg_class_teacher_counselor_rel (
+        id, tenant_id, create_dept, create_by, create_time,
+        update_by, update_time, deleted, class_id, class_name,
+        teacher_id, teacher_name, teacher_type, start_date, end_date, remark
+    ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
+    """
+    params = (
+        rel_data.get("id"),
+        rel_data.get("tenant_id", "000000"),
+        rel_data.get("create_dept", 0),
+        rel_data.get("create_by", 1),
+        rel_data.get("create_time"),
+        rel_data.get("update_by", 1),
+        rel_data.get("update_time"),
+        rel_data.get("deleted", 0),
+        rel_data.get("class_id"),
+        rel_data.get("class_name"),
+        rel_data.get("teacher_id"),
+        rel_data.get("teacher_name"),
+        rel_data.get("teacher_type"),
+        rel_data.get("start_date"),
+        rel_data.get("end_date"),
+        rel_data.get("remark")
+    )
+    cursor.execute(sql, params)
+
+
+def main():
+    print("开始:06 - 班级数据入库")
+    base_timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
+    seq_counter = 0
+
+    connection = get_connection()
+    try:
+        # 在开始时询问是否删除 sys_dept 中 dept_category 以 bj- 开头的记录
+        def prompt_delete_sys_dept_bj(conn) -> bool:
+            try:
+                ans = input("是否先删除 `sys_dept` 中 dept_category 以 'bj-' 开头的记录? [y/N]: ").strip().lower()
+            except (EOFError, KeyboardInterrupt):
+                print("\\n未检测到交互输入,默认不删除 sys_dept 的 bj- 记录。")
+                return False
+            if ans not in ("y", "yes"):
+                return False
+            try:
+                with conn.cursor() as c:
+                    c.execute("SELECT COUNT(1) AS cnt FROM sys_dept WHERE dept_category LIKE %s", (f"bj-%",))
+                    cnt = int(c.fetchone().get("cnt", 0))
+                print(f"将删除 sys_dept 中 {cnt} 条 dept_category LIKE 'bj-%' 的记录。")
+                try:
+                    confirm = input("确认删除并继续?此操作不可恢复。输入 y 确认: ").strip().lower()
+                except (EOFError, KeyboardInterrupt):
+                    print("\\n未检测到交互输入,取消删除。")
+                    return False
+                if confirm not in ("y", "yes"):
+                    print("已取消删除操作,继续执行导入(不删除)。")
+                    return False
+                with conn.cursor() as c:
+                    c.execute("DELETE FROM sys_dept WHERE dept_category LIKE %s", (f"bj-%",))
+                conn.commit()
+                print("已删除并提交:sys_dept 中 dept_category LIKE 'bj-%' 的记录。")
+                return True
+            except Exception as e:
+                print("删除 sys_dept 过程中发生异常,跳过删除:", e)
+                return False
+
+        try:
+            deleted_sys_dept_bj = prompt_delete_sys_dept_bj(connection)
+            if deleted_sys_dept_bj:
+                print("用户已确认并删除 sys_dept 中 bj- 开头的记录,继续后续流程。")
+            else:
+                print("未删除 sys_dept 的 bj- 记录,继续执行导入流程。")
+        except Exception as e:
+            print("询问或删除 sys_dept(bj-) 过程中发生异常,继续执行导入:", e)
+
+        # 在正式执行导入前,询问用户是否要先删除历史数据
+        def prompt_delete_tables(conn) -> bool:
+            """
+            交互式询问并在确认后删除 xg_class_msg 与 xg_class_teacher_counselor_rel 中的数据。
+            返回 True 表示已执行删除并提交,False 表示未删除。
+            """
+            try:
+                ans = input("是否先删除 `xg_class_msg` 与 `xg_class_teacher_counselor_rel` 中的现有数据? [y/N]: ").strip().lower()
+            except (EOFError, KeyboardInterrupt):
+                print("\\n未检测到交互输入,默认不删除。")
+                return False
+            if ans not in ("y", "yes"):
+                return False
+
+            with conn.cursor() as c:
+                c.execute("SELECT COUNT(1) AS cnt FROM xg_class_msg")
+                cnt_msg = int(c.fetchone().get("cnt", 0))
+                c.execute("SELECT COUNT(1) AS cnt FROM xg_class_teacher_counselor_rel")
+                cnt_rel = int(c.fetchone().get("cnt", 0))
+            print(f"将删除 xg_class_msg 中 {cnt_msg} 条,xg_class_teacher_counselor_rel 中 {cnt_rel} 条。")
+            try:
+                confirm = input("确认删除并继续?此操作不可恢复。输入 y 确认: ").strip().lower()
+            except (EOFError, KeyboardInterrupt):
+                print("\\n未检测到交互输入,取消删除。")
+                return False
+            if confirm not in ("y", "yes"):
+                print("已取消删除操作,继续执行导入(不删除)。")
+                return False
+
+            with conn.cursor() as c:
+                # 先删除关系表,再删除班级表
+                c.execute("DELETE FROM xg_class_teacher_counselor_rel")
+                c.execute("DELETE FROM xg_class_msg")
+            conn.commit()
+            print("已删除并提交:xg_class_msg 与 xg_class_teacher_counselor_rel。")
+            return True
+
+        # 在继续前询问是否删除历史数据(仅在交互式环境有意义)
+        try:
+            deleted = prompt_delete_tables(connection)
+            if deleted:
+                print("用户已确认并删除历史数据,继续执行后续导入流程。")
+            else:
+                print("未删除历史数据,继续执行导入流程。")
+        except Exception as e:
+            print("询问或删除过程中发生异常,继续执行导入:", e)
+
+        # 注意:脚本已移除对 his_bjsj 的写入/删除操作。请确保 his_bjsj 表由外部迁移或手动创建并填充。
+        print("提示:脚本不会对 his_bjsj 执行删除或插入操作,后续将直接从 his_bjsj 读取数据。")
+
+        # 从 his_bjsj 中读取班级数据作为唯一数据源(his_bjsj 应由前面的抽取步骤或外部迁移提前填充)
+        class_rows = fetch_classes_from_his_bjsj(connection)
+        print(f"共发现 {len(class_rows)} 个去重后的班级记录(将按专业分组并插入)")
+
+        # 预分组:按 major(parent) 收集要插入的班级以便计算 order_num
+        by_parent: Dict[int, List[Dict[str, Any]]] = {}
+        prepared_classes: List[Dict[str, Any]] = []
+
+        for r in class_rows:
+            class_code = r.get("class_code")
+            class_name = r.get("class_name")
+            major_code = str(r.get("major_code") or "")
+            major_name = r.get("major_name")
+
+            major_dept = resolve_major_dept(connection, major_name, major_code, str(r.get("college_code") or ""))
+            # 若未命中,则尝试从 his_bjsj 补全班级信息后再次匹配
+            if not major_dept:
+                try:
+                    with connection.cursor() as c:
+                        c.execute("SELECT major_code, major_name, college_code, enrollment_year, enrollment_date FROM his_bjsj WHERE class_code = %s LIMIT 1", (class_code,))
+                        bj = c.fetchone()
+                        if bj:
+                            # 用 his_bjsj 的值覆盖 row 中的同类字段以便后续匹配
+                            major_code = str(bj.get("major_code") or "")
+                            major_name = bj.get("major_name") or major_name
+                            # 也更新原始 row,便于日志和后续处理
+                            r["major_code"] = major_code
+                            r["major_name"] = major_name
+                            r["college_code"] = bj.get("college_code") or r.get("college_code")
+                            r["enrollment_year"] = bj.get("enrollment_year") or r.get("enrollment_year")
+                            r["enrollment_date"] = bj.get("enrollment_date") or r.get("enrollment_date")
+                            major_dept = resolve_major_dept(connection, major_name, major_code, str(r.get("college_code") or ""))
+                except Exception:
+                    major_dept = None
+
+            if not major_dept:
+                # 记录并打印更详细的信息(包含专业编号与名称),同时输出候选建议
+                suggestions = log_skipped_major(connection, r)
+                print(f"跳过班级(未找到父专业):{class_code} {class_name} 专业({major_code} - {major_name})")
+                if suggestions:
+                    cand_str = "; ".join([f"{s.get('dept_id')}:{s.get('dept_name')}" for s in suggestions])
+                    print(f"  建议候选专业:{cand_str}")
+                else:
+                    print("  无相似候选专业。已写入 logs/06_insert_classes_skipped_majors.jsonl 以便人工核对。")
+                continue
+
+            parent_id = major_dept["dept_id"]
+            prepared = {
+                "row": r,
+                "parent_id": parent_id,
+                "major_dept": major_dept
+            }
+            by_parent.setdefault(parent_id, []).append(prepared)
+            prepared_classes.append(prepared)
+
+        # 在每个 parent_id 下按 enrollment_year, class_name 排序并计算 order_num 起点
+        insertion_plan: List[Tuple[int, Dict[str, Any]]] = []  # list of (order_num, prepared)
+        for parent_id, class_list in by_parent.items():
+            max_order = get_max_order_num(connection, parent_id)
+            # sort by enrollment_year (统一转为 int,发生异常或为空时视为 0) 然后按 class_name
+            def _safe_year_key(p: Dict[str, Any]) -> int:
+                val = p["row"].get("enrollment_year")
+                if val is None or val == "":
+                    return 0
+                try:
+                    return int(val)
+                except Exception:
+                    return 0
+
+            class_list.sort(key=lambda p: (_safe_year_key(p), p["row"].get("class_name") or ""))
+            for idx, item in enumerate(class_list, start=1):
+                item_order = max_order + idx
+                item["order_num"] = item_order
+                insertion_plan.append((item_order, item))
+
+        # 按插入顺序执行插入(可调整为按parent分批提交)
+        inserted_count = 0
+        class_msg_inserted = 0
+        teacher_rel_inserted = 0
+
+        # 使用单个连接事务(最后 commit)
+        with connection.cursor() as cursor:
+            for item in sorted(insertion_plan, key=lambda x: (x[1]["parent_id"], x[0])):
+                prepared = item[1]
+                row = prepared["row"]
+                parent_id = prepared["parent_id"]
+                order_num = prepared["order_num"]
+
+                class_code = row.get("class_code")
+                class_name = row.get("class_name") or ""
+                enrollment_year = row.get("enrollment_year")
+                grade = row.get("grade") or str(enrollment_year or "")
+                school_system_raw = row.get("school_system")
+                try:
+                    school_system = int(school_system_raw) if school_system_raw not in (None, "") else None
+                except Exception:
+                    school_system = None
+                class_type = row.get("class_type")
+                training_type = row.get("training_type")
+                teacher_name = row.get("class_teacher_name")
+                college_code = str(row.get("college_code") or "")
+
+                # 判断班级是否已存在(sys_dept: dept_category = 'bj-' + class_code)
+                dept_category = f"bj-{class_code}"
+                cursor.execute("SELECT COUNT(1) AS cnt FROM sys_dept WHERE dept_name = %s AND dept_category = %s", (class_name, dept_category))
+                if cursor.fetchone().get("cnt", 0) > 0:
+                    print(f"已存在 sys_dept - 跳过: {class_name} ({dept_category})")
+                    continue
+
+                # 生成 dept_id
+                seq_counter += 1
+                dept_id = generate_id(base_timestamp, seq_counter)
+                ancestors = get_parent_ancestors(connection, parent_id)
+                current_time = datetime.now()
+
+                # 插入 sys_dept
+                insert_sys_dept(cursor, dept_id, "000000", parent_id, ancestors, class_name[:30], dept_category, order_num, current_time)
+                inserted_count += 1
+                print(f"插入 sys_dept: dept_id={dept_id}, parent_id={parent_id}, name={class_name}, order_num={order_num}")
+
+                # ========== 构建并插入 xg_class_msg ==========
+                # major info
+                major_info = prepared["major_dept"]
+                major_id = major_info.get("dept_id")
+                major_name = major_info.get("dept_name", row.get("major_name"))
+
+                # college info(通过 college_code -> name -> 在 sys_dept 查找)
+                college_name_guess = COLLEGE_CODE_TO_NAME.get(college_code)
+                college_id = None
+                college_name = None
+                if college_name_guess:
+                    # 尝试在 sys_dept 中查找学院 dept_id
+                    cursor.execute("SELECT dept_id, dept_name FROM sys_dept WHERE dept_category = %s LIMIT 1", (f"xy-{college_code}",))
+                    crow = cursor.fetchone()
+                    if crow:
+                        college_id = crow.get("dept_id")
+                        college_name = crow.get("dept_name")
+                    else:
+                        # 退回到 mapping 的名称
+                        college_name = college_name_guess
+                else:
+                    # 尝试直接用 college_code 在 sys_dept 查找
+                    col_dept = resolve_college_dept(connection, college_code)
+                    if col_dept:
+                        college_id = col_dept.get("dept_id")
+                        college_name = col_dept.get("dept_name")
+
+                # 如果仍未解析到 college_id,尝试通过学院名称在 sys_dept 中查找(fallback)
+                if not college_id:
+                    # 优先使用映射名称,其次使用已有 college_name
+                    search_name = COLLEGE_CODE_TO_NAME.get(college_code) or college_name
+                    if search_name:
+                        try:
+                            cursor.execute("SELECT dept_id, dept_name FROM sys_dept WHERE dept_name = %s AND dept_category LIKE %s LIMIT 1", (search_name, "xy-%"))
+                            crow2 = cursor.fetchone()
+                            if crow2:
+                                college_id = crow2.get("dept_id")
+                                college_name = crow2.get("dept_name")
+                        except Exception:
+                            # 不阻塞主流程,保留 college_id 为 None
+                            pass
+
+                # 班级在读/毕业判断
+                has_active = class_has_active_student(connection, class_code)
+                class_status = '1' if has_active else '3'
+                is_graduation = 0 if has_active else 1
+
+                # 计算毕业年份
+                try:
+                    enrollment_year_int = int(enrollment_year) if enrollment_year else None
+                except Exception:
+                    enrollment_year_int = None
+                graduation_year = None
+                if enrollment_year_int is not None and school_system is not None:
+                    try:
+                        graduation_year = enrollment_year_int + school_system
+                    except Exception:
+                        graduation_year = None
+
+                # 尝试解析班主任 user_id,并填充到 class_msg.class_teacher_id
+                class_teacher_id = resolve_teacher_user(connection, teacher_name)
+
+                class_msg = {
+                    "id": dept_id,
+                    "tenant_id": "000000",
+                    "create_time": current_time,
+                    "update_time": current_time,
+                    "college_id": college_id,
+                    "college_name": college_name,
+                    "major_id": major_id,
+                    "major_name": major_name,
+                    "class_code": class_code,
+                    "class_name": class_name,
+                    "class_type": class_type,
+                    "grade": grade,
+                    "enrollment_year": enrollment_year_int,
+                    "graduation_year": graduation_year,
+                    "training_type": training_type,
+                    "class_teacher_id": class_teacher_id,
+                    "class_teacher_name": teacher_name,
+                    "class_status": class_status,
+                    "is_graduation": is_graduation,
+                    "graduation_date": None,
+                    "school_system": school_system
+                }
+
+                insert_xg_class_msg(cursor, class_msg)
+                class_msg_inserted += 1
+                print(f"插入 xg_class_msg: id={dept_id}, class_code={class_code}, class_name={class_name}")
+
+                # ========== 插入班主任关系表(如能唯一解析到 teacher_id) ==========
+                teacher_id = resolve_teacher_user(connection, teacher_name)
+                if teacher_id:
+                    seq_counter += 1
+                    rel_id = generate_id(base_timestamp, seq_counter)
+                    # 处理 start_date:优先使用 enrollment_date 的解析结果;若为空且有 enrollment_year,则使用该年 9 月 1 日
+                    parsed_enroll_date = parse_date_to_mysql(row.get("enrollment_date"))
+                    start_date_value = parsed_enroll_date
+                    try:
+                        if not start_date_value:
+                            # enrollment_year_int 在前面已计算
+                            if enrollment_year_int:
+                                start_date_value = f"{enrollment_year_int}-09-01 00:00:00"
+                    except Exception:
+                        start_date_value = parsed_enroll_date
+
+                    rel = {
+                        "id": rel_id,
+                        "tenant_id": "000000",
+                        "create_time": current_time,
+                        "update_time": current_time,
+                        "class_id": dept_id,
+                        "class_name": class_name,
+                        "teacher_id": teacher_id,
+                        "teacher_name": teacher_name,
+                        "teacher_type": "class_teacher",
+                        "start_date": start_date_value,
+                        "end_date": None,
+                        "remark": None
+                    }
+                    insert_teacher_rel(cursor, rel)
+                    teacher_rel_inserted += 1
+                    print(f"插入班主任关系: rel_id={rel_id}, class_id={dept_id}, teacher_id={teacher_id}")
+                else:
+                    if teacher_name:
+                        print(f"无法唯一解析班主任 '{teacher_name}' 到 sys_user.user_id,跳过关系插入")
+
+            # 提交事务
+            connection.commit()
+            print("✓ 事务已提交")
+
+        # 结束统计
+        print("=" * 60)
+        print(f"sys_dept 插入: {inserted_count}")
+        print(f"xg_class_msg 插入: {class_msg_inserted}")
+        print(f"xg_class_teacher_counselor_rel 插入: {teacher_rel_inserted}")
+        print("=" * 60)
+
+    except Exception as e:
+        print("✗ 处理过程中发生异常:", e)
+    finally:
+        connection.close()
+
+
+if __name__ == "__main__":
+    main()
+
+

+ 119 - 0
学校历史数据迁移/07-reorder_classes_by_grade.py

@@ -0,0 +1,119 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""
+07 - 按年级重排序 sys_dept 中专业下的班级 (修改 order_num)
+
+说明:
+- 针对 sys_dept 中 dept_category 以 'zy-' 开头的专业,查找 parent_id 为该专业的子部门(dept_category LIKE 'bj-%')
+- 使用 xg_class_msg.enrollment_year 或 sys_dept.dept_name 进行排序(优先 enrollment_year,空值视为 0),然后按 dept_name
+- 为每个父专业从 1 开始重新分配 order_num(可修改为起始值为当前最大值 + 1)
+- 脚本执行前会交互确认(避免误删/误改);支持 DRY_RUN 模式,仅打印将要更新的改动
+
+使用方法:
+- 运行前确认 `db_config.DB_CONFIG` 正确,执行: python 07-reorder_classes_by_grade.py
+"""
+from __future__ import annotations
+from typing import List, Dict, Any, Optional, Tuple
+from datetime import datetime
+import pymysql
+from db_config import DB_CONFIG
+
+DRY_RUN = False
+
+
+def get_connection():
+    return pymysql.connect(cursorclass=pymysql.cursors.DictCursor, **DB_CONFIG)
+
+
+def fetch_all_majors(conn) -> List[Dict[str, Any]]:
+    sql = "SELECT dept_id, dept_name FROM sys_dept WHERE dept_category LIKE %s ORDER BY dept_name"
+    with conn.cursor() as c:
+        c.execute(sql, ("zy-%",))
+        return c.fetchall()
+
+
+def fetch_classes_under_major(conn, parent_id: int) -> List[Dict[str, Any]]:
+    """
+    返回列表包含 sys_dept.dept_id, dept_name, xg_class_msg.enrollment_year
+    """
+    sql = """
+    SELECT sd.dept_id, sd.dept_name, xc.enrollment_year
+    FROM sys_dept sd
+    LEFT JOIN xg_class_msg xc ON sd.dept_id = xc.id
+    WHERE sd.parent_id = %s AND sd.dept_category LIKE %s
+    """
+    with conn.cursor() as c:
+        c.execute(sql, (parent_id, "bj-%"))
+        return c.fetchall()
+
+
+def normalize_year(val: Any) -> int:
+    if val is None or val == "":
+        return 0
+    try:
+        return int(val)
+    except Exception:
+        return 0
+
+
+def update_order_nums(conn, updates: List[Tuple[int, int]]):
+    """
+    updates: list of (order_num, dept_id)
+    """
+    with conn.cursor() as c:
+        for order_num, dept_id in updates:
+            if DRY_RUN:
+                print(f"[DRY_RUN] UPDATE sys_dept SET order_num={order_num} WHERE dept_id={dept_id}")
+            else:
+                c.execute("UPDATE sys_dept SET order_num = %s WHERE dept_id = %s", (order_num, dept_id))
+        if not DRY_RUN:
+            conn.commit()
+
+
+def prompt_confirm() -> bool:
+    try:
+        ans = input("确认按年级重排序 sys_dept 中专业下的班级并修改 order_num 吗?[y/N]: ").strip().lower()
+    except (EOFError, KeyboardInterrupt):
+        print("\n未检测到交互输入,取消操作。")
+        return False
+    return ans in ("y", "yes")
+
+
+def main():
+    print("开始:07 - 按年级重排序 sys_dept 专业下的班级(修改 order_num)")
+    if not prompt_confirm():
+        print("已取消操作。")
+        return
+
+    conn = get_connection()
+    try:
+        majors = fetch_all_majors(conn)
+        print(f"发现 {len(majors)} 个专业,将逐个处理...")
+        total_changed = 0
+        for m in majors:
+            parent_id = m.get("dept_id")
+            major_name = m.get("dept_name")
+            classes = fetch_classes_under_major(conn, parent_id)
+            if not classes:
+                continue
+            # sort by enrollment_year(int, 0) asc, then dept_name asc
+            classes.sort(key=lambda r: (normalize_year(r.get("enrollment_year")), r.get("dept_name") or ""))
+            updates: List[Tuple[int, int]] = []
+            for idx, row in enumerate(classes, start=1):
+                updates.append((idx, row.get("dept_id")))
+            if updates:
+                print(f"专业 {major_name}({parent_id}) 将更新 {len(updates)} 条 order_num。")
+                update_order_nums(conn, updates)
+                total_changed += len(updates)
+
+        print(f"完成:共更新 order_num {total_changed} 条记录。")
+    except Exception as e:
+        print("发生异常:", e)
+    finally:
+        conn.close()
+
+
+if __name__ == "__main__":
+    main()
+
+

+ 549 - 0
学校历史数据迁移/08-insert_students_from_his.py

@@ -0,0 +1,549 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""
+从 his_xssj 表把历史学生数据同步到 xg 系统表
+
+主要写入表:
+ - xg_student_msg
+ - sys_user
+ - sys_user_role
+
+特性:
+ - 同步前提示是否清空 xg_student_msg(需用户确认)
+ - 跳过 班级编号 为 '#YDQX#' 或 空字符串 的记录
+ - 批量插入并分批提交,包含简单的重试机制
+ - 通过 sys_dept 表查找班级/专业/学院信息;通过 xg_class_msg 获取班级关联字段
+ - 字段映射/清洗遵循项目要求(见任务描述)
+
+注意:此脚本依赖同目录下的 `db_config.py` 提供 `DB_CONFIG` 与 `TABLE_MAPPING`
+"""
+from __future__ import annotations
+
+import argparse
+import logging
+import sys
+import time
+from datetime import datetime
+from typing import Any, Dict, List, Optional, Tuple
+
+import pymysql
+from pymysql.cursors import DictCursor
+
+from db_config import DB_CONFIG, TABLE_MAPPING
+
+# 常量配置(可根据需要改为从外部配置)
+STUDENT_ROLE_ID = 1938120227459309569
+DEFAULT_TENANT_ID = "000000"
+DEFAULT_CREATE_BY = 1
+DEFAULT_UPDATE_BY = 1
+DEFAULT_PASSWORD_HASH = "$2a$10$Vm51QDgTteXRQKpERT2okumJh1sVm5YpvkiVQdaih/rgaOtHrm6yi"
+BATCH_SIZE = 200
+DB_RETRY_MAX = 3
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s %(levelname)s %(message)s",
+    handlers=[logging.StreamHandler(sys.stdout)],
+)
+logger = logging.getLogger("sync_his_xssj")
+
+
+def get_db_connection():
+    return pymysql.connect(
+        **DB_CONFIG,
+        cursorclass=DictCursor,
+        autocommit=False,
+        connect_timeout=30,
+        read_timeout=60,
+        write_timeout=60,
+    )
+
+
+def parse_enrollment_date(raw_value: Optional[str]) -> Optional[datetime]:
+    if not raw_value:
+        return None
+    text = str(raw_value).strip()
+    if not text:
+        return None
+    # 常见格式 "9/1/2009 0:0:0" 或 "2009-09-01 00:00:00"
+    fmts = ["%m/%d/%Y %H:%M:%S", "%m/%d/%Y %H:%M", "%m/%d/%Y", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"]
+    for fmt in fmts:
+        try:
+            return datetime.strptime(text, fmt)
+        except Exception:
+            continue
+    # 作为最后手段尝试截取日期部分
+    try:
+        return datetime.strptime(text.split()[0], "%m/%d/%Y")
+    except Exception:
+        logger.warning("无法解析入学日期 -> raw='%s'", raw_value)
+        return None
+
+
+def normalize_phone(raw_value: Optional[str], max_len: int = 11) -> Optional[str]:
+    if not raw_value:
+        return None
+    s = str(raw_value).strip()
+    return s[:max_len] if s else None
+
+
+def map_gender(text: Optional[str]) -> str:
+    if not text:
+        return "0"
+    t = str(text).strip()
+    if t == "女":
+        return "1"
+    return "0"
+
+
+def map_sys_user_status(his_status: Optional[str]) -> str:
+    """sys_user.status: 毕业、结业、分流、停学、退学、休学 -> 1 ; 复学、在读 -> 0"""
+    if not his_status:
+        return "0"
+    s = str(his_status).strip()
+    if s in ("毕业", "结业", "分流", "停学", "退学", "休学"):
+        return "1"
+    return "0"
+
+
+def map_student_status(his_status: Optional[str]) -> str:
+    """xg_student_msg.student_status 映射"""
+    if not his_status:
+        return "1"
+    mapping = {
+        "毕业": "5",
+        "结业": "7",
+        "分流": "9",
+        "复学": "1",
+        "停学": "8",
+        "退学": "4",
+        "在读": "1",
+        "休学": "2",
+    }
+    return mapping.get(str(his_status).strip(), "1")
+
+
+def find_class_dept(connection, class_code: Optional[str], class_name: Optional[str]) -> Tuple[Optional[int], Optional[str], Optional[int], Optional[str], Optional[int], Optional[str]]:
+    """
+    根据 his_xssj 的班级编号与班级名称,查找 sys_dept 中对应的班级记录,
+    并向上查找专业(zy-)与学院(xy-)节点,返回 (class_id,class_name, major_id,major_name, college_id,college_name)
+    """
+    if not class_code or not class_name:
+        return None, None, None, None, None, None
+
+    dept_category = f"bj-{class_code}"
+    with connection.cursor() as cursor:
+        cursor.execute(
+            "SELECT dept_id, dept_name, parent_id, dept_category FROM `sys_dept` WHERE dept_category = %s AND dept_name = %s LIMIT 1",
+            (dept_category, class_name),
+        )
+        row = cursor.fetchone()
+        if not row:
+            logger.warning("未在 sys_dept 中找到班级 -> dept_category=%s, dept_name=%s", dept_category, class_name)
+            return None, None, None, None, None, None
+
+        class_id = row["dept_id"]
+        class_name_ret = row["dept_name"]
+        parent_id = row.get("parent_id")
+
+        major_id = None
+        major_name = None
+        college_id = None
+        college_name = None
+
+        # 向上遍历父级查找 zy- 与 xy- 前缀
+        current_parent = parent_id
+        while current_parent:
+            cursor.execute("SELECT dept_id, dept_name, parent_id, dept_category FROM `sys_dept` WHERE dept_id = %s LIMIT 1", (current_parent,))
+            parent_row = cursor.fetchone()
+            if not parent_row:
+                break
+            cat = parent_row.get("dept_category") or ""
+            if cat.startswith("zy-") and major_id is None:
+                major_id = parent_row["dept_id"]
+                major_name = parent_row["dept_name"]
+            if cat.startswith("xy-") and college_id is None:
+                college_id = parent_row["dept_id"]
+                college_name = parent_row["dept_name"]
+            current_parent = parent_row.get("parent_id")
+
+        return class_id, class_name_ret, major_id, major_name, college_id, college_name
+
+
+def load_class_msg(connection, class_id: Optional[int]) -> Dict[str, Any]:
+    """
+    从 xg_class_msg 表读取与班级相关的字段,用于补齐 xg_student_msg 的若干字段
+    """
+    if not class_id:
+        return {}
+    with connection.cursor() as cursor:
+        cursor.execute("SELECT * FROM `xg_class_msg` WHERE id = %s LIMIT 1", (class_id,))
+        return cursor.fetchone() or {}
+
+
+def sync_from_his_xssj(truncate_before_sync: bool = False):
+    table_his = "his_xssj"
+    table_xg_student = TABLE_MAPPING.get("xg_student_msg", "xg_student_msg")
+    table_sys_user = "sys_user"
+    table_sys_user_role = "sys_user_role"
+
+    connection = get_db_connection()
+    try:
+        with connection.cursor() as cursor:
+            # 1) 是否清空目标表
+            if truncate_before_sync:
+                logger.info("正在清空表 %s ...", table_xg_student)
+                cursor.execute(f"TRUNCATE TABLE `{table_xg_student}`")
+                connection.commit()
+                logger.info("已清空 %s", table_xg_student)
+
+            # 2) 读取需同步的数据(跳过 班级编号 = '#YDQX#' 或 '')
+            select_sql = f"SELECT * FROM `{table_his}` WHERE `班级编号` IS NOT NULL AND `班级编号` NOT IN (%s, %s)"
+            cursor.execute(select_sql, ("#YDQX#", ""))
+            rows = cursor.fetchall()
+            logger.info("从 %s 读取到 %d 条候选记录(已过滤 '#YDQX#' 与空班级编号)", table_his, len(rows))
+
+            if not rows:
+                logger.info("没有符合条件的历史学生数据,退出。")
+                return
+
+            # 预加载已存在的学号集合与 sys_user user_id 以便去重
+            cursor.execute(f"SELECT student_no FROM `{table_xg_student}`")
+            existing_student_nos = {r["student_no"] for r in cursor.fetchall()}
+            cursor.execute("SELECT user_id FROM `sys_user`")
+            existing_user_ids = {int(r["user_id"]) for r in cursor.fetchall()}
+
+            base_datetime = datetime.now()
+            timestamp_str = base_datetime.strftime("%Y%m%d%H%M%S")
+            sequence = 1
+
+            inserted_student_count = 0
+            inserted_user_count = 0
+            inserted_role_count = 0
+            batch_counter = 0
+
+            # Prepare SQL statements (只插入必要字段)
+            insert_xg_student_sql = f"""
+            INSERT INTO `{table_xg_student}` (
+                id, tenant_id, create_dept, create_by, create_time,
+                update_by, update_time, deleted, student_no, id_card,
+                name, gender, birth_date, political_status, phone,
+                is_boarder, home_address, guardian_phone, admission_category,
+                student_status, enrollment_year, enrollment_date,
+                school_system, academic_level, training_type,
+                major_id, major_name, class_id, class_name, grade,
+                college_id, college_name, counselor_id, counselor_name,
+                class_teacher_id, class_teacher_name
+            ) VALUES (
+                %s, %s, %s, %s, %s,
+                %s, %s, %s, %s, %s,
+                %s, %s, %s, %s, %s,
+                %s, %s, %s, %s, %s,
+                %s, %s, %s,
+                %s, %s, %s, %s, %s,
+                %s, %s, %s, %s,
+                %s, %s, %s, %s
+            )
+            """
+
+            insert_sys_user_sql = f"""
+            INSERT INTO `{table_sys_user}` (
+                user_id, dept_id, user_name, nick_name, user_type,
+                email, phonenumber, sex, avatar, password, status,
+                del_flag, login_ip, login_date, create_by, create_time,
+                update_by, update_time, remark, tenant_id, account_type
+            ) VALUES (
+                %s, %s, %s, %s, %s,
+                %s, %s, %s, %s, %s, %s,
+                %s, %s, %s, %s, %s,
+                %s, %s, %s, %s, %s
+            )
+            """
+
+            insert_user_role_sql = f"""
+            INSERT INTO `{table_sys_user_role}` (`user_id`, `role_id`) VALUES (%s, %s)
+            """
+
+            for row in rows:
+                try:
+                    student_no = row.get("学号") or row.get("学生学号") or row.get("xh") or row.get("xs_xh") or row.get("xs_xh_x") or row.get("学号".strip())
+                    # 如果无法从通用列名获取学号,尝试直接使用字段名 '学号'(上面多重尝试尽量兼容)
+                    if not student_no:
+                        logger.warning("跳过无学号记录: %s", row)
+                        continue
+
+                    student_no = str(student_no).strip()
+                    if student_no in existing_student_nos:
+                        logger.info("跳过已存在学号: %s", student_no)
+                        continue
+
+                    name = row.get("姓名") or row.get("xm") or row.get("学生姓名")
+                    if not name:
+                        logger.warning("跳过无姓名记录: %s", row)
+                        continue
+
+                    # 跳过指定班级编号的记录(再次防护)
+                    class_code = row.get("班级编号")
+                    if not class_code or str(class_code).strip() in ("", "#YDQX#"):
+                        continue
+
+                    class_name = row.get("班级名称") or row.get("班级")
+                    class_id, class_name_val, major_id, major_name, college_id, college_name = find_class_dept(connection, str(class_code).strip(), class_name)
+
+                    # 从 xg_class_msg 读取额外字段
+                    class_msg = load_class_msg(connection, class_id)
+
+                    # 处理字段映射
+                    id_card = row.get("身份证号") or row.get("sfzjh")
+                    gender = map_gender(row.get("性别") or row.get("xs_xb"))
+                    birth_date = None
+                    try:
+                        raw_birth = row.get("出生日期") or row.get("csrq")
+                        if raw_birth:
+                            birth_date = datetime.strptime(str(raw_birth).split()[0], "%Y-%m-%d").date()
+                    except Exception:
+                        birth_date = None
+
+                    political = row.get("政治面貌")
+                    political_status = None
+                    if political:
+                        # 用户提供的映射示例: 群众 - 13 、 预备党员 - 02 、 团员 - 03
+                        mapping_pol = {"群众": "13", "预备党员": "02", "团员": "03"}
+                        political_status = mapping_pol.get(str(political).strip())
+
+                    phone = normalize_phone(row.get("联系方式") or row.get("联系电话") or row.get("lxdh"))
+                    guardian_phone = normalize_phone(row.get("家长电话") or row.get("guardian_phone"), max_len=20)
+                    is_boarder = 1 if str(row.get("是否住校") or "").strip() == "是" else 0
+                    home_address = row.get("家庭住址") or row.get("jtdz")
+                    admission_category = "统招"
+                    student_status_mapped = map_student_status(row.get("学生状态") or row.get("xsdqzt") or row.get("状态"))
+
+                    enrollment_year = None
+                    try:
+                        enr = row.get("入学年份") or row.get("rxnj")
+                        if enr:
+                            enrollment_year = int(str(enr).strip())
+                    except Exception:
+                        enrollment_year = None
+
+                    enrollment_date_dt = None
+                    try:
+                        enr_date_raw = row.get("入学日期") or row.get("rxrq")
+                        if enr_date_raw:
+                            parsed = parse_enrollment_date(str(enr_date_raw))
+                            if parsed:
+                                enrollment_date_dt = parsed.date()
+                    except Exception:
+                        enrollment_date_dt = None
+
+                    # 生成唯一 id (与 sys_user.user_id 一致)
+                    if sequence > 99999:
+                        logger.error("单次同步ID序列超过99999,停止以避免ID溢出")
+                        break
+                    unique_id = int(f"{timestamp_str}{sequence:05d}")
+                    sequence += 1
+
+                    # 插入 xg_student_msg
+                    params_xg_student = (
+                        unique_id,
+                        DEFAULT_TENANT_ID,
+                        class_id or 0,
+                        DEFAULT_CREATE_BY,
+                        base_datetime,
+                        DEFAULT_UPDATE_BY,
+                        base_datetime,
+                        0,
+                        student_no,
+                        id_card,
+                        name,
+                        gender,
+                        birth_date,
+                        political_status,
+                        phone,
+                        is_boarder,
+                        home_address,
+                        guardian_phone,
+                        admission_category,
+                        student_status_mapped,
+                        enrollment_year,
+                        enrollment_date_dt,
+                        class_msg.get("school_system"),
+                        class_msg.get("class_type"),
+                        class_msg.get("training_type"),
+                        major_id,
+                        major_name,
+                        class_id,
+                        class_name_val,
+                        class_msg.get("grade"),
+                        college_id,
+                        college_name,
+                        class_msg.get("counselor_id"),
+                        class_msg.get("counselor_name"),
+                        class_msg.get("class_teacher_id"),
+                        class_msg.get("class_teacher_name"),
+                    )
+
+                    # 插入 sys_user (如果不存在)
+                    params_sys_user = (
+                        unique_id,
+                        class_id,
+                        student_no,
+                        name,
+                        "sys_user",
+                        None,
+                        phone,
+                        gender,
+                        None,
+                        DEFAULT_PASSWORD_HASH,
+                        map_sys_user_status(row.get("学生状态") or row.get("xsdqzt")),
+                        "0",
+                        None,
+                        None,
+                        DEFAULT_CREATE_BY,
+                        base_datetime,
+                        DEFAULT_UPDATE_BY,
+                        base_datetime,
+                        "历史数据导入",
+                        DEFAULT_TENANT_ID,
+                        "2",
+                    )
+
+                    # 执行插入(带重试)
+                    attempts = 0
+                    while attempts < DB_RETRY_MAX:
+                        try:
+                            with connection.cursor() as c:
+                                c.execute(insert_xg_student_sql, params_xg_student)
+                                # sys_user 可能已经存在(通过 user_id 判断)
+                                if unique_id not in existing_user_ids:
+                                    c.execute(insert_sys_user_sql, params_sys_user)
+                                    existing_user_ids.add(unique_id)
+                                    inserted_user_count += 1
+                                # sys_user_role
+                                c.execute("SELECT 1 FROM `sys_user_role` WHERE user_id = %s AND role_id = %s LIMIT 1", (unique_id, STUDENT_ROLE_ID))
+                                if not c.fetchone():
+                                    c.execute(insert_user_role_sql, (unique_id, STUDENT_ROLE_ID))
+                                    inserted_role_count += 1
+
+                                inserted_student_count += 1
+                                existing_student_nos.add(student_no)
+                                batch_counter += 1
+
+                                if batch_counter >= BATCH_SIZE:
+                                    connection.commit()
+                                    logger.info("已批量提交 %d 条记录", batch_counter)
+                                    batch_counter = 0
+                            break
+                        except pymysql.err.OperationalError as e:
+                            attempts += 1
+                            logger.warning("数据库操作失败(尝试 %d/%d): %s", attempts, DB_RETRY_MAX, e)
+                            time.sleep(attempts * 2)
+                            # 重新获取连接
+                            try:
+                                connection.ping(reconnect=True)
+                            except Exception:
+                                connection = get_db_connection()
+                        except Exception as e:
+                            logger.exception("插入记录失败: %s; 数据: %s", e, params_xg_student)
+                            # 不重试一般数据错误,跳过此记录
+                            break
+                except Exception as outer_exc:
+                    logger.exception("处理单条 his_xssj 记录出错: %s", outer_exc)
+
+            # 提交剩余
+            if batch_counter > 0:
+                connection.commit()
+                logger.info("已提交剩余 %d 条记录", batch_counter)
+
+            logger.info("同步完成:插入学生 %d 条,新增 sys_user %d 条,新增角色绑定 %d 条", inserted_student_count, inserted_user_count, inserted_role_count)
+    finally:
+        try:
+            connection.close()
+        except Exception:
+            pass
+
+
+def prompt_confirm_truncate() -> bool:
+    answer = input("是否先清空 `xg_student_msg` 表?输入 y 确认,其他任意键取消: ").strip().lower()
+    return answer == "y"
+
+
+def prompt_confirm_delete_students() -> bool:
+    answer = input("是否删除 `sys_user` 中 account_type=2 的学生用户及其 sys_user_role 关联?输入 y 确认,其他任意键取消: ").strip().lower()
+    return answer == "y"
+
+
+def split_batches(values: List[int], size: int = 1000) -> List[List[int]]:
+    if not values:
+        return []
+    return [values[i : i + size] for i in range(0, len(values), size)]
+
+
+def delete_student_accounts() -> Tuple[int, int]:
+    """
+    删除 sys_user 中 account_type='2' 的用户以及其在 sys_user_role 中的关联记录。
+    返回 (deleted_users_count, deleted_roles_count)
+    """
+    deleted_users = 0
+    deleted_roles = 0
+    conn = get_db_connection()
+    try:
+        with conn.cursor() as cursor:
+            cursor.execute("SELECT user_id FROM `sys_user` WHERE account_type = %s", ("2",))
+            rows = cursor.fetchall()
+            user_ids = [int(r["user_id"]) for r in rows]
+            if not user_ids:
+                logger.info("sys_user 中无 account_type=2 的用户,无需删除。")
+                return 0, 0
+
+            logger.info("找到 %d 个 student sys_user 将被删除(包括其 sys_user_role 关联)", len(user_ids))
+
+            for batch in split_batches(user_ids, size=500):
+                placeholders = ",".join(["%s"] * len(batch))
+                # 删除角色关联
+                sql_roles = f"DELETE FROM `sys_user_role` WHERE user_id IN ({placeholders})"
+                cursor.execute(sql_roles, batch)
+                deleted_roles += cursor.rowcount or 0
+                # 删除用户
+                sql_users = f"DELETE FROM `sys_user` WHERE user_id IN ({placeholders})"
+                cursor.execute(sql_users, batch)
+                deleted_users += cursor.rowcount or 0
+            conn.commit()
+            logger.info("已删除 %d users, %d role rows", deleted_users, deleted_roles)
+            return deleted_users, deleted_roles
+    finally:
+        try:
+            conn.close()
+        except Exception:
+            pass
+
+
+def main():
+    parser = argparse.ArgumentParser(description="从 his_xssj 同步历史学生到 xg 系统")
+    parser.add_argument("--yes-delete-students", action="store_true", help="跳过提示,直接删除 sys_user 中 account_type=2 的学生及其 sys_user_role 关联")
+    parser.add_argument("--yes-truncate", action="store_true", help="跳过提示,直接清空 xg_student_msg 表后同步")
+    args = parser.parse_args()
+
+    # 询问是否删除 sys_user 中 account_type=2 的学生用户(以及其 sys_user_role)
+    if getattr(args, "yes_delete_students", False):
+        do_delete_students = True
+    else:
+        do_delete_students = prompt_confirm_delete_students()
+
+    if do_delete_students:
+        deleted_users, deleted_roles = delete_student_accounts()
+        logger.info("删除操作完成:删除用户 %d,删除角色关联 %d", deleted_users, deleted_roles)
+
+    # 询问是否清空表
+    if args.yes_truncate:
+        do_truncate = True
+    else:
+        do_truncate = prompt_confirm_truncate()
+
+    sync_from_his_xssj(truncate_before_sync=do_truncate)
+
+
+if __name__ == "__main__":
+    main()
+
+

BIN
学校历史数据迁移/__pycache__/db_config.cpython-312.pyc


+ 111 - 0
学校历史数据迁移/compare_student_headers.py

@@ -0,0 +1,111 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+比较两个学生 Excel 文件的表头(列名)。
+
+用法:
+  python compare_student_headers.py \
+    --file-a 学校历史数据迁移/student1222.xls \
+    --file-b 学校历史数据迁移/student1127.xls
+
+脚本会打印:
+- 是否完全相同(顺序及名称)
+- 仅在 A 中存在的列
+- 仅在 B 中存在的列
+- 顺序不同但内容相同的列(名称相同但位置不同)
+"""
+from __future__ import annotations
+import os
+import sys
+import argparse
+from typing import List
+import pandas as pd
+
+
+def read_headers(path: str) -> List[str]:
+    if not os.path.exists(path):
+        raise FileNotFoundError(path)
+    ext = os.path.splitext(path)[1].lower()
+    if ext == ".xls":
+        df = pd.read_excel(path, nrows=0, engine="xlrd")
+    else:
+        # .xlsx or others
+        df = pd.read_excel(path, nrows=0, engine="openpyxl")
+    return [str(c).strip() for c in list(df.columns)]
+
+
+def main():
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--file-a", type=str, required=True)
+    parser.add_argument("--file-b", type=str, required=True)
+    args = parser.parse_args()
+
+    try:
+        headers_a = read_headers(args.file_a)
+    except Exception as e:
+        print(f"读取 {args.file_a} 表头失败: {e}")
+        sys.exit(2)
+    try:
+        headers_b = read_headers(args.file_b)
+    except Exception as e:
+        print(f"读取 {args.file_b} 表头失败: {e}")
+        sys.exit(2)
+
+    print(f"{args.file_a} 列数: {len(headers_a)}")
+    print(f"{args.file_b} 列数: {len(headers_b)}")
+
+    if headers_a == headers_b:
+        print("表头完全相同(顺序及名称一致)。")
+        return
+
+    set_a = set(headers_a)
+    set_b = set(headers_b)
+    only_a = sorted(list(set_a - set_b))
+    only_b = sorted(list(set_b - set_a))
+
+    if not only_a and not only_b:
+        print("列集合相同,但顺序不同。")
+        # show first few mismatches by position
+        diffs = []
+        for i, (ca, cb) in enumerate(zip(headers_a, headers_b)):
+            if ca != cb:
+                diffs.append((i + 1, ca, cb))
+        if diffs:
+            print("位置不一致的前若干列(位置, A列名, B列名):")
+            for d in diffs[:20]:
+                print(f"  {d[0]:>3}: {d[1]}   |   {d[2]}")
+        else:
+            # maybe lengths differ; list remainder
+            if len(headers_a) != len(headers_b):
+                print("两表长度不同,下面列出多余部分:")
+                if len(headers_a) > len(headers_b):
+                    print("A 多余:", headers_a[len(headers_b):len(headers_b)+20])
+                else:
+                    print("B 多余:", headers_b[len(headers_a):len(headers_a)+20])
+        return
+
+    # print differences
+    if only_a:
+        print(f"仅在 A 中存在的列 ({len(only_a)}):")
+        for c in only_a[:100]:
+            print("  -", c)
+    if only_b:
+        print(f"仅在 B 中存在的列 ({len(only_b)}):")
+        for c in only_b[:100]:
+            print("  -", c)
+
+    # also list positional mismatches up to 20
+    diffs = []
+    for i in range(min(len(headers_a), len(headers_b))):
+        if headers_a[i] != headers_b[i]:
+            diffs.append((i + 1, headers_a[i], headers_b[i]))
+    if diffs:
+        print("位置不一致的前若干列(位置, A列名, B列名):")
+        for d in diffs[:20]:
+            print(f"  {d[0]:>3}: {d[1]}   |   {d[2]}")
+
+
+if __name__ == "__main__":
+    main()
+
+

+ 26 - 0
学校历史数据迁移/db_config.py

@@ -0,0 +1,26 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""
+数据库配置文件
+用于超星数据移植与定时同步脚本
+"""
+
+# 数据库配置信息
+DB_CONFIG = {
+    "host": "47.99.164.66",
+    "port": 3306,
+    "database": "hzdyjs_xg",
+    "user": "root",
+    "password": "hn02le.34lkdLKD",
+    "charset": "utf8mb4"
+}
+
+# 表名映射(API类型 -> 数据库表名)
+TABLE_MAPPING = {
+    "class": "his_bjsj",           # 班级数据
+    "department": "his_xbsj",      # 系部数据
+    "student": "his_xssj",         # 学生数据
+    "major": "his_zysj"  ,          # 专业数据
+    "teacher": "his_lssj"          # 老师数据
+}
+

+ 15 - 0
学校历史数据迁移/his_lssj_create.sql

@@ -0,0 +1,15 @@
+CREATE TABLE `his_lssj` (
+  `id` BIGINT(20) NOT NULL AUTO_INCREMENT,
+  `tenant_id` BIGINT(20) DEFAULT NULL,
+  `deleted` TINYINT(1) NOT NULL DEFAULT 0,
+  `create_by` BIGINT(20) DEFAULT NULL,
+  `create_time` DATETIME DEFAULT NULL,
+  `update_by` BIGINT(20) DEFAULT NULL,
+  `update_time` DATETIME DEFAULT NULL,
+  `工号` TEXT DEFAULT NULL,
+  `姓名` TEXT DEFAULT NULL,
+  `部门` TEXT DEFAULT NULL,
+  `性别` TEXT DEFAULT NULL,
+  `手机号码` TEXT DEFAULT NULL,
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

+ 56 - 0
学校历史数据迁移/his_xssj_create.sql

@@ -0,0 +1,56 @@
+CREATE TABLE `his_xssj` (
+  `id` BIGINT(20) NOT NULL AUTO_INCREMENT,
+  `tenant_id` BIGINT(20) DEFAULT NULL,
+  `deleted` TINYINT(1) NOT NULL DEFAULT 0,
+  `create_by` BIGINT(20) DEFAULT NULL,
+  `create_time` DATETIME DEFAULT NULL,
+  `update_by` BIGINT(20) DEFAULT NULL,
+  `update_time` DATETIME DEFAULT NULL,
+  `学号` TEXT DEFAULT NULL,
+  `姓名` TEXT DEFAULT NULL,
+  `性别` TEXT DEFAULT NULL,
+  `学生状态` TEXT DEFAULT NULL,
+  `身份证号` TEXT DEFAULT NULL,
+  `出生日期` TEXT DEFAULT NULL,
+  `籍贯` TEXT DEFAULT NULL,
+  `政治面貌` TEXT DEFAULT NULL,
+  `入学年份` TEXT DEFAULT NULL,
+  `入学日期` TEXT DEFAULT NULL,
+  `教学系部` TEXT DEFAULT NULL,
+  `班级名称` TEXT DEFAULT NULL,
+  `班主任` TEXT DEFAULT NULL,
+  `录取号` TEXT DEFAULT NULL,
+  `学生类别` TEXT DEFAULT NULL,
+  `学院编号` TEXT DEFAULT NULL,
+  `专业编号` TEXT DEFAULT NULL,
+  `专业名称` TEXT DEFAULT NULL,
+  `层次编号` TEXT DEFAULT NULL,
+  `报读层次` TEXT DEFAULT NULL,
+  `所属年级` TEXT DEFAULT NULL,
+  `班级编号` TEXT DEFAULT NULL,
+  `学制编码` TEXT DEFAULT NULL,
+  `学生标识号` TEXT DEFAULT NULL,
+  `生源省区编码` TEXT DEFAULT NULL,
+  `生源省区` TEXT DEFAULT NULL,
+  `银行卡号` TEXT DEFAULT NULL,
+  `联系方式` TEXT DEFAULT NULL,
+  `个人邮箱` TEXT DEFAULT NULL,
+  `家庭住址` TEXT DEFAULT NULL,
+  `招生季度` TEXT DEFAULT NULL,
+  `毕业院校` TEXT DEFAULT NULL,
+  `文化程度` TEXT DEFAULT NULL,
+  `收入来源` TEXT DEFAULT NULL,
+  `家庭年总收入` TEXT DEFAULT NULL,
+  `家庭人均收入` TEXT DEFAULT NULL,
+  `爱好特长` TEXT DEFAULT NULL,
+  `户口性质` TEXT DEFAULT NULL,
+  `户口所在地` TEXT DEFAULT NULL,
+  `是否低保` TEXT DEFAULT NULL,
+  `家长电话` TEXT DEFAULT NULL,
+  `家长姓名` TEXT DEFAULT NULL,
+  `是否空身份证` TEXT DEFAULT NULL,
+  `学生自定义类别` TEXT DEFAULT NULL,
+  `是否住校` TEXT DEFAULT NULL,
+  `宿舍号码` TEXT DEFAULT NULL,
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 1 - 0
学校历史数据迁移/logs/06_insert_classes_skipped_majors.jsonl


+ 7 - 0
学校历史数据迁移/requirements.txt

@@ -0,0 +1,7 @@
+pandas>=2.2.0
+sqlalchemy>=2.0
+pymysql>=1.0.3
+xlrd>=2.0.1
+openpyxl>=3.0.0
+
+

BIN
学校历史数据迁移/student1127.xls


BIN
学校历史数据迁移/student1222.xls


+ 45 - 0
学校历史数据迁移/sys_dept.sql

@@ -0,0 +1,45 @@
+/*
+ Navicat Premium Dump SQL
+
+ Source Server         : 47.99.164.66  hn02le.34lkdLKD
+ Source Server Type    : MySQL
+ Source Server Version : 50736 (5.7.36)
+ Source Host           : 47.99.164.66:3306
+ Source Schema         : hzdyjs_xg
+
+ Target Server Type    : MySQL
+ Target Server Version : 50736 (5.7.36)
+ File Encoding         : 65001
+
+ Date: 22/12/2025 16:50:49
+*/
+
+SET NAMES utf8mb4;
+SET FOREIGN_KEY_CHECKS = 0;
+
+-- ----------------------------
+-- Table structure for sys_dept
+-- ----------------------------
+DROP TABLE IF EXISTS `sys_dept`;
+CREATE TABLE `sys_dept` (
+  `dept_id` bigint(20) NOT NULL COMMENT '部门id',
+  `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
+  `parent_id` bigint(20) DEFAULT '0' COMMENT '父部门id',
+  `ancestors` varchar(500) DEFAULT '' COMMENT '祖级列表',
+  `dept_name` varchar(30) DEFAULT '' COMMENT '部门名称',
+  `dept_category` varchar(100) DEFAULT NULL COMMENT '部门类别编码',
+  `order_num` int(4) DEFAULT '0' COMMENT '显示顺序',
+  `leader` bigint(20) DEFAULT NULL COMMENT '负责人',
+  `phone` varchar(11) DEFAULT NULL COMMENT '联系电话',
+  `email` varchar(50) DEFAULT NULL COMMENT '邮箱',
+  `status` char(1) DEFAULT '0' COMMENT '部门状态(0正常 1停用)',
+  `del_flag` char(1) DEFAULT '0' COMMENT '删除标志(0代表存在 1代表删除)',
+  `create_dept` bigint(20) DEFAULT NULL COMMENT '创建部门',
+  `create_by` bigint(20) DEFAULT NULL COMMENT '创建者',
+  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+  `update_by` bigint(20) DEFAULT NULL COMMENT '更新者',
+  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+  PRIMARY KEY (`dept_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门表';
+
+SET FOREIGN_KEY_CHECKS = 1;

+ 50 - 0
学校历史数据迁移/sys_user.sql

@@ -0,0 +1,50 @@
+/*
+ Navicat Premium Dump SQL
+
+ Source Server         : 47.99.164.66  hn02le.34lkdLKD
+ Source Server Type    : MySQL
+ Source Server Version : 50736 (5.7.36)
+ Source Host           : 47.99.164.66:3306
+ Source Schema         : hzdyjs_xg
+
+ Target Server Type    : MySQL
+ Target Server Version : 50736 (5.7.36)
+ File Encoding         : 65001
+
+ Date: 22/12/2025 11:12:33
+*/
+
+SET NAMES utf8mb4;
+SET FOREIGN_KEY_CHECKS = 0;
+
+-- ----------------------------
+-- Table structure for sys_user
+-- ----------------------------
+DROP TABLE IF EXISTS `sys_user`;
+CREATE TABLE `sys_user` (
+  `user_id` bigint(20) NOT NULL COMMENT '用户ID',
+  `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
+  `dept_id` bigint(20) DEFAULT NULL COMMENT '部门ID',
+  `user_name` varchar(30) NOT NULL COMMENT '用户账号',
+  `nick_name` varchar(30) NOT NULL COMMENT '用户昵称',
+  `user_type` varchar(10) DEFAULT 'sys_user' COMMENT '用户类型(sys_user系统用户)',
+  `email` varchar(50) DEFAULT '' COMMENT '用户邮箱',
+  `phonenumber` varchar(11) DEFAULT '' COMMENT '手机号码',
+  `sex` char(1) DEFAULT '0' COMMENT '用户性别(0男 1女 2未知)',
+  `avatar` bigint(20) DEFAULT NULL COMMENT '头像地址',
+  `password` varchar(100) DEFAULT '' COMMENT '密码',
+  `status` char(1) DEFAULT '0' COMMENT '帐号状态(0正常 1停用)',
+  `del_flag` char(1) DEFAULT '0' COMMENT '删除标志(0代表存在 1代表删除)',
+  `login_ip` varchar(128) DEFAULT '' COMMENT '最后登录IP',
+  `login_date` datetime DEFAULT NULL COMMENT '最后登录时间',
+  `create_dept` bigint(20) DEFAULT NULL COMMENT '创建部门',
+  `create_by` bigint(20) DEFAULT NULL COMMENT '创建者',
+  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+  `update_by` bigint(20) DEFAULT NULL COMMENT '更新者',
+  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
+  `account_type` varchar(255) DEFAULT '1' COMMENT '账户类型 1 - 教职工 2- 学生',
+  PRIMARY KEY (`user_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户信息表';
+
+SET FOREIGN_KEY_CHECKS = 1;

+ 30 - 0
学校历史数据迁移/sys_user_role.sql

@@ -0,0 +1,30 @@
+/*
+ Navicat Premium Dump SQL
+
+ Source Server         : 47.99.164.66  hn02le.34lkdLKD
+ Source Server Type    : MySQL
+ Source Server Version : 50736 (5.7.36)
+ Source Host           : 47.99.164.66:3306
+ Source Schema         : hzdyjs_xg
+
+ Target Server Type    : MySQL
+ Target Server Version : 50736 (5.7.36)
+ File Encoding         : 65001
+
+ Date: 22/12/2025 11:31:59
+*/
+
+SET NAMES utf8mb4;
+SET FOREIGN_KEY_CHECKS = 0;
+
+-- ----------------------------
+-- Table structure for sys_user_role
+-- ----------------------------
+DROP TABLE IF EXISTS `sys_user_role`;
+CREATE TABLE `sys_user_role` (
+  `user_id` bigint(20) NOT NULL COMMENT '用户ID',
+  `role_id` bigint(20) NOT NULL COMMENT '角色ID',
+  PRIMARY KEY (`user_id`,`role_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户和角色关联表';
+
+SET FOREIGN_KEY_CHECKS = 1;

BIN
学校历史数据迁移/teacher.xlsx


+ 98 - 0
学校历史数据迁移/xg_class_msg.sql

@@ -0,0 +1,98 @@
+/*
+ Navicat Premium Dump SQL
+
+ Source Server         : 47.99.164.66  hn02le.34lkdLKD
+ Source Server Type    : MySQL
+ Source Server Version : 50736 (5.7.36)
+ Source Host           : 47.99.164.66:3306
+ Source Schema         : hzdyjs_xg
+
+ Target Server Type    : MySQL
+ Target Server Version : 50736 (5.7.36)
+ File Encoding         : 65001
+
+ Date: 22/12/2025 15:11:55
+*/
+
+SET NAMES utf8mb4;
+SET FOREIGN_KEY_CHECKS = 0;
+
+-- ----------------------------
+-- Table structure for xg_class_msg
+-- ----------------------------
+DROP TABLE IF EXISTS `xg_class_msg`;
+CREATE TABLE `xg_class_msg` (
+  `id` bigint(20) NOT NULL COMMENT '主键ID(雪花ID)',
+  `tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号',
+  `create_dept` bigint(20) DEFAULT NULL COMMENT '创建部门',
+  `create_by` bigint(20) DEFAULT NULL COMMENT '创建者',
+  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+  `update_by` bigint(20) DEFAULT NULL COMMENT '更新者',
+  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+  `deleted` tinyint(1) DEFAULT '0' COMMENT '删除标志(0代表存在 1代表删除)',
+  `college_id` bigint(20) DEFAULT NULL COMMENT '学院代码',
+  `college_name` varchar(100) DEFAULT NULL COMMENT '学院名称',
+  `major_id` bigint(20) DEFAULT NULL COMMENT '专业代码',
+  `major_name` varchar(100) DEFAULT NULL COMMENT '专业名称',
+  `class_code` varchar(32) NOT NULL COMMENT '班级代码',
+  `class_name` varchar(100) NOT NULL COMMENT '班级名称',
+  `class_type` varchar(20) DEFAULT '1' COMMENT '班级类型:0 - 三年制中级技工 、 1 - 三年制技师 、 2 - 四年制高级工 、3 - 五年制高级技工  、4 - 六年制技师',
+  `grade` varchar(10) NOT NULL COMMENT '年级',
+  `enrollment_year` int(4) NOT NULL COMMENT '入学年份',
+  `graduation_year` int(4) DEFAULT NULL COMMENT '毕业年份',
+  `training_type` varchar(20) DEFAULT NULL COMMENT '培养方式:全日制,在职,函授,夜大等',
+  `degree_type` varchar(20) DEFAULT NULL COMMENT '学位类型:学士,硕士,博士,专科',
+  `counselor_id` bigint(20) DEFAULT NULL COMMENT '辅导员ID',
+  `counselor_name` varchar(50) DEFAULT NULL COMMENT '辅导员姓名',
+  `class_teacher_id` bigint(20) DEFAULT NULL COMMENT '班主任ID',
+  `class_teacher_name` varchar(50) DEFAULT NULL COMMENT '班主任姓名',
+  `class_advisor_id` bigint(20) DEFAULT NULL COMMENT '班级导师ID',
+  `class_advisor_name` varchar(50) DEFAULT NULL COMMENT '班级导师姓名',
+  `class_monitor_id` bigint(20) DEFAULT NULL COMMENT '班长ID',
+  `class_monitor_name` varchar(50) DEFAULT NULL COMMENT '班长姓名',
+  `deputy_monitor_id` bigint(20) DEFAULT NULL COMMENT '副班长ID',
+  `deputy_monitor_name` varchar(50) DEFAULT NULL COMMENT '副班长姓名',
+  `class_status` char(1) DEFAULT '1' COMMENT '班级状态:1-正常,2-暂停,3-毕业,4-解散',
+  `is_graduation` tinyint(1) DEFAULT '0' COMMENT '是否已毕业:0-未毕业,1-已毕业',
+  `graduation_date` date DEFAULT NULL COMMENT '毕业日期',
+  `planned_capacity` int(11) DEFAULT '50' COMMENT '计划容量',
+  `current_student_count` int(11) DEFAULT '0' COMMENT '当前学生人数',
+  `max_student_count` int(11) DEFAULT '50' COMMENT '最大学生人数',
+  `male_count` int(11) DEFAULT '0' COMMENT '男生人数',
+  `female_count` int(11) DEFAULT '0' COMMENT '女生人数',
+  `active_count` int(11) DEFAULT '0' COMMENT '在籍人数',
+  `transfer_in_count` int(11) DEFAULT '0' COMMENT '转入人数',
+  `transfer_out_count` int(11) DEFAULT '0' COMMENT '转出人数',
+  `dropout_count` int(11) DEFAULT '0' COMMENT '退学人数',
+  `graduate_count` int(11) DEFAULT '0' COMMENT '毕业人数',
+  `office_location` varchar(200) DEFAULT NULL COMMENT '班级办公室位置',
+  `class_location` varchar(100) DEFAULT NULL COMMENT '班级教室位置',
+  `contact_phone` varchar(20) DEFAULT NULL COMMENT '联系电话',
+  `qq_group` varchar(20) DEFAULT NULL COMMENT 'QQ群号',
+  `wechat_group` varchar(100) DEFAULT NULL COMMENT '微信群',
+  `email` varchar(100) DEFAULT NULL COMMENT '班级邮箱',
+  `class_schedule` text COMMENT '课程安排(JSON格式)',
+  `class_culture` text COMMENT '班级文化建设',
+  `class_rules` text COMMENT '班级规章制度',
+  `class_description` varchar(500) DEFAULT NULL COMMENT '班级描述',
+  `last_count_update` datetime DEFAULT NULL COMMENT '最后统计更新时间',
+  `ext_field1` varchar(200) DEFAULT NULL COMMENT '扩展字段1',
+  `ext_field2` varchar(200) DEFAULT NULL COMMENT '扩展字段2',
+  `ext_field3` varchar(200) DEFAULT NULL COMMENT '扩展字段3',
+  `remark` varchar(500) DEFAULT NULL COMMENT '备注信息',
+  `school_system` int(11) DEFAULT '4' COMMENT '学制(年)',
+  PRIMARY KEY (`id`) USING BTREE,
+  KEY `uk_class_code_tenant` (`class_code`,`tenant_id`),
+  KEY `idx_tenant_id` (`tenant_id`),
+  KEY `idx_deleted` (`deleted`),
+  KEY `idx_college_id` (`college_id`),
+  KEY `idx_enrollment_year` (`enrollment_year`),
+  KEY `idx_grade` (`grade`),
+  KEY `idx_major_id` (`major_id`),
+  KEY `idx_counselor_id` (`counselor_id`),
+  KEY `idx_class_teacher_id` (`class_teacher_id`),
+  KEY `idx_class_status` (`class_status`),
+  KEY `idx_college_grade` (`college_id`,`grade`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='班级信息表-新版';
+
+SET FOREIGN_KEY_CHECKS = 1;

+ 51 - 0
学校历史数据迁移/xg_class_teacher_counselor_rel.sql

@@ -0,0 +1,51 @@
+/*
+ Navicat Premium Dump SQL
+
+ Source Server         : 47.99.164.66  hn02le.34lkdLKD
+ Source Server Type    : MySQL
+ Source Server Version : 50736 (5.7.36)
+ Source Host           : 47.99.164.66:3306
+ Source Schema         : hzdyjs_xg
+
+ Target Server Type    : MySQL
+ Target Server Version : 50736 (5.7.36)
+ File Encoding         : 65001
+
+ Date: 22/12/2025 15:46:09
+*/
+
+SET NAMES utf8mb4;
+SET FOREIGN_KEY_CHECKS = 0;
+
+-- ----------------------------
+-- Table structure for xg_class_teacher_counselor_rel
+-- ----------------------------
+DROP TABLE IF EXISTS `xg_class_teacher_counselor_rel`;
+CREATE TABLE `xg_class_teacher_counselor_rel` (
+  `id` bigint(20) NOT NULL COMMENT '主键ID(雪花ID)',
+  `tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号',
+  `create_dept` bigint(20) DEFAULT NULL COMMENT '创建部门',
+  `create_by` bigint(20) DEFAULT NULL COMMENT '创建者',
+  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+  `update_by` bigint(20) DEFAULT NULL COMMENT '更新者',
+  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+  `deleted` tinyint(1) DEFAULT '0' COMMENT '删除标志(0代表存在 1代表删除)',
+  `class_id` bigint(20) NOT NULL COMMENT '班级ID,关联xg_class_msg.id',
+  `class_name` varchar(100) DEFAULT NULL COMMENT '班级名称',
+  `teacher_id` bigint(20) NOT NULL COMMENT '教师ID,关联sys_user.user_id',
+  `teacher_name` varchar(50) DEFAULT NULL COMMENT '教师姓名',
+  `teacher_type` varchar(20) NOT NULL COMMENT '教师类型:class_teacher-班主任,counselor-辅导员,advisor-导师',
+  `start_date` datetime NOT NULL COMMENT '开始日期',
+  `end_date` datetime DEFAULT NULL COMMENT '结束日期',
+  `remark` varchar(500) DEFAULT NULL COMMENT '备注信息',
+  PRIMARY KEY (`id`) USING BTREE,
+  KEY `idx_tenant_id` (`tenant_id`),
+  KEY `idx_deleted` (`deleted`),
+  KEY `idx_class_id` (`class_id`),
+  KEY `idx_teacher_id` (`teacher_id`),
+  KEY `idx_teacher_type` (`teacher_type`),
+  KEY `idx_start_date` (`start_date`),
+  KEY `idx_end_date` (`end_date`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='班级班主任辅导员关联表';
+
+SET FOREIGN_KEY_CHECKS = 1;

+ 150 - 0
学校历史数据迁移/xg_student_msg.sql

@@ -0,0 +1,150 @@
+/*
+ Navicat Premium Dump SQL
+
+ Source Server         : 47.99.164.66  hn02le.34lkdLKD
+ Source Server Type    : MySQL
+ Source Server Version : 50736 (5.7.36)
+ Source Host           : 47.99.164.66:3306
+ Source Schema         : hzdyjs_xg
+
+ Target Server Type    : MySQL
+ Target Server Version : 50736 (5.7.36)
+ File Encoding         : 65001
+
+ Date: 23/12/2025 15:15:38
+*/
+
+SET NAMES utf8mb4;
+SET FOREIGN_KEY_CHECKS = 0;
+
+-- ----------------------------
+-- Table structure for xg_student_msg
+-- ----------------------------
+DROP TABLE IF EXISTS `xg_student_msg`;
+CREATE TABLE `xg_student_msg` (
+  `id` bigint(20) NOT NULL COMMENT '主键ID(雪花ID)',
+  `tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号',
+  `create_dept` bigint(20) DEFAULT NULL COMMENT '创建部门',
+  `create_by` bigint(20) DEFAULT NULL COMMENT '创建者',
+  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+  `update_by` bigint(20) DEFAULT NULL COMMENT '更新者',
+  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+  `deleted` tinyint(1) DEFAULT '0' COMMENT '删除标志(0代表存在 1代表删除)',
+  `student_no` varchar(32) NOT NULL COMMENT '学号',
+  `id_card` varchar(18) DEFAULT NULL COMMENT '身份证号',
+  `name` varchar(50) NOT NULL COMMENT '姓名',
+  `name_english` varchar(100) DEFAULT NULL COMMENT '英文姓名',
+  `former_name` varchar(50) DEFAULT NULL COMMENT '曾用名',
+  `gender` char(1) NOT NULL DEFAULT '0' COMMENT '性别:0-男,1-女',
+  `birth_date` date DEFAULT NULL COMMENT '出生日期',
+  `nationality` varchar(20) DEFAULT NULL COMMENT '民族 ',
+  `native_place` varchar(20) DEFAULT NULL COMMENT '籍贯',
+  `political_status` varchar(20) DEFAULT NULL COMMENT '政治面貌',
+  `hukou_location` varchar(1000) DEFAULT NULL COMMENT '户籍所在地',
+  `home_address` varchar(1000) DEFAULT NULL COMMENT '家庭地址',
+  `current_address` varchar(1000) DEFAULT NULL COMMENT '现住址',
+  `postal_code` varchar(10) DEFAULT NULL COMMENT '邮政编码',
+  `photo_url` varchar(500) DEFAULT NULL COMMENT '照片URL',
+  `photo_path` varchar(500) DEFAULT NULL COMMENT '学生照片存储路径',
+  `phone` varchar(20) DEFAULT NULL COMMENT '手机号码',
+  `is_boarder` tinyint(1) DEFAULT '0' COMMENT '是否住校:0-否,1-是',
+  `education_level` varchar(20) DEFAULT NULL COMMENT '文化程度',
+  `household_register_type` varchar(20) DEFAULT NULL COMMENT '户口类型:农业、非农业',
+  `religion` varchar(200) DEFAULT NULL COMMENT '宗教信仰',
+  `source_region` varchar(100) DEFAULT NULL COMMENT '生源地区(省市县)',
+  `graduate_school` varchar(100) DEFAULT NULL COMMENT '毕业院校',
+  `email` varchar(100) DEFAULT NULL COMMENT '电子邮箱',
+  `qq` varchar(20) DEFAULT NULL COMMENT 'QQ号码',
+  `wechat` varchar(50) DEFAULT NULL COMMENT '微信号',
+  `marital_status` varchar(10) DEFAULT NULL COMMENT '婚姻状况:1 未婚、2 已婚、3 离异、4 丧偶 ',
+  `emergency_contact_name` varchar(50) DEFAULT NULL COMMENT '紧急联系人姓名',
+  `emergency_contact_phone` varchar(20) DEFAULT NULL COMMENT '紧急联系人电话',
+  `emergency_contact_relation` varchar(100) DEFAULT NULL COMMENT '紧急联系人关系',
+  `is_poor_student` tinyint(1) DEFAULT '0' COMMENT '是否贫困生:0-否,1-是',
+  `is_minority` tinyint(1) DEFAULT '0' COMMENT '是否少数民族:0-否,1-是',
+  `is_disabled` tinyint(1) DEFAULT '0' COMMENT '是否残疾:0-否,1-是',
+  `is_orphan` tinyint(1) DEFAULT '0' COMMENT '是否孤儿:0-否,1-是',
+  `is_left_behind_child` tinyint(1) DEFAULT '0' COMMENT '是否留守儿童:0-否,1-是',
+  `is_single_child` tinyint(1) DEFAULT '0' COMMENT '是否独生子女:0-否,1-是',
+  `is_low_income` tinyint(1) DEFAULT '0' COMMENT '是否低保:0-否,1-是',
+  `has_loan` tinyint(1) DEFAULT '0' COMMENT '是否有助学贷款:0-否,1-是',
+  `loan_amount` decimal(10,2) DEFAULT NULL COMMENT '助学贷款金额',
+  `specialties` text COMMENT '特长爱好',
+  `awards` text COMMENT '获奖情况',
+  `violations` text COMMENT '违纪记录',
+  `social_activities` text COMMENT '社会活动',
+  `father_name` varchar(50) DEFAULT NULL COMMENT '父亲姓名',
+  `father_phone` varchar(20) DEFAULT NULL COMMENT '父亲电话',
+  `father_work_unit` varchar(1000) DEFAULT NULL COMMENT '父亲工作单位',
+  `mother_name` varchar(50) DEFAULT NULL COMMENT '母亲姓名',
+  `mother_phone` varchar(20) DEFAULT NULL COMMENT '母亲电话',
+  `mother_work_unit` varchar(1000) DEFAULT NULL COMMENT '母亲工作单位',
+  `guardian_name` varchar(50) DEFAULT NULL COMMENT '监护人姓名',
+  `guardian_phone` varchar(20) DEFAULT NULL COMMENT '监护人电话',
+  `guardian_relation` varchar(20) DEFAULT NULL COMMENT '监护人关系',
+  `family_income` decimal(12,2) DEFAULT NULL COMMENT '家庭年收入',
+  `per_capita_income` decimal(12,2) DEFAULT NULL COMMENT '人均收入',
+  `income_source` varchar(100) DEFAULT NULL COMMENT '收入来源',
+  `family_economic_status` varchar(20) DEFAULT NULL COMMENT '家庭经济状况:富裕、一般、困难、特困',
+  `family_members` tinyint(2) DEFAULT NULL COMMENT '家庭成员数量',
+  `height` decimal(10,2) DEFAULT NULL COMMENT '身高(cm)',
+  `weight` decimal(10,2) DEFAULT NULL COMMENT '体重(kg)',
+  `blood_type` varchar(10) DEFAULT NULL COMMENT '血型',
+  `health_status` varchar(1000) DEFAULT NULL COMMENT '健康状况',
+  `special_condition` varchar(1000) DEFAULT NULL COMMENT '特殊体质或疾病',
+  `medical_history` varchar(1000) DEFAULT NULL COMMENT '病史',
+  `allergies` varchar(1000) DEFAULT NULL COMMENT '过敏史',
+  `disability_type` varchar(50) DEFAULT NULL COMMENT '残疾类型 1 - 视力残疾 2 - 听力残疾 3 - 智力残疾 9 - 其他残疾',
+  `disability_level` varchar(20) DEFAULT NULL COMMENT '残疾等级',
+  `admission_category` varchar(20) DEFAULT NULL COMMENT '录取类别:统招、自主招生、专项计划等',
+  `student_status` char(1) DEFAULT '1' COMMENT '学籍状态:1-在籍,2-休学,3-复学,4-退学,5-毕业 , 6- 不在籍',
+  `enrollment_year` int(4) DEFAULT NULL COMMENT '入学年份',
+  `enrollment_date` date DEFAULT NULL COMMENT '入学日期',
+  `bank_card_no` varchar(30) DEFAULT NULL COMMENT '银行卡号',
+  `bank_name` varchar(100) DEFAULT NULL COMMENT '开户银行',
+  `bank_branch` varchar(100) DEFAULT NULL COMMENT '开户支行',
+  `card_holder_name` varchar(50) DEFAULT NULL COMMENT '持卡人姓名',
+  `info_status` char(1) DEFAULT '1' COMMENT '信息状态:1-正常,2-待审核,3-审核中,4-已驳回,5-已锁定',
+  `data_source` varchar(20) DEFAULT 'manual' COMMENT '数据来源:manual-手工录入,import-批量导入,sync-系统同步',
+  `last_batch_id` bigint(20) DEFAULT NULL COMMENT '最后修改批次ID',
+  `last_audit_time` datetime DEFAULT NULL COMMENT '最后审核时间',
+  `is_complete` tinyint(1) DEFAULT '0' COMMENT '信息是否完整:0-不完整,1-完整',
+  `completion_rate` decimal(10,2) DEFAULT '0.00' COMMENT '完整度百分比',
+  `school_system` int(2) DEFAULT '4' COMMENT '学制(年)- 预留,数据从班级表关联',
+  `academic_level` varchar(20) DEFAULT NULL COMMENT '学历层次 - 预留,数据从班级表关联',
+  `training_type` varchar(20) DEFAULT NULL COMMENT '培养方式:全日制、在职、函授、夜大等 - 预留,数据从班级表关联',
+  `major_id` varchar(100) DEFAULT NULL COMMENT '专业id - 预留,数据从班级表关联',
+  `major_name` varchar(100) DEFAULT NULL COMMENT '专业名称 - 预留,数据从班级表关联',
+  `class_id` varchar(100) DEFAULT NULL COMMENT '班级id - 预留,数据从班级表关联',
+  `class_name` varchar(100) DEFAULT NULL COMMENT '班级名称 - 预留,数据从班级表关联',
+  `grade` varchar(10) DEFAULT NULL COMMENT '年级 - 预留,数据从班级表关联',
+  `college_id` varchar(100) DEFAULT NULL COMMENT '学院id - 预留,数据从班级表关联',
+  `college_name` varchar(100) DEFAULT NULL COMMENT '学院名称 - 预留,数据从班级表关联',
+  `counselor_id` bigint(20) DEFAULT NULL COMMENT '辅导员ID - 预留,数据从班级表关联',
+  `counselor_name` varchar(50) DEFAULT NULL COMMENT '辅导员姓名 - 预留,数据从班级表关联',
+  `class_teacher_id` bigint(20) DEFAULT NULL COMMENT '班主任ID - 预留,数据从班级表关联',
+  `class_teacher_name` varchar(50) DEFAULT NULL COMMENT '班主任姓名 - 预留,数据从班级表关联',
+  `dormitory_building` varchar(100) DEFAULT NULL COMMENT '宿舍楼栋 - 预留,数据从班级表关联 楼栋id',
+  `dormitory_room` varchar(100) DEFAULT NULL COMMENT '宿舍房间号 - 预留,数据从班级表关联 房间id',
+  `ext_field1` varchar(200) DEFAULT NULL COMMENT '扩展字段1 - 床位id',
+  `ext_field2` varchar(200) DEFAULT NULL COMMENT '扩展字段2',
+  `ext_field3` varchar(200) DEFAULT NULL COMMENT '扩展字段3',
+  `ext_field4` varchar(200) DEFAULT NULL COMMENT '扩展字段4',
+  `ext_field5` varchar(500) DEFAULT NULL COMMENT '扩展字段5',
+  `remark` varchar(500) DEFAULT NULL COMMENT '备注信息',
+  PRIMARY KEY (`id`) USING BTREE,
+  KEY `idx_tenant_id` (`tenant_id`),
+  KEY `idx_deleted` (`deleted`),
+  KEY `idx_name` (`name`),
+  KEY `idx_student_status` (`student_status`),
+  KEY `idx_info_status` (`info_status`),
+  KEY `idx_enrollment_year` (`enrollment_year`),
+  KEY `idx_last_batch_id` (`last_batch_id`),
+  KEY `idx_phone` (`phone`),
+  KEY `idx_create_time` (`create_time`),
+  KEY `idx_status_filter` (`tenant_id`,`student_status`,`info_status`,`deleted`),
+  KEY `uk_student_no_tenant` (`student_no`,`tenant_id`) USING BTREE,
+  KEY `uk_id_card_tenant` (`id_card`,`tenant_id`) USING BTREE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生信息表';
+
+SET FOREIGN_KEY_CHECKS = 1;

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác