перед настройкой выгрузки из СКУД, вместо ps1.

This commit is contained in:
2026-07-28 13:57:50 +03:00
parent 45f9b78d5c
commit 2b9e54580b
5 changed files with 208 additions and 48 deletions
+10
View File
@@ -0,0 +1,10 @@
fio,reason,note
Королёва Наталья Александровна,Удаленная работа,Постоянная удаленка
Николаева Ирина Леонидовна,Удаленная работа,Постоянная удаленка
Познякова Татьяна Сергеевна,Удаленная работа,Постоянная удаленка
Софьин Никита Сергеевич,Удаленная работа,Постоянная удаленка
Чуб Александр Васильевич,Удаленная работа,Постоянная удаленка
Шуличенко Иван Иванович,Удаленная работа,Постоянная удаленка
Пухаренко Юрий Владимирович,Удаленная работа,Постоянная удаленка
Пшеничный Виктор Петрович,Удаленная работа,Постоянная удаленка
Незнанова Валерия Игоревна,Удаленная работа,Постоянная удаленка
1 fio reason note
2 Королёва Наталья Александровна Удаленная работа Постоянная удаленка
3 Николаева Ирина Леонидовна Удаленная работа Постоянная удаленка
4 Познякова Татьяна Сергеевна Удаленная работа Постоянная удаленка
5 Софьин Никита Сергеевич Удаленная работа Постоянная удаленка
6 Чуб Александр Васильевич Удаленная работа Постоянная удаленка
7 Шуличенко Иван Иванович Удаленная работа Постоянная удаленка
8 Пухаренко Юрий Владимирович Удаленная работа Постоянная удаленка
9 Пшеничный Виктор Петрович Удаленная работа Постоянная удаленка
10 Незнанова Валерия Игоревна Удаленная работа Постоянная удаленка
+1 -2
View File
@@ -7,9 +7,8 @@
"Уборщик служебных помещений"
],
"fio": [
"Шуличенко Иван Иванович",
"Таткало Валерий Валерьевич",
ухаренко Юрий Владимирович",
етренюк Андрей Германович",
"Чуркина Елена Геннадьевна",
"Михалев Сергей Геннадьевич"
],
+124 -22
View File
@@ -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-файлов...")
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_summary_excel(
merged_df=merged,
date_str=DATE_TODAY
)
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)
+3 -4
View File
@@ -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()
+69 -19
View File
@@ -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: