Загрузка данных


#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Скрипт для автоматической сборки «Формы сбора данных для анкетирования»
из выгрузки расписания по кафедрам (43 файла Excel).
"""

from __future__ import annotations

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

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 = (
    "учебная практика",
    "производственная практика",
    "преддипломная практика",
    "научно-исследовательская работа",
    "государственная итоговая аттестация",
    "подготовка к процедуре защиты",
)

GROUP_SUFFIX_TO_FACULTY: Dict[str, str] = {
    "10": "ТФ",
    "0": "ФИДИЖ",
    "1": "ТФ",
    "2": "ФМ",
    "3": "ФИТ",
    "4": "ФУиГХ",
    "5": "ФХТиБ",
    "6": "ФЭиУ",
    "7": "ПФ",
    "8": "ИГРИК",
    "9": "ПИШ ТЛ",
}

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"), "ПИШ ТЛ", "ПИШ ТЛ"),
    (("физическое воспитание", "физической культуры", "кафедра фв", "фв"), "ФВ", "ФБК"),
    (("центр проектной деятельности", "цпд"), "ЦПД", "ЦПД"),
    (("иллюстрация и эстамп", "ииэ"), "ИиЭ", "ИГРИК"),
    (("рисунок и живопись", "риж"), "РиЖ", "ИГРИК"),
    (("художественно-техническое оформление", "хтопп"), "ХТОПП", "ИГРИК"),
    (("инновационные материалы принтмедиа", "имп"), "ИМП", "ПФ"),
    (("полиграфические системы", "кафедра пс"), "ПС", "ПФ"),
    (("технологии и управление качеством в полиграф", "тиуквпиуп"), "ТиУКвПиУП", "ПФ"),
    (("динамика, прочность машин", "динамика прочность", "дпмисм"), "ДПМиСМ", "ТФ"),
    (("наземные транспортные средства", "нтс"), "НТС", "ТФ"),
    (("энергоустановки для транспорта", "эдтимэ"), "ЭдТиМЭ", "ТФ"),
    (("дизайн",), "Дизайн", "ТФ"),
    (("гуманитарные дисциплины", "кафедра гд"), "ГД", "ФБК"),
    (("иностранные языки", "иностранных языков", "кафедра ия"), "ИЯ", "ФБК"),
    (("высшая математика", "математика", "математики"), "Математика", "ФБК"),
    (("теория механизмов", "техническая механика", "тмикм"), "ТМиКМ", "ФБК"),
    (("физика", "физики"), "Физика", "ФБК"),
    (("журналистика и массовые коммуникации", "жимк"), "ЖиМК", "ФИДИЖ"),
    (("издательское дело и книговедение", "идик"), "ИДиК", "ФИДИЖ"),
    (("русский язык и литература", "русский язык и история", "ряил"), "РЯиЛ", "ФИДИЖ"),
    (("информационная безопасность", "кафедра иб"), "ИБ", "ФИТ"),
    (("инфокогнитивные технологии", "икт"), "ИКТ", "ФИТ"),
    (("информатика и информационные технологии", "ииит"), "ИиИТ", "ФИТ"),
    (("прикладная информатика", "кафедра пи"), "ПИ", "ФИТ"),
    (("смарт-технологии", "смарт технологии", "смарт"), "СМАРТ", "ФИТ"),
    (("автоматика и управление", "аиу"), "АиУ", "ФМ"),
    (("материаловедение",), "Материаловедение", "ФМ"),
    (("машины и технология литейного", "митлп"), "МиТЛП", "ФМ"),
    (("обработка материалов давлением", "омдиат"), "ОМДиАТ", "ФМ"),
    (("оборудование и технологии сварочного", "отисп"), "ОТиСП", "ФМ"),
    (("стандартизация, метрология и сертификация", "стандартизация метрология", "смис"), "СМиС", "ФМ"),
    (("технологии и оборудование машиностроения", "технология и оборудование машиностроения", "тиом"), "ТиОМ", "ФМ"),
    (("промышленное, гражданское и подземное", "промышленное гражданское", "пгпс"), "ПГПС", "ФУиГХ"),
    (("промышленная теплоэнергетика", "кафедра пт"), "ПТ", "ФУиГХ"),
    (("электрооборудование и промышленная электроника", "эипэ"), "ЭиПЭ", "ФУиГХ"),
    (("аппаратурное оформление и автоматизация", "аоиатп"), "АОиАТП", "ФХТиБ"),
    (("процессы и аппараты химической", "пахт"), "ПАХТ", "ФХТиБ"),
    (("техника низких температур", "тнт"), "ТНТ", "ФХТиБ"),
    (("химическая и биологическая", "химия и биотехнология", "хбт"), "ХБТ", "ФХТиБ"),
    (("экологическая безопасность технических", "эбтс"), "ЭБТС", "ФХТиБ"),
    (("менеджмент",), "Менеджмент", "ФЭиУ"),
    (("реклама и связи с общественностью", "рисом"), "РиСОМ", "ФЭиУ"),
    (("управление персоналом", "кафедра уп"), "УП", "ФЭиУ"),
    (("экономика и организация", "эио"), "ЭиО", "ФЭиУ"),
]

KAFEDRA_TO_FACULTY: Dict[str, str] = {
    "ИиЭ": "ИГРИК", "РиЖ": "ИГРИК", "ХТОПП": "ИГРИК", "ПИШ ТЛ": "ПИШ ТЛ",
    "ИМП": "ПФ", "ПС": "ПФ", "ТиУКвПиУП": "ПФ", "ДПМиСМ": "ТФ",
    "Дизайн": "ТФ", "НТС": "ТФ", "ЭдТиМЭ": "ТФ", "ГД": "ФБК",
    "ИЯ": "ФБК", "Математика": "ФБК", "ТМиКМ": "ФБК", "Физика": "ФБК",
    "ФВ": "ФБК", "ЖиМК": "ФИДИЖ", "ИДиК": "ФИДИЖ", "РЯиЛ": "ФИДИЖ",
    "ИБ": "ФИТ", "ИКТ": "ФИТ", "ИиИТ": "ФИТ", "ПИ": "ФИТ",
    "СМАРТ": "ФИТ", "АиУ": "ФМ", "Материаловедение": "ФМ", "МиТЛП": "ФМ",
    "ОМДиАТ": "ФМ", "ОТиСП": "ФМ", "СМиС": "ФМ", "ТиОМ": "ФМ",
    "ПГПС": "ФУиГХ", "ПТ": "ФУиГХ", "ЭиПЭ": "ФУиГХ", "АОиАТП": "ФХТиБ",
    "ПАХТ": "ФХТиБ", "ТНТ": "ФХТиБ", "ХБТ": "ФХТиБ", "ЭБТС": "ФХТиБ",
    "Менеджмент": "ФЭиУ", "РиСОМ": "ФЭиУ", "УП": "ФЭиУ", "ЭиО": "ФЭиУ",
    "ЦПД": "ЦПД",
}


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 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_no_num.lower() == code.lower():
            return 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:
            return abbr, KAFEDRA_TO_FACULTY[abbr]

    logging.warning(f"Кафедра для файла '{filepath.name}' определена как '{stem_no_num}'")
    return stem_no_num, "НЕ ОПРЕДЕЛЕН"


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{3,4}[а-яА-Яa-zA-Z]*)$", 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:
        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)


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]],
]:
    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]] = {}

    if not ref_path or not ref_path.exists():
        return group_map, teacher_disc_map, teacher_kaf_map

    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("Кафедра"))
            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)
    except Exception as e:
        logging.warning(f"Не удалось прочитать '{ref_path}': {e}")

    return group_map, teacher_disc_map, teacher_kaf_map


def read_department_file(filepath: Path) -> pd.DataFrame:
    df = pd.read_excel(filepath, sheet_name=0, header=None)
    if df.empty:
        return pd.DataFrame(columns=["Преподаватель", "Группы", "Дисциплина", "Тип занятий"])

    header_row_idx = 0
    for r_idx in range(min(10, len(df))):
        row_values = [clean_text(v).lower() for v in df.iloc[r_idx].tolist()]
        if "преподаватель" in row_values or "группы" in row_values or "дисциплина" in row_values:
            header_row_idx = r_idx
            break

    headers = [clean_text(v) for v in df.iloc[header_row_idx].tolist()]
    data = df.iloc[header_row_idx + 1 :].copy()
    data.columns = headers

    col_map: Dict[str, str] = {}
    for col in data.columns:
        c_low = col.lower()
        if "преподават" in c_low 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
        elif "тип занят" in c_low and "Тип занятий" not in col_map:
            col_map["Тип занятий"] = col

    if "Преподаватель" not in col_map and data.shape[1] > 1:
        col_map["Преподаватель"] = data.columns[1]
    if "Группы" not in col_map and data.shape[1] > 3:
        col_map["Группы"] = data.columns[3]
    if "Дисциплина" not in col_map and data.shape[1] > 8:
        col_map["Дисциплина"] = data.columns[8]
    if "Тип занятий" not in col_map and data.shape[1] > 7:
        col_map["Тип занятий"] = data.columns[7]

    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


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 = 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()
    cpd_rows_from_schedule: List[Tuple[str, str, str]] = []

    for fpath in excel_files:
        kafedra, fac_pps = infer_kafedra_from_filename(fpath)
        logging.info(f"Обработка: {fpath.name} -> {kafedra} ({fac_pps})")
        df_raw = read_department_file(fpath)

        for _, row in df_raw.iterrows():
            teacher_fio = strip_kafedra_from_teacher(row["Преподаватель"])
            groups_str = row["Группы"]
            discipline = row["Дисциплина"]

            if not discipline and not groups_str and not teacher_fio:
                continue

            if kafedra == "ФВ" and teacher_fio and "вакансия" not in teacher_fio.lower():
                fv_teachers_set.add(teacher_fio)

            if not groups_str or not discipline:
                continue

            group_list = [g.strip() for g in re.split(r"[|,;\s]+", groups_str) if g.strip()]

            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 only_ochnaya and form_edu != "Очная":
                    continue

                if exclude_graduating:
                    if level_edu == "Бакалавриат" and course >= 4:
                        continue
                    if level_edu == "Магистратура" and course >= 2:
                        continue

                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

                if discipline.lower().strip() == "проектная деятельность":
                    if teacher_fio and "вакансия" not in teacher_fio.lower():
                        cpd_rows_from_schedule.append((grp, "Проектная деятельность", teacher_fio))
                    continue

                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=[
                "Факультет/институт группы", "Группа", "Форма обучения",
                "Уровень образования", "Наименование дисциплины",
                "Преподаватель", "Кафедра ", "Факультет/институт ППС", "Примечание",
            ]
        )

    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["Проверено "] = ""

    df_cpd = _load_or_build_cpd(cpd_path, ref_path, cpd_rows_from_schedule)

    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:
            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) == 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()
            all_kafs = ", ".join(group_df["Кафедра "].tolist())
            chosen_row["Примечание"] = f"Проверить кафедру ({all_kafs})"

        resolved_rows.append(chosen_row)

    return pd.concat([df_unique, pd.DataFrame(resolved_rows)], ignore_index=True)


def _load_or_build_cpd(
    cpd_path: Optional[Path],
    ref_path: Optional[Path],
    cpd_rows_from_schedule: List[Tuple[str, str, str]],
) -> pd.DataFrame:
    if cpd_path and cpd_path.exists():
        df_cpd = pd.read_excel(cpd_path, sheet_name=0)
        if len(df_cpd.columns) >= 3:
            df_cpd = df_cpd.iloc[:, :3].copy()
            df_cpd.columns = ["Учебная группа", "Название проекта", "Руководители проекта"]
            return df_cpd.drop_duplicates().sort_values(by=["Учебная группа", "Название проекта"]).reset_index(drop=True)

    if cpd_rows_from_schedule:
        df_cpd = pd.DataFrame(
            cpd_rows_from_schedule,
            columns=["Учебная группа", "Название проекта", "Руководители проекта"],
        )
        return df_cpd.drop_duplicates().sort_values(by=["Учебная группа", "Название проекта"]).reset_index(drop=True)

    return pd.DataFrame(columns=["Учебная группа", "Название проекта", "Руководители проекта"])


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=9, bold=True, color="1F2937")

    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")
    fill_warn = PatternFill(start_color="FEF3C7", end_color="FEF3C7", 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)

    # ЛИСТ 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
        has_note = bool(clean_text(r[8]))
        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 has_note:
                c.fill = fill_warn
            elif 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": 6.0, "F": 6.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 = 22

    for col_idx in range(1, 4):
        c = ws3.cell(row=1, column=col_idx)
        c.font = font_cpd_header
        c.fill = fill_header
        c.border = border_header
        c.alignment = align_center if col_idx == 1 else align_left

    for row_idx, r in enumerate(df_cpd.itertuples(index=False), start=2):
        ws3.append(list(r))
        for col_idx in range(1, 4):
            c = ws3.cell(row=row_idx, column=col_idx)
            c.font = font_body_11
            c.border = border_data
            c.alignment = align_center if col_idx == 1 else align_left

    ws3.auto_filter.ref = f"A1:C{max(1, ws3.max_row)}"
    ws3.freeze_panes = "A2"

    col_widths_s3 = {"A": 16.0, "B": 52.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()