From 2b9e54580ba92959c86933b1e5a162bf80e43df4 Mon Sep 17 00:00:00 2001 From: manoraga Date: Tue, 28 Jul 2026 13:57:50 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=20=D0=BD=D0=B0?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=BE=D0=B9=20=D0=B2=D1=8B?= =?UTF-8?q?=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8=20=D0=B8=D0=B7=20=D0=A1?= =?UTF-8?q?=D0=9A=D0=A3=D0=94,=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20ps?= =?UTF-8?q?1.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/static_reason_workers.csv | 10 +++ exceptions.json | 5 +- main.py | 146 ++++++++++++++++++++++++++++----- modules/data_loader.py | 7 +- modules/excel_exporter.py | 88 +++++++++++++++----- 5 files changed, 208 insertions(+), 48 deletions(-) create mode 100644 data/static_reason_workers.csv diff --git a/data/static_reason_workers.csv b/data/static_reason_workers.csv new file mode 100644 index 0000000..fdb4137 --- /dev/null +++ b/data/static_reason_workers.csv @@ -0,0 +1,10 @@ +fio,reason,note +Королёва Наталья Александровна,Удаленная работа,Постоянная удаленка +Николаева Ирина Леонидовна,Удаленная работа,Постоянная удаленка +Познякова Татьяна Сергеевна,Удаленная работа,Постоянная удаленка +Софьин Никита Сергеевич,Удаленная работа,Постоянная удаленка +Чуб Александр Васильевич,Удаленная работа,Постоянная удаленка +Шуличенко Иван Иванович,Удаленная работа,Постоянная удаленка +Пухаренко Юрий Владимирович,Удаленная работа,Постоянная удаленка +Пшеничный Виктор Петрович,Удаленная работа,Постоянная удаленка +Незнанова Валерия Игоревна,Удаленная работа,Постоянная удаленка \ No newline at end of file diff --git a/exceptions.json b/exceptions.json index 1b20130..742ba5f 100644 --- a/exceptions.json +++ b/exceptions.json @@ -7,9 +7,8 @@ "Уборщик служебных помещений" ], "fio": [ - "Шуличенко Иван Иванович", - "Таткало Валерий Валерьевич", - "Пухаренко Юрий Владимирович", + "Таткало Валерий Валерьевич", + "Петренюк Андрей Германович", "Чуркина Елена Геннадьевна", "Михалев Сергей Геннадьевич" ], diff --git a/main.py b/main.py index ade968a..0c1c9e9 100644 --- a/main.py +++ b/main.py @@ -1,21 +1,76 @@ import os import sys +import argparse import pandas as pd sys.path.append(os.path.dirname(os.path.abspath(__file__))) -from config import DATE_TODAY, DATE_YESTERDAY, OUTPUT_DIR +from config import DATE_TODAY, DATE_YESTERDAY, OUTPUT_DIR, DATA_DIR, normalize_fio from modules.data_validator import check_file_freshness from modules.data_loader import load_all_data from modules.excel_exporter import generate_summary_excel, generate_detailed_excel from modules.ai_verifier import ai_verify_scud_against_staff from modules.text_reporter import generate_markdown_report -from modules.knowledge_base import load_knowledge_base +from modules.feedback_loop import review_ai_decisions + + +def load_static_reason_workers(): + """ + Загружает реестр сотрудников со статическими/особыми причинами + из static_reason_workers.csv. + Возвращает словарь: {fio_clean: reason} + """ + static_path = os.path.join(DATA_DIR, "static_reason_workers.csv") + if not os.path.exists(static_path): + return {} + try: + df_static = pd.read_csv(static_path, encoding='utf-8') + if 'fio' in df_static.columns and 'reason' in df_static.columns: + df_static['fio_clean'] = df_static['fio'].apply(normalize_fio) + return dict(zip(df_static['fio_clean'], df_static['reason'])) + except Exception as e: + print(f"[⚠️] Ошибка чтения static_reason_workers.csv: {e}") + return {} + + +def aggregate_scud_by_employee(df, debug=False): + """ + Агрегирует записи СКУД по сотруднику (fio_clean). + Устраняет дубликаты карт/табельных номеров (например, Кондрашова Е.В.): + Если человек пришел хотя бы по одной карте — Пришел = True. + """ + if df.empty or 'fio_clean' not in df.columns: + return df + + aggregated = [] + for fio_clean, group in df.groupby('fio_clean', sort=False): + is_present = group['Пришел'].any() if 'Пришел' in group.columns else False + + if is_present and 'Пришел' in group.columns: + present_rows = group[group['Пришел'] == True] + best_row = present_rows.iloc[0].to_dict() if not present_rows.empty else group.iloc[0].to_dict() + else: + best_row = group.iloc[0].to_dict() + + best_row['Пришел'] = is_present + + if debug and len(group) > 1: + print(f" [DEBUG] Найдено дубликатов карт/записей для {fio_clean}: {len(group)} шт. Статус присутствия: {is_present}") + + aggregated.append(best_row) + + return pd.DataFrame(aggregated) def main(): + parser = argparse.ArgumentParser(description="Система модульного контроллинга СКУД ⟷ 1С") + parser.add_argument('-d', '--debug', action='store_true', help="Запуск в режиме отладки (подробный вывод)") + args = parser.parse_args() + + DEBUG = args.debug + print("=" * 60) - print("ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С") + print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG MODE]' if DEBUG else ''}") print("=" * 60) # 0. Валидация файлов @@ -38,11 +93,18 @@ def main(): print("[✓] Проверка актуальности успешно пройдена!\n") - # 1. Загрузка - print("[1/5] Загрузка данных СКУД, 1С и исключений...") + # 1. Загрузка данных + print("[1/5] Загрузка данных СКУД, 1С, реестра статических причин и исключений...") raw_scud_df, staff_fios_clean, df_absent = load_all_data() + static_reasons_dict = load_static_reason_workers() - # 2. ИИ-сверка + if DEBUG: + print(f" [DEBUG] Загружено записей СКУД: {len(raw_scud_df)}") + print(f" [DEBUG] Загружено сотрудников из 1С: {len(staff_fios_clean)}") + print(f" [DEBUG] Загружено докум. отсутствий 1С: {len(df_absent)}") + print(f" [DEBUG] Загружено статических причин: {len(static_reasons_dict)}") + + # 2. ИИ-сверка ФИО print("[2/5] Проверка опечаток и сопоставление ФИО через ИИ...") scud_unrecognized = raw_scud_df[~raw_scud_df['fio_clean'].isin(staff_fios_clean)]['fio_clean'].tolist() fio_mapping = ai_verify_scud_against_staff(scud_unrecognized, staff_fios_clean) @@ -53,30 +115,55 @@ def main(): lambda x: fio_mapping[x]['staff_fio'] if x in fio_mapping else x ) - # 3. Объединение - print("[3/5] Объединение реестров 1С и СКУД...") + # 2.1 Агрегация нескольких карт/дубликатов + raw_scud_df = aggregate_scud_by_employee(raw_scud_df, debug=DEBUG) + + # 3. Объединение данных и иерархия приоритетов + print("[3/5] Объединение реестров 1С, СКУД, статических причин и исключений...") merged = raw_scud_df[raw_scud_df['fio_clean'].isin(staff_fios_clean)].copy() + + # 🎯 ПРИОРИТЕТ 1: Выгрузка докум. отсутствий 1С:ЗУП (Отсутствия сотрудников - выгрузка.xlsx) merged = merged.merge(df_absent, on='fio_clean', how='left') + # 🎯 ПРИОРИТЕТ 2: Статические причины из static_reason_workers.csv + # Применяются ТОЛЬКО если в 1С:ЗУП документа нет и сотрудник отсутствует в СКУД + if static_reasons_dict: + for fio_clean, reason_val in static_reasons_dict.items(): + mask_apply_static = ( + (merged['Пришел'] == False) & + (merged['Вид_отсутствия'].isna() | (merged['Вид_отсутствия'].astype(str).str.strip() == '')) & + (merged['fio_clean'] == fio_clean) + ) + merged.loc[mask_apply_static, 'Вид_отсутствия'] = reason_val + + # 🎯 ПРИОРИТЕТ 3: Исключения по должности/департаменту/ФИО (exceptions.json) + # Если сотрудник попал в исключения (is_excluded = True), НЕ пришел в СКУД и НЕ имеет документа в 1С: + # Присваиваем ему статус "Исключение (ОВК/Подрядчики)", чтобы он НЕ ПОПАДАЛ в неизвестные! + mask_apply_exceptions = ( + (merged['Пришел'] == False) & + (merged['Вид_отсутствия'].isna() | (merged['Вид_отсутствия'].astype(str).str.strip() == '')) & + (merged.get('is_excluded', False) == True) + ) + merged.loc[mask_apply_exceptions, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)' + absent_explained = merged[(merged['Пришел'] == False) & (merged['Вид_отсутствия'].notna())] absent_unexplained = merged[(merged['Пришел'] == False) & (merged['Вид_отсутствия'].isna())] scud_present_but_absent_in_1c = merged[(merged['Пришел'] == True) & (merged['Вид_отсутствия'].notna())] - # 4. Формирование Excel-файлов (автоматическое наименование вида "27 июля 2026 сводка.xlsx") - print("[4/5] Формирование Excel-файлов...") - - generate_summary_excel( - merged_df=merged, - date_str=DATE_TODAY - ) + if DEBUG: + print("\n--- [DEBUG STATS] ---") + print(f" Всего по списку: {len(merged)}") + print(f" Присутствуют на рабочем месте: {len(merged[merged['Пришел'] == True])}") + print(f" Официально отсутствуют (1С + стат. причины + исключения): {len(absent_explained)}") + print(f" Истинно неизвестные случаи: {len(absent_unexplained)}") + if not absent_unexplained.empty: + print(" Список неизвестных:") + for fio in absent_unexplained['Сотрудник'].dropna().tolist(): + print(f" - {fio}") + print("---------------------\n") - generate_detailed_excel( - merged_df=merged, - date_str=DATE_YESTERDAY - ) - - # 5. Текстовый ИИ-отчет - print("[5/5] Запуск ИИ-аудитора...") + # 4. ИИ-анализ и интерактивная проверка (HITL) + print("[4/5] Запуск ИИ-аудитора и проверка аномалий...") report_text = generate_markdown_report( merged_df=merged, absent_explained=absent_explained, @@ -86,6 +173,21 @@ def main(): date_str=DATE_TODAY ) + suspicious_cases = [] + if not scud_present_but_absent_in_1c.empty: + for _, row in scud_present_but_absent_in_1c.iterrows(): + suspicious_cases.append({ + 'fio_target': row.get('Сотрудник', ''), + 'reason': f"Пришел по СКУД, но в 1С зафиксировано: {row.get('Вид_отсутствия', '')}" + }) + + review_ai_decisions(report_text, suspicious_cases) + + # 5. Формирование Excel и Markdown отчетов + print("[5/5] Формирование итоговых Excel и Markdown отчетов...") + generate_summary_excel(merged_df=merged, date_str=DATE_TODAY) + generate_detailed_excel(merged_df=merged, date_str=DATE_YESTERDAY) + print("\n" + "=" * 60) print("ГОТОВАЯ ТЕКСТОВАЯ СВОДКА ИИ:") print("=" * 60) diff --git a/modules/data_loader.py b/modules/data_loader.py index dd8bdf7..24cc1c4 100644 --- a/modules/data_loader.py +++ b/modules/data_loader.py @@ -57,19 +57,18 @@ def load_scud_data(): val_status = str(row.get(status_col, '')).strip().lower() if status_col else '' val_time = str(row.get(start_col, '')).strip() if start_col else '' - # Разделение: Слово "Отсутствовал" в СКУД или пустая метка времени = ФИЗИЧЕСКИ НЕ БЫЛО if 'отсутствовал' in val_status or val_time in ['нет входа', 'none', 'nan', '', '00:00']: return False - - # Во всех остальных случаях (есть время прохода) считаем, что человек ФИЗИЧЕСКИ переступил порог return True df_scud['Пришел'] = df_scud.apply(is_person_present, axis=1) + + # Отмечаем флаг исключений, но НЕ удаляем сразу df_scud['is_excluded'] = df_scud.apply( lambda r: is_excluded(r.get('Сотрудник', ''), r.get('Подразделение', ''), r.get('Должность', ''), exceptions), axis=1 ) - return df_scud[~df_scud['is_excluded']].copy() + return df_scud def load_all_data(): df_scud = load_scud_data() diff --git a/modules/excel_exporter.py b/modules/excel_exporter.py index 599c8f7..daa5b79 100644 --- a/modules/excel_exporter.py +++ b/modules/excel_exporter.py @@ -33,6 +33,7 @@ FILL_HEADER = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="s FILL_TOTAL_LIST = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid") FILL_UNEXPLAINED = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") FILL_PRESENT = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid") +FILL_ANOMALY = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") CATEGORY_PASTEL_COLORS = ["FFF2CC", "E1D5E7", "E1F5FE", "FFF0F5", "E8F8F5", "FCF3CF"] @@ -45,7 +46,7 @@ def apply_borders_to_cell(cell, border=THIN_BORDER): cell.border = border -def format_row_cells(ws, r_num, fill_obj, is_bold=False, align_b="right", bold_font=None): +def format_row_cells(ws, r_num, fill_obj, is_bold=False, align_b="right", bold_font=None, wrap_b=False): cell_a = ws.cell(row=r_num, column=1) cell_b = ws.cell(row=r_num, column=2) if fill_obj: @@ -57,7 +58,7 @@ def format_row_cells(ws, r_num, fill_obj, is_bold=False, align_b="right", bold_f cell_a.font = bold_font cell_b.font = bold_font if align_b: - cell_b.alignment = Alignment(horizontal=align_b) + cell_b.alignment = Alignment(horizontal=align_b, vertical="center", wrap_text=wrap_b) # --- 1. СВОДКА НА СЕГОДНЯ --- @@ -70,11 +71,10 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None): ws = wb.active ws.title = "Лист_1" - # 🎯 ДВОЙНОЕ ВКЛЮЧЕНИЕ СИМВОЛОВ СТРУКТУРЫ В EXCEL (Обязательно для вывода плюсиков) ws.sheet_properties.outlinePr.summaryBelow = False ws.sheet_properties.outlinePr.summaryRight = False - ws.sheet_properties.outlinePr.showOutlineSymbols = True # Включает боковую панель - ws.sheet_view.showOutlineSymbols = True # Отображает плюсики + ws.sheet_properties.outlinePr.showOutlineSymbols = True + ws.sheet_view.showOutlineSymbols = True bold_font = Font(name="Calibri", size=11, bold=True) @@ -93,24 +93,26 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None): current_row = 4 - # 3. НЕИЗВЕСТНО (По умолчанию РАСКРЫТО: hidden=False) + # 3. НЕИЗВЕСТНО (По умолчанию РАСКРЫТО, нумерация 1. ФИО) unexplained = merged_df[(merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].isna())] ws.cell(row=current_row, column=1, value="неизвестно") ws.cell(row=current_row, column=2, value=len(unexplained)) format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=True, bold_font=bold_font) current_row += 1 - for fio in sorted(unexplained['Сотрудник'].dropna().unique()): - ws.cell(row=current_row, column=1, value=fio) + for idx, fio in enumerate(sorted(unexplained['Сотрудник'].dropna().unique()), 1): + ws.cell(row=current_row, column=1, value=f"{idx}. {fio}") format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=False) ws.row_dimensions[current_row].outlineLevel = 1 ws.row_dimensions[current_row].hidden = False current_row += 1 - # 4. КАТЕГОРИИ ОТСУТСТВИЙ (Свернуты hidden=True, под плюсики) - absent_groups = merged_df[merged_df['Вид_отсутствия'].notna()].groupby('Вид_отсутствия') - for idx, (cat_name, group) in enumerate(absent_groups): - hex_color = CATEGORY_PASTEL_COLORS[idx % len(CATEGORY_PASTEL_COLORS)] + # 4. КАТЕГОРИИ ОТСУТСТВИЙ (Свернуты hidden=True) + absent_only = merged_df[(merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].notna())] + absent_groups = absent_only.groupby('Вид_отсутствия') + + for idx_cat, (cat_name, group) in enumerate(absent_groups): + hex_color = CATEGORY_PASTEL_COLORS[idx_cat % len(CATEGORY_PASTEL_COLORS)] cat_fill = PatternFill(start_color=hex_color, end_color=hex_color, fill_type="solid") ws.cell(row=current_row, column=1, value=cat_name) @@ -118,21 +120,69 @@ def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None): format_row_cells(ws, current_row, cat_fill, is_bold=True, bold_font=bold_font) current_row += 1 - for fio in sorted(group['Сотрудник'].dropna().unique()): - ws.cell(row=current_row, column=1, value=fio) + for idx, fio in enumerate(sorted(group['Сотрудник'].dropna().unique()), 1): + ws.cell(row=current_row, column=1, value=f"{idx}. {fio}") format_row_cells(ws, current_row, cat_fill, is_bold=False) ws.row_dimensions[current_row].outlineLevel = 1 ws.row_dimensions[current_row].hidden = True current_row += 1 - # 5. ИТОГО НА РАБОТЕ - present = merged_df[merged_df['Пришел'] == True] + # 🎯 5. ИТОГО НА РАБОТЕ (Включает: пришли по СКУД + Командировки + Удаленная работа) + is_working_mask = (merged_df['Пришел'] == True) | ( + merged_df['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False) + ) + present = merged_df[is_working_mask] + ws.cell(row=current_row, column=1, value="Итого на работе") ws.cell(row=current_row, column=2, value=len(present)) format_row_cells(ws, current_row, FILL_PRESENT, is_bold=True, bold_font=bold_font) + current_row += 1 - ws.column_dimensions['A'].width = 35.0 - ws.column_dimensions['B'].width = 12.0 + # 🚨 6. АНОМАЛИИ (В самом низу таблицы, раскрывающийся список) + # Столбец A: f"{idx}. {fio} (На работе)" + # Столбец B: f"В 1С: {reason}" + # Ширина столбца B = 32.0 (ровно 230px). Автоперенос + динамическая высота строк. + anomalies = merged_df[(merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna())] + + ws.cell(row=current_row, column=1, value="Аномалии") + ws.cell(row=current_row, column=2, value=len(anomalies)) + format_row_cells(ws, current_row, FILL_ANOMALY, is_bold=True, bold_font=bold_font) + current_row += 1 + + chars_per_line_b = 30 # Влезает символов в столбец B шириной 230px (32.0 units) + + if not anomalies.empty: + for idx, (_, row) in enumerate(anomalies.iterrows(), 1): + fio = row.get('Сотрудник', '') + reason = row.get('Вид_отсутствия', 'Неизвестная причина') + reason_text = f"В 1С: {reason}" + + cell_a = ws.cell(row=current_row, column=1, value=f"{idx}. {fio} (На работе)") + cell_b = ws.cell(row=current_row, column=2, value=reason_text) + + cell_a.fill = FILL_ANOMALY + cell_b.fill = FILL_ANOMALY + apply_borders_to_cell(cell_a) + apply_borders_to_cell(cell_b) + + # Автоперенос текста в столбце B + cell_b.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True) + cell_a.alignment = Alignment(horizontal="left", vertical="center") + + # Адаптивный расчет высоты строки + if len(reason_text) > chars_per_line_b: + lines_count = math.ceil(len(reason_text) / chars_per_line_b) + ws.row_dimensions[current_row].height = max(lines_count * 18, 22) + else: + ws.row_dimensions[current_row].height = 20 + + ws.row_dimensions[current_row].outlineLevel = 1 + ws.row_dimensions[current_row].hidden = True # Свернут по умолчанию + current_row += 1 + + # 📏 Настройка ширины колонок: B = 32.0 (ровно 230 пикселей) + ws.column_dimensions['A'].width = 45.0 + ws.column_dimensions['B'].width = 32.0 # Ровно 230px в Excel try: wb.save(output_path) @@ -225,7 +275,7 @@ def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None): for col in ws.columns: col_letter = get_column_letter(col[0].column) if col_letter == 'G': - ws.column_dimensions['G'].width = 25.0 # Ровно 180 пикселей + ws.column_dimensions['G'].width = 25.0 else: max_len = 0 for cell in col: