feat(turnstile): двухконтурный учет СКУД, реестры исключений с автокомплитом 1С и калибровка таймзон
This commit is contained in:
@@ -181,9 +181,33 @@ def load_absent_data(date_str):
|
||||
except Exception as e:
|
||||
print(f" [⚠️] Ошибка обработки static_reason_workers.csv: {e}")
|
||||
|
||||
# Обогащение реестрами "Мест. командир." и "Иное"
|
||||
try:
|
||||
from services.manual_absences_repo import get_active_manual_absences_for_date
|
||||
manual_records = get_active_manual_absences_for_date(date_str)
|
||||
if manual_records:
|
||||
existing_fios = set(df_absent['fio_clean'].dropna().tolist()) if not df_absent.empty else set()
|
||||
manual_rows = []
|
||||
for r in manual_records:
|
||||
fc = r['fio_clean']
|
||||
if fc not in existing_fios:
|
||||
label = "Мест. командир." if r['absence_type'] == 'LOCAL_TRIP' else "Иное"
|
||||
manual_rows.append({
|
||||
'fio_clean': fc,
|
||||
'Вид_отсутствия': label
|
||||
})
|
||||
existing_fios.add(fc)
|
||||
if manual_rows:
|
||||
df_absent = pd.concat([df_absent, pd.DataFrame(manual_rows)], ignore_index=True)
|
||||
print(f" [✓] Реестры 'Мест. командир.' / 'Иное': добавлено {len(manual_rows)} чел. в отчет за {date_str}")
|
||||
except Exception as e:
|
||||
print(f" [⚠️] Ошибка применения manual_absences: {e}")
|
||||
|
||||
return df_absent if not df_absent.empty else None
|
||||
|
||||
|
||||
|
||||
|
||||
def load_1c_data_smart(date_str, use_db=False):
|
||||
df_staff = None
|
||||
df_absent = None
|
||||
|
||||
+16
-21
@@ -71,20 +71,6 @@ def safe_close_workbook(wb, output_path, target_dir, filename):
|
||||
return output_path
|
||||
|
||||
|
||||
def calculate_autoclose_time(time_in_str: str) -> tuple[str, str, str]:
|
||||
try:
|
||||
parts = time_in_str.strip().split(':')
|
||||
hh = int(parts[0])
|
||||
mm = int(parts[1]) if len(parts) > 1 else 0
|
||||
ss = int(parts[2]) if len(parts) > 2 else 0
|
||||
|
||||
dt_in = datetime(2000, 1, 1, hh, mm, ss)
|
||||
dt_out = dt_in + timedelta(hours=8, minutes=30)
|
||||
return dt_out.strftime("%H:%M:%S"), "08:30", "0:00"
|
||||
except Exception:
|
||||
return "17:00:00", "08:30", "0:00"
|
||||
|
||||
|
||||
def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_minutes=30):
|
||||
"""
|
||||
Расчет отклонения от нормы.
|
||||
@@ -228,7 +214,7 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
|
||||
ws.write(current_row, 1, "", fmt_np_rr)
|
||||
current_row += 1
|
||||
|
||||
# 3. Официальные отсутствия (Сотрудники из исключений при наличии документа 1С попадают сюда)
|
||||
# 3. Официальные отсутствия
|
||||
reason_clean = merged_df['Вид_отсутствия'].astype(str).str.lower()
|
||||
is_remote_reason = reason_clean.str.contains('удален|дистанцион', regex=True, na=False)
|
||||
|
||||
@@ -253,10 +239,16 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
|
||||
ws.write(current_row, 1, len(group), fmt_cat_hr)
|
||||
current_row += 1
|
||||
|
||||
for fio in sorted(group['Сотрудник'].dropna().unique()):
|
||||
is_other_category = (str(cat_name).strip().lower() == "иное")
|
||||
|
||||
for _, row in group.sort_values(by='Сотрудник').iterrows():
|
||||
fio = row.get('Сотрудник', '')
|
||||
# Если категория "Иное" — берем детальную причину из manual_absences / detailed_reason
|
||||
detail_val = row.get('detailed_reason', row.get('comment', '')) if is_other_category else ""
|
||||
|
||||
ws.set_row(current_row, 20, None, {'level': 1, 'hidden': True, 'collapsed': True})
|
||||
ws.write(current_row, 0, fio, fmt_cat_rl)
|
||||
ws.write(current_row, 1, "", fmt_cat_rr)
|
||||
ws.write(current_row, 1, detail_val, fmt_cat_rr)
|
||||
current_row += 1
|
||||
|
||||
# 4. Итого на работе (Только общее число, без раскрывающегося списка ФИО. Включает исключения без справок)
|
||||
@@ -404,10 +396,13 @@ def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None):
|
||||
first_act_val = str(row.get('Первая_активность', '—')).strip()
|
||||
has_first_act = first_act_val not in ['—', '', 'None', 'nan']
|
||||
|
||||
if in_val not in ['Нет входа', '—', '', 'nan', 'None'] and out_val in ['Нет выхода', '—', '', 'nan', 'None'] and not has_reason:
|
||||
out_val, in_building_str, deviation_val = calculate_autoclose_time(in_val)
|
||||
else:
|
||||
deviation_val = calculate_deviation(in_building_str, reason=absence_reason if has_reason else "", norm_hours=8, lunch_minutes=30)
|
||||
# Автозакрытие отключено по согласованию с ОК: сохраняем факт отсутствия выхода
|
||||
deviation_val = calculate_deviation(
|
||||
in_building_str,
|
||||
reason=absence_reason if has_reason else "",
|
||||
norm_hours=8,
|
||||
lunch_minutes=30
|
||||
)
|
||||
|
||||
dept_scud_val = row.get('department_scud', row.get('department', row.get('Подразделение', '')))
|
||||
|
||||
|
||||
@@ -29,7 +29,15 @@ def init_exceptions_table():
|
||||
|
||||
def get_all_exceptions_from_db() -> Dict[str, List[str]]:
|
||||
init_exceptions_table()
|
||||
cfg = {"departments": [], "positions": [], "fio": [], "position_keywords": [], "include_fio": []}
|
||||
cfg = {
|
||||
"departments": [],
|
||||
"positions": [],
|
||||
"fio": [],
|
||||
"position_keywords": [],
|
||||
"include_fio": [],
|
||||
"turnstile_fio": [],
|
||||
"turnstile_departments": []
|
||||
}
|
||||
|
||||
with get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
@@ -37,7 +45,6 @@ def get_all_exceptions_from_db() -> Dict[str, List[str]]:
|
||||
rows = cursor.fetchall()
|
||||
|
||||
if not rows and os.path.exists(EXCEPTIONS_PATH):
|
||||
# Первичная миграция из JSON в SQLite
|
||||
sync_json_to_db()
|
||||
return get_all_exceptions_from_db()
|
||||
|
||||
@@ -49,7 +56,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"] else value.strip()
|
||||
# ФИО очищаем и приводим к нормализованному виду
|
||||
val_clean = normalize_fio(value) if category in ["fio", "include_fio", "turnstile_fio"] else value.strip()
|
||||
if not val_clean:
|
||||
return False
|
||||
with get_connection() as conn:
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: services/manual_absences_repo.py
|
||||
ROLE: Репозиторий ручных реестров ("Мест. командир.", "Иное") и поиск по штату 1С.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
import os
|
||||
import csv
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Optional
|
||||
from core.connection import get_connection
|
||||
from config import normalize_fio, DATA_DIR
|
||||
|
||||
REASONS_CSV_PATH = os.path.join(DATA_DIR, "static_reason_absence.csv")
|
||||
|
||||
|
||||
def init_manual_absences_table() -> None:
|
||||
with get_connection() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS manual_absences (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
absence_type TEXT NOT NULL, -- 'LOCAL_TRIP' или 'OTHER'
|
||||
fio TEXT NOT NULL,
|
||||
fio_clean TEXT NOT NULL,
|
||||
department TEXT DEFAULT '',
|
||||
position TEXT DEFAULT '',
|
||||
date_start TEXT, -- 'YYYY-MM-DD'
|
||||
date_end TEXT, -- 'YYYY-MM-DD'
|
||||
reason TEXT NOT NULL,
|
||||
comment TEXT DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_manual_abs_dates ON manual_absences(date_start, date_end);")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_manual_abs_fio ON manual_absences(fio_clean);")
|
||||
conn.commit()
|
||||
|
||||
|
||||
def get_static_reasons() -> List[str]:
|
||||
"""Возвращает список причин из data/static_reason_absence.csv."""
|
||||
if not os.path.exists(REASONS_CSV_PATH):
|
||||
# Если файл еще не создан, создаем базовый набор причин
|
||||
os.makedirs(os.path.dirname(REASONS_CSV_PATH), exist_ok=True)
|
||||
default_reasons = ["По семейным обстоятельствам", "Медосмотр", "Сдача крови", "Учебный отпуск", "Административный отпуск"]
|
||||
with open(REASONS_CSV_PATH, "w", encoding="utf-8", newline="") as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(["reason"])
|
||||
for r in default_reasons:
|
||||
writer.writerow([r])
|
||||
return default_reasons
|
||||
|
||||
reasons = []
|
||||
try:
|
||||
with open(REASONS_CSV_PATH, "r", encoding="utf-8") as f:
|
||||
reader = csv.reader(f)
|
||||
for row in reader:
|
||||
if row and row[0].strip() and row[0].strip().lower() != "reason":
|
||||
reasons.append(row[0].strip())
|
||||
except Exception:
|
||||
pass
|
||||
return reasons
|
||||
|
||||
|
||||
def search_staff_suggestions(query: str, limit: int = 15) -> List[Dict[str, str]]:
|
||||
"""Живой поиск сотрудников по zup_staff для автокомплита."""
|
||||
q = (query or "").strip()
|
||||
if not q or len(q) < 2:
|
||||
return []
|
||||
|
||||
# Приводим к разным регистрам для гарантированного поиска кириллицы в SQLite
|
||||
q_lower = q.lower()
|
||||
q_title = q.capitalize()
|
||||
|
||||
with get_connection(row_factory=True) as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 1. Находим действительно самый свежий срез штата (по created_at или по структуре даты ГГГГ-ММ-ДД)
|
||||
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()
|
||||
latest_date = row[0] if row else None
|
||||
|
||||
if not latest_date:
|
||||
return []
|
||||
|
||||
# 2. Поиск с сортировкой: сначала те, у кого фамилия НАЧИНАЕТСЯ с запроса
|
||||
sql = """
|
||||
SELECT DISTINCT fio, fio_clean, department, position
|
||||
FROM zup_staff
|
||||
WHERE snapshot_date = ?
|
||||
AND (
|
||||
fio LIKE ? OR fio LIKE ? OR fio_clean LIKE ? OR fio_clean LIKE ?
|
||||
OR fio LIKE ? OR fio_clean LIKE ?
|
||||
)
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN fio LIKE ? OR fio_clean LIKE ? THEN 0
|
||||
ELSE 1
|
||||
END,
|
||||
fio ASC
|
||||
LIMIT ?
|
||||
"""
|
||||
prefix_pattern_title = f"{q_title}%"
|
||||
prefix_pattern_lower = f"{q_lower}%"
|
||||
any_pattern_title = f"%{q_title}%"
|
||||
any_pattern_lower = f"%{q_lower}%"
|
||||
|
||||
cursor.execute(sql, (
|
||||
latest_date,
|
||||
prefix_pattern_title, prefix_pattern_lower, prefix_pattern_title, prefix_pattern_lower,
|
||||
any_pattern_title, any_pattern_lower,
|
||||
prefix_pattern_title, prefix_pattern_title,
|
||||
limit
|
||||
))
|
||||
rows = cursor.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"fio": r["fio"],
|
||||
"fio_clean": r["fio_clean"],
|
||||
"department": r["department"] or "—",
|
||||
"position": r["position"] or "—"
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def add_manual_absence(
|
||||
absence_type: str,
|
||||
fio: str,
|
||||
reason: str,
|
||||
department: str = "",
|
||||
position: str = "",
|
||||
date_start: Optional[str] = None,
|
||||
date_end: Optional[str] = None,
|
||||
comment: str = ""
|
||||
) -> int:
|
||||
init_manual_absences_table()
|
||||
clean_fio = normalize_fio(fio)
|
||||
if not clean_fio:
|
||||
return 0
|
||||
|
||||
today_str = datetime.now().strftime("%Y-%m-%d")
|
||||
d_start = date_start.strip() if date_start and date_start.strip() else today_str
|
||||
d_end = date_end.strip() if date_end and date_end.strip() else today_str
|
||||
|
||||
with get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT INTO manual_absences (
|
||||
absence_type, fio, fio_clean, department, position,
|
||||
date_start, date_end, reason, comment
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (absence_type.upper(), fio.strip(), clean_fio, department.strip(), position.strip(), d_start, d_end, reason.strip(), comment.strip()))
|
||||
conn.commit()
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
def delete_manual_absence(item_id: int) -> bool:
|
||||
init_manual_absences_table()
|
||||
with get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM manual_absences WHERE id = ?", (item_id,))
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def get_manual_absences_list(absence_type: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
init_manual_absences_table()
|
||||
with get_connection(row_factory=True) as conn:
|
||||
cursor = conn.cursor()
|
||||
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()]
|
||||
|
||||
|
||||
def get_active_manual_absences_for_date(date_str: str) -> List[Dict[str, Any]]:
|
||||
"""Выбирает записи, активные на дату отчета (формат даты ДД.ММ.ГГГГ)."""
|
||||
init_manual_absences_table()
|
||||
try:
|
||||
dt_target = datetime.strptime(date_str.replace('_', '.'), "%d.%m.%Y").strftime("%Y-%m-%d")
|
||||
except Exception:
|
||||
dt_target = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
with get_connection(row_factory=True) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT id, absence_type, fio, fio_clean, department, position, date_start, date_end, reason
|
||||
FROM manual_absences
|
||||
WHERE (date_start IS NULL OR date_start <= ?)
|
||||
AND (date_end IS NULL OR date_end >= ?)
|
||||
""", (dt_target, dt_target))
|
||||
return [dict(r) for r in cursor.fetchall()]
|
||||
@@ -189,6 +189,19 @@ def merge_scud_and_1c(
|
||||
if reason and reason.lower() != "nan":
|
||||
absences_map[fio] = reason
|
||||
|
||||
manual_reasons_map = {}
|
||||
try:
|
||||
from services.manual_absences_repo import get_active_manual_absences_for_date
|
||||
# date_clean берется из даты контекста либо из текущих суток
|
||||
target_date_val = df_res.get('Дата', pd.Series()).iloc[0] if 'Дата' in df_res.columns and not df_res.empty else None
|
||||
if target_date_val:
|
||||
m_records = get_active_manual_absences_for_date(str(target_date_val))
|
||||
for mr in m_records:
|
||||
manual_reasons_map[mr['fio_clean']] = mr['reason']
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
df_res["detailed_reason"] = df_res["fio_clean"].map(manual_reasons_map).fillna("")
|
||||
df_res["причина отсутствия"] = df_res["fio_clean"].map(absences_map)
|
||||
df_res["Вид_отсутствия"] = df_res["причина отсутствия"]
|
||||
|
||||
|
||||
+109
-53
@@ -3,6 +3,7 @@
|
||||
FILE: services/scud_export.py
|
||||
ROLE: Прямой экспорт данных СКУД Орион (MS SQL) в SQLite и чистый Excel (XlsxWriter).
|
||||
Корректная фильтрация транзитных проходов турникетов парковки и двора.
|
||||
Учет только левого PERCo (DoorIndex = 1) и факта физического прохода (Event = 32).
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
@@ -78,46 +79,50 @@ 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));
|
||||
DECLARE @EndDate DATETIME = {end_datetime_sql};
|
||||
|
||||
WITH DailyLogs AS (
|
||||
WITH PercoPassages AS (
|
||||
-- Физические факты прохода (Event = 32)
|
||||
SELECT
|
||||
log.HozOrgan AS EmployeeID,
|
||||
log.TimeVal,
|
||||
log.Event,
|
||||
log.Mode,
|
||||
-- Приоритет отдается физическому направлению контроллера (Mode):
|
||||
CASE
|
||||
WHEN log.Mode = 2 THEN 'OUT'
|
||||
WHEN log.Mode = 1 THEN 'IN'
|
||||
WHEN log.Event IN (2, 27, 29, 33, 55, 65) THEN 'OUT'
|
||||
WHEN log.Event IN (1, 21, 26, 54, 64) THEN 'IN'
|
||||
WHEN log.Mode = 2 THEN 'OUT'
|
||||
ELSE 'OTHER'
|
||||
END AS Direction,
|
||||
-- Вычисляем самое последнее событие сотрудника за день:
|
||||
ROW_NUMBER() OVER (PARTITION BY log.HozOrgan ORDER BY log.TimeVal DESC, log.Event DESC) AS RnLast
|
||||
END AS Direction
|
||||
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
|
||||
WHERE log.TimeVal BETWEEN @StartDate AND @EndDate
|
||||
AND log.HozOrgan IS NOT NULL
|
||||
AND log.HozOrgan > 0
|
||||
AND log.Event IN (1, 2, 21, 26, 27, 28, 29, 32, 33, 54, 55, 64, 65)
|
||||
AND log.Event = 32
|
||||
AND log.Mode IN (1, 2)
|
||||
AND (
|
||||
-- Контур 1: Левый турникет открыт для всех
|
||||
log.DoorIndex = 1
|
||||
OR
|
||||
-- Контур 2: Правый турникет разрешен только для реестра двора
|
||||
(
|
||||
log.DoorIndex = 2
|
||||
AND ({turnstile_filter_sql})
|
||||
)
|
||||
)
|
||||
),
|
||||
Passages AS (
|
||||
SELECT
|
||||
EmployeeID,
|
||||
MIN(TimeVal) AS FirstRawEvent,
|
||||
MAX(TimeVal) AS LastRawEvent,
|
||||
-- Первый вход за день:
|
||||
MIN(CASE WHEN Direction = 'IN' THEN TimeVal END) AS FirstIn,
|
||||
-- Фиксируем выход ТОЛЬКО если самое последнее событие за день было именно выходом (OUT):
|
||||
MAX(CASE WHEN RnLast = 1 AND Direction = 'OUT' THEN TimeVal END) AS FinalOut
|
||||
FROM DailyLogs
|
||||
MAX(CASE WHEN Direction = 'OUT' THEN TimeVal END) AS FinalOut
|
||||
FROM PercoPassages
|
||||
GROUP BY EmployeeID
|
||||
),
|
||||
EvaluatedPassages AS (
|
||||
SELECT
|
||||
p.*,
|
||||
-- Время выхода проставляется только при окончательном уходе сотрудника из здания:
|
||||
CASE
|
||||
WHEN p.FinalOut IS NOT NULL
|
||||
AND p.FirstIn IS NOT NULL
|
||||
@@ -157,15 +162,13 @@ SELECT
|
||||
ISNULL(pass.FirstIn, pass.FirstRawEvent),
|
||||
CASE
|
||||
WHEN pass.FilteredLastOut IS NOT NULL THEN pass.FilteredLastOut
|
||||
WHEN @TargetDate = CAST(GETDATE() AS DATE) THEN GETDATE()
|
||||
ELSE ISNULL(pass.FirstIn, pass.FirstRawEvent)
|
||||
ELSE @EndDate
|
||||
END) / 60 AS VARCHAR), 2) + ':' +
|
||||
RIGHT('0' + CAST(DATEDIFF(MINUTE,
|
||||
ISNULL(pass.FirstIn, pass.FirstRawEvent),
|
||||
CASE
|
||||
WHEN pass.FilteredLastOut IS NOT NULL THEN pass.FilteredLastOut
|
||||
WHEN @TargetDate = CAST(GETDATE() AS DATE) THEN GETDATE()
|
||||
ELSE ISNULL(pass.FirstIn, pass.FirstRawEvent)
|
||||
ELSE @EndDate
|
||||
END) % 60 AS VARCHAR), 2)
|
||||
ELSE N'00:00'
|
||||
END AS [Находился_в_здании],
|
||||
@@ -196,7 +199,7 @@ ORDER BY p.Name ASC;
|
||||
SQL_RAW_EVENTS_QUERY = r"""
|
||||
DECLARE @InputDate DATE = '{target_date}';
|
||||
DECLARE @StartDate DATETIME = CAST(@InputDate AS DATETIME);
|
||||
DECLARE @EndDate DATETIME = DATEADD(SECOND, -1, DATEADD(DAY, 1, @StartDate));
|
||||
DECLARE @EndDate DATETIME = {end_datetime_sql};
|
||||
|
||||
SELECT
|
||||
log.TimeVal,
|
||||
@@ -211,6 +214,7 @@ SELECT
|
||||
ISNULL(CAST(div.Name AS NVARCHAR(255)), N'Без подразделения') AS [Подразделение],
|
||||
log.Event,
|
||||
log.Mode,
|
||||
log.DoorIndex,
|
||||
CASE
|
||||
WHEN log.Mode = 2 THEN 'OUT'
|
||||
WHEN log.Mode = 1 THEN 'IN'
|
||||
@@ -229,6 +233,7 @@ WHERE log.TimeVal BETWEEN @StartDate AND @EndDate
|
||||
ORDER BY log.TimeVal ASC;
|
||||
"""
|
||||
|
||||
|
||||
def save_df_to_clean_excel(df: pd.DataFrame, file_path: str, sheet_name: str = "Отчет"):
|
||||
workbook = xlsxwriter.Workbook(file_path, {'constant_memory': False})
|
||||
worksheet = workbook.add_worksheet(sheet_name)
|
||||
@@ -279,12 +284,12 @@ def save_df_to_clean_excel(df: pd.DataFrame, file_path: str, sheet_name: str = "
|
||||
workbook.close()
|
||||
|
||||
|
||||
def get_targets(input_date: str | None):
|
||||
def get_targets(input_date: str | None, input_time: str | None = None):
|
||||
targets = []
|
||||
if input_date:
|
||||
try:
|
||||
parsed = datetime.strptime(input_date, "%d.%m.%Y").date()
|
||||
targets.append({"name": "Указанная дата", "date": parsed})
|
||||
parsed = datetime.strptime(input_date.replace('_', '.'), "%d.%m.%Y").date()
|
||||
targets.append({"name": "Указанная дата", "date": parsed, "target_time": input_time})
|
||||
except ValueError:
|
||||
log(f"ОШИБКА: Неверный формат даты '{input_date}'. Используйте ДД.ММ.ГГГГ", "ERROR")
|
||||
sys.exit(1)
|
||||
@@ -293,13 +298,13 @@ def get_targets(input_date: str | None):
|
||||
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})
|
||||
targets.append({"name": "Вчера", "date": yesterday, "target_time": None})
|
||||
targets.append({"name": "Сегодня", "date": today, "target_time": None})
|
||||
|
||||
return targets
|
||||
|
||||
|
||||
def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: bool = False):
|
||||
def run_export(input_date: str | None = None, input_time: str | None = None, save_xlsx: bool = True, debug: bool = False):
|
||||
if debug:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
log("=== ВКЛЮЧЕН РЕЖИМ ОТЛАДКИ (DEBUG MODE) ===", "WARNING")
|
||||
@@ -307,7 +312,7 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo
|
||||
log("=== [ЭТАП 0] Выгрузка свежих данных СКУД напрямую из БД Орион ===")
|
||||
os.makedirs(SCUD_DIR, exist_ok=True)
|
||||
|
||||
targets = get_targets(input_date)
|
||||
targets = get_targets(input_date, input_time)
|
||||
conn_str = (
|
||||
f"DRIVER={{{ODBC_DRIVER}}};"
|
||||
f"SERVER={SERVER_NAME};"
|
||||
@@ -323,19 +328,66 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo
|
||||
processing_date = target["date"]
|
||||
processing_date_str = processing_date.strftime("%d.%m.%Y")
|
||||
period_label = target["name"]
|
||||
is_yesterday = (period_label == "Вчера")
|
||||
target_time = target["target_time"]
|
||||
|
||||
today_date = datetime.now().date()
|
||||
is_past_day = (processing_date < today_date) or (period_label == "Вчера")
|
||||
|
||||
if is_yesterday and has_yesterday_final_snapshot(processing_date_str):
|
||||
log(f"[ℹ️] Вчерашний день ({processing_date_str}) уже зафиксирован финишным снапшотом Y. Пропускаем запрос к MS SQL.")
|
||||
if is_past_day and not target_time and has_yesterday_final_snapshot(processing_date_str):
|
||||
log(f"[ℹ️] День ({processing_date_str}) уже зафиксирован финишным снапшотом _FINAL. Пропускаем.")
|
||||
continue
|
||||
|
||||
if is_yesterday:
|
||||
# 1. Формируем фильтр правого турникета из exceptions_registry
|
||||
exc_data = load_exceptions()
|
||||
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]
|
||||
|
||||
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:
|
||||
dept_in = ", ".join([f"N'{d}'" for d in t_depts])
|
||||
conditions.append(f"ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') IN ({dept_in})")
|
||||
|
||||
turnstile_filter_sql = " OR ".join(conditions) if conditions else "1 = 0"
|
||||
|
||||
# 2. Безопасное математическое определение @EndDate через DATEADD (независимо от локали сервера)
|
||||
if target_time:
|
||||
t_clean = target_time.strip()
|
||||
t_parts = t_clean.split(":")
|
||||
h = int(t_parts[0])
|
||||
m = int(t_parts[1]) if len(t_parts) > 1 else 0
|
||||
s = int(t_parts[2]) if len(t_parts) > 2 else 0
|
||||
|
||||
snapshot_time = f"{processing_date.strftime('%Y-%m-%d')} {h:02d}:{m:02d}:{s:02d}"
|
||||
end_datetime_sql = f"DATEADD(SECOND, {s}, DATEADD(MINUTE, {m}, DATEADD(HOUR, {h}, @StartDate)))"
|
||||
is_final = False
|
||||
elif is_past_day:
|
||||
snapshot_time = f"{processing_date.strftime('%Y-%m-%d')} 23:59:59"
|
||||
end_datetime_sql = "DATEADD(SECOND, -1, DATEADD(DAY, 1, @StartDate))"
|
||||
is_final = True
|
||||
else:
|
||||
snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
end_datetime_sql = "GETDATE()"
|
||||
is_final = False
|
||||
|
||||
log(f"--- Обработка периода: {period_label} ({processing_date_str}) --- [Снапшот: {snapshot_time}]")
|
||||
sql_query = SQL_QUERY_TEMPLATE.format(target_date=processing_date.strftime("%Y-%m-%d"))
|
||||
log(f"--- Обработка периода: {period_label} ({processing_date_str}) --- [Срез: {snapshot_time}]")
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
connection = None
|
||||
try:
|
||||
@@ -353,31 +405,34 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo
|
||||
|
||||
df['Пришел'] = df['Статус'].str.contains('Присутствовал', case=False, na=False) & (~mask_anomaly)
|
||||
|
||||
save_scud_to_db(df, processing_date_str, snapshot_time=snapshot_time, is_yesterday=is_yesterday)
|
||||
save_scud_to_db(df, processing_date_str, snapshot_time=snapshot_time, is_yesterday=is_final)
|
||||
|
||||
raw_sql = SQL_RAW_EVENTS_QUERY.format(target_date=processing_date.strftime("%Y-%m-%d"))
|
||||
raw_sql = SQL_RAW_EVENTS_QUERY.format(
|
||||
target_date=processing_date.strftime("%Y-%m-%d"),
|
||||
end_datetime_sql=end_datetime_sql
|
||||
)
|
||||
df_raw = pd.read_sql(raw_sql, connection)
|
||||
if len(df_raw) > 0:
|
||||
df_raw['fio_clean'] = df_raw['Сотрудник'].apply(clean_scud_fio_light)
|
||||
inserted_count = save_raw_events_to_db(df_raw, processing_date_str)
|
||||
log(f"[✓] В scud_events_raw сохранено {inserted_count} сырых событий проходов за {processing_date_str}!", "SUCCESS")
|
||||
log(f"[✓] В scud_events_raw сохранено {inserted_count} сырых событий за {processing_date_str}!", "SUCCESS")
|
||||
|
||||
log(f"[✓] Записи за {processing_date_str} успешно сохранены в SQLite!", "SUCCESS")
|
||||
log(f"[✓] Записи за {processing_date_str} успешно сохранены в SQLite!", "SUCCESS")
|
||||
|
||||
if save_xlsx:
|
||||
file_name = f"Сотрудники_{processing_date_str}.xlsx"
|
||||
file_path = os.path.join(SCUD_DIR, file_name)
|
||||
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")
|
||||
if os.path.exists(file_path):
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except OSError as e:
|
||||
log(f"ОШИБКА при удалении старого файла {file_name}: {e}", "ERROR")
|
||||
|
||||
save_df_to_clean_excel(df, file_path, sheet_name="Отчет")
|
||||
log(f"[✓] Успешно экспортирован файл: data/scud/{file_name}", "SUCCESS")
|
||||
else:
|
||||
log(f"Запрос за {processing_date_str} вернул 0 строк.", "WARNING")
|
||||
save_df_to_clean_excel(df, 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")
|
||||
@@ -392,9 +447,10 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--date", dest="input_date", default=None)
|
||||
parser.add_argument("--date", dest="input_date", default=None, help="Дата среза (ДД.ММ.ГГГГ)")
|
||||
parser.add_argument("--time", dest="input_time", default=None, help="Время среза (ЧЧ:ММ)")
|
||||
parser.add_argument("-d", "--debug", action="store_true")
|
||||
parser.add_argument("--no-xlsx", dest="save_xlsx", action="store_false", default=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
run_export(args.input_date, save_xlsx=args.save_xlsx, debug=args.debug)
|
||||
run_export(args.input_date, input_time=args.input_time, save_xlsx=args.save_xlsx, debug=args.debug)
|
||||
@@ -3,7 +3,7 @@
|
||||
FILE: services/snapshots/service.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: services / snapshots
|
||||
ROLE: Бизнес-логика срезов СКУД (выборка, валидация Y-срезов, удаление).
|
||||
ROLE: Бизнес-логика срезов СКУД (выборка, отображение времени, удаление).
|
||||
|
||||
AI-CONTEXT-ANCHORS:
|
||||
- ANCHOR[SNAPSHOT_GET_REGISTRY]: Выборка срезов с разметкой защищенных Y-снапшотов.
|
||||
@@ -18,20 +18,39 @@ from core.repositories.scud_repo import get_available_snapshots, delete_snapshot
|
||||
|
||||
# ANCHOR[SNAPSHOT_GET_REGISTRY]
|
||||
def get_snapshots_registry(date_str: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Возвращает реестр снапшотов за дату или за все доступные дни."""
|
||||
"""
|
||||
Возвращает реестр снапшотов.
|
||||
В поле snapshot_time объединяет время среза и фактическое время создания снапшота.
|
||||
"""
|
||||
clean_date = date_str.strip() if date_str else ""
|
||||
rows = get_available_snapshots(date_str=clean_date if clean_date else None)
|
||||
|
||||
snapshots = [
|
||||
{
|
||||
"snapshot_id": r[0],
|
||||
"log_date": r[1],
|
||||
"snapshot_time": r[2],
|
||||
"record_count": r[3],
|
||||
"is_final": str(r[0]).startswith("Y")
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
snapshots = []
|
||||
for r in rows:
|
||||
snap_id = r[0]
|
||||
log_date = r[1]
|
||||
snap_time = r[2]
|
||||
rec_count = r[3]
|
||||
created_at = r[4] if len(r) > 4 else None
|
||||
|
||||
slice_time_str = snap_time.split()[1] if snap_time and " " in snap_time else snap_time
|
||||
|
||||
created_str = ""
|
||||
if created_at and " " in str(created_at):
|
||||
c_date, c_time = str(created_at).split()[:2]
|
||||
c_parts = c_date.split("-")
|
||||
c_date_fmt = f"{c_parts[2]}.{c_parts[1]}" if len(c_parts) == 3 else c_date
|
||||
created_str = f" · создан {c_date_fmt} {c_time[:5]}"
|
||||
|
||||
display_label = f"Срез {slice_time_str}{created_str}"
|
||||
|
||||
snapshots.append({
|
||||
"snapshot_id": snap_id,
|
||||
"log_date": log_date,
|
||||
"snapshot_time": display_label,
|
||||
"record_count": rec_count,
|
||||
"is_final": str(snap_id).startswith("Y") or "_FINAL" in str(snap_id)
|
||||
})
|
||||
|
||||
return {
|
||||
"query_date": clean_date or "все",
|
||||
|
||||
Reference in New Issue
Block a user