Загрузка данных
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Скрипт для автоматической сборки «Формы сбора данных для анкетирования»
из выгрузки расписания по кафедрам (43 файла Excel).
Формирует итоговый файл Excel с 3 листами (аналогично прошлогодней форме):
1. «Общая (кроме ФВ, ЦПД)» — все дисциплины по группам и преподавателям
2. «Список групп и ППС ФВ» — две параллельные таблицы: группы с физкультурой и полный список ППС каф. ФВ
3. «ЦПД » — таблица учебных групп, названий проектов и руководителей проектов ЦПД
Запуск:
py main.py --input-dir . --output "итог\z.xlsx"
"""
from __future__ import annotations
from collections import Counter, defaultdict
import argparse
import logging
import os
import re
import sys
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple
import openpyxl
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
import pandas as pd
# ==============================================================================
# 1. НАСТРОЙКИ ПО УМОЛЧАНИЮ
# ==============================================================================
CURRENT_ACADEMIC_YEAR_SHORT = 26
ONLY_OCHNAYA = True
EXCLUDE_GRADUATING_BACHELORS_AND_MASTERS = False
DEDUPLICATE_MULTI_DEPT_TEACHERS = True
FV_DISCIPLINE_KEYWORDS = (
"физической культуре",
"физическая культура",
"физкультура",
"прикладная физическая культура",
)
EXCLUDED_DISCIPLINE_EXACT = {
"проектная деятельность",
}
EXCLUDED_DISCIPLINE_KEYWORDS = (
"учебная практика",
"производственная практика",
"преддипломная практика",
"научно-исследовательская работа",
"государственная итоговая аттестация",
"подготовка к процедуре защиты",
)
# Полный базовый список преподавателей кафедры «Физическое воспитание» (Лист 2, правая таблица)
# Дополняется автоматически новыми ФИО, если в выгрузке есть файл кафедры ФВ
DEFAULT_FV_TEACHERS: Tuple[str, ...] = (
"Александрова Гузель Абдуллаевна",
"Алькова Кристина Игоревна",
"Белоглазов Максим Валерьевич",
"Бодакин Александр Владимирович",
"Воробей Олеся Владимировна",
"Гвоздева Кристина Игоревна",
"Данилова Дина Александровна",
"Дерипаско Татьяна Николаевна",
"Дзекоев Владимир Русланович",
"Егоров Дмитрий Евгеньевич",
"Егорычева Элина Викторовна",
"Ермолаев Борис Валерьевич",
"Жеребкин Алексей Николаевич",
"Жеребкин Николай Николаевич",
"Комлев Никита Алексеевич",
"Корнеев Евгений Владимирович",
"Кулешова Татьяна Владимировна",
"Макарова Элина Владимировна",
"Максимов Николай Евгеньевич",
"Маликов Александр Юрьевич",
"Маслова Елена Александровна",
"Плешаков Александр Александрович",
"Посохова Татьяна Васильевна",
"Прийменко Даля Игоревна",
"Путилина Вера Юрьевна",
"Румянцев Сергей Викторович",
"Румянцева Светлана Владимировна",
"Сальников Алексей Александрович",
"Семенова Марина Александровна",
"Страбыкин Сергей Иванович",
"Тренкунов Дмитрий Валерьевич",
"Фабрика Александр Игоревич",
"Файзуллин Дмитрий Эдуардович",
"Федоров Александр Владимирович",
"Харин Сергей Владимирович",
"Цаголова Нина Георгиевна",
"Черепанова Ирина Олеговна",
"Черкасова Екатерина Владимировна",
"Чернов Юрий Игоревич",
"Шилова Марина Викторовна",
"Щербакова Елена Евгеньевна",
"Щербин Дмитрий Владимирович",
"Яхненко Дмитрий Валерьевич",
)
# ==============================================================================
# 2. СПРАВОЧНИКИ ФАКУЛЬТЕТОВ, КАФЕДР И НАПРАВЛЕНИЙ МОСКОВСКОГО ПОЛИТЕХА
# ==============================================================================
GROUP_SUFFIX_TO_FACULTY: Dict[str, str] = {
"10": "ТФ",
"0": "ФИДИЖ",
"1": "ТФ",
"2": "ФМ",
"3": "ФИТ",
"4": "ФУиГХ",
"5": "ФХТиБ",
"6": "ФЭиУ",
"7": "ПФ",
"8": "ИГРИК",
"9": "ПИШ ТЛ",
}
GROUP_PREFIX2_TO_KAFEDRA: Dict[str, str] = {
"01": "ИДиК",
"02": "ЖиМК",
"11": "НТС",
"12": "НТС",
"13": "ДПМиСМ",
"14": "Дизайн",
"15": "ЭдТиМЭ",
"16": "ДПМиСМ",
"17": "ДПМиСМ",
"18": "НТС",
"20": "АиУ",
"21": "СМиС",
"22": "ОМДиАТ",
"23": "СМиС",
"24": "МиТЛП",
"25": "АиУ",
"26": "АиУ",
"27": "ОМДиАТ",
"28": "Материаловедение",
"29": "АиУ",
"32": "ИКТ",
"33": "ИиИТ",
"35": "ИБ",
"36": "ПИ",
"37": "ИБ",
"41": "ПГПС",
"42": "ЭиПЭ",
"43": "ПТ",
"44": "ПГПС",
"45": "ПГПС",
"51": "ЭБТС",
"52": "АОиАТП",
"53": "ХБТ",
"54": "ТНТ",
"55": "АОиАТП",
"56": "ПАХТ",
"57": "АОиАТП",
"61": "Менеджмент",
"62": "ЭиО",
"63": "РиС",
"64": "УП",
"65": "УП",
"66": "УП",
"67": "Менеджмент",
"71": "ИМП",
"72": "ТиУКвПиУП",
"73": "ПС",
"74": "ТиУКвПиУП",
"75": "ТиУКвПиУП",
"81": "ХТОПП",
"82": "ХТОПП",
"91": "ПИШ ТЛ",
"92": "ПИШ ТЛ",
"93": "ПИШ ТЛ",
"94": "ПИШ ТЛ",
}
SPECIALIST_SUFFIX_PREFIXES: Tuple[str, ...] = (
"11", "37", "41", "45", "55", "81",
)
SPECIALIST_EXACT_GROUPS: Set[str] = {
"211-231", "211-331", "211-451", "211-521",
"211-811", "211-812", "221-331", "221-451",
}
DEPARTMENT_RULES: List[Tuple[Tuple[str, ...], str, str]] = [
# ПИШ ТЛ
(("передов", "пиш", "fdr", "инженерная школа", "инженерной школы"), "ПИШ ТЛ", "ПИШ ТЛ"),
# ФВ (Физическое воспитание)
(("физкульт", "физическ", "физвоспит", "спорт", "фв"), "ФВ", "ФБК"),
# ЦПД
(("проектн", "цпд"), "ЦПД", "ЦПД"),
# ИГРИК
(("иллюстрац", "эстамп", "ииэ"), "ИиЭ", "ИГРИК"),
(("рисун", "живопис", "риж"), "РиЖ", "ИГРИК"),
(("художествен", "печатн", "хтопп"), "ХТОПП", "ИГРИК"),
# ПФ
(("принтмедиа", "инновацион", "имп"), "ИМП", "ПФ"),
(("полиграфических систем", "полиграфические системы", "полиграф систем", "кафедра пс"), "ПС", "ПФ"),
(("качеств", "упаковоч", "тиуквпиуп", "тиук"), "ТиУКвПиУП", "ПФ"),
# ТФ
(("динамик", "прочност", "сопротивлен", "дпмисм"), "ДПМиСМ", "ТФ"),
(("наземн", "транспортных средств", "транспортные средства", "нтс"), "НТС", "ТФ"),
(("энергоустанов", "малой энергет", "эдтимэ"), "ЭдТиМЭ", "ТФ"),
(("дизайн",), "Дизайн", "ТФ"),
# ФБК
(("гуманитарн", "кафедра гд"), "ГД", "ФБК"),
(("иностран", "кафедра ия"), "ИЯ", "ФБК"),
(("математ",), "Математика", "ФБК"),
(("механизм", "технической механик", "техническая механик", "компьютерного моделир", "тмикм"), "ТМиКМ", "ФБК"),
(("физик",), "Физика", "ФБК"),
# ФИДИЖ
(("журналист", "массовых коммуникац", "массовые коммуникац", "жимк"), "ЖиМК", "ФИДИЖ"),
(("издательск", "книговед", "идик"), "ИДиК", "ФИДИЖ"),
(("русск", "литератур", "ряил", "ряиил"), "РЯиЛ", "ФИДИЖ"),
# ФИТ
(("информационной безопасн", "информационная безопасн", "кафедра иб"), "ИБ", "ФИТ"),
(("инфокогнитив", "икт"), "ИКТ", "ФИТ"),
(("информатики и", "информатика и", "ииит"), "ИиИТ", "ФИТ"),
(("прикладной информат", "прикладная информат", "кафедра пи"), "ПИ", "ФИТ"),
(("смарт", "smart"), "СМАРТ", "ФИТ"),
# ФХТиБ
(("аппаратурн", "автоматизации технологическ", "автоматизация технологическ", "аоиатп"), "АОиАТП", "ФХТиБ"),
# ФМ
(("автоматик", "аиу"), "АиУ", "ФМ"),
(("материаловед",), "Материаловедение", "ФМ"),
(("литей", "митлп"), "МиТЛП", "ФМ"),
(("давлен", "аддитивн", "омдиат"), "ОМДиАТ", "ФМ"),
(("сварочн", "сварк", "отисп"), "ОТиСП", "ФМ"),
(("стандартизац", "метролог", "сертификац", "смис"), "СМиС", "ФМ"),
(("оборудование машиностроен", "оборудования машиностроен", "машиностроен", "тиом"), "ТиОМ", "ФМ"),
# ФУиГХ
(("гражданск", "подземн", "строительств", "пгпс"), "ПГПС", "ФУиГХ"),
(("теплоэнергет", "кафедра пт"), "ПТ", "ФУиГХ"),
(("электрооборуд", "промышленной электроник", "промышленная электроник", "эипэ"), "ЭиПЭ", "ФУиГХ"),
# ФХТиБ (остальные)
(("процессов и аппарат", "процессы и аппарат", "пахт"), "ПАХТ", "ФХТиБ"),
(("низких температур", "низкие температур", "капиц", "тнт"), "ТНТ", "ФХТиБ"),
(("биотехнолог", "биологическ", "химической и", "химическая и", "химии и", "химия и", "хбт"), "ХБТ", "ФХТиБ"),
(("экологическ", "техносферн", "эбтс"), "ЭБТС", "ФХТиБ"),
# ФЭиУ («Реклама и связи с общественностью...» -> «РиС»)
(("менеджмент",), "Менеджмент", "ФЭиУ"),
(("реклам", "связей с обществен", "связи с обществен", "рисом", "рис"), "РиС", "ФЭиУ"),
(("персонал", "кафедра уп"), "УП", "ФЭиУ"),
(("экономик", "эио"), "ЭиО", "ФЭиУ"),
]
KAFEDRA_TO_FACULTY: Dict[str, str] = {
"ИиЭ": "ИГРИК", "РиЖ": "ИГРИК", "ХТОПП": "ИГРИК",
"ПИШ ТЛ": "ПИШ ТЛ",
"ИМП": "ПФ", "ПС": "ПФ", "ТиУКвПиУП": "ПФ",
"ДПМиСМ": "ТФ", "Дизайн": "ТФ", "НТС": "ТФ", "ЭдТиМЭ": "ТФ",
"ГД": "ФБК", "ИЯ": "ФБК", "Математика": "ФБК", "ТМиКМ": "ФБК", "Физика": "ФБК", "ФВ": "ФБК",
"ЖиМК": "ФИДИЖ", "ИДиК": "ФИДИЖ", "РЯиЛ": "ФИДИЖ",
"ИБ": "ФИТ", "ИКТ": "ФИТ", "ИиИТ": "ФИТ", "ПИ": "ФИТ", "СМАРТ": "ФИТ",
"АиУ": "ФМ", "Материаловедение": "ФМ", "МиТЛП": "ФМ", "ОМДиАТ": "ФМ",
"ОТиСП": "ФМ", "СМиС": "ФМ", "ТиОМ": "ФМ",
"ПГПС": "ФУиГХ", "ПТ": "ФУиГХ", "ЭиПЭ": "ФУиГХ",
"АОиАТП": "ФХТиБ", "ПАХТ": "ФХТиБ", "ТНТ": "ФХТиБ", "ХБТ": "ФХТиБ", "ЭБТС": "ФХТиБ",
"Менеджмент": "ФЭиУ", "РиС": "ФЭиУ", "РиСОМ": "ФЭиУ", "УП": "ФЭиУ", "ЭиО": "ФЭиУ",
"ЦПД": "ЦПД",
}
# ==============================================================================
# 3. ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ОЧИСТКИ И КЛАССИФИКАЦИИ
# ==============================================================================
def clean_text(val: object) -> str:
if val is None or (isinstance(val, float) and pd.isna(val)):
return ""
s = str(val).replace("\xa0", " ").replace("\t", " ").replace("\r", " ").replace("\n", " ")
return re.sub(r"\s+", " ", s).strip()
def strip_kafedra_from_teacher(teacher: str) -> str:
return re.sub(r"\s*\([^)]*\)\s*$", "", clean_text(teacher)).strip()
def make_short_abbreviation(name: str) -> str:
s = re.sub(r"\([^)]*\)", "", name).strip()
s = re.sub(r"^(кафедра|ноц|центр|департамент|институт|факультет|школа)\s+", "", s, flags=re.I).strip()
words = [w for w in re.split(r"[\s,\-]+", s) if w]
if len(words) <= 1 and len(s) <= 16:
return s.capitalize() if s.islower() else s
stop_words = {"в", "на", "по", "для", "при", "им", "им.", "имени", "с", "у"}
abbr_parts = []
for w in words:
w_low = w.lower()
if w_low == "и":
abbr_parts.append("и")
elif w_low in stop_words:
continue
elif w.isupper() and len(w) <= 6:
abbr_parts.append(w)
else:
abbr_parts.append(w[0].upper())
abbr = "".join(abbr_parts).strip("и")
return abbr if len(abbr) >= 2 else s[:12]
def infer_kafedra_from_filename(filepath: Path) -> Tuple[str, str]:
stem = clean_text(filepath.stem)
stem_no_num = re.sub(r"^\d+[\.\-\s_]+", "", stem).strip()
stem_lower = stem_no_num.lower()
for code, fac in KAFEDRA_TO_FACULTY.items():
if stem_lower == code.lower():
norm_code = "РиС" if code == "РиСОМ" else code
return norm_code, fac
for keywords, code, fac in DEPARTMENT_RULES:
for kw in keywords:
if kw in stem_lower:
return code, fac
m = re.search(r"\(([^)]+)\)\s*$", stem_no_num)
if m:
abbr = m.group(1).strip()
if abbr in KAFEDRA_TO_FACULTY:
norm_code = "РиС" if abbr == "РиСОМ" else abbr
return norm_code, KAFEDRA_TO_FACULTY[abbr]
short_code = make_short_abbreviation(stem_no_num)
fac = KAFEDRA_TO_FACULTY.get(short_code, "НЕ ОПРЕДЕЛЕН")
logging.warning(
f"Файл '{filepath.name}' не найден в базовом словаре -> сокращён до '{short_code}'."
)
return short_code, fac
def parse_group_info(
group_code: str,
academic_year_short: int = CURRENT_ACADEMIC_YEAR_SHORT,
historical_group_map: Optional[Dict[str, Tuple[str, str, str]]] = None,
) -> Optional[Tuple[str, str, str, int]]:
g = clean_text(group_code)
m = re.match(r"^(\d{2})(\d)-(\d{2,4}[а-яА-Яa-zA-Z0-9]*)$", g)
if not m:
return None
adm_year = int(m.group(1))
form_digit = m.group(2)
suffix = m.group(3)
course = academic_year_short - adm_year + 1
faculty = None
for prefix_len in (2, 1):
cand = suffix[:prefix_len]
if cand in GROUP_SUFFIX_TO_FACULTY:
faculty = GROUP_SUFFIX_TO_FACULTY[cand]
break
if not faculty and historical_group_map and g in historical_group_map:
faculty = historical_group_map[g][0]
if not faculty:
faculty = "ФЭиУ"
if form_digit in ("1", "4"):
form_edu = "Очная"
elif form_digit in ("2", "5"):
form_edu = "Очно-заочная"
elif form_digit in ("3", "6"):
form_edu = "Заочная"
else:
form_edu = f"Код {form_digit}"
if form_digit in ("4", "5", "6"):
level_edu = "Магистратура"
else:
if historical_group_map and g in historical_group_map:
_, _, hist_level = historical_group_map[g]
level_edu = hist_level
elif g in SPECIALIST_EXACT_GROUPS or suffix.startswith(SPECIALIST_SUFFIX_PREFIXES) or course >= 5:
level_edu = "Специалитет"
else:
level_edu = "Бакалавриат"
return faculty, form_edu, level_edu, course
def is_fv_discipline(discipline: str, kafedra: str) -> bool:
if kafedra == "ФВ":
return True
return any(kw in discipline.lower() for kw in FV_DISCIPLINE_KEYWORDS)
def is_excluded_discipline(discipline: str) -> bool:
d_low = discipline.lower().strip()
if d_low in EXCLUDED_DISCIPLINE_EXACT:
return True
return any(kw in d_low for kw in EXCLUDED_DISCIPLINE_KEYWORDS)
# ==============================================================================
# 4. ЧТЕНИЕ ФАЙЛОВ ВЫГРУЗКИ КАФЕДР, ЦПД И ПРОШЛОГОДНЕГО ШАБЛОНА
# ==============================================================================
def load_historical_reference(ref_path: Optional[Path]) -> Tuple[
Dict[str, Tuple[str, str, str]],
Dict[Tuple[str, str], Tuple[str, str]],
Dict[str, Set[str]],
Set[str],
]:
group_map: Dict[str, Tuple[str, str, str]] = {}
teacher_disc_map: Dict[Tuple[str, str], Tuple[str, str]] = {}
teacher_kaf_map: Dict[str, Set[str]] = {}
ref_fv_teachers: Set[str] = set()
if not ref_path or not ref_path.exists():
return group_map, teacher_disc_map, teacher_kaf_map, ref_fv_teachers
try:
df_ref = pd.read_excel(ref_path, sheet_name=0)
for _, row in df_ref.iterrows():
grp = clean_text(row.get("Группа"))
fac_grp = clean_text(row.get("Факультет/институт группы"))
form_edu = clean_text(row.get("Форма обучения"))
lvl_edu = clean_text(row.get("Уровень образования"))
disc = clean_text(row.get("Наименование дисциплины"))
fio = strip_kafedra_from_teacher(clean_text(row.get("Преподаватель")))
kaf = clean_text(row.get("Кафедра ") or row.get("Кафедра"))
if kaf == "РиСОМ":
kaf = "РиС"
fac_pps = clean_text(row.get("Факультет/институт ППС"))
if grp and fac_grp and lvl_edu:
group_map[grp] = (fac_grp, form_edu, lvl_edu)
if fio and disc and kaf and fac_pps:
teacher_disc_map[(fio, disc)] = (kaf, fac_pps)
if fio and kaf:
teacher_kaf_map.setdefault(fio, set()).add(kaf)
# Читаем также преподавателей ФВ со 2-го листа прошлогодней формы (если есть)
try:
df_ref_s2 = pd.read_excel(ref_path, sheet_name=1)
if df_ref_s2.shape[1] >= 7:
for val in df_ref_s2.iloc[2:, 6].dropna().tolist():
t_name = clean_text(val)
if t_name and t_name.lower() != "преподаватель":
ref_fv_teachers.add(t_name)
except Exception:
pass
except Exception as e:
logging.warning(f"Не удалось прочитать '{ref_path}': {e}")
return group_map, teacher_disc_map, teacher_kaf_map, ref_fv_teachers
def read_department_file(filepath: Path) -> pd.DataFrame:
"""
Читает файл выгрузки кафедры или ЦПД.
Поддерживает как стандартную 9-колоночную выгрузку расписания, так и прямую 3-колоночную выгрузку ЦПД.
"""
df = pd.read_excel(filepath, sheet_name=0, header=None)
if df.empty:
return pd.DataFrame(columns=["Преподаватель", "Группы", "Дисциплина", "Тип занятий"])
header_row_idx = 0
header_keywords = (
"преподаватель", "группы", "группа", "учебная группа",
"дисциплина", "название проекта", "наименование проекта",
"руководители проекта", "руководитель проекта", "проект",
)
for r_idx in range(min(10, len(df))):
row_values = [clean_text(v).lower() for v in df.iloc[r_idx].tolist()]
if any(any(kw in cell for kw in header_keywords) for cell in row_values if cell):
header_row_idx = r_idx
break
headers = [clean_text(v) for v in df.iloc[header_row_idx].tolist()]
# Делаем имена колонок уникальными на случай пустых или дублирующихся заголовков
unique_headers: List[str] = []
seen_counts: Dict[str, int] = {}
for idx, h in enumerate(headers):
base = h if h else f"_col_{idx}"
if base in seen_counts:
seen_counts[base] += 1
unique_headers.append(f"{base}_{seen_counts[base]}")
else:
seen_counts[base] = 0
unique_headers.append(base)
data = df.iloc[header_row_idx + 1 :].copy()
data.columns = unique_headers
col_map: Dict[str, str] = {}
for col in data.columns:
c_low = col.lower()
if any(w in c_low for w in ("преподават", "руковод", "куратор", "наставник", "фио")) and "Преподаватель" not in col_map:
col_map["Преподаватель"] = col
elif "групп" in c_low and "Группы" not in col_map:
col_map["Группы"] = col
elif any(w in c_low for w in ("проект", "назван", "тема")) and "Дисциплина" not in col_map:
col_map["Дисциплина"] = col
elif "дисциплин" in c_low and "Дисциплина" not in col_map:
col_map["Дисциплина"] = col
elif "тип занят" in c_low and "Тип занятий" not in col_map:
col_map["Тип занятий"] = col
# Fallback по позициям колонок
if data.shape[1] >= 9:
if "Преподаватель" not in col_map:
col_map["Преподаватель"] = data.columns[1]
if "Группы" not in col_map:
col_map["Группы"] = data.columns[3]
if "Дисциплина" not in col_map:
col_map["Дисциплина"] = data.columns[8]
if "Тип занятий" not in col_map:
col_map["Тип занятий"] = data.columns[7]
elif data.shape[1] == 3:
# Формат: Учебная группа | Название проекта | Руководители проекта
if "Группы" not in col_map:
col_map["Группы"] = data.columns[0]
if "Дисциплина" not in col_map:
col_map["Дисциплина"] = data.columns[1]
if "Преподаватель" not in col_map:
col_map["Преподаватель"] = data.columns[2]
out = pd.DataFrame()
out["Преподаватель"] = data[col_map["Преподаватель"]].map(clean_text) if "Преподаватель" in col_map else ""
out["Группы"] = data[col_map["Группы"]].map(clean_text) if "Группы" in col_map else ""
out["Дисциплина"] = data[col_map["Дисциплина"]].map(clean_text) if "Дисциплина" in col_map else ""
out["Тип занятий"] = data[col_map["Тип занятий"]].map(clean_text) if "Тип занятий" in col_map else ""
return out
# ==============================================================================
# 5. ОСНОВНАЯ ЛОГИКА СБОРКИ ДАННЫХ
# ==============================================================================
def process_all_files(
input_dir: Path,
ref_path: Optional[Path] = None,
cpd_path: Optional[Path] = None,
academic_year_short: int = CURRENT_ACADEMIC_YEAR_SHORT,
only_ochnaya: bool = ONLY_OCHNAYA,
exclude_graduating: bool = EXCLUDE_GRADUATING_BACHELORS_AND_MASTERS,
deduplicate_multi_dept: bool = DEDUPLICATE_MULTI_DEPT_TEACHERS,
) -> Tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame]:
hist_group_map, hist_teacher_disc_map, hist_teacher_kaf_map, ref_fv_teachers = load_historical_reference(ref_path)
excel_files = sorted(
[
f for f in input_dir.iterdir()
if f.is_file()
and f.suffix.lower() in (".xlsx", ".xls")
and not f.name.startswith("~$")
and "форма сбора данных" not in f.name.lower()
]
)
if not excel_files:
raise FileNotFoundError(f"В папке '{input_dir.resolve()}' не найдено файлов расписания (.xlsx / .xls).")
logging.info(f"Найдено файлов кафедр для обработки: {len(excel_files)}")
raw_main_rows: List[Dict[str, str]] = []
fv_groups_set: Set[Tuple[str, str, str]] = set()
# Инициализируем список ППС кафедры ФВ полным штатным списком (+ из эталона),
# и дополнительно пополняем всеми преподавателями физкультуры из текущей выгрузки
fv_teachers_set: Set[str] = set(DEFAULT_FV_TEACHERS) | set(ref_fv_teachers)
# Словарь проектов ЦПД: (Учебная группа, Название проекта) -> set(Руководители проекта)
cpd_projects_map: Dict[Tuple[str, str], Set[str]] = defaultdict(set)
for fpath in excel_files:
kafedra, fac_pps = infer_kafedra_from_filename(fpath)
df_raw = read_department_file(fpath)
if fac_pps == "НЕ ОПРЕДЕЛЕН" and not df_raw.empty:
grp_fac_counter: Counter = Counter()
for g_str in df_raw["Группы"]:
for g_code in re.split(r"[|,;\s]+", str(g_str)):
g_code = g_code.strip()
if g_code:
p = parse_group_info(g_code, academic_year_short, hist_group_map)
if p and p[0] != "НЕ ОПРЕДЕЛЕН":
grp_fac_counter[p[0]] += 1
if grp_fac_counter:
fac_pps = grp_fac_counter.most_common(1)[0][0]
else:
fac_pps = "ФБК"
logging.info(f"Обработка: {fpath.name} -> Кафедра: {kafedra} | Факультет: {fac_pps}")
for _, row in df_raw.iterrows():
teacher_raw = clean_text(row["Преподаватель"])
teacher_fio = strip_kafedra_from_teacher(teacher_raw)
groups_str = clean_text(row["Группы"])
discipline = clean_text(row["Дисциплина"])
if not discipline and not groups_str and not teacher_fio:
continue
# Если это файл кафедры ФВ или дисциплина ФВ — сразу добавляем преподавателей в список ППС ФВ
if (kafedra == "ФВ" or is_fv_discipline(discipline, kafedra)) and teacher_fio:
for part_t in re.split(r"[;|]+", teacher_fio):
part_t = strip_kafedra_from_teacher(part_t)
if part_t and "вакансия" not in part_t.lower():
fv_teachers_set.add(part_t)
if not groups_str or not discipline:
continue
group_list = [g.strip() for g in re.split(r"[|,;\s]+", groups_str) if g.strip()]
# ------------------------------------------------------------------
# ОБРАБОТКА ФАЙЛА ЦПД («Центр проектной деятельности») -> ЛИСТ 3
# ------------------------------------------------------------------
if kafedra == "ЦПД":
disc_low = discipline.lower().strip()
# «Управление проектами» — обычная лекционная дисциплина ЦПД (идет на Лист 1)
if disc_low != "управление проектами":
if disc_low != "проектная деятельность" and teacher_fio and "вакансия" not in teacher_fio.lower():
# Разбиваем руководителей, если в одной ячейке указано несколько через ';'
sup_list = [
strip_kafedra_from_teacher(s)
for s in re.split(r"[;|]+", teacher_raw)
if strip_kafedra_from_teacher(s) and "вакансия" not in s.lower()
]
for grp in group_list:
parsed = parse_group_info(grp, academic_year_short, hist_group_map)
if not parsed:
continue
_, form_edu, _, _ = parsed
if only_ochnaya and form_edu != "Очная":
continue
for sup in sup_list:
cpd_projects_map[(grp, discipline)].add(sup)
continue
# ------------------------------------------------------------------
# ОБРАБОТКА ОБЫЧНЫХ КАФЕДР -> ЛИСТ 1 И ЛИСТ 2
# ------------------------------------------------------------------
for grp in group_list:
parsed = parse_group_info(grp, academic_year_short, hist_group_map)
if not parsed:
continue
fac_group, form_edu, level_edu, course = parsed
if fac_group == "НЕ ОПРЕДЕЛЕН":
fac_group = fac_pps
if only_ochnaya and form_edu != "Очная":
continue
if exclude_graduating:
if level_edu == "Бакалавриат" and course >= 4:
continue
if level_edu == "Магистратура" and course >= 2:
continue
# Дисциплины физкультуры -> Лист 2
if is_fv_discipline(discipline, kafedra):
if level_edu != "Магистратура":
fv_groups_set.add((fac_group, grp, "Элективные дисциплины по физической культуре и спорту"))
if teacher_fio and "вакансия" not in teacher_fio.lower():
fv_teachers_set.add(teacher_fio)
continue
# Исключаем «Проектную деятельность», практики и ГИА из Листа 1
if is_excluded_discipline(discipline) or not teacher_fio or "вакансия" in teacher_fio.lower():
continue
raw_main_rows.append(
{
"Факультет/институт группы": fac_group,
"Группа": grp,
"Форма обучения": form_edu,
"Уровень образования": level_edu,
"Наименование дисциплины": discipline,
"ФИО": teacher_fio,
"Преподаватель": f"{teacher_fio} ({kafedra})",
"Кафедра ": kafedra,
"Факультет/институт ППС": fac_pps,
"Примечание": "",
}
)
df_main = pd.DataFrame(raw_main_rows)
if not df_main.empty:
df_main = df_main.drop_duplicates(
subset=["Группа", "Наименование дисциплины", "ФИО", "Кафедра "]
).copy()
if deduplicate_multi_dept:
df_main = _resolve_multi_department_duplicates(
df_main, hist_teacher_disc_map, hist_teacher_kaf_map
)
df_main = df_main.sort_values(
by=["Группа", "Кафедра ", "Наименование дисциплины", "Преподаватель"],
kind="mergesort",
).reset_index(drop=True)
df_main = df_main[
[
"Факультет/институт группы",
"Группа",
"Форма обучения",
"Уровень образования",
"Наименование дисциплины",
"Преподаватель",
"Кафедра ",
"Факультет/институт ППС",
"Примечание",
]
]
else:
df_main = pd.DataFrame(
columns=[
"Факультет/институт группы", "Группа", "Форма обучения",
"Уровень образования", "Наименование дисциплины",
"Преподаватель", "Кафедра ", "Факультет/институт ППС", "Примечание",
]
)
# Дополняем список групп ФВ (1-3 курс бакалавриата/специалитета + ИГРИК), если в выгрузке не все группы
if not df_main.empty and not fv_groups_set:
for _, r in df_main[["Факультет/институт группы", "Группа", "Уровень образования"]].drop_duplicates().iterrows():
if r["Уровень образования"] != "Магистратура":
parsed = parse_group_info(r["Группа"], academic_year_short, hist_group_map)
if parsed and (parsed[3] <= 3 or r["Факультет/институт группы"] == "ИГРИК"):
fv_groups_set.add(
(r["Факультет/институт группы"], r["Группа"], "Элективные дисциплины по физической культуре и спорту")
)
df_fv_groups = pd.DataFrame(
sorted(list(fv_groups_set), key=lambda x: (x[0], x[1])),
columns=["Факультет", "Группа", "Дисциплина"],
)
df_fv_groups["Проверено"] = ""
df_fv_teachers = pd.DataFrame(
sorted(list(fv_teachers_set)),
columns=["Преподаватель"],
)
df_fv_teachers["Проверено "] = ""
# Сборка Листа 3 «ЦПД »
df_cpd = _build_cpd_dataframe(cpd_path, cpd_projects_map, academic_year_short, hist_group_map, only_ochnaya)
return df_main, df_fv_groups, df_fv_teachers, df_cpd
def _resolve_multi_department_duplicates(
df: pd.DataFrame,
hist_teacher_disc_map: Dict[Tuple[str, str], Tuple[str, str]],
hist_teacher_kaf_map: Dict[str, Set[str]],
) -> pd.DataFrame:
dup_mask = df.duplicated(subset=["Группа", "Наименование дисциплины", "ФИО"], keep=False)
if not dup_mask.any():
return df
df_unique = df[~dup_mask].copy()
df_dups = df[dup_mask].copy()
resolved_rows = []
for (grp, disc, fio), group_df in df_dups.groupby(["Группа", "Наименование дисциплины", "ФИО"], sort=False):
chosen_row = None
if (fio, disc) in hist_teacher_disc_map:
hist_kaf, _ = hist_teacher_disc_map[(fio, disc)]
m_hist = group_df[group_df["Кафедра "] == hist_kaf]
if len(m_hist) == 1:
chosen_row = m_hist.iloc[0].to_dict()
if chosen_row is None:
m_sfx = re.match(r"^\d{3}-(\d{2})", str(grp))
if m_sfx:
pref2 = m_sfx.group(1)
expected_kaf = GROUP_PREFIX2_TO_KAFEDRA.get(pref2)
if expected_kaf:
m_kaf = group_df[group_df["Кафедра "] == expected_kaf]
if len(m_kaf) == 1:
chosen_row = m_kaf.iloc[0].to_dict()
if chosen_row is None:
fac_grp = group_df.iloc[0]["Факультет/институт группы"]
m_fac = group_df[group_df["Факультет/институт ППС"] == fac_grp]
if len(m_fac) == 1:
chosen_row = m_fac.iloc[0].to_dict()
elif len(m_fac) > 1:
chosen_row = m_fac.iloc[0].to_dict()
elif len(m_fac) == 0 and fac_grp != "ПИШ ТЛ" and "ПИШ ТЛ" in group_df["Кафедра "].values:
non_pish = group_df[group_df["Кафедра "] != "ПИШ ТЛ"]
if len(non_pish) >= 1:
chosen_row = non_pish.iloc[0].to_dict()
if chosen_row is None:
chosen_row = group_df.iloc[0].to_dict()
resolved_rows.append(chosen_row)
return pd.concat([df_unique, pd.DataFrame(resolved_rows)], ignore_index=True)
def _build_cpd_dataframe(
cpd_path: Optional[Path],
cpd_projects_map: Dict[Tuple[str, str], Set[str]],
academic_year_short: int,
hist_group_map: Dict[str, Tuple[str, str, str]],
only_ochnaya: bool,
) -> pd.DataFrame:
"""
Формирует таблицу для Листа 3 «ЦПД » точно как на фото:
Колонки: ['Учебная группа', 'Название проекта', 'Руководители проекта'].
Если у одного проекта в группе несколько руководителей — объединяет их через '; ' в алфавитном порядке.
"""
if cpd_path and cpd_path.exists():
df_ext = read_department_file(cpd_path)
for _, row in df_ext.iterrows():
teacher_raw = clean_text(row["Преподаватель"])
groups_str = clean_text(row["Группы"])
discipline = clean_text(row["Дисциплина"])
if not groups_str or not discipline or not teacher_raw:
continue
disc_low = discipline.lower().strip()
if disc_low in ("управление проектами", "проектная деятельность"):
continue
sup_list = [
strip_kafedra_from_teacher(s)
for s in re.split(r"[;|]+", teacher_raw)
if strip_kafedra_from_teacher(s) and "вакансия" not in s.lower()
]
for grp in [g.strip() for g in re.split(r"[|,;\s]+", groups_str) if g.strip()]:
parsed = parse_group_info(grp, academic_year_short, hist_group_map)
if parsed and (not only_ochnaya or parsed[1] == "Очная"):
for sup in sup_list:
cpd_projects_map[(grp, discipline)].add(sup)
if not cpd_projects_map:
return pd.DataFrame(columns=["Учебная группа", "Название проекта", "Руководители проекта"])
rows = []
for (grp, proj_name), supervisors in cpd_projects_map.items():
sup_joined = "; ".join(sorted(supervisors))
rows.append(
{
"Учебная группа": grp,
"Название проекта": proj_name,
"Руководители проекта": sup_joined,
}
)
df_cpd = pd.DataFrame(rows)
df_cpd = df_cpd.sort_values(
by=["Учебная группа", "Название проекта"],
kind="mergesort",
).reset_index(drop=True)
return df_cpd
# ==============================================================================
# 6. ОФОРМЛЕНИЕ И СОХРАНЕНИЕ EXCEL-КНИГИ
# ==============================================================================
def export_to_excel(
output_path: Path,
df_main: pd.DataFrame,
df_fv_groups: pd.DataFrame,
df_fv_teachers: pd.DataFrame,
df_cpd: pd.DataFrame,
) -> None:
wb = openpyxl.Workbook()
font_header_12 = Font(name="Calibri", size=12, bold=True, color="1F2937")
font_body_12 = Font(name="Calibri", size=12, bold=False, color="111827")
font_header_11 = Font(name="Calibri", size=11, bold=True, color="1F2937")
font_body_11 = Font(name="Calibri", size=11, bold=False, color="111827")
font_cpd_header = Font(name="Tahoma", size=8, bold=True, color="000000")
font_cpd_body = Font(name="Calibri", size=11, bold=False, color="000000")
fill_header = PatternFill(start_color="E8EEF5", end_color="E8EEF5", fill_type="solid")
fill_sub = PatternFill(start_color="F3F6FA", end_color="F3F6FA", fill_type="solid")
fill_zebra = PatternFill(start_color="F9FAFB", end_color="F9FAFB", fill_type="solid")
thin_side = Side(style="thin", color="D1D5DB")
med_bottom = Side(style="medium", color="9CA3AF")
border_data = Border(left=thin_side, right=thin_side, top=thin_side, bottom=thin_side)
border_header = Border(left=thin_side, right=thin_side, top=thin_side, bottom=med_bottom)
align_left = Alignment(horizontal="left", vertical="center", wrap_text=False)
align_center = Alignment(horizontal="center", vertical="center", wrap_text=False)
align_cpd_header = Alignment(horizontal="center", vertical="center", wrap_text=True)
align_cpd_group = Alignment(horizontal="left", vertical="top", wrap_text=False)
align_cpd_wrap = Alignment(horizontal="left", vertical="top", wrap_text=True)
# --------------------------------------------------------------------------
# ЛИСТ 1: «Общая (кроме ФВ, ЦПД)» (НЕ ТРОГАЕМ!)
# --------------------------------------------------------------------------
ws1 = wb.active
ws1.title = "Общая (кроме ФВ, ЦПД)"
ws1.views.sheetView[0].showGridLines = True
headers_s1 = [
"Факультет/институт группы", "Группа", "Форма обучения",
"Уровень образования", "Наименование дисциплины",
"Преподаватель", "Кафедра ", "Факультет/институт ППС", "Примечание",
]
ws1.append(headers_s1)
ws1.row_dimensions[1].height = 24
for col_idx in range(1, len(headers_s1) + 1):
cell = ws1.cell(row=1, column=col_idx)
cell.font = font_header_12
cell.fill = fill_header
cell.border = border_header
cell.alignment = align_center if col_idx in (1, 2, 3, 4, 7, 8) else align_left
for row_idx, r in enumerate(df_main.itertuples(index=False), start=2):
ws1.append(list(r))
is_even = row_idx % 2 == 0
for col_idx in range(1, len(headers_s1) + 1):
c = ws1.cell(row=row_idx, column=col_idx)
c.font = font_body_12
c.border = border_data
c.alignment = align_center if col_idx in (1, 2, 3, 4, 7, 8) else align_left
if is_even:
c.fill = fill_zebra
ws1.auto_filter.ref = f"A1:I{max(2, ws1.max_row)}"
ws1.freeze_panes = "A2"
col_widths_s1 = {
"A": 25.0, "B": 14.0, "C": 16.0, "D": 21.0,
"E": 55.0, "F": 42.0, "G": 20.0, "H": 24.0, "I": 26.0,
}
for col_letter, width in col_widths_s1.items():
ws1.column_dimensions[col_letter].width = width
# --------------------------------------------------------------------------
# ЛИСТ 2: «Список групп и ППС ФВ»
# --------------------------------------------------------------------------
ws2 = wb.create_sheet(title="Список групп и ППС ФВ")
ws2.views.sheetView[0].showGridLines = True
ws2.cell(row=2, column=1, value="Список групп ").font = font_header_11
ws2.cell(row=2, column=1).fill = fill_sub
ws2.cell(row=2, column=7, value="Список ППС каф. ФВ ").font = font_header_11
ws2.cell(row=2, column=7).fill = fill_sub
ws2.row_dimensions[2].height = 20
left_headers = ["Факультет", "Группа", "Дисциплина", "Проверено"]
for idx, text in enumerate(left_headers, start=1):
c = ws2.cell(row=3, column=idx, value=text)
c.font = font_header_12
c.fill = fill_header
c.border = border_header
c.alignment = align_center if idx in (1, 2, 4) else align_left
right_headers = ["Преподаватель", "Проверено "]
for idx, text in enumerate(right_headers, start=7):
c = ws2.cell(row=3, column=idx, value=text)
c.font = font_header_11
c.fill = fill_header
c.border = border_header
c.alignment = align_center if idx == 8 else align_left
ws2.row_dimensions[3].height = 22
for r_offset, r in enumerate(df_fv_groups.itertuples(index=False), start=4):
ws2.cell(row=r_offset, column=1, value=r[0])
ws2.cell(row=r_offset, column=2, value=r[1])
ws2.cell(row=r_offset, column=3, value=r[2])
ws2.cell(row=r_offset, column=4, value=r[3] if r[3] else None)
for c_idx in range(1, 5):
c = ws2.cell(row=r_offset, column=c_idx)
c.font = font_body_11 if c_idx == 3 else font_body_12
c.border = border_data
c.alignment = align_center if c_idx in (1, 2, 4) else align_left
for r_offset, r in enumerate(df_fv_teachers.itertuples(index=False), start=4):
ws2.cell(row=r_offset, column=7, value=r[0])
ws2.cell(row=r_offset, column=8, value=r[1] if r[1] else None)
for c_idx in (7, 8):
c = ws2.cell(row=r_offset, column=c_idx)
c.font = font_body_11
c.border = border_data
c.alignment = align_center if c_idx == 8 else align_left
ws2.auto_filter.ref = f"A3:D{max(3, len(df_fv_groups) + 3)}"
ws2.freeze_panes = "A4"
col_widths_s2 = {
"A": 14.0, "B": 14.0, "C": 70.0, "D": 14.0,
"E": 13.0, "F": 13.0, "G": 42.0, "H": 14.0,
}
for col_letter, width in col_widths_s2.items():
ws2.column_dimensions[col_letter].width = width
# --------------------------------------------------------------------------
# ЛИСТ 3: «ЦПД » (ТОЧНО КАК НА ФОТО)
# --------------------------------------------------------------------------
ws3 = wb.create_sheet(title="ЦПД ")
ws3.views.sheetView[0].showGridLines = True
headers_s3 = ["Учебная группа", "Название проекта", "Руководители проекта"]
ws3.append(headers_s3)
ws3.row_dimensions[1].height = 24
for col_idx in range(1, 4):
c = ws3.cell(row=1, column=col_idx)
c.font = font_cpd_header
c.alignment = align_cpd_header
for row_idx, r in enumerate(df_cpd.itertuples(index=False), start=2):
ws3.append(list(r))
c_grp = ws3.cell(row=row_idx, column=1)
c_proj = ws3.cell(row=row_idx, column=2)
c_sup = ws3.cell(row=row_idx, column=3)
c_grp.font = font_cpd_body
c_proj.font = font_cpd_body
c_sup.font = font_cpd_body
c_grp.alignment = align_cpd_group
c_proj.alignment = align_cpd_wrap
c_sup.alignment = align_cpd_wrap
ws3.auto_filter.ref = f"A1:C{max(1, ws3.max_row)}"
ws3.freeze_panes = "A2"
col_widths_s3 = {"A": 10.0, "B": 48.0, "C": 52.0}
for col_letter, width in col_widths_s3.items():
ws3.column_dimensions[col_letter].width = width
output_path.parent.mkdir(parents=True, exist_ok=True)
wb.save(str(output_path))
logging.info(f"Файл успешно сохранён: {output_path.resolve()}")
def main() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(message)s",
datefmt="%H:%M:%S",
)
parser = argparse.ArgumentParser(
description="Сборка формы анкетирования из 43 файлов выгрузки расписания по кафедрам."
)
parser.add_argument(
"--input-dir",
"-i",
type=Path,
default=Path("./выгрузка"),
help="Папка с 43 файлами выгрузки по кафедрам",
)
parser.add_argument(
"--output",
"-o",
type=Path,
default=Path("Форма сбора данных для анкетирования 2026.xlsx"),
help="Путь к итоговому файлу Excel",
)
parser.add_argument(
"--ref",
"-r",
type=Path,
default=Path("Форма сбора данных для анкетирования ЗАПОЛНЕНО 2.0.xlsx"),
help="Прошлогодний файл для сверки (необязательно)",
)
parser.add_argument(
"--cpd",
type=Path,
default=None,
help="Отдельный файл выгрузки проектов ЦПД (необязательно)",
)
parser.add_argument(
"--year",
type=int,
default=CURRENT_ACADEMIC_YEAR_SHORT,
help="Две последние цифры учебного года (26 для 2026/2027)",
)
parser.add_argument(
"--all-forms",
action="store_true",
help="Включить все формы обучения (по умолчанию только Очная)",
)
parser.add_argument(
"--exclude-graduating",
action="store_true",
help="Исключить выпускные курсы",
)
args = parser.parse_args()
input_dir: Path = args.input_dir
if not input_dir.exists():
input_dir = Path(".")
ref_path = args.ref if args.ref and args.ref.exists() else None
df_main, df_fv_groups, df_fv_teachers, df_cpd = process_all_files(
input_dir=input_dir,
ref_path=ref_path,
cpd_path=args.cpd,
academic_year_short=args.year,
only_ochnaya=not args.all_forms,
exclude_graduating=args.exclude_graduating,
deduplicate_multi_dept=DEDUPLICATE_MULTI_DEPT_TEACHERS,
)
export_to_excel(
output_path=args.output,
df_main=df_main,
df_fv_groups=df_fv_groups,
df_fv_teachers=df_fv_teachers,
df_cpd=df_cpd,
)
logging.info(
f"\nУспешно собрано:\n"
f" - Лист 1: {len(df_main)} строк ({df_main['Группа'].nunique() if not df_main.empty else 0} групп)\n"
f" - Лист 2: {len(df_fv_groups)} групп, {len(df_fv_teachers)} преподавателей ФВ\n"
f" - Лист 3: {len(df_cpd)} строк"
)
if __name__ == "__main__":
main()