feat(turnstile): двухконтурный учет СКУД, реестры исключений с автокомплитом 1С и калибровка таймзон

This commit is contained in:
2026-09-10 15:36:32 +03:00
parent 6c9b131cf2
commit 74332aa38a
31 changed files with 4509 additions and 10769 deletions
+121 -38
View File
@@ -21,34 +21,50 @@ def has_yesterday_final_snapshot(date_str: str) -> bool:
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT 1 FROM scud_logs WHERE log_date = ? AND (snapshot_id LIKE 'Y%' OR snapshot_time LIKE '%23:59:59' OR snapshot_time LIKE '%22:00:00') LIMIT 1",
"""
SELECT 1 FROM scud_logs
WHERE log_date = ?
AND (snapshot_id LIKE 'Y%' OR snapshot_id LIKE '%_FINAL%' OR snapshot_time LIKE '%23:59:59' OR snapshot_time LIKE '%22:00:00')
LIMIT 1
""",
(date_str,)
)
return cursor.fetchone() is not None
def get_or_create_snapshot_id(snapshot_time: str, date_str: str = None, is_yesterday: bool = False) -> str:
try:
dt_snap = datetime.strptime(snapshot_time, "%Y-%m-%d %H:%M:%S").date()
date_prefix = dt_snap.strftime("%Y%m%d")
except (ValueError, TypeError):
dt_snap = datetime.now().date()
date_prefix = dt_snap.strftime("%Y%m%d")
"""
Генерирует понятный и уникальный ID снапшота:
- Дата префикса берется строго из даты самих логов (date_str).
- Для итоговых срезов дня: YYYYYMMDD_FINAL (строго с буквой Y в начале).
- Для дневных срезов на время: YYYYMMDD_HHMM.
"""
if date_str:
try:
dt_log = datetime.strptime(date_str, "%d.%m.%Y").date()
if dt_log < dt_snap:
is_yesterday = True
dt_log = datetime.strptime(date_str.replace('_', '.'), "%d.%m.%Y").date()
date_prefix = dt_log.strftime("%Y%m%d")
except Exception:
pass
dt_log = datetime.now().date()
date_prefix = dt_log.strftime("%Y%m%d")
else:
try:
dt_snap = datetime.strptime(snapshot_time.split()[0], "%Y-%m-%d").date()
date_prefix = dt_snap.strftime("%Y%m%d")
except (ValueError, TypeError, IndexError):
date_prefix = datetime.now().strftime("%Y%m%d")
prefix = "Y" if is_yesterday else ""
time_part = "2359"
try:
t_str = snapshot_time.split()[1] if " " in snapshot_time else snapshot_time
t_parts = t_str.split(":")
time_part = f"{t_parts[0]}{t_parts[1]}"
except Exception:
pass
with get_connection() as conn:
cursor = conn.cursor()
# 1. Проверяем, существует ли уже срез с точно таким же временем и датой
# Если срез с точно таким же временем и датой уже существует — возвращаем его ID
if date_str:
cursor.execute(
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_time = ? AND snapshot_id IS NOT NULL LIMIT 1",
@@ -64,21 +80,32 @@ def get_or_create_snapshot_id(snapshot_time: str, date_str: str = None, is_yeste
if row and row[0]:
return row[0]
# 2. Извлекаем ВСЕ существующие ID за текущие календарные сутки
# Финальный суточный ID: строго с префиксом Y
if is_yesterday or time_part in ["2359", "2200"]:
base_final_id = f"Y{date_prefix}_FINAL"
cursor.execute("SELECT 1 FROM scud_logs WHERE snapshot_id = ? LIMIT 1", (base_final_id,))
if not cursor.fetchone():
return base_final_id
return base_final_id
# Дневной срез на определенное время
base_id = f"{date_prefix}_{time_part}"
cursor.execute("SELECT 1 FROM scud_logs WHERE snapshot_id = ? LIMIT 1", (base_id,))
if not cursor.fetchone():
return base_id
cursor.execute("""
SELECT DISTINCT snapshot_id
FROM scud_logs
WHERE snapshot_id LIKE ? OR snapshot_id LIKE ?
""", (f"{date_prefix}-%", f"Y{date_prefix}-%"))
WHERE snapshot_id LIKE ?
""", (f"{base_id}-%",))
rows = cursor.fetchall()
max_seq = 0
max_seq = 1
for (s_id,) in rows:
if not s_id:
continue
try:
# Извлекаем число после последнего дефиса
parts = str(s_id).split('-')
if len(parts) >= 2 and parts[-1].isdigit():
num = int(parts[-1])
@@ -88,15 +115,16 @@ def get_or_create_snapshot_id(snapshot_time: str, date_str: str = None, is_yeste
continue
next_seq = max_seq + 1
return f"{prefix}{date_prefix}-{next_seq:03d}"
return f"{base_id}-{next_seq:03d}"
def save_scud_to_db(df_scud: pd.DataFrame, date_str: str, snapshot_time: str = None, is_yesterday: bool = False) -> None:
if df_scud is None or df_scud.empty:
return
now_local_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if not snapshot_time:
snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
snapshot_time = now_local_str
snapshot_id = get_or_create_snapshot_id(snapshot_time, date_str=date_str, is_yesterday=is_yesterday)
@@ -114,7 +142,8 @@ def save_scud_to_db(df_scud: pd.DataFrame, date_str: str, snapshot_time: str = N
1 if r.get('Пришел', False) else 0,
r.get('anomaly_flag', 'NONE'),
snapshot_time,
snapshot_id
snapshot_id,
now_local_str # ⭐️ Передаем локальное время машины напрямую
)
for _, r in df_scud.iterrows()
]
@@ -126,9 +155,9 @@ def save_scud_to_db(df_scud: pd.DataFrame, date_str: str, snapshot_time: str = N
INSERT INTO scud_logs (
log_date, fio, fio_clean, department, position,
time_in, first_activity, time_out, time_in_building,
is_present, anomaly_flag, snapshot_time, snapshot_id
is_present, anomaly_flag, snapshot_time, snapshot_id, created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", data_to_insert)
conn.commit()
@@ -148,25 +177,26 @@ def load_scud_from_db_by_snapshot(date_str: str, snapshot_param: str = None) ->
with get_connection() as conn:
df = pd.DataFrame()
# 1. Если передан конкретный ID снапшота (например 'Y20260820-004')
if snapshot_param:
df = pd.read_sql_query(
"SELECT * FROM scud_logs WHERE snapshot_id = ?",
conn, params=(str(snapshot_param),)
)
# 2. Если ищем за дату (для вчерашнего дня строго ищем Y-снапшот)
if df.empty and date_str:
cursor = conn.cursor()
# ⭐️ Жесткий приоритет 1: Ищем снапшот с префиксом 'Y'
cursor.execute(
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_id LIKE 'Y%' ORDER BY snapshot_time DESC, id DESC LIMIT 1",
"""
SELECT snapshot_id FROM scud_logs
WHERE log_date = ?
AND (snapshot_id LIKE 'Y%' OR snapshot_id LIKE '%_FINAL%')
ORDER BY snapshot_time DESC, id DESC LIMIT 1
""",
(date_str,)
)
row = cursor.fetchone()
# Приоритет 2: Если Y нет (например, за сегодня), берем самый свежий по времени
if not row:
cursor.execute(
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? ORDER BY snapshot_time DESC, id DESC LIMIT 1",
@@ -209,7 +239,12 @@ def get_available_snapshots(date_str: str = None):
with get_connection() as conn:
cursor = conn.cursor()
query = """
SELECT snapshot_id, log_date, snapshot_time, COUNT(*) as cnt
SELECT
snapshot_id,
log_date,
snapshot_time,
COUNT(*) as cnt,
MIN(created_at) as created_at
FROM scud_logs
WHERE snapshot_time IS NOT NULL
"""
@@ -239,11 +274,8 @@ def delete_snapshots_by_date(date_str: str) -> int:
conn.commit()
return cnt
def save_raw_events_to_db(df_raw_events: pd.DataFrame, date_str: str) -> int:
"""
Сохраняет ленту сырых физических проходов турникетов в таблицу scud_events_raw.
Перезаписывает сырые события за указанную дату для исключения дубликатов.
"""
if df_raw_events is None or df_raw_events.empty:
return 0
@@ -257,6 +289,7 @@ def save_raw_events_to_db(df_raw_events: pd.DataFrame, date_str: str) -> int:
str(r.get('Подразделение', '')),
int(r.get('Event', 0)),
int(r.get('Mode', 0)),
int(r.get('DoorIndex')) if pd.notna(r.get('DoorIndex')) else None,
str(r.get('Direction', 'OTHER'))
)
for _, r in df_raw_events.iterrows()
@@ -268,9 +301,59 @@ def save_raw_events_to_db(df_raw_events: pd.DataFrame, date_str: str) -> int:
cursor.executemany("""
INSERT INTO scud_events_raw (
log_date, time_val, hoz_organ, fio, fio_clean,
department, event_code, mode, direction
department, event_code, mode, door_index, direction
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", records)
conn.commit()
return len(records)
return len(records)
def get_building_presence(date_str: str, only_inside: bool = True) -> list[dict]:
with get_connection() as conn:
cursor = conn.cursor()
query = """
WITH RankedEvents AS (
SELECT
hoz_organ,
fio,
department,
time_val,
direction,
ROW_NUMBER() OVER (
PARTITION BY hoz_organ
ORDER BY time_val DESC, id DESC
) as rn
FROM scud_events_raw
WHERE log_date = ?
)
SELECT
hoz_organ,
fio,
department,
time_val,
direction
FROM RankedEvents
WHERE rn = 1
"""
if only_inside:
query += " AND direction = 'IN'"
query += " ORDER BY fio ASC;"
cursor.execute(query, (date_str,))
rows = cursor.fetchall()
results = [
{
"hoz_organ": r[0],
"fio": r[1],
"department": r[2],
"last_event_time": r[3],
"direction": r[4],
"status": "В здании" if r[4] == "IN" else "Вышел"
}
for r in rows
]
results.sort(key=lambda x: x["fio"].lower())
return results