перед внедрением API и веб интерфейса.

This commit is contained in:
2026-08-04 17:43:00 +03:00
parent d667b2d846
commit 7eea236417
11 changed files with 357 additions and 87 deletions
+37 -26
View File
@@ -5,6 +5,8 @@ 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')
@@ -24,42 +26,51 @@ def is_excluded(fio, dept, pos, exceptions):
def load_staff_data(date_str):
"""
Загружает файл Штатного расписания 1С за конкретную дату.
Если файла за эту дату нет — возвращает None без подмены на старые даты.
Загружает файл Штатного расписания 1С за конкретную дату из папки 1c.
"""
filepath = find_dated_file("Штат", date_str)
if not filepath:
return None
fallback_path = os.path.join(ZUP_1C_DIR, "штат.xlsx")
if os.path.exists(fallback_path):
filepath = fallback_path
else:
return None
df_raw = pd.read_excel(filepath, skiprows=8)
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
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):
"""
Загружает файл Отсутствий сотрудников 1С за конкретную дату.
Если файла за эту дату нет — возвращает None без подмены на старые даты.
Загружает документально подтвержденные отсутствия сотрудников
НАПРЯМУЮ из базы MS SQL 1С:ЗУП за указанную дату.
"""
filepath = find_dated_file("Отсутствия", date_str)
if not filepath:
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
df_raw = pd.read_excel(filepath, skiprows=3)
df_absent = df_raw[1:].copy().reset_index(drop=True)
df_absent = df_absent.rename(columns={
df_absent.columns[0]: 'ФИО',
df_absent.columns[2]: 'Вид_отсутствия'
})
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'])
def load_scud_data(target_date_type="today"):
"""Загружает файл СКУД за 'today' или 'yesterday'."""
@@ -100,7 +111,7 @@ def load_scud_data(target_date_type="today"):
def load_all_data():
"""
Загружает слитные датасеты за Сегодня и за Вчера.
Если файла 1С за Сегодня нет, соответствующие датасеты вернет как None.
Если данных за Сегодня нет, возвращает для них None.
"""
df_scud_today = load_scud_data(target_date_type="today")
try:
+25 -6
View File
@@ -1,5 +1,6 @@
import os
import sqlite3
import json
import pandas as pd
from datetime import datetime
from config import DATA_DIR
@@ -94,21 +95,38 @@ def init_db():
conn.commit()
# Автоматически загружаем базовые правила компании
# Автоматически загружаем базовые правила и системный промпт
sync_knowledge_base_to_db()
def sync_knowledge_base_to_db():
"""
Автоматически мигрирует все правила из knowledge_base.json в таблицу ai_knowledge_base SQLite.
Автоматически мигрирует правила из knowledge_base.json и системный промпт в таблицу ai_knowledge_base SQLite.
"""
kb_path = os.path.join(os.path.dirname(DATA_DIR), "knowledge_base.json")
system_ai_prompt = (
"СИСТЕМНЫЙ ПРОМПТ ИИ (MATCHING & VERIFICATION):\n"
"Ты — экспертный ИИ-аудитор СКУД и кадрового учета 1С. Твоя задача — проводить интеллектуальный анализ аномалий "
"и сопоставление (Fuzzy Matching) нераспознанных ФИО из СКУД с эталонным Штатным расписанием 1С.\n\n"
"1. ЗАЩИТА ОТ ПОЛНЫХ ТЁЗОК (FULL NAME COLLISION):\n"
" - Если в эталонном штате 1С присутствуют два или более сотрудников с ОДИНАКОВЫМИ ФИО (полные тёзки), "
"ты ОБЯЗАН учитывать контекст: Подразделение и Должность.\n"
" - Ни в коем случае не объединяй записи разных людей! Если контекст не позволяет однозначно определить, кто именно из тёзок совершил проход, ПОМЕТЬ СЛУЧАЙ КАК НЕОДНОЗНАЧНЫЙ и НЕ делай автоматическую подмену.\n\n"
"2. ПРАВИЛА СОПОСТАВЛЕНИЯ (MATCHING RULES):\n"
" - Допускаются исправления мелких опечаток, перестановки порядка (Имя Фамилия -> Фамилия Имя), замена букв Е/Ё, И/Й.\n"
" - Учитывай сокращения инициалов (например, 'Иванов И.В.' -> 'Иванов Иван Васильевич').\n"
" - Если уверенность в совпадении меньше 85% — НЕ сопоставляй, оставляй запись как нераспознанную.\n\n"
"3. ФОРМАТ ВЫХОДНЫХ ДАННЫХ:\n"
" Верни строго JSON-объект без вводных слов и Markdown-разметки: {\"mappings\": [...], \"unmatched\": [...]}"
)
rules_to_insert = [
system_ai_prompt,
"ГЛОБАЛЬНОЕ ПРАВИЛО 1: Любое физическое присутствие сотрудника по СКУД при наличии в 1С документа отсутствия (отпуск, больничный, уход за ребенком и др.), КРОМЕ командировок и служебных поездок — ЯВЛЯЕТСЯ СТРОГОЙ ИСТИННОЙ АНОМАЛИЕЙ.",
"ГЛОБАЛЬНОЕ ПРАВИЛО 2: Документы отсутствий из 1С:ЗУП имеют 100% высший приоритет над статическим реестром удалёнщиков.",
"ГЛОБАЛЬНОЕ ПРАВИЛО 3: Удалённые сотрудники имеют право беспрепятственно работать в офисе по СКУД в любое время (это не является аномалией)."
]
kb_path = os.path.join(os.path.dirname(DATA_DIR), "knowledge_base.json")
if os.path.exists(kb_path):
try:
with open(kb_path, "r", encoding="utf-8") as f:
@@ -124,11 +142,12 @@ def sync_knowledge_base_to_db():
cursor = conn.cursor()
for rule in rules_to_insert:
cursor.execute(
"INSERT OR IGNORE INTO ai_knowledge_base (rule_text) VALUES (?)",
(rule.strip(),)
"INSERT OR IGNORE INTO ai_knowledge_base (rule_text, added_by) VALUES (?, ?)",
(rule.strip(), "System_AI")
)
conn.commit()
def save_scud_to_db(df_scud, date_str):
"""Оптимизированное сохранение логов СКУД за дату."""
if df_scud.empty:
+50 -52
View File
@@ -1,40 +1,39 @@
import os
import logging
from datetime import date
from datetime import date, datetime
import pyodbc
import pandas as pd
from config import DATA_DIR, normalize_fio
from config import DATA_DIR, normalize_fio, ZUP_SQL_CONFIG
logger = logging.getLogger(__name__)
# Конфигурация MS SQL Server 1С:ЗУП 3.1
ZUP_SQL_CONFIG = {
"driver": "{ODBC Driver 18 for SQL Server}",
"server": os.getenv("ZUP_MS_SQL_SERVER", "192.168.1.10"), # Укажите IP MS SQL 1С
"database": os.getenv("ZUP_MS_SQL_DB", "zup_db"),
"user": os.getenv("ZUP_MS_SQL_USER", "sa"),
"password": os.getenv("ZUP_MS_SQL_PASS", "your_password"),
}
def get_zup_connection_string() -> str:
"""Формирует строку подключения pyodbc к MS SQL Server."""
"""Формирует строку подключения 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=yes;"
f"Encrypt=no;"
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: date) -> pd.DataFrame:
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 = ?;
@@ -86,17 +85,21 @@ def fetch_zup_absences_from_sql(target_date: date) -> pd.DataFrame:
ORDER BY [ФИО] ASC;
"""
conn_str = get_zup_connection_string()
with pyodbc.connect(conn_str) as conn:
df = pd.read_sql(query, conn, params=[target_date])
if not df.empty:
df['fio_clean'] = df['ФИО'].apply(normalize_fio)
return df
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 1С:ЗУП."""
"""Резервная выгрузка штата из MS SQL."""
query = """
SELECT DISTINCT
LTRIM(RTRIM(ref_emp._Description)) AS [ФИО],
@@ -107,53 +110,48 @@ def fetch_zup_staff_from_sql() -> pd.DataFrame:
AND ref_emp._Marked = 0x00
ORDER BY [ФИО] ASC;
"""
conn_str = get_zup_connection_string()
with pyodbc.connect(conn_str) as conn:
df = pd.read_sql(query, conn)
if not df.empty:
df['fio_clean'] = df['ФИО'].apply(normalize_fio)
return df
def sync_zup_to_excel(target_date: date) -> bool:
"""
Извлекает данные из MS SQL и автоматически создает локальные Excel-файлы 1С:
data/1c/Штат_DD_MM_YYYY.xlsx
data/1c/Отсутствия_DD_MM_YYYY.xlsx
"""
try:
date_str_file = target_date.strftime("%d_%m_%Y")
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)
df_absences = fetch_zup_absences_from_sql(target_date_obj)
logger.info(f"[1С Direct SQL] Выгрузка за {target_date.strftime('%d.%m.%Y')}: Штат ({len(df_staff)} чел.), Отсутствия ({len(df_absences)} чел.)")
# Сохранение в формате, который ожидает data_loader.py
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")
# Формируем структуру Excel с нужным количеством пропущенных строк (skiprows)
with pd.ExcelWriter(staff_excel_path, engine='openpyxl') as writer:
# Пустой заголовок (8 строк) для эмуляции отчета 1С
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:
# Пустой заголовок (3 строки) для эмуляции отчета 1С
dummy_headers = pd.DataFrame([[""] * 3] * 3)
dummy_headers.to_excel(writer, index=False, header=False)
# Данные
df_absences[['ФИО', 'Вид_отсутствия']].to_excel(writer, startrow=3, index=False)
logger.info(f"[✓] Успешно созданы файлы 1С за {target_date.strftime('%d.%m.%Y')}: {staff_excel_path}, {absences_excel_path}")
return True
except Exception as e: