feat(reports): stabilize on-demand generation, live presence and 1C fallback
This commit is contained in:
@@ -36,7 +36,9 @@ def get_all_exceptions_from_db() -> Dict[str, List[str]]:
|
||||
"position_keywords": [],
|
||||
"include_fio": [],
|
||||
"turnstile_fio": [],
|
||||
"turnstile_departments": []
|
||||
"turnstile_departments": [],
|
||||
"fligel_fio": [], # ⭐️ Сотрудники с доступом через Флигель (DoorIndex = 23)
|
||||
"fligel_departments": [] # ⭐️ Отделы с доступом через Флигель
|
||||
}
|
||||
|
||||
with get_connection() as conn:
|
||||
@@ -56,8 +58,8 @@ def get_all_exceptions_from_db() -> Dict[str, List[str]]:
|
||||
|
||||
def add_exception_to_db(category: str, value: str, comment: str = "") -> bool:
|
||||
init_exceptions_table()
|
||||
# ФИО очищаем и приводим к нормализованному виду
|
||||
val_clean = normalize_fio(value) if category in ["fio", "include_fio", "turnstile_fio"] else value.strip()
|
||||
# Нормализуем ФИО в том числе для реестра флигеля
|
||||
val_clean = normalize_fio(value) if category in ["fio", "include_fio", "turnstile_fio", "fligel_fio"] else value.strip()
|
||||
if not val_clean:
|
||||
return False
|
||||
with get_connection() as conn:
|
||||
|
||||
@@ -176,23 +176,31 @@ def delete_manual_absence(item_id: int) -> bool:
|
||||
|
||||
|
||||
def get_manual_absences_list(absence_type: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
init_manual_absences_table()
|
||||
today_str = datetime.now().strftime("%Y-%m-%d")
|
||||
# ⭐️ Обязательно row_factory=True для преобразования строк SQLite в dict
|
||||
with get_connection(row_factory=True) as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 1. Автоочистка просроченных командировок и отсутствий
|
||||
cursor.execute("""
|
||||
DELETE FROM manual_absences
|
||||
WHERE date_end IS NOT NULL
|
||||
AND date_end != ''
|
||||
AND date_end < ?
|
||||
""", (today_str,))
|
||||
conn.commit()
|
||||
|
||||
# 2. Выборка активных записей
|
||||
query = "SELECT id, absence_type, fio, department, position, reason, date_start, date_end, comment FROM manual_absences"
|
||||
params = []
|
||||
if absence_type:
|
||||
cursor.execute("""
|
||||
SELECT id, absence_type, fio, fio_clean, department, position, date_start, date_end, reason, comment, created_at
|
||||
FROM manual_absences
|
||||
WHERE absence_type = ?
|
||||
ORDER BY id DESC
|
||||
""", (absence_type.upper(),))
|
||||
else:
|
||||
cursor.execute("""
|
||||
SELECT id, absence_type, fio, fio_clean, department, position, date_start, date_end, reason, comment, created_at
|
||||
FROM manual_absences
|
||||
ORDER BY id DESC
|
||||
""")
|
||||
return [dict(r) for r in cursor.fetchall()]
|
||||
query += " WHERE absence_type = ?"
|
||||
params.append(absence_type)
|
||||
query += " ORDER BY id DESC"
|
||||
|
||||
cursor.execute(query, params)
|
||||
rows = cursor.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_active_manual_absences_for_date(date_str: str) -> List[Dict[str, Any]]:
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: services/presence_service.py
|
||||
ROLE: Сервис оперативного мониторинга («Кто в здании прямо сейчас»).
|
||||
1. Строгое соответствие общему штату 1С (286 чел.).
|
||||
2. Служебный персонал (уборщики, контролеры) маркируется как EXCLUDED
|
||||
и не искажает вкладки "Не пришли" и "В здании".
|
||||
3. Сотрудники флигеля/двора (Чупряев, Пухаренко, БЛ) при выходе через
|
||||
турникет во двор остаются со статусом "В здании (Флигель/Двор)".
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
import pandas as pd
|
||||
|
||||
from core.connection import get_connection
|
||||
from config import DATE_TODAY, normalize_fio
|
||||
from services.exceptions_repo import get_all_exceptions_from_db
|
||||
from services.data_loader import load_1c_data_smart
|
||||
from services.scud_export import run_export
|
||||
|
||||
logger = logging.getLogger("PRESENCE_SERVICE")
|
||||
|
||||
|
||||
def get_latest_zup_staff() -> Optional[pd.DataFrame]:
|
||||
"""Загружает самый свежий доступный срез штата из zup_staff."""
|
||||
with get_connection(row_factory=True) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT snapshot_date
|
||||
FROM zup_staff
|
||||
ORDER BY
|
||||
SUBSTR(snapshot_date, 7, 4) DESC,
|
||||
SUBSTR(snapshot_date, 4, 2) DESC,
|
||||
SUBSTR(snapshot_date, 1, 2) DESC,
|
||||
id DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
|
||||
latest_date = row[0]
|
||||
df = pd.read_sql_query(
|
||||
"SELECT fio as 'ФИО', fio_clean, department as 'Подразделение', position as 'Должность' FROM zup_staff WHERE snapshot_date = ?",
|
||||
conn, params=(latest_date,)
|
||||
)
|
||||
return df if not df.empty else None
|
||||
|
||||
|
||||
def get_live_presence(date_str: Optional[str] = None, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
clean_date = (date_str or DATE_TODAY).replace('_', '.')
|
||||
data_source = "LOCAL_SQLITE"
|
||||
|
||||
# 1. Принудительный опрос MS SQL при запросе
|
||||
if force_refresh:
|
||||
logger.info(f"[Presence] Прямой опрос MS SQL Орион за {clean_date}...")
|
||||
try:
|
||||
run_export(input_date=clean_date, save_xlsx=False, debug=False)
|
||||
data_source = "LIVE_MSSQL"
|
||||
except Exception as e:
|
||||
logger.error(f"[Presence] Ошибка при обращении к MS SQL: {e}")
|
||||
|
||||
# 2. Выборка последних событий по каждому сотруднику за сегодня
|
||||
events_raw = []
|
||||
with get_connection(row_factory=True) as conn:
|
||||
cursor = conn.cursor()
|
||||
query = """
|
||||
WITH RankedEvents AS (
|
||||
SELECT
|
||||
hoz_organ,
|
||||
fio,
|
||||
fio_clean,
|
||||
department,
|
||||
time_val,
|
||||
direction,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY fio_clean
|
||||
ORDER BY time_val DESC, id DESC
|
||||
) as rn
|
||||
FROM scud_events_raw
|
||||
WHERE log_date = ?
|
||||
)
|
||||
SELECT hoz_organ, fio, fio_clean, department, time_val, direction
|
||||
FROM RankedEvents
|
||||
WHERE rn = 1;
|
||||
"""
|
||||
cursor.execute(query, (clean_date,))
|
||||
events_raw = [dict(r) for r in cursor.fetchall()]
|
||||
|
||||
last_events_map = {r['fio_clean']: r for r in events_raw}
|
||||
|
||||
# 3. Штат 1С и кадровые отклонения
|
||||
df_staff, df_abs = load_1c_data_smart(clean_date, use_db=True)
|
||||
if df_staff is None or df_staff.empty:
|
||||
df_staff = get_latest_zup_staff()
|
||||
|
||||
absences_map = {}
|
||||
if df_abs is not None and not df_abs.empty:
|
||||
for _, row in df_abs.iterrows():
|
||||
fc = row.get('fio_clean')
|
||||
reason = str(row.get('Вид_отсутствия', '')).strip()
|
||||
if fc and reason:
|
||||
absences_map[fc] = reason
|
||||
|
||||
# 4. Исключения и реестр флигеля из базы данных
|
||||
try:
|
||||
exceptions_cfg = get_all_exceptions_from_db()
|
||||
except Exception:
|
||||
exceptions_cfg = {}
|
||||
|
||||
exc_fios = set([normalize_fio(f) for f in exceptions_cfg.get("fio", []) if f])
|
||||
exc_depts = set([str(d).strip().lower() for d in exceptions_cfg.get("departments", []) if d])
|
||||
exc_positions = set([str(p).strip().lower() for p in exceptions_cfg.get("positions", []) if p])
|
||||
|
||||
# Реестр флигеля / двора (ФИО и отделы)
|
||||
fligel_fios = set([normalize_fio(f) for f in exceptions_cfg.get("fligel_fio", []) if f])
|
||||
fligel_depts = set([str(d).strip().lower() for d in exceptions_cfg.get("fligel_departments", []) if d])
|
||||
# Авто-добавление ключевых сотрудников и подразделений лаборатории БЛ
|
||||
fligel_depts.update(["бл", "бетонная лаборатория", "испытательная геотехническая лаборатория"])
|
||||
fligel_fios.update([normalize_fio("Чупряев Антон Михайлович"), normalize_fio("Пухаренко Ольга Юрьевна")])
|
||||
|
||||
# Ключевые слова технического персонала (клининг, контролеры КПП)
|
||||
default_exc_keywords = ["уборщ", "дворник", "контролер", "внутреннего контроля", "клининг"]
|
||||
|
||||
def check_is_excluded(fio_c: str, dept: str, pos: str) -> bool:
|
||||
if fio_c in exc_fios:
|
||||
return True
|
||||
d_lower = str(dept).strip().lower()
|
||||
if d_lower in exc_depts or any(k in d_lower for k in ["контрол", "клининг"]):
|
||||
return True
|
||||
p_lower = str(pos).strip().lower()
|
||||
if p_lower in exc_positions or any(k in p_lower for k in default_exc_keywords):
|
||||
return True
|
||||
return False
|
||||
|
||||
def check_is_fligel(fio_c: str, dept: str) -> bool:
|
||||
if fio_c in fligel_fios:
|
||||
return True
|
||||
d_lower = str(dept).strip().lower()
|
||||
return d_lower in fligel_depts or any(k in d_lower for k in ["бетонная лаб", "геотехническая лаб"])
|
||||
|
||||
# 5. Формирование полного списка сотрудников
|
||||
staff_items = []
|
||||
seen_fios = set()
|
||||
|
||||
# 5.1. Обработка всех сотрудников из официального штата 1С
|
||||
if df_staff is not None and not df_staff.empty:
|
||||
for _, s_row in df_staff.iterrows():
|
||||
fio_raw = s_row.get('ФИО', '')
|
||||
fio_c = s_row.get('fio_clean', normalize_fio(fio_raw))
|
||||
if not fio_c or fio_c in seen_fios:
|
||||
continue
|
||||
|
||||
seen_fios.add(fio_c)
|
||||
dept_1c = str(s_row.get('Подразделение', '—')).strip()
|
||||
pos = str(s_row.get('Должность', '—')).strip()
|
||||
|
||||
is_excluded = check_is_excluded(fio_c, dept_1c, pos)
|
||||
is_fligel = check_is_fligel(fio_c, dept_1c)
|
||||
|
||||
absence_reason = absences_map.get(fio_c, "")
|
||||
event = last_events_map.get(fio_c)
|
||||
has_events_today = event is not None
|
||||
last_time = event['time_val'].split()[-1][:5] if event and ' ' in event['time_val'] else (event['time_val'][:5] if event else '—')
|
||||
direction = str(event.get('direction', '')).upper() if event else ""
|
||||
|
||||
dept_val = str(event['department']).strip() if (event and event.get('department') and str(event['department']).strip() not in ['—', 'Без подразделения', '']) else dept_1c
|
||||
|
||||
is_remote = "удален" in absence_reason.lower() or "дистанцион" in absence_reason.lower()
|
||||
is_trip_or_leave = bool(absence_reason) and not is_remote
|
||||
|
||||
# ⭐️ ЛОГИКА ОПРЕДЕЛЕНИЯ СТАТУСА:
|
||||
if is_excluded:
|
||||
# Сотрудник входит в штат 1С, но имеет служебный статус исключения
|
||||
status = "EXCLUDED"
|
||||
status_label = "Исключение (Служебный)"
|
||||
elif is_fligel and has_events_today:
|
||||
# ⭐️ Сотрудник флигеля/двора: выход через турникет = внутридневной выход во двор на рабочее место
|
||||
status = "INSIDE"
|
||||
status_label = "В здании (Флигель/Двор)"
|
||||
elif direction == "OUT":
|
||||
status = "OUTSIDE"
|
||||
status_label = "Вышел"
|
||||
elif direction == "IN" or has_events_today:
|
||||
status = "INSIDE"
|
||||
status_label = "В здании"
|
||||
elif is_remote:
|
||||
status = "REMOTE"
|
||||
status_label = "Удаленная работа"
|
||||
elif is_trip_or_leave:
|
||||
status = "OFFICIAL_ABSENCE"
|
||||
status_label = absence_reason
|
||||
else:
|
||||
status = "NOT_ENTERED"
|
||||
status_label = "Не пришел"
|
||||
|
||||
staff_items.append({
|
||||
"fio": fio_raw,
|
||||
"fio_clean": fio_c,
|
||||
"department": dept_val,
|
||||
"position": pos,
|
||||
"status": status,
|
||||
"status_label": status_label,
|
||||
"last_time": last_time,
|
||||
"last_direction": direction or "NONE",
|
||||
"absence_reason": absence_reason,
|
||||
"is_excluded": is_excluded,
|
||||
"is_fligel": is_fligel
|
||||
})
|
||||
|
||||
# Сортировка: В здании -> Вышли -> Удаленка -> Отсутствуют -> Не пришли -> Исключения
|
||||
status_order = {
|
||||
"INSIDE": 0,
|
||||
"OUTSIDE": 1,
|
||||
"REMOTE": 2,
|
||||
"OFFICIAL_ABSENCE": 3,
|
||||
"NOT_ENTERED": 4,
|
||||
"EXCLUDED": 5
|
||||
}
|
||||
staff_items.sort(key=lambda x: (status_order.get(x["status"], 6), x["fio"].lower()))
|
||||
|
||||
# 6. Метрики
|
||||
total_staff = len(staff_items)
|
||||
inside_count = sum(1 for x in staff_items if x["status"] == "INSIDE")
|
||||
outside_count = sum(1 for x in staff_items if x["status"] == "OUTSIDE")
|
||||
remote_count = sum(1 for x in staff_items if x["status"] == "REMOTE")
|
||||
absence_count = sum(1 for x in staff_items if x["status"] == "OFFICIAL_ABSENCE")
|
||||
not_entered_count = sum(1 for x in staff_items if x["status"] == "NOT_ENTERED")
|
||||
excluded_count = sum(1 for x in staff_items if x["status"] == "EXCLUDED")
|
||||
|
||||
latest_event_time = "—"
|
||||
if events_raw:
|
||||
times = [r['time_val'] for r in events_raw if r.get('time_val')]
|
||||
if times:
|
||||
max_t = max(times)
|
||||
latest_event_time = max_t.split()[-1][:5] if ' ' in max_t else max_t[:5]
|
||||
|
||||
return {
|
||||
"date": clean_date,
|
||||
"data_source": data_source,
|
||||
"latest_event_time": latest_event_time,
|
||||
"timestamp": datetime.now().strftime("%H:%M:%S"),
|
||||
"metrics": {
|
||||
"total_staff": total_staff,
|
||||
"inside": inside_count,
|
||||
"outside": outside_count,
|
||||
"remote": remote_count,
|
||||
"official_absence": absence_count,
|
||||
"not_entered": not_entered_count,
|
||||
"excluded": excluded_count
|
||||
},
|
||||
"records": staff_items
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: services/scud_etl/pipeline.py
|
||||
ROLE: Загрузка наилучших срезов СКУД и штата/отсутствий 1С с умным fallback-ом.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
@@ -12,7 +13,7 @@ import pandas as pd
|
||||
from core.connection import get_connection
|
||||
from core.database import load_scud_from_db_by_snapshot
|
||||
from config import DATA_DIR
|
||||
from services.data_loader import load_staff_data, load_absent_data
|
||||
from services.data_loader import load_1c_data_smart, load_staff_data, load_absent_data
|
||||
|
||||
logger = logging.getLogger("SCUD_PIPELINE")
|
||||
|
||||
@@ -57,8 +58,42 @@ def load_best_snapshot_for_date(date_str: str, prefer_final_y: bool = False) ->
|
||||
|
||||
def load_1c_files_for_date(date_str: str) -> Tuple[Optional[pd.DataFrame], Optional[pd.DataFrame]]:
|
||||
"""
|
||||
Загружает реестры штата и отсутствий 1С на указанную дату через data_loader.
|
||||
Загружает штат и отсутствия с каскадным fallback:
|
||||
1. Синхронизирует свежие файлы с сетевой шары.
|
||||
2. Загружает штат и отсутствия через load_1c_data_smart(..., use_db=True).
|
||||
3. Если штат пуст — берет последний доступный срез штата из zup_staff в SQLite.
|
||||
"""
|
||||
df_staff = load_staff_data(date_str)
|
||||
df_abs = load_absent_data(date_str)
|
||||
clean_date = date_str.replace('_', '.')
|
||||
|
||||
try:
|
||||
from services.share_copier import copy_1c_files_from_share
|
||||
copy_1c_files_from_share()
|
||||
except Exception as e:
|
||||
logger.warning(f"[Pipeline] Ошибка копирования с шары: {e}")
|
||||
|
||||
df_staff, df_abs = load_1c_data_smart(clean_date, use_db=True)
|
||||
|
||||
# Fallback за штат: ако данашњи штат још увек није доступан, користи се претходни из базе
|
||||
if df_staff is None or df_staff.empty:
|
||||
with get_connection(row_factory=True) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT snapshot_date
|
||||
FROM zup_staff
|
||||
ORDER BY
|
||||
SUBSTR(snapshot_date, 7, 4) DESC,
|
||||
SUBSTR(snapshot_date, 4, 2) DESC,
|
||||
SUBSTR(snapshot_date, 1, 2) DESC,
|
||||
id DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
row = cursor.fetchone()
|
||||
if row and row[0]:
|
||||
fallback_date = row[0]
|
||||
df_staff = pd.read_sql_query(
|
||||
"SELECT fio as 'ФИО', fio_clean, department as 'Подразделение', position as 'Должность' FROM zup_staff WHERE snapshot_date = ?",
|
||||
conn, params=(fallback_date,)
|
||||
)
|
||||
logger.info(f"[Pipeline] Для даты {clean_date} применен штат за {fallback_date} ({len(df_staff)} чел.)")
|
||||
|
||||
return df_staff, df_abs
|
||||
@@ -5,7 +5,7 @@ ROLE: Изолированные SQL-шаблоны для MS SQL Server (СКУ
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
# 1. Основной срез СКУД с учетом двухконтурного правого турникета
|
||||
# 1. Основной срез СКУД с учетом турникетов и флигеля
|
||||
SQL_QUERY_TEMPLATE = r"""
|
||||
DECLARE @InputDate DATE = '{target_date}';
|
||||
DECLARE @TargetDate DATE = @InputDate;
|
||||
@@ -40,6 +40,12 @@ WITH PercoPassages AS (
|
||||
log.DoorIndex = 2
|
||||
AND ({turnstile_filter_sql})
|
||||
)
|
||||
OR
|
||||
-- Контур 3: Флигель 1 эт. разрешен только для реестра флигеля
|
||||
(
|
||||
log.DoorIndex = 23
|
||||
AND ({fligel_filter_sql})
|
||||
)
|
||||
)
|
||||
),
|
||||
Passages AS (
|
||||
@@ -158,10 +164,23 @@ SELECT
|
||||
FROM pLogData log WITH (NOLOCK)
|
||||
INNER JOIN pList p WITH (NOLOCK) ON log.HozOrgan = p.ID
|
||||
LEFT JOIN PDivision div WITH (NOLOCK) ON p.Section = div.ID
|
||||
LEFT JOIN PPost post WITH (NOLOCK) ON p.Post = post.ID
|
||||
WHERE log.TimeVal BETWEEN @StartDate AND @EndDate
|
||||
AND log.HozOrgan IS NOT NULL
|
||||
AND log.HozOrgan > 0
|
||||
AND log.Event IN (28, 32)
|
||||
-- ⭐️ Фильтрация сотрудников Ленморниипроект (исключение арендаторов и гостей)
|
||||
AND 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 log.TimeVal ASC;
|
||||
"""
|
||||
@@ -12,7 +12,7 @@ import pandas as pd
|
||||
|
||||
from config import DATE_TODAY
|
||||
from core.database import load_scud_from_db_by_snapshot
|
||||
from services.scud_etl.pipeline import load_1c_files_for_date
|
||||
from services.scud_etl.pipeline import load_best_snapshot_for_date, load_1c_files_for_date
|
||||
from services.scud_etl.merger import merge_scud_and_1c, calculate_summary_metrics
|
||||
from services.scud_etl.anomaly_detector import detect_registry_anomalies
|
||||
from services.snapshots.finder import find_or_create_snapshot_for_time
|
||||
@@ -27,37 +27,46 @@ def generate_svodka_service(
|
||||
snapshot_id: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Формирует оперативную сводку на указанную дату / время:
|
||||
- target_date: дата сводки (по умолчанию сегодня).
|
||||
- target_time: время среза (например '14:30').
|
||||
- snapshot_id: точный ID среза.
|
||||
Формирует оперативную сводку:
|
||||
- По умолчанию берет ПОСЛЕДНИЙ готовый снапшот из базы SQLite (без долгого опроса MS SQL).
|
||||
"""
|
||||
date_clean = str(target_date or DATE_TODAY).replace('_', '.')
|
||||
applied_note = ""
|
||||
|
||||
# Если передано время, но не указан конкретный snapshot_id — ищем ближайший или запрашиваем экспорт
|
||||
if target_time and not snapshot_id:
|
||||
found_id, note = find_or_create_snapshot_for_time(date_clean, target_time, allow_ondemand_export=True)
|
||||
snapshot_id = found_id
|
||||
# 1. Загрузка среза СКУД строго из готовых в базе
|
||||
if snapshot_id:
|
||||
df_scud = load_scud_from_db_by_snapshot(date_clean, snapshot_param=snapshot_id)
|
||||
elif target_time:
|
||||
found_id, note = find_or_create_snapshot_for_time(date_clean, target_time, allow_ondemand_export=False)
|
||||
applied_note = note
|
||||
if note:
|
||||
logger.info(note)
|
||||
df_scud = load_scud_from_db_by_snapshot(date_clean, snapshot_param=found_id)
|
||||
else:
|
||||
# По умолчанию: берем самый свежий существующий срез за дату
|
||||
df_scud = load_best_snapshot_for_date(date_clean, prefer_final_y=False)
|
||||
|
||||
df_scud = load_scud_from_db_by_snapshot(date_clean, snapshot_param=snapshot_id)
|
||||
if df_scud is None or df_scud.empty:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Срез СКУД за {date_clean} ({applied_note or snapshot_id or 'последний доступный'}) не найден в базе."
|
||||
"message": f"Срез СКУД за {date_clean} не найден в базе данных."
|
||||
}
|
||||
|
||||
# Извлекаем время фактического среза для имени файла
|
||||
actual_snap_time = ""
|
||||
if 'snapshot_time' in df_scud.columns and not df_scud.empty:
|
||||
raw_st = str(df_scud['snapshot_time'].iloc[0]).strip()
|
||||
if " " in raw_st:
|
||||
actual_snap_time = raw_st.split()[1][:5].replace(':', '-')
|
||||
|
||||
# 2. Загружаем актуальные кадровые данные 1С (штат + отсутствия + реестры)
|
||||
df_staff, df_abs = load_1c_files_for_date(date_clean)
|
||||
|
||||
# 3. Слияние и расчет
|
||||
df_merged = merge_scud_and_1c(df_scud, df_staff, df_abs)
|
||||
metrics = calculate_summary_metrics(df_merged)
|
||||
anomalies = detect_registry_anomalies(df_merged, df_raw_scud=df_scud)
|
||||
|
||||
# Добавляем суффикс времени в имя файла, если сводка строилась на точный срез
|
||||
time_suffix = f" на {target_time.replace(':', '-')}" if target_time else ""
|
||||
# 4. Формирование книги Excel
|
||||
time_suffix = f" на {actual_snap_time}" if actual_snap_time else ""
|
||||
filename = f"{format_date_ru(date_clean)} сводка{time_suffix}.xlsx"
|
||||
generate_summary_excel(df_merged, date_str=date_clean, filename=filename)
|
||||
|
||||
@@ -68,13 +77,13 @@ def generate_svodka_service(
|
||||
"status": "success",
|
||||
"report_type": "SVODKA",
|
||||
"date": date_clean,
|
||||
"target_time": target_time,
|
||||
"snapshot_id": snapshot_id or "AUTO_LATEST",
|
||||
"target_time": actual_snap_time,
|
||||
"snapshot_id": snapshot_id or "LATEST_READY",
|
||||
"filename": filename,
|
||||
"filepath": full_filepath,
|
||||
"download_url": f"/api/v1/files/download/reports/{os.path.basename(full_filepath)}",
|
||||
"metrics": metrics,
|
||||
"anomalies_count": len(anomalies),
|
||||
"note": applied_note,
|
||||
"message": f"Ежедневная сводка на {date_clean} {target_time or ''} успешно сформирована."
|
||||
"message": f"Ежедневная сводка на {date_clean} успешно сформирована."
|
||||
}
|
||||
+69
-12
@@ -3,7 +3,8 @@
|
||||
FILE: services/scud_export.py
|
||||
ROLE: Прямой экспорт данных СКУД Орион (MS SQL) в SQLite и чистый Excel (XlsxWriter).
|
||||
Корректная фильтрация транзитных проходов турникетов парковки и двора.
|
||||
Учет только левого PERCo (DoorIndex = 1) и факта физического прохода (Event = 32).
|
||||
Учет только левого PERCo (DoorIndex = 1), правого PERCo (DoorIndex = 2)
|
||||
и входа через Флигель (DoorIndex = 23).
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
@@ -109,6 +110,12 @@ WITH PercoPassages AS (
|
||||
log.DoorIndex = 2
|
||||
AND ({turnstile_filter_sql})
|
||||
)
|
||||
OR
|
||||
-- Контур 3: Флигель 1 эт. разрешен только для реестра флигеля
|
||||
(
|
||||
log.DoorIndex = 23
|
||||
AND ({fligel_filter_sql})
|
||||
)
|
||||
)
|
||||
),
|
||||
Passages AS (
|
||||
@@ -226,11 +233,24 @@ SELECT
|
||||
FROM pLogData log WITH (NOLOCK)
|
||||
INNER JOIN pList p WITH (NOLOCK) ON log.HozOrgan = p.ID
|
||||
LEFT JOIN PDivision div WITH (NOLOCK) ON p.Section = div.ID
|
||||
LEFT JOIN PPost post WITH (NOLOCK) ON p.Post = post.ID
|
||||
WHERE log.TimeVal BETWEEN @StartDate AND @EndDate
|
||||
AND log.HozOrgan IS NOT NULL
|
||||
AND log.HozOrgan > 0
|
||||
AND log.Event IN (28, 32)
|
||||
-- ⭐️ Фильтрация сотрудников Ленморниипроект (исключение арендаторов и гостей)
|
||||
AND 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 log.TimeVal ASC;
|
||||
"""
|
||||
|
||||
@@ -313,6 +333,26 @@ def run_export(input_date: str | None = None, input_time: str | None = None, sav
|
||||
log("=== [ЭТАП 0] Выгрузка свежих данных СКУД напрямую из БД Орион ===")
|
||||
os.makedirs(SCUD_DIR, exist_ok=True)
|
||||
|
||||
# ⭐️ 1. ПОЛУЧАСОВАЯ СИНХРОНИЗАЦИЯ 1С:ЗУП (Файлы с шары + База данных)
|
||||
try:
|
||||
log("[1C] Проверка сетевой шары и синхронизация файлов 1С...")
|
||||
copy_1c_files_from_share()
|
||||
|
||||
target_sync_date = input_date or datetime.now().strftime("%d.%m.%Y")
|
||||
# Прямой опрос свежих отсутствий и сохранение в SQLite
|
||||
df_fresh_abs = load_absent_data(target_sync_date)
|
||||
if df_fresh_abs is not None and not df_fresh_abs.empty:
|
||||
save_absences_to_db(df_fresh_abs, target_sync_date)
|
||||
log(f"[1C] [✓] Актуализировано {len(df_fresh_abs)} отсутствий в SQLite за {target_sync_date}", "SUCCESS")
|
||||
|
||||
# Если на шаре появился свежий штат — также обновляем его в базе
|
||||
df_fresh_staff = load_staff_data(target_sync_date)
|
||||
if df_fresh_staff is not None and not df_fresh_staff.empty:
|
||||
save_staff_to_db(df_fresh_staff, target_sync_date)
|
||||
log(f"[1C] [✓] Актуализирован штат ({len(df_fresh_staff)} чел.) в SQLite за {target_sync_date}", "SUCCESS")
|
||||
except Exception as e:
|
||||
log(f"[1C] ⚠️ Ошибка фоновой синхронизации 1С: {e}", "WARNING")
|
||||
|
||||
targets = get_targets(input_date, input_time)
|
||||
conn_str = (
|
||||
f"DRIVER={{{ODBC_DRIVER}}};"
|
||||
@@ -338,22 +378,23 @@ def run_export(input_date: str | None = None, input_time: str | None = None, sav
|
||||
log(f"[ℹ️] День ({processing_date_str}) уже зафиксирован финишным снапшотом _FINAL. Пропускаем.")
|
||||
continue
|
||||
|
||||
# 1. Формируем фильтр правого турникета из exceptions_registry
|
||||
exc_data = load_exceptions()
|
||||
|
||||
# 1. Формируем фильтр правого турникета (DoorIndex = 2)
|
||||
t_fios = [f.replace("'", "''") for f in exc_data.get('turnstile_fio', []) if f]
|
||||
t_depts = [d.replace("'", "''") for d in exc_data.get('turnstile_departments', []) if d]
|
||||
|
||||
full_fio_sql = (
|
||||
"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"
|
||||
"))"
|
||||
)
|
||||
|
||||
conditions = []
|
||||
if t_fios:
|
||||
fio_in = ", ".join([f"N'{f}'" for f in t_fios])
|
||||
# Склеиваем Фамилию + Имя + Отчество для точного сравнения с реестром ФИО
|
||||
full_fio_sql = (
|
||||
"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"
|
||||
"))"
|
||||
)
|
||||
conditions.append(f"{full_fio_sql} IN ({fio_in})")
|
||||
|
||||
if t_depts:
|
||||
@@ -362,7 +403,22 @@ def run_export(input_date: str | None = None, input_time: str | None = None, sav
|
||||
|
||||
turnstile_filter_sql = " OR ".join(conditions) if conditions else "1 = 0"
|
||||
|
||||
# 2. Безопасное математическое определение @EndDate через DATEADD (независимо от локали сервера)
|
||||
# 1.1 Формируем фильтр Флигеля (DoorIndex = 23)
|
||||
fl_fios = [f.replace("'", "''") for f in exc_data.get('fligel_fio', []) if f]
|
||||
fl_depts = [d.replace("'", "''") for d in exc_data.get('fligel_departments', []) if d]
|
||||
|
||||
fl_conditions = []
|
||||
if fl_fios:
|
||||
fl_fio_in = ", ".join([f"N'{f}'" for f in fl_fios])
|
||||
fl_conditions.append(f"{full_fio_sql} IN ({fl_fio_in})")
|
||||
|
||||
if fl_depts:
|
||||
fl_dept_in = ", ".join([f"N'{d}'" for d in fl_depts])
|
||||
fl_conditions.append(f"ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') IN ({fl_dept_in})")
|
||||
|
||||
fligel_filter_sql = " OR ".join(fl_conditions) if fl_conditions else "1 = 0"
|
||||
|
||||
# 2. Безопасное математическое определение @EndDate через DATEADD
|
||||
if target_time:
|
||||
t_clean = target_time.strip()
|
||||
t_parts = t_clean.split(":")
|
||||
@@ -387,7 +443,8 @@ def run_export(input_date: str | None = None, input_time: str | None = None, sav
|
||||
sql_query = SQL_QUERY_TEMPLATE.format(
|
||||
target_date=processing_date.strftime("%Y-%m-%d"),
|
||||
end_datetime_sql=end_datetime_sql,
|
||||
turnstile_filter_sql=turnstile_filter_sql
|
||||
turnstile_filter_sql=turnstile_filter_sql,
|
||||
fligel_filter_sql=fligel_filter_sql
|
||||
)
|
||||
|
||||
connection = None
|
||||
|
||||
@@ -109,4 +109,24 @@ def cleanup_old_intermediate_snapshots(days_to_keep_all: int = 2) -> int:
|
||||
|
||||
if deleted_rows_count > 0:
|
||||
logger.info(f"Ротация завершена: удалено {deleted_rows_count} строк промежуточных срезов.")
|
||||
return deleted_rows_count
|
||||
return deleted_rows_count
|
||||
|
||||
def purge_day_intermediate_snapshots(date_str: str) -> int:
|
||||
"""
|
||||
Удаляет все промежуточные дневные срезы за указанную дату,
|
||||
оставляя нетронутым итоговый вечерний срез Y..._FINAL.
|
||||
"""
|
||||
clean_date = date_str.replace('_', '.')
|
||||
with get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
DELETE FROM scud_logs
|
||||
WHERE log_date = ?
|
||||
AND snapshot_id NOT LIKE 'Y%'
|
||||
AND snapshot_id NOT LIKE '%_FINAL%'
|
||||
""", (clean_date,))
|
||||
deleted = cursor.rowcount
|
||||
conn.commit()
|
||||
if deleted > 0:
|
||||
logger.info(f"[Retention] Удалено {deleted} строк промежуточных срезов за {clean_date}. Сохранен только финишный Y.")
|
||||
return deleted
|
||||
Reference in New Issue
Block a user