первая версия новой ветки
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import json
|
||||
import re
|
||||
import requests
|
||||
from config import OLLAMA_URL, OLLAMA_MODEL
|
||||
from modules.database import get_all_rules_from_db
|
||||
|
||||
|
||||
def ask_ollama(prompt, system_prompt=None):
|
||||
"""
|
||||
Универсальная функция отправки запросов к локальной модели Ollama (Qwen 2.5).
|
||||
"""
|
||||
payload = {
|
||||
"model": OLLAMA_MODEL,
|
||||
"prompt": f"{system_prompt}\n\n{prompt}" if system_prompt else prompt,
|
||||
"stream": False
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(OLLAMA_URL, json=payload, timeout=120)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if "response" in data:
|
||||
return data["response"].strip()
|
||||
elif "message" in data and "content" in data["message"]:
|
||||
return data["message"]["content"].strip()
|
||||
else:
|
||||
return f"⚠️ Неизвестная структура ответа Ollama: {data}"
|
||||
else:
|
||||
return f"⚠️ Ошибка Ollama (Код {response.status_code}): {response.text}"
|
||||
except requests.exceptions.ConnectionError:
|
||||
return "⚠️ Не удалось подключиться к Ollama. Проверьте, запущен ли сервис (ollama run qwen2.5:14b)."
|
||||
except Exception as e:
|
||||
return f"⚠️ Ошибка при обращении к Ollama: {e}"
|
||||
|
||||
|
||||
def get_system_rules_context():
|
||||
"""
|
||||
Загружает глобальные системные правила компании прямо из Базы Данных.
|
||||
"""
|
||||
rules = get_all_rules_from_db()
|
||||
if not rules:
|
||||
return ""
|
||||
rules_text = "\n".join([f"- {r}" for r in rules])
|
||||
return f"\nОБЯЗАТЕЛЬНЫЕ ГЛОБАЛЬНЫЕ ПРАВИЛА И ПРИОРИТЕТЫ КОМПАНИИ:\n{rules_text}\n"
|
||||
|
||||
|
||||
def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios):
|
||||
"""
|
||||
ИИ-сверка опечаток и несовпадений ФИО между СКУД и 1С:ЗУП.
|
||||
Записи из 1С считаются 100% эталоном.
|
||||
"""
|
||||
if not unrecognized_scud_fios or not staff_fios:
|
||||
return {}
|
||||
|
||||
rules_context = get_system_rules_context()
|
||||
|
||||
prompt = f"""
|
||||
Ты — кадровый аудитор безопасности СКУД.
|
||||
{rules_context}
|
||||
В СКУД записаны неопознанные ФИО: {json.dumps(unrecognized_scud_fios, ensure_ascii=False)}
|
||||
В официальном Штатном расписании 1С записаны ЭТАЛОНЫ: {json.dumps(staff_fios, ensure_ascii=False)}
|
||||
|
||||
СТРОГИЕ ПРАВИЛА:
|
||||
1. Запись из Штат 1С — это 100% ПРАВИЛЬНЫЙ эталон.
|
||||
2. В СКУД допущена опечатка.
|
||||
3. Любое присутствие сотрудника по СКУД при наличии в 1С документа отсутствия (кроме командировок) является гарантированной аномалией.
|
||||
4. В поле "warning" опиши обнаруженную опечатку.
|
||||
5. Запрещено выдумывать опечатки!
|
||||
|
||||
ОТВЕЧАЙ ТОЛЬКО ИСКЛЮЧИТЕЛЬНО В ФОРМАТЕ JSON!
|
||||
|
||||
Формат ответа:
|
||||
{{
|
||||
"verified_matches": [
|
||||
{{
|
||||
"scud_fio": "ФИО из СКУД",
|
||||
"staff_fio": "эталон ФИО из Штат 1С",
|
||||
"warning": "Описание опечатки в СКУД или 'Точное совпадение (без опечаток)'"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
"""
|
||||
raw_response = ask_ollama(
|
||||
prompt,
|
||||
system_prompt="Ты — JSON API. Выдавай ТОЛЬКО валидный JSON без markdown-разметки."
|
||||
)
|
||||
|
||||
mapping = {}
|
||||
try:
|
||||
match = re.search(r'\{.*\}', raw_response, re.DOTALL)
|
||||
if match:
|
||||
json_str = match.group(0)
|
||||
json_str = re.sub(r'\}\s*[^}\]]*$', '}', json_str)
|
||||
data = json.loads(json_str)
|
||||
for item in data.get("verified_matches", []):
|
||||
scud_f = item.get("scud_fio")
|
||||
staff_f = item.get("staff_fio")
|
||||
warn = item.get("warning", "Точное совпадение (без опечаток)")
|
||||
if scud_f and staff_f:
|
||||
mapping[scud_f] = {"staff_fio": staff_f, "warning": warn}
|
||||
except Exception as e:
|
||||
print(f"[!] Ошибка разбора JSON от ИИ при сверке опечаток: {e}")
|
||||
|
||||
return mapping
|
||||
@@ -0,0 +1,137 @@
|
||||
import os
|
||||
import pandas as pd
|
||||
import warnings
|
||||
from config import (
|
||||
normalize_fio, clean_scud_fio_light, load_exceptions,
|
||||
DATE_TODAY, DATE_YESTERDAY, find_dated_file, ZUP_1C_DIR, SCUD_DIR
|
||||
)
|
||||
# Импортируем прямую SQL-выгрузку отсутствий из 1С
|
||||
from modules.zup_extractor import fetch_zup_absences_from_sql
|
||||
|
||||
warnings.filterwarnings('ignore', category=UserWarning, module='pandas')
|
||||
|
||||
|
||||
def is_excluded(fio, dept, pos, exceptions):
|
||||
"""Проверяет, входит ли сотрудник или должность в список исключений."""
|
||||
fio_clean = normalize_fio(fio)
|
||||
dept_str = str(dept).strip().upper() if pd.notna(dept) else ""
|
||||
pos_str = str(pos).strip().lower() if pd.notna(pos) else ""
|
||||
|
||||
if fio_clean in exceptions["fio"] or dept_str in exceptions["departments"] or pos_str in exceptions["positions"]:
|
||||
return True
|
||||
if any(keyword in pos_str for keyword in exceptions["position_keywords"]):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def load_staff_data(date_str):
|
||||
"""
|
||||
Загружает файл Штатного расписания 1С за конкретную дату из папки 1c.
|
||||
"""
|
||||
filepath = find_dated_file("Штат", date_str)
|
||||
if not filepath:
|
||||
fallback_path = os.path.join(ZUP_1C_DIR, "штат.xlsx")
|
||||
if os.path.exists(fallback_path):
|
||||
filepath = fallback_path
|
||||
else:
|
||||
return None
|
||||
|
||||
try:
|
||||
df_raw = pd.read_excel(filepath, skiprows=8)
|
||||
|
||||
# Индексы столбцов в файле 1С: 1 — ФИО, 5 — Подразделение, 12 — Должность
|
||||
df_staff = df_raw.iloc[:, [1, 5, 12]].copy()
|
||||
df_staff.columns = ['ФИО', 'Подразделение', 'Должность']
|
||||
|
||||
df_staff = df_staff.dropna(subset=['ФИО']).reset_index(drop=True)
|
||||
df_staff = df_staff[~df_staff['ФИО'].astype(str).str.contains('Всего|Организация|Сотрудник|ФИО', case=False, na=False)]
|
||||
df_staff['fio_clean'] = df_staff['ФИО'].apply(normalize_fio)
|
||||
return df_staff
|
||||
except Exception as e:
|
||||
print(f"[❌] Ошибка загрузки штата из {filepath}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def load_absent_data(date_str):
|
||||
"""
|
||||
Загружает документально подтвержденные отсутствия сотрудников
|
||||
НАПРЯМУЮ из базы MS SQL 1С:ЗУП за указанную дату.
|
||||
"""
|
||||
try:
|
||||
df_absent = fetch_zup_absences_from_sql(date_str)
|
||||
|
||||
if df_absent is not None and not df_absent.empty:
|
||||
df_absent = df_absent.dropna(subset=['ФИО', 'Вид_отсутствия'])
|
||||
df_absent = df_absent[~df_absent['ФИО'].astype(str).str.contains('АО "ЛЕНМОРНИИПРОЕКТ"|Сотрудник', case=False, na=False)]
|
||||
df_absent['fio_clean'] = df_absent['ФИО'].apply(normalize_fio)
|
||||
return df_absent[['fio_clean', 'Вид_отсутствия']].dropna(subset=['fio_clean'])
|
||||
else:
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"[⚠️] Ошибка SQL-выгрузки отсутствий за {date_str}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def load_scud_data(target_date_type="today"):
|
||||
"""Загружает файл СКУД за 'today' или 'yesterday'."""
|
||||
target_date = DATE_TODAY if target_date_type == "today" else DATE_YESTERDAY
|
||||
scud_path = find_dated_file("Сотрудники", target_date, search_dirs=[SCUD_DIR])
|
||||
|
||||
if not scud_path:
|
||||
scud_path = find_dated_file("Сотрудники", DATE_TODAY, search_dirs=[SCUD_DIR])
|
||||
if not scud_path:
|
||||
raise FileNotFoundError(f"Файл СКУД [{target_date_type}] за {target_date} не найден в {SCUD_DIR}!")
|
||||
|
||||
df_scud = pd.read_excel(scud_path)
|
||||
exceptions = load_exceptions()
|
||||
|
||||
df_scud['fio_raw'] = df_scud['Сотрудник'].astype(str) if 'Сотрудник' in df_scud.columns else df_scud[df_scud.columns[0]].astype(str)
|
||||
df_scud['fio_clean'] = df_scud['fio_raw'].apply(clean_scud_fio_light)
|
||||
|
||||
start_col = 'Начало_дня' if 'Начало_дня' in df_scud.columns else ('Начало дня' if 'Начало дня' in df_scud.columns else None)
|
||||
status_col = 'Статус' if 'Статус' in df_scud.columns else None
|
||||
|
||||
def is_person_present(row):
|
||||
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
|
||||
|
||||
|
||||
def load_all_data():
|
||||
"""
|
||||
Загружает слитные датасеты за Сегодня и за Вчера.
|
||||
Если данных за Сегодня нет, возвращает для них None.
|
||||
"""
|
||||
df_scud_today = load_scud_data(target_date_type="today")
|
||||
try:
|
||||
df_scud_yesterday = load_scud_data(target_date_type="yesterday")
|
||||
except FileNotFoundError:
|
||||
df_scud_yesterday = df_scud_today.copy()
|
||||
|
||||
# Загружаем 1С за Сегодня (может быть None)
|
||||
df_staff_today = load_staff_data(DATE_TODAY)
|
||||
df_absent_today = load_absent_data(DATE_TODAY)
|
||||
|
||||
# Загружаем 1С за Вчера (гарантированно есть)
|
||||
df_staff_yesterday = load_staff_data(DATE_YESTERDAY)
|
||||
df_absent_yesterday = load_absent_data(DATE_YESTERDAY)
|
||||
|
||||
return (
|
||||
df_scud_today,
|
||||
df_scud_yesterday,
|
||||
df_staff_today,
|
||||
df_absent_today,
|
||||
df_staff_yesterday,
|
||||
df_absent_yesterday
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from config import DATE_TODAY, DATE_YESTERDAY, find_dated_file, ZUP_1C_DIR, SCUD_DIR
|
||||
|
||||
|
||||
def check_file_freshness(max_age_hours=24):
|
||||
"""
|
||||
Проверяет существование входных файлов 1С и СКУД.
|
||||
Возвращает флаги доступности файлов за Сегодня и Вчера.
|
||||
"""
|
||||
warnings = []
|
||||
errors = []
|
||||
|
||||
# 1. Проверка файлов 1С за СЕГОДНЯ
|
||||
staff_today = find_dated_file("Штат", DATE_TODAY)
|
||||
absent_today = find_dated_file("Отсутствия", DATE_TODAY)
|
||||
has_today_1c = bool(staff_today and absent_today)
|
||||
|
||||
if not has_today_1c:
|
||||
warnings.append(
|
||||
f"Файлы 1С за СЕГОДНЯ ({DATE_TODAY}) еще не выложены на сетевую шару. "
|
||||
f"Формирование Ежедневной сводки за сегодня будет временно пропущено."
|
||||
)
|
||||
|
||||
# 2. Проверка файлов 1С за ВЧЕРА (критически важны для детального отчета)
|
||||
staff_yesterday = find_dated_file("Штат", DATE_YESTERDAY)
|
||||
absent_yesterday = find_dated_file("Отсутствия", DATE_YESTERDAY)
|
||||
|
||||
if not staff_yesterday or not absent_yesterday:
|
||||
errors.append(
|
||||
f"Отсутствуют файлы 1С за ВЧЕРА ({DATE_YESTERDAY}) в папке {ZUP_1C_DIR}. "
|
||||
f"Невозможно построить детальный отчет за прошлую смену."
|
||||
)
|
||||
|
||||
# 3. Проверка файлов СКУД
|
||||
scud_today = find_dated_file("Сотрудники", DATE_TODAY, search_dirs=[SCUD_DIR])
|
||||
if not scud_today:
|
||||
warnings.append(f"Сегодняшний файл СКУД за {DATE_TODAY} еще не сформирован в {SCUD_DIR}")
|
||||
|
||||
is_valid = len(errors) == 0
|
||||
return is_valid, warnings, errors, has_today_1c
|
||||
@@ -0,0 +1,349 @@
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
import openpyxl
|
||||
import pandas as pd
|
||||
from datetime import datetime
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
from config import OUTPUT_DIR
|
||||
|
||||
# Словарь месяцев для текстового формата в названиях файлов
|
||||
MONTHS_RU = {
|
||||
1: "января", 2: "февраля", 3: "марта", 4: "апреля",
|
||||
5: "мая", 6: "июня", 7: "июля", 8: "августа",
|
||||
9: "сентября", 10: "октября", 11: "ноября", 12: "декабря"
|
||||
}
|
||||
|
||||
def format_date_ru(date_str):
|
||||
"""Преобразует дату формата '27.07.2026' в '27 июля 2026'"""
|
||||
try:
|
||||
dt = datetime.strptime(date_str, "%d.%m.%Y")
|
||||
return f"{dt.day} {MONTHS_RU[dt.month]} {dt.year}"
|
||||
except Exception:
|
||||
return date_str
|
||||
|
||||
# Границы ячеек
|
||||
THIN_SIDE = Side(border_style="thin", color="D3D3D3")
|
||||
THIN_BORDER = Border(left=THIN_SIDE, right=THIN_SIDE, top=THIN_SIDE, bottom=THIN_SIDE)
|
||||
|
||||
# Палитра заливки для Сводки
|
||||
FILL_HEADER = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid")
|
||||
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"]
|
||||
|
||||
# Палитра для Детального отчета
|
||||
YELLOW_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") # Обычные отсутствия
|
||||
LIGHT_RED_FILL = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") # Неизвестные случаи
|
||||
GREEN_FILL = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid") # Аномалии (пришел в отпуске)
|
||||
|
||||
|
||||
def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_minutes=30):
|
||||
"""Рассчитывает точное отклонение от нормы с учетом обеда 30 мин и уважительных причин"""
|
||||
if pd.notna(reason) and isinstance(reason, str) and reason.strip() != "":
|
||||
return "0:00"
|
||||
|
||||
if not isinstance(time_in_building_str, str) or time_in_building_str in ['00:00', '0', '', 'None', 'nan', 'NaN']:
|
||||
return f"-{norm_hours}:00"
|
||||
|
||||
try:
|
||||
parts = time_in_building_str.strip().split(':')
|
||||
hh = int(parts[0])
|
||||
mm = int(parts[1]) if len(parts) > 1 else 0
|
||||
total_in_building_minutes = hh * 60 + mm
|
||||
|
||||
if total_in_building_minutes == 0:
|
||||
return f"-{norm_hours}:00"
|
||||
|
||||
work_minutes = max(0, total_in_building_minutes - lunch_minutes)
|
||||
norm_minutes = norm_hours * 60
|
||||
diff = work_minutes - norm_minutes
|
||||
|
||||
if diff == 0:
|
||||
return "0:00"
|
||||
|
||||
sign = "-" if diff < 0 else ""
|
||||
abs_diff = abs(diff)
|
||||
res_hh = abs_diff // 60
|
||||
res_mm = abs_diff % 60
|
||||
|
||||
return f"{sign}{res_hh}:{res_mm:02d}"
|
||||
except Exception:
|
||||
return f"-{norm_hours}:00"
|
||||
|
||||
|
||||
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, wrap_b=False):
|
||||
cell_a = ws.cell(row=r_num, column=1)
|
||||
cell_b = ws.cell(row=r_num, column=2)
|
||||
if fill_obj:
|
||||
cell_a.fill = fill_obj
|
||||
cell_b.fill = fill_obj
|
||||
apply_borders_to_cell(cell_a)
|
||||
apply_borders_to_cell(cell_b)
|
||||
if is_bold and bold_font:
|
||||
cell_a.font = bold_font
|
||||
cell_b.font = bold_font
|
||||
if align_b:
|
||||
cell_b.alignment = Alignment(horizontal=align_b, vertical="center", wrap_text=wrap_b)
|
||||
|
||||
|
||||
# --- 1. СВОДКА НА СЕГОДНЯ ---
|
||||
def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
|
||||
if not filename:
|
||||
filename = f"{format_date_ru(date_str)} сводка.xlsx"
|
||||
|
||||
output_path = os.path.join(OUTPUT_DIR, filename)
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Лист_1"
|
||||
|
||||
ws.sheet_properties.outlinePr.summaryBelow = False
|
||||
ws.sheet_properties.outlinePr.summaryRight = False
|
||||
ws.sheet_properties.outlinePr.showOutlineSymbols = True
|
||||
ws.sheet_view.showOutlineSymbols = True
|
||||
|
||||
bold_font = Font(name="Calibri", size=11, bold=True)
|
||||
|
||||
# 1. Шапка
|
||||
ws.cell(row=1, column=1, value="Сводка на")
|
||||
ws.cell(row=1, column=2, value=date_str)
|
||||
format_row_cells(ws, 1, FILL_HEADER, is_bold=True, bold_font=bold_font)
|
||||
|
||||
apply_borders_to_cell(ws.cell(row=2, column=1))
|
||||
apply_borders_to_cell(ws.cell(row=2, column=2))
|
||||
|
||||
# 2. По списку
|
||||
ws.cell(row=3, column=1, value="По списку")
|
||||
ws.cell(row=3, column=2, value=len(merged_df))
|
||||
format_row_cells(ws, 3, FILL_TOTAL_LIST, is_bold=True, bold_font=bold_font)
|
||||
|
||||
current_row = 4
|
||||
|
||||
# 3. НЕИЗВЕСТНО (По умолчанию РАСКРЫТО, чистые ФИО без цифр)
|
||||
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)
|
||||
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_only = merged_df[
|
||||
(merged_df['Пришел'] == False) &
|
||||
(merged_df['Вид_отсутствия'].notna()) &
|
||||
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
|
||||
]
|
||||
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)
|
||||
ws.cell(row=current_row, column=2, value=len(group))
|
||||
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)
|
||||
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. ИТОГО НА РАБОТЕ (Свернут hidden=True)
|
||||
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
|
||||
|
||||
if not present.empty:
|
||||
for fio in sorted(present['Сотрудник'].dropna().unique()):
|
||||
ws.cell(row=current_row, column=1, value=fio)
|
||||
format_row_cells(ws, current_row, FILL_PRESENT, is_bold=False)
|
||||
ws.row_dimensions[current_row].outlineLevel = 1
|
||||
ws.row_dimensions[current_row].hidden = True
|
||||
current_row += 1
|
||||
|
||||
# 6. АНОМАЛИИ (Свернут hidden=True)
|
||||
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
|
||||
|
||||
if not anomalies.empty:
|
||||
for _, row in anomalies.iterrows():
|
||||
fio = row.get('Сотрудник', '')
|
||||
reason = row.get('Вид_отсутствия', 'Неизвестная причина')
|
||||
reason_text = f"В 1С: {reason}"
|
||||
|
||||
cell_a = ws.cell(row=current_row, column=1, value=f"{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)
|
||||
|
||||
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
|
||||
|
||||
ws.column_dimensions['A'].width = 45.0
|
||||
ws.column_dimensions['B'].width = 32.0
|
||||
|
||||
try:
|
||||
wb.save(output_path)
|
||||
print(f"[✓] Ежедневная сводка сохранена: {output_path}")
|
||||
except PermissionError:
|
||||
alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx")
|
||||
alt_path = os.path.join(OUTPUT_DIR, alt_filename)
|
||||
wb.save(alt_path)
|
||||
print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}")
|
||||
|
||||
|
||||
# --- 2. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА ---
|
||||
def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None):
|
||||
if not filename:
|
||||
filename = f"{format_date_ru(date_str)} отчет.xlsx"
|
||||
|
||||
output_path = os.path.join(OUTPUT_DIR, filename)
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Детальный_отчет"
|
||||
|
||||
ws["B2"] = "Дата:"
|
||||
ws["D2"] = date_str
|
||||
ws["B2"].font = Font(name="Arial", size=10, bold=True)
|
||||
ws["D2"].font = Font(name="Arial", size=10, bold=True)
|
||||
|
||||
headers = [
|
||||
"№", "ФИО", "Подразделение", "время входа", "время выхода",
|
||||
"находился в здании", "причина отсутствия", "норма", "отклонение от нормы"
|
||||
]
|
||||
ws.append([])
|
||||
ws.append(headers)
|
||||
|
||||
header_fill = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid")
|
||||
for col_idx in range(1, len(headers) + 1):
|
||||
cell = ws.cell(row=4, column=col_idx)
|
||||
cell.fill = header_fill
|
||||
cell.font = Font(name="Arial", size=10, bold=True)
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||
apply_borders_to_cell(cell)
|
||||
|
||||
start_col = 'Начало дня' if 'Начало дня' in merged_df.columns else 'Начало_дня'
|
||||
end_col = 'Конец дня' if 'Конец дня' in merged_df.columns else 'Конец_дня'
|
||||
hours_col = 'Часы' if 'Часы' in merged_df.columns else 'Находился_в_здании'
|
||||
|
||||
chars_per_line_g = 24
|
||||
|
||||
for idx, row in merged_df.reset_index(drop=True).iterrows():
|
||||
is_present = row.get('Пришел', False)
|
||||
absence_reason = row.get('Вид_отсутствия', '')
|
||||
has_reason = pd.notna(absence_reason) and str(absence_reason).strip() != ''
|
||||
|
||||
in_building_str = str(row.get(hours_col, '00:00'))
|
||||
|
||||
deviation_val = calculate_deviation(in_building_str, reason=absence_reason if has_reason else "", norm_hours=8, lunch_minutes=30)
|
||||
|
||||
ws.append([
|
||||
idx + 1,
|
||||
row.get('Сотрудник', ''),
|
||||
row.get('Подразделение', ''),
|
||||
row.get(start_col, 'Нет входа'),
|
||||
row.get(end_col, 'Нет выхода'),
|
||||
in_building_str,
|
||||
absence_reason if has_reason else '',
|
||||
8,
|
||||
deviation_val
|
||||
])
|
||||
|
||||
row_num = 5 + idx
|
||||
|
||||
if is_present and has_reason:
|
||||
row_fill = GREEN_FILL
|
||||
elif not is_present:
|
||||
row_fill = YELLOW_FILL if has_reason else LIGHT_RED_FILL
|
||||
else:
|
||||
row_fill = None
|
||||
|
||||
val_g_str = str(absence_reason) if has_reason else ""
|
||||
if len(val_g_str) > chars_per_line_g:
|
||||
needed_lines = math.ceil(len(val_g_str) / chars_per_line_g)
|
||||
ws.row_dimensions[row_num].height = max(needed_lines * 18, 22)
|
||||
else:
|
||||
ws.row_dimensions[row_num].height = 20
|
||||
|
||||
for col_idx in range(1, len(headers) + 1):
|
||||
cell = ws.cell(row=row_num, column=col_idx)
|
||||
apply_borders_to_cell(cell)
|
||||
if row_fill:
|
||||
cell.fill = row_fill
|
||||
|
||||
if col_idx == 7:
|
||||
cell.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True)
|
||||
elif col_idx in [1, 4, 5, 6, 8, 9]:
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||||
else:
|
||||
cell.alignment = Alignment(horizontal="left", vertical="center")
|
||||
|
||||
for col in ws.columns:
|
||||
col_letter = get_column_letter(col[0].column)
|
||||
if col_letter == 'G':
|
||||
ws.column_dimensions['G'].width = 25.0
|
||||
else:
|
||||
max_len = 0
|
||||
for cell in col:
|
||||
if cell.value is not None:
|
||||
line_max = max(len(l) for l in str(cell.value).split("\n"))
|
||||
if line_max > max_len:
|
||||
max_len = line_max
|
||||
ws.column_dimensions[col_letter].width = max(max_len + 4, 12)
|
||||
|
||||
try:
|
||||
wb.save(output_path)
|
||||
print(f"[✓] Детальный отчет сохранен: {output_path}")
|
||||
except PermissionError:
|
||||
alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx")
|
||||
alt_path = os.path.join(OUTPUT_DIR, alt_filename)
|
||||
wb.save(alt_path)
|
||||
print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}")
|
||||
|
||||
|
||||
def export_raw_scud(df_scud, filename="СКУД_Сырые_данные.xlsx"):
|
||||
output_path = os.path.join(OUTPUT_DIR, filename)
|
||||
df_scud.to_excel(output_path, index=False)
|
||||
@@ -0,0 +1,57 @@
|
||||
import os
|
||||
import json
|
||||
from modules.database import add_rule_to_db
|
||||
|
||||
try:
|
||||
from modules.knowledge_base import save_rule
|
||||
except ImportError:
|
||||
try:
|
||||
from modules.knowledge_base import add_rule as save_rule
|
||||
except ImportError:
|
||||
save_rule = None
|
||||
|
||||
|
||||
def review_ai_decisions(report_text, suspicious_cases):
|
||||
"""
|
||||
Интерактивный консольный модуль обучения (Human-in-the-Loop).
|
||||
Запрашивает подтверждение оператора ТОЛЬКО по нетипичным/сложным случаям.
|
||||
"""
|
||||
if not suspicious_cases:
|
||||
print("\n[✓] Все аномалии валидированы автоматически. Спорных вопросов для оператора нет.")
|
||||
return
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("РЕЖИМ ОБУЧЕНИЯ И ПРОВЕРКИ РЕШЕНИЙ ИИ (HUMAN-IN-THE-LOOP)")
|
||||
print("=" * 60)
|
||||
|
||||
for idx, case in enumerate(suspicious_cases, 1):
|
||||
fio_target = case.get('fio_target', 'Неизвестно')
|
||||
reason = case.get('reason', 'Нет описания')
|
||||
|
||||
print(f"\n[?] Спорный случай {idx}/{len(suspicious_cases)}: {fio_target}")
|
||||
print(f" Детали: {reason}")
|
||||
|
||||
user_input = input("Подтверждаете аномалию? (д / да / н / нет / или введите текст правила): ").strip()
|
||||
user_input_clean = user_input.lower()
|
||||
|
||||
# Расширенный список вариантов "ДА"
|
||||
if user_input_clean in ['д', 'да', 'da', 'yes', 'y', 'ок', 'ага', '+']:
|
||||
print(" [✓] Решение подтверждено оператором.")
|
||||
elif user_input_clean in ['н', 'нет', 'no', 'n', '-']:
|
||||
print(" [ℹ️] Решение отклонено оператором.")
|
||||
elif user_input:
|
||||
# Если оператор ввел действительно новое текстовое правило
|
||||
new_rule = f"Правило по {fio_target}: {user_input}"
|
||||
|
||||
add_rule_to_db(new_rule, added_by="Human_Admin")
|
||||
if save_rule:
|
||||
try:
|
||||
save_rule(new_rule)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(f" [✓] Новое правило записано в Базу Знаний: {new_rule}")
|
||||
else:
|
||||
print(" [ℹ️] Запись пропущена.")
|
||||
|
||||
print("\n[✓] ЭТАП ОБУЧЕНИЯ ЗАВЕРШЕН.")
|
||||
@@ -0,0 +1,20 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
KB_FILE = os.path.join("data", "knowledge_base.json")
|
||||
|
||||
def load_knowledge_base():
|
||||
"""Загружает накопленный опыт и правила компании"""
|
||||
if not os.path.exists(KB_FILE):
|
||||
return {"rules": [], "fio_corrections": {}}
|
||||
with open(KB_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
def add_rule_to_kb(new_rule):
|
||||
"""Добавляет новое правило, принятое человеком, в базу знаний"""
|
||||
kb = load_knowledge_base()
|
||||
if new_rule not in kb["rules"]:
|
||||
kb["rules"].append(new_rule)
|
||||
with open(KB_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(kb, f, ensure_ascii=False, indent=2)
|
||||
print(f"[✓] База знаний обновлена! Новое правило: {new_rule}")
|
||||
@@ -0,0 +1,279 @@
|
||||
"""
|
||||
Модуль автоматического экспорта данных СКУД (Orion) из MS SQL Server в SQLite и Excel.
|
||||
Исправлена логика определения направления проходов (Mode = 2 -> OUT).
|
||||
Адаптирован для Linux (Ubuntu 24.04).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pandas as pd
|
||||
import pyodbc
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
from config import SCUD_DIR, clean_scud_fio_light, load_exceptions
|
||||
from modules.database import save_scud_to_db
|
||||
|
||||
warnings.filterwarnings("ignore", message="pandas only supports SQLAlchemy connectable")
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
LOG_DIR = os.path.join(SCRIPT_DIR, "..", "logs")
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
||||
TODAY_DATE_STR = datetime.now().strftime("%d.%m.%Y")
|
||||
LOG_FILE = os.path.join(LOG_DIR, f"export_{TODAY_DATE_STR}.log")
|
||||
|
||||
logger = logging.getLogger("scud_export")
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
if not logger.handlers:
|
||||
_file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8")
|
||||
_console_handler = logging.StreamHandler(sys.stdout)
|
||||
_formatter = logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
|
||||
_file_handler.setFormatter(_formatter)
|
||||
_console_handler.setFormatter(_formatter)
|
||||
logger.addHandler(_file_handler)
|
||||
logger.addHandler(_console_handler)
|
||||
|
||||
|
||||
def log(message: str, level: str = "INFO"):
|
||||
level_map = {
|
||||
"INFO": logging.INFO,
|
||||
"ERROR": logging.ERROR,
|
||||
"WARNING": logging.WARNING,
|
||||
"SUCCESS": logging.INFO,
|
||||
}
|
||||
if level == "SUCCESS":
|
||||
logger.info(f"[SUCCESS] {message}")
|
||||
else:
|
||||
logger.log(level_map.get(level, logging.INFO), message)
|
||||
|
||||
|
||||
SERVER_NAME = r"172.16.31.221\SQL"
|
||||
DATABASE_NAME = "Orion-14.01.21-1"
|
||||
SQL_USER = "sa"
|
||||
SQL_PASSWORD = "123456"
|
||||
ODBC_DRIVER = "ODBC Driver 18 for SQL Server"
|
||||
|
||||
SQL_QUERY_TEMPLATE = r"""
|
||||
DECLARE @InputDate DATE = '{target_date}';
|
||||
DECLARE @TargetDate DATE = @InputDate;
|
||||
|
||||
DECLARE @StartDate DATETIME = CAST(@TargetDate AS DATETIME);
|
||||
DECLARE @EndDate DATETIME = DATEADD(SECOND, -1, DATEADD(DAY, 1, @StartDate));
|
||||
|
||||
WITH DailyLogs AS (
|
||||
SELECT
|
||||
log.HozOrgan AS EmployeeID,
|
||||
log.TimeVal,
|
||||
log.Event,
|
||||
log.Mode,
|
||||
CASE
|
||||
WHEN log.Mode = 2 OR log.Event IN (29, 27, 33) THEN 'OUT'
|
||||
WHEN log.Mode = 1 OR log.Event IN (28, 26, 32) THEN 'IN'
|
||||
ELSE 'OTHER'
|
||||
END AS Direction,
|
||||
ROW_NUMBER() OVER (PARTITION BY log.HozOrgan ORDER BY log.TimeVal DESC) AS RowNumDesc
|
||||
FROM pLogData log WITH (NOLOCK)
|
||||
WHERE log.TimeVal BETWEEN @StartDate AND @EndDate
|
||||
AND log.HozOrgan IS NOT NULL
|
||||
AND log.HozOrgan > 0
|
||||
AND (log.Event IN (28, 29, 26, 27, 32, 33) OR log.Mode IN (1, 2))
|
||||
),
|
||||
Passages AS (
|
||||
SELECT
|
||||
EmployeeID,
|
||||
MIN(CASE WHEN Direction = 'IN' THEN TimeVal END) AS FirstIn,
|
||||
MAX(CASE WHEN Direction = 'OUT' THEN TimeVal END) AS LastOut,
|
||||
MAX(CASE WHEN RowNumDesc = 1 THEN Direction END) AS LastEventType
|
||||
FROM DailyLogs
|
||||
GROUP BY EmployeeID
|
||||
)
|
||||
SELECT
|
||||
N'ЛЕНМОРНИИПРОЕКТ' AS [Фирма],
|
||||
ISNULL(CAST(div.Name AS NVARCHAR(255)), N'Без подразделения') AS [Подразделение],
|
||||
LTRIM(RTRIM(
|
||||
ISNULL(CAST(p.Name AS NVARCHAR(255)), N'') +
|
||||
CASE WHEN p.FirstName IS NOT NULL AND CAST(p.FirstName AS NVARCHAR(255)) <> ''
|
||||
THEN N' ' + CAST(p.FirstName AS NVARCHAR(255)) ELSE N'' END +
|
||||
CASE WHEN p.MidName IS NOT NULL AND CAST(p.MidName AS NVARCHAR(255)) <> ''
|
||||
THEN N' ' + CAST(p.MidName AS NVARCHAR(255)) ELSE N'' END
|
||||
)) AS [Сотрудник],
|
||||
ISNULL(CAST(post.Name AS NVARCHAR(255)), N'—') AS [Должность],
|
||||
ISNULL(CAST(p.TabNumber AS NVARCHAR(50)), N'—') AS [Таб_№],
|
||||
CONVERT(VARCHAR(10), @TargetDate, 104) AS [Дата],
|
||||
ISNULL(CAST(CONVERT(VARCHAR(8), pass.FirstIn, 108) AS NVARCHAR(20)), N'Нет входа') AS [Начало_дня],
|
||||
CASE
|
||||
WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn)
|
||||
THEN N'Нет выхода'
|
||||
WHEN pass.LastOut IS NOT NULL AND pass.LastOut > pass.FirstIn
|
||||
THEN CAST(CONVERT(VARCHAR(8), pass.LastOut, 108) AS NVARCHAR(20))
|
||||
ELSE N'Нет выхода'
|
||||
END AS [Конец_дня],
|
||||
CASE
|
||||
WHEN pass.EmployeeID IS NOT NULL AND pass.FirstIn IS NOT NULL THEN
|
||||
RIGHT('0' + CAST(DATEDIFF(MINUTE,
|
||||
pass.FirstIn,
|
||||
CASE
|
||||
WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN GETDATE()
|
||||
ELSE ISNULL(pass.LastOut, pass.FirstIn)
|
||||
END) / 60 AS VARCHAR), 2) + ':' +
|
||||
RIGHT('0' + CAST(DATEDIFF(MINUTE,
|
||||
pass.FirstIn,
|
||||
CASE
|
||||
WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN GETDATE()
|
||||
ELSE ISNULL(pass.LastOut, pass.FirstIn)
|
||||
END) % 60 AS VARCHAR), 2)
|
||||
ELSE N'00:00'
|
||||
END AS [Находился_в_здании],
|
||||
CASE
|
||||
WHEN pass.EmployeeID IS NOT NULL THEN N'Присутствовал'
|
||||
ELSE N'Отсутствовал (Нет событий)'
|
||||
END AS [Статус]
|
||||
FROM pList p WITH (NOLOCK)
|
||||
LEFT JOIN PDivision div WITH (NOLOCK) ON p.Section = div.ID
|
||||
LEFT JOIN PPost post WITH (NOLOCK) ON p.Post = post.ID
|
||||
LEFT JOIN Passages pass ON p.ID = pass.EmployeeID
|
||||
WHERE
|
||||
ISNULL(p.StatusRecord, 0) = 0
|
||||
AND p.DateTimeInArchive IS NULL
|
||||
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Аренд%'
|
||||
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT IN (N'Без подразделения', N'')
|
||||
AND p.Name NOT LIKE N'бр.%'
|
||||
AND p.Name NOT LIKE N'Гость%'
|
||||
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT IN (N'БГИ', N'КНР')
|
||||
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Рабоч%'
|
||||
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Врем%'
|
||||
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Практика%'
|
||||
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'тест%'
|
||||
AND ISNULL(CAST(post.Name AS NVARCHAR(255)), N'') NOT LIKE N'Практикант%'
|
||||
ORDER BY p.Name ASC;
|
||||
"""
|
||||
|
||||
|
||||
def auto_fit_columns(file_path: str, sheet_name: str, padding: float = 2.0, min_width: float = 8.0, max_width: float = 60.0):
|
||||
from openpyxl import load_workbook
|
||||
|
||||
wb = load_workbook(file_path)
|
||||
ws = wb[sheet_name]
|
||||
|
||||
for col_cells in ws.columns:
|
||||
max_len = 0
|
||||
col_letter = get_column_letter(col_cells[0].column)
|
||||
for cell in col_cells:
|
||||
if cell.value is not None:
|
||||
cell_len = len(str(cell.value))
|
||||
if cell_len > max_len:
|
||||
max_len = cell_len
|
||||
width = max(min_width, min(max_len + padding, max_width))
|
||||
ws.column_dimensions[col_letter].width = width
|
||||
|
||||
wb.save(file_path)
|
||||
|
||||
|
||||
def get_targets(input_date: str | None):
|
||||
targets = []
|
||||
if input_date:
|
||||
try:
|
||||
parsed = datetime.strptime(input_date, "%d.%m.%Y").date()
|
||||
targets.append({"name": "Указанная дата", "date": parsed})
|
||||
except ValueError:
|
||||
log(f"ОШИБКА: Неверный формат даты '{input_date}'. Используйте ДД.ММ.ГГГГ", "ERROR")
|
||||
sys.exit(1)
|
||||
else:
|
||||
now = datetime.now()
|
||||
yesterday = (now - timedelta(days=3 if now.weekday() == 0 else 1)).date()
|
||||
today = now.date()
|
||||
|
||||
targets.append({"name": "Вчера", "date": yesterday})
|
||||
targets.append({"name": "Сегодня", "date": today})
|
||||
|
||||
return targets
|
||||
|
||||
|
||||
def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: bool = False):
|
||||
if debug:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
log("=== ВКЛЮЧЕН РЕЖИМ ОТЛАДКИ (DEBUG MODE) ===", "WARNING")
|
||||
|
||||
log("=== [ЭТАП 0] Выгрузка свежих данных СКУД напрямую из БД Орион ===")
|
||||
os.makedirs(SCUD_DIR, exist_ok=True)
|
||||
|
||||
targets = get_targets(input_date)
|
||||
|
||||
conn_str = (
|
||||
f"DRIVER={{{ODBC_DRIVER}}};"
|
||||
f"SERVER={SERVER_NAME};"
|
||||
f"DATABASE={DATABASE_NAME};"
|
||||
f"UID={SQL_USER};"
|
||||
f"PWD={SQL_PASSWORD};"
|
||||
f"TrustServerCertificate=yes;"
|
||||
f"Encrypt=no;"
|
||||
)
|
||||
|
||||
success = True
|
||||
for target in targets:
|
||||
processing_date = target["date"]
|
||||
processing_date_str = processing_date.strftime("%d.%m.%Y")
|
||||
period_label = target["name"]
|
||||
|
||||
log(f"--- Обработка периода: {period_label} ({processing_date_str}) ---")
|
||||
|
||||
sql_query = SQL_QUERY_TEMPLATE.format(target_date=processing_date.strftime("%Y-%m-%d"))
|
||||
|
||||
connection = None
|
||||
try:
|
||||
connection = pyodbc.connect(conn_str, timeout=120)
|
||||
df = pd.read_sql(sql_query, connection)
|
||||
|
||||
log(f"База вернула {len(df)} строк за {processing_date_str}")
|
||||
|
||||
if len(df) > 0:
|
||||
# 1. Готовим колонки для локальной БД
|
||||
df['fio_clean'] = df['Сотрудник'].apply(clean_scud_fio_light)
|
||||
df['Пришел'] = df['Статус'].str.contains('Присутствовал', case=False, na=False)
|
||||
|
||||
# 2. ПРЯМОЕ СОХРАНЕНИЕ В SQLite (scud_orion_ai.db)
|
||||
save_scud_to_db(df, processing_date_str)
|
||||
log(f"[✓] Записи за {processing_date_str} успешно сохранены в SQLite!", "SUCCESS")
|
||||
|
||||
# 3. Сохранение в Excel (если включен флаг save_xlsx)
|
||||
if save_xlsx:
|
||||
file_name = f"Сотрудники_{processing_date_str}.xlsx"
|
||||
file_path = os.path.join(SCUD_DIR, file_name)
|
||||
|
||||
if os.path.exists(file_path):
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except OSError as e:
|
||||
log(f"ОШИБКА при удалении старого файла {file_name}: {e}", "ERROR")
|
||||
|
||||
df.to_excel(file_path, sheet_name="Отчет", index=False, engine="openpyxl")
|
||||
auto_fit_columns(file_path, sheet_name="Отчет")
|
||||
log(f"[✓] Успешно экспортирован файл: data/scud/{file_name}", "SUCCESS")
|
||||
else:
|
||||
log(f"Запрос за {processing_date_str} вернул 0 строк.", "WARNING")
|
||||
|
||||
except Exception as e:
|
||||
log(f"🛑 ОШИБКА выгрузки СКУД за {processing_date_str}: {e}", "ERROR")
|
||||
success = False
|
||||
finally:
|
||||
if connection is not None:
|
||||
connection.close()
|
||||
|
||||
log("=== Выгрузка СКУД завершена ===\n")
|
||||
return success
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Экспорт данных СКУД в SQLite и Excel")
|
||||
parser.add_argument("--date", dest="input_date", default=None)
|
||||
parser.add_argument("-d", "--debug", action="store_true")
|
||||
parser.add_argument("--no-xlsx", dest="save_xlsx", action="store_false", default=True, help="Отключить сохранение в XLSX")
|
||||
args = parser.parse_args()
|
||||
|
||||
run_export(args.input_date, save_xlsx=args.save_xlsx, debug=args.debug)
|
||||
@@ -0,0 +1,69 @@
|
||||
import os
|
||||
import shutil
|
||||
from config import DATE_TODAY, DATE_YESTERDAY, ZUP_1C_DIR, SHARE_1C_DIR
|
||||
|
||||
|
||||
def find_file_strictly_on_share(prefix, date_str):
|
||||
"""
|
||||
Ищет файл со строгой привязкой ТОЛЬКО к сетевой шаре SHARE_1C_DIR,
|
||||
не обращаясь к локальным папкам.
|
||||
"""
|
||||
if not os.path.exists(SHARE_1C_DIR):
|
||||
return None
|
||||
|
||||
date_dots = date_str
|
||||
date_underscores = date_str.replace('.', '_')
|
||||
|
||||
try:
|
||||
for f in os.listdir(SHARE_1C_DIR):
|
||||
if f.endswith('.xlsx') or f.endswith('.csv'):
|
||||
if f.lower().startswith(prefix.lower()):
|
||||
if date_dots in f or date_underscores in f:
|
||||
return os.path.join(SHARE_1C_DIR, f)
|
||||
except Exception as e:
|
||||
print(f" [⚠️] Ошибка чтения сетевой шары: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def copy_1c_files_from_share():
|
||||
r"""
|
||||
Копирует свежие файлы 1С (Штат и Отсутствия) за Сегодня и Вчера
|
||||
из сетевой шары \\storage\SCUD\Обмен\Штат в локальную папку data/1c/
|
||||
"""
|
||||
print(f"[0.5/5] Проверка и копирование файлов 1С с шары: {SHARE_1C_DIR}...")
|
||||
|
||||
if not os.path.exists(SHARE_1C_DIR):
|
||||
print(f"[⚠️] Сетевой шар недоступен или путь не найден: {SHARE_1C_DIR}")
|
||||
print(" Используем ранее сохраненные локальные файлы из data/1c/\n")
|
||||
return False
|
||||
|
||||
dates_to_copy = [DATE_TODAY, DATE_YESTERDAY]
|
||||
prefixes = ["Штат", "Отсутствия"]
|
||||
copied_count = 0
|
||||
|
||||
os.makedirs(ZUP_1C_DIR, exist_ok=True)
|
||||
|
||||
for d_str in dates_to_copy:
|
||||
for prefix in prefixes:
|
||||
remote_file = find_file_strictly_on_share(prefix, d_str)
|
||||
|
||||
if remote_file and os.path.exists(remote_file):
|
||||
filename = os.path.basename(remote_file)
|
||||
local_target_path = os.path.join(ZUP_1C_DIR, filename)
|
||||
|
||||
try:
|
||||
shutil.copy2(remote_file, local_target_path)
|
||||
print(f" [✓] Успешно скопирован с шары: {filename} -> data/1c/")
|
||||
copied_count += 1
|
||||
except Exception as e:
|
||||
print(f" [⚠️] Ошибка копирования {filename}: {e}")
|
||||
else:
|
||||
d_fmt = d_str.replace('.', '_')
|
||||
print(f" [ℹ️] На сетевой шаре отсутствует {prefix} за {d_str} ({prefix}_{d_fmt}.xlsx)")
|
||||
|
||||
if copied_count > 0:
|
||||
print(f"[✓] Успешно скопировано файлов с сетевой шары: {copied_count} шт.\n")
|
||||
else:
|
||||
print("[ℹ️] Новых файлов за указанные даты на сетевой шаре не обнаружено.\n")
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,135 @@
|
||||
import json
|
||||
import difflib
|
||||
import re
|
||||
from modules.ai_verifier import ask_ollama
|
||||
from modules.knowledge_base import load_knowledge_base
|
||||
|
||||
|
||||
def find_python_fuzzy_matches(unexplained_df, raw_absent_df):
|
||||
"""
|
||||
Точный поиск совпадений ФИО силами Python (без галлюцинаций ИИ).
|
||||
Сравнивает фамилию и полное имя с порогом сходства >= 0.80.
|
||||
"""
|
||||
if unexplained_df.empty or raw_absent_df is None or raw_absent_df.empty:
|
||||
return []
|
||||
|
||||
# Собираем словарь чистых ФИО 1С -> сырые ФИО 1С
|
||||
absent_dict = {}
|
||||
for idx, r in raw_absent_df.iterrows():
|
||||
raw_fio = str(r.get('ФИО', ''))
|
||||
clean_fio = str(r.get('fio_clean', ''))
|
||||
reason = str(r.get('Вид_отсутствия', ''))
|
||||
if clean_fio and clean_fio not in ['Ао "Ленморниипроект"', 'Сотрудник', 'Nan', 'None']:
|
||||
absent_dict[clean_fio] = {'raw_fio': raw_fio, 'reason': reason}
|
||||
|
||||
matches = []
|
||||
unexplained_fios = unexplained_df['fio_clean'].unique() if 'fio_clean' in unexplained_df.columns else []
|
||||
|
||||
for fio in unexplained_fios:
|
||||
fio_parts = fio.split()
|
||||
if not fio_parts:
|
||||
continue
|
||||
surname = fio_parts[0].lower()
|
||||
|
||||
for abs_clean, abs_info in absent_dict.items():
|
||||
abs_parts = abs_clean.split()
|
||||
if not abs_parts:
|
||||
continue
|
||||
abs_surname = abs_parts[0].lower()
|
||||
|
||||
# Строгая проверка совпадения фамилии
|
||||
if surname == abs_surname:
|
||||
ratio = difflib.SequenceMatcher(None, fio.lower(), abs_clean.lower()).ratio()
|
||||
if ratio >= 0.75:
|
||||
matches.append({
|
||||
"target_fio": fio,
|
||||
"found_in_1c_raw": abs_info['raw_fio'],
|
||||
"reason_1c": abs_info['reason']
|
||||
})
|
||||
break
|
||||
|
||||
return matches
|
||||
|
||||
|
||||
def generate_markdown_report(merged_df, absent_explained, absent_unexplained, scud_present_but_absent_in_1c, anomalies_list=None, raw_scud_df=None, raw_staff_df=None, raw_absent_df=None, date_str="29.07.2026"):
|
||||
kb = load_knowledge_base()
|
||||
custom_rules = kb.get("rules", [])
|
||||
custom_rules_str = "\n".join([f"- {r}" for r in custom_rules]) if custom_rules else "Специфических правил компании пока нет."
|
||||
|
||||
# 1. Точный расчет неточных совпадений через Python (Исключает галлюцинации ИИ)
|
||||
fio_mismatches_python = find_python_fuzzy_matches(absent_unexplained, raw_absent_df)
|
||||
|
||||
# 2. Список истинно неизвестных для ИИ
|
||||
unexplained_list = []
|
||||
if not absent_unexplained.empty:
|
||||
for idx, r in absent_unexplained.iterrows():
|
||||
fio = r.get('Сотрудник', r.get('fio_clean', ''))
|
||||
dept = r.get('Подразделение', '—')
|
||||
pos = r.get('Должность', '—')
|
||||
unexplained_list.append(f"{fio} — {dept}, {pos}")
|
||||
|
||||
total_staff = len(merged_df)
|
||||
is_working_mask = (merged_df['Пришел'] == True) | (
|
||||
merged_df['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False)
|
||||
)
|
||||
present_cnt = len(merged_df[is_working_mask])
|
||||
explained_cnt = len(absent_explained)
|
||||
unexplained_cnt = len(absent_unexplained)
|
||||
anomalies_cnt = len(anomalies_list) if anomalies_list else 0
|
||||
|
||||
prompt = f"""
|
||||
Ты — старший аудитор кадровой безопасности и контроллинга СКУД.
|
||||
Сформируй итоговую сводку кадрового контроля на {date_str}.
|
||||
|
||||
📌 БАЗА ЗНАНИЙ И ПРАВИЛА ПРЕДПРИЯТИЯ:
|
||||
{custom_rules_str}
|
||||
|
||||
🚨 ВХОДНЫЕ МЕТРИКИ:
|
||||
- Всего сотрудников: {total_staff}
|
||||
- Работают (офис / удаленка / командировки): {present_cnt}
|
||||
- Официально отсутствуют: {explained_cnt}
|
||||
- Неизвестно (истинно неотмеченные): {unexplained_cnt}
|
||||
- Выявлено аномалий/конфликтов реестров: {anomalies_cnt}
|
||||
|
||||
---
|
||||
🚨 ПОДТВЕРЖДЁННЫЕ АНОМАЛИИ ({anomalies_cnt} шт):
|
||||
{json.dumps(anomalies_list, ensure_ascii=False, indent=2)}
|
||||
|
||||
---
|
||||
⚠️ ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО В 1С ({len(fio_mismatches_python)} шт):
|
||||
{json.dumps(fio_mismatches_python, ensure_ascii=False, indent=2)}
|
||||
|
||||
---
|
||||
📊 СПИСОК НЕИЗВЕСТНЫХ СЛУЧАЕВ ({unexplained_cnt} чел):
|
||||
{json.dumps(unexplained_list, ensure_ascii=False, indent=2)}
|
||||
|
||||
СТРОГИЕ ИНСТРУКЦИИ ДЛЯ ИИ:
|
||||
1. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО придумывать несуществующие совпадения ФИО или приписывать суффиксы "(осн.)". Используй ТОЛЬКО массив `ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО`. Если этот массив пуст, напиши в этом разделе: "Ошибок сопоставления ФИО и неточностей в 1С не обнаружено."
|
||||
2. В разделе "Неизвестные случаи" выведи НУМЕРОВАННЫЙ СПИСОК всех {unexplained_cnt} человек ровно в том виде, в котором они переданы выше.
|
||||
3. В разделе "Рекомендации" дай 2-3 конкретные системные рекомендации для кадровой службы. НЕ ПЕРЕЧИСЛЯЙ конкретные ФИО в тексте рекомендаций.
|
||||
|
||||
СТРОГИЙ ШАБЛОН ОТВЕТА:
|
||||
|
||||
**Сводка контроллинга СКУД и 1С:ЗУП на {date_str}**
|
||||
|
||||
- Всего офисных сотрудников: **{total_staff}**
|
||||
- Работают (офис / удаленка / командировки): **{present_cnt}**
|
||||
- Официально отсутствуют: **{explained_cnt}**
|
||||
- Неизвестно (истинно неотмеченные): **{unexplained_cnt}** чел.
|
||||
- Выявлено аномалий/конфликтов реестров: **{anomalies_cnt}** шт.
|
||||
|
||||
#### 🚨 Выявленные ИИ аномалии и конфликты источников ({anomalies_cnt}):
|
||||
(Описание аномалий из anomalies_list. Если их нет — "Аномалий не обнаружено.")
|
||||
|
||||
#### ⚠️ Подозрения на ошибки сопоставления ФИО и несоответствия 1С:
|
||||
(Выведи данные ИСКЛЮЧИТЕЛЬНО из массива fio_mismatches_python. Если он пуст — "Ошибок сопоставления ФИО и неточностей в 1С не обнаружено.")
|
||||
|
||||
#### Неизвестные случаи: {unexplained_cnt}
|
||||
(Выведи нумерованный список всех {unexplained_cnt} человек)
|
||||
|
||||
### Точечные рекомендации:
|
||||
(2-3 системные рекомендации без указания ФИО сотрудников)
|
||||
"""
|
||||
|
||||
sys_prompt = "Ты — эксперт кадрового аудита. Будь точен, не придумывай непереданные данные."
|
||||
return ask_ollama(prompt, system_prompt=sys_prompt)
|
||||
@@ -0,0 +1,159 @@
|
||||
import os
|
||||
import logging
|
||||
from datetime import date, datetime
|
||||
import pyodbc
|
||||
import pandas as pd
|
||||
from config import DATA_DIR, normalize_fio, ZUP_SQL_CONFIG
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_zup_connection_string() -> str:
|
||||
"""Формирует строку подключения pyodbc к MS SQL Server из config.py."""
|
||||
return (
|
||||
f"DRIVER={ZUP_SQL_CONFIG['driver']};"
|
||||
f"SERVER={ZUP_SQL_CONFIG['server']};"
|
||||
f"DATABASE={ZUP_SQL_CONFIG['database']};"
|
||||
f"UID={ZUP_SQL_CONFIG['user']};"
|
||||
f"PWD={ZUP_SQL_CONFIG['password']};"
|
||||
f"TrustServerCertificate={ZUP_SQL_CONFIG.get('trust_server_certificate', 'yes')};"
|
||||
f"Encrypt={ZUP_SQL_CONFIG.get('encrypt', 'no')};"
|
||||
)
|
||||
|
||||
|
||||
def fetch_zup_absences_from_sql(target_date) -> pd.DataFrame:
|
||||
"""
|
||||
Извлекает оперативные отсутствия и действующие декреты из MS SQL 1С:ЗУП 3.1
|
||||
на указанную дату (принимает как datetime.date, так и строку 'DD.MM.YYYY' / 'DD_MM_YYYY').
|
||||
"""
|
||||
if isinstance(target_date, str):
|
||||
clean_date_str = target_date.replace('_', '.')
|
||||
try:
|
||||
target_date = datetime.strptime(clean_date_str, "%d.%m.%Y").date()
|
||||
except ValueError:
|
||||
logger.error(f"[❌] Неверный формат даты для SQL-запроса: {target_date}. Ожидался DD.MM.YYYY")
|
||||
return pd.DataFrame()
|
||||
|
||||
query = """
|
||||
DECLARE @TargetDate DATE = ?;
|
||||
|
||||
-- 1. Оперативные отсутствия (Отпуска, Командировки, Больничные, Отгулы)
|
||||
SELECT
|
||||
LTRIM(RTRIM(ref_emp._Description)) AS [ФИО],
|
||||
CASE state._Fld16925RRef
|
||||
WHEN 0x9C10B2452D414FDF4A90E2B2AB81D3F7 THEN N'Отпуск основной'
|
||||
WHEN 0xBA63FCF94B4AD0664ED369D2E6505D67 THEN N'Командировка'
|
||||
WHEN 0x8C3B61F23954155A40EB0108FC0932DB THEN N'Болезнь'
|
||||
WHEN 0x853001C18D0965EE4B2702405C94054A THEN N'Отпуск неоплачиваемый по разрешению работодателя'
|
||||
WHEN 0xB7335AEFD8708C3E462861FC59489A38 THEN N'Отпуск по беременности и родам'
|
||||
ELSE N'Другое отсутствие'
|
||||
END AS [Вид_отсутствия]
|
||||
|
||||
FROM dbo._InfoRg16921 state WITH (NOLOCK)
|
||||
INNER JOIN dbo._Reference299 ref_emp WITH (NOLOCK)
|
||||
ON state._Fld16922RRef = ref_emp._IDRRef
|
||||
|
||||
WHERE @TargetDate BETWEEN CAST(CASE WHEN YEAR(state._Fld16926) > 3000 THEN DATEADD(YEAR, -2000, state._Fld16926) ELSE state._Fld16926 END AS DATE)
|
||||
AND CAST(CASE WHEN YEAR(state._Fld16927) > 3000 THEN DATEADD(YEAR, -2000, state._Fld16927) ELSE state._Fld16927 END AS DATE)
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- 2. Динамический выбор ДЕЙСТВУЮЩИХ декретниц по уходу за ребенком
|
||||
SELECT
|
||||
active_state.fio AS [ФИО],
|
||||
N'Отпуск по уходу за ребенком' AS [Вид_отсутствия]
|
||||
|
||||
FROM (
|
||||
SELECT
|
||||
LTRIM(RTRIM(ref_emp._Description)) AS fio,
|
||||
all_states._Fld16925RRef AS state_guid,
|
||||
CAST(CASE WHEN YEAR(all_states._Fld16926) > 3000 THEN DATEADD(YEAR, -2000, all_states._Fld16926) ELSE all_states._Fld16926 END AS DATE) AS date_start,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY all_states._Fld16922RRef
|
||||
ORDER BY all_states._Fld16926 DESC
|
||||
) AS rn
|
||||
FROM dbo._InfoRg16921 all_states WITH (NOLOCK)
|
||||
INNER JOIN dbo._Reference299 ref_emp WITH (NOLOCK)
|
||||
ON all_states._Fld16922RRef = ref_emp._IDRRef
|
||||
WHERE CAST(CASE WHEN YEAR(all_states._Fld16926) > 3000 THEN DATEADD(YEAR, -2000, all_states._Fld16926) ELSE all_states._Fld16926 END AS DATE) <= @TargetDate
|
||||
) active_state
|
||||
|
||||
WHERE active_state.rn = 1
|
||||
AND active_state.state_guid = 0xA4FBA038663B3C2A48DA151C262855E1
|
||||
AND active_state.date_start >= DATEADD(YEAR, -3, @TargetDate)
|
||||
|
||||
ORDER BY [ФИО] ASC;
|
||||
"""
|
||||
|
||||
try:
|
||||
conn_str = get_zup_connection_string()
|
||||
with pyodbc.connect(conn_str, timeout=5) as conn:
|
||||
df = pd.read_sql(query, conn, params=[target_date])
|
||||
|
||||
if not df.empty:
|
||||
df['fio_clean'] = df['ФИО'].apply(normalize_fio)
|
||||
return df
|
||||
except Exception as e:
|
||||
logger.error(f"[❌] Ошибка SQL-выгрузки отсутствий за {target_date}: {e}")
|
||||
return pd.DataFrame()
|
||||
|
||||
|
||||
def fetch_zup_staff_from_sql() -> pd.DataFrame:
|
||||
"""Резервная выгрузка штата из MS SQL."""
|
||||
query = """
|
||||
SELECT DISTINCT
|
||||
LTRIM(RTRIM(ref_emp._Description)) AS [ФИО],
|
||||
N'Организация' AS [Подразделение],
|
||||
N'Сотрудник' AS [Должность]
|
||||
FROM dbo._Reference299 ref_emp WITH (NOLOCK)
|
||||
WHERE ref_emp._Description <> ''
|
||||
AND ref_emp._Marked = 0x00
|
||||
ORDER BY [ФИО] ASC;
|
||||
"""
|
||||
try:
|
||||
conn_str = get_zup_connection_string()
|
||||
with pyodbc.connect(conn_str, timeout=5) as conn:
|
||||
df = pd.read_sql(query, conn)
|
||||
|
||||
if not df.empty:
|
||||
df['fio_clean'] = df['ФИО'].apply(normalize_fio)
|
||||
return df
|
||||
except Exception as e:
|
||||
logger.error(f"[❌] Ошибка выгрузки штата из MS SQL: {e}")
|
||||
return pd.DataFrame()
|
||||
|
||||
|
||||
def sync_zup_to_excel(target_date) -> bool:
|
||||
"""Создает дамп в Excel при необходимости."""
|
||||
try:
|
||||
if isinstance(target_date, str):
|
||||
clean_date_str = target_date.replace('_', '.')
|
||||
target_date_obj = datetime.strptime(clean_date_str, "%d.%m.%Y").date()
|
||||
else:
|
||||
target_date_obj = target_date
|
||||
|
||||
date_str_file = target_date_obj.strftime("%d_%m_%Y")
|
||||
c_1c_dir = os.path.join(DATA_DIR, "1c")
|
||||
os.makedirs(c_1c_dir, exist_ok=True)
|
||||
|
||||
df_staff = fetch_zup_staff_from_sql()
|
||||
df_absences = fetch_zup_absences_from_sql(target_date_obj)
|
||||
|
||||
staff_excel_path = os.path.join(c_1c_dir, f"Штат_{date_str_file}.xlsx")
|
||||
absences_excel_path = os.path.join(c_1c_dir, f"Отсутствия_{date_str_file}.xlsx")
|
||||
|
||||
with pd.ExcelWriter(staff_excel_path, engine='openpyxl') as writer:
|
||||
dummy_headers = pd.DataFrame([[""] * 13] * 8)
|
||||
dummy_headers.to_excel(writer, index=False, header=False)
|
||||
df_staff[['ФИО', 'Подразделение', 'Должность']].to_excel(writer, startrow=8, index=False)
|
||||
|
||||
with pd.ExcelWriter(absences_excel_path, engine='openpyxl') as writer:
|
||||
dummy_headers = pd.DataFrame([[""] * 3] * 3)
|
||||
dummy_headers.to_excel(writer, index=False, header=False)
|
||||
df_absences[['ФИО', 'Вид_отсутствия']].to_excel(writer, startrow=3, index=False)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[❌] Ошибка прямого импорта из MS SQL 1С: {e}")
|
||||
return False
|
||||
Reference in New Issue
Block a user